diff --git a/.env.production.example b/.env.production.example
index dfc0a7497..c90fb5377 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -230,6 +230,18 @@ API_TOKEN_HMAC_KEY=""
# ("AI response was cut off"). See docs/integrations/ai-providers.md.
# INSIGHTS_MAX_TOKENS=""
+# Per-user hourly ceiling for POST /api/insights/generate (optional).
+# Default 10. Lower it on a tight LLM budget; the 24h cache already
+# short-circuits read traffic. See docs/integrations/ai-providers.md.
+# INSIGHTS_RATE_LIMIT_PER_HOUR=""
+
+# Per-user hourly ceiling for the document-AI actions (optional). Read/index,
+# extract, suggest and summarise share one bucket per user. Default 6,
+# clamped to 1-1000. Raise it when users batch-read many documents against a
+# generous provider budget; lower it on a tight one. See
+# docs/integrations/ai-providers.md.
+# DOCUMENT_AI_LIMIT_PER_HOUR=""
+
# -----------------------------------------------------------------------------
# Off-host backups -- all-or-none
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 029a4ebbf..8a90bfbdf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,22 @@
# Changelog
+## [1.37.13] — 2026-08-13
+
+### Added
+
+- A read lab document offers its values for your lab record, right on the document. When a document is classified as a lab report, the detail view shows a review step with each extracted value, its unit and its date, and you approve or reject them one by one before anything is written. Nothing is committed without your confirmation, and a value the reader was unsure about is marked for a closer look first. Reading a document again by hand now stages values the same way the automatic path does, so a report that arrived before automatic reading was switched on is no longer stuck.
+- The hourly limit for reading documents with AI is an operator setting now, not a fixed number. Self-hosters set `DOCUMENT_AI_LIMIT_PER_HOUR` to match their provider budget. The limit message tells you when the next read is actually possible instead of a vague "try again in a few minutes", and a read that never reached the provider no longer uses up one of your slots.
+- The web AI settings can grant consent again after a withdrawal. Revoking AI consent stays a standing decision that no page visit can quietly undo; what is new is an explicit control that lets you, and only you, opt back in when you want the AI features again.
+- The Coach keeps an answer you walked away from. Navigating off the page still stops the reply, deliberately, but the conversation now shows the interrupted turn instead of losing it, coming back opens that conversation rather than a fresh chat, and a retry control asks the same question again in place.
+
+### Fixed
+
+- The health score could not read lab-based pillars. A column-name mismatch made every glycaemia and lipids read fail, so the score treated accounts with lab results as unreadable and the two pillars never scored. The reader uses the real columns now, and an account without lab results shows the pillar as not tracked rather than as an error.
+- Reading a large Apple Health export no longer exhausts the server's memory. The archive streams through the parser in bounded batches instead of accumulating in memory, so a multi-hundred-megabyte export with millions of records imports on a default self-host setup. An import the available memory genuinely cannot carry is refused up front with a message that says what to raise, and an import that was interrupted mid-run shows up as failed with a retry, rather than spinning forever.
+- Automatic document reading no longer hides the manual read action on a document that has not actually been read yet, so a single failed or skipped document has a way back.
+- Read-everything now includes PDFs for providers that accept images. The pages are rendered to images for those providers, the same way a single manual read already did, so a Codex-style setup no longer skips every PDF in the vault.
+- The About-me note and the free-text allergy notes moved from Account settings into Anamnese. The free-text notes sit directly under the structured allergy list they supplement, labelled as the place for what the structured list cannot hold, and the account page keeps to account matters.
+
## [1.37.12] — 2026-08-11
### Changed
diff --git a/docker-compose.yml b/docker-compose.yml
index 7add1e61d..e304d1cab 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -199,6 +199,18 @@ x-healthlog-env: &healthlog_env
# Listed here so an operator value in .env reaches the container (the
# `environment:` block is a whitelist). See docs/integrations/ai-providers.md.
INSIGHTS_MAX_TOKENS: "${INSIGHTS_MAX_TOKENS:-}"
+ # Per-user hourly ceiling for POST /api/insights/generate (optional;
+ # default 10). Was documented but missing from this whitelist, so an
+ # operator value never reached the container. See
+ # docs/integrations/ai-providers.md.
+ INSIGHTS_RATE_LIMIT_PER_HOUR: "${INSIGHTS_RATE_LIMIT_PER_HOUR:-}"
+ # Per-user hourly ceiling for the document-AI actions (read/index, extract,
+ # suggest, summarise — one shared bucket; optional; default 6, clamped
+ # 1-1000). Raise on a self-host with a generous provider budget, lower on a
+ # tight one. Listed here so an operator value in .env reaches the container
+ # (the `environment:` block is a whitelist). See
+ # docs/integrations/ai-providers.md.
+ DOCUMENT_AI_LIMIT_PER_HOUR: "${DOCUMENT_AI_LIMIT_PER_HOUR:-}"
# v1.17.1 — SMTP transport for the email notification channel (optional,
# all-or-none: HOST + PORT + FROM enable it; USER/PASS optional; SECURE
# toggles implicit TLS). Listed here so operator values in .env reach the
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 8072fcec3..ba89940e5 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.37.12
+ version: 1.37.13
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -8904,10 +8904,24 @@ paths:
tags:
- Consent
summary: Grant web AI consent (idempotent ai_full)
- description: Mint an `ai_full` consent receipt for the calling web user if none is active; a no-op when one already
- exists. Mirrors the iOS master grant; the web AI-settings surface calls this on mount so existing web accounts
- on a shared-key deployment gain a receipt without a re-consent step. Revocation flows through DELETE
- /api/consent/ai/latest.
+ description: "Mint an `ai_full` consent receipt for the calling web user if none is active; a no-op when one already
+ exists. Mirrors the iOS master grant. Without a body (the mount heal) it grants only to an account with no
+ consent history, so a revocation stands until the user grants again; with `intent: \"affirmative\"` (the
+ explicit grant control) it records the user's own consent act, which may supersede an earlier revocation.
+ Revocation flows through DELETE /api/consent/ai/latest."
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ intent:
+ default: heal
+ type: string
+ enum:
+ - heal
+ - affirmative
responses:
"200":
description: Grant outcome.
@@ -12453,10 +12467,12 @@ paths:
description: "Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the
stored original and stages STRUCTURED FACTS for review. AI-consent / rate / budget gated. With no provider
configured this returns 422 (`documents.inbound.providerUnsupported`) — the stored document is untouched, only
- the enhancement fails. Two modes by content-type: VISION (empty body) decrypts and scans the stored original
- (PDF needs an Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: \"text\", text }`
- structures browser-OCR'd text. Extraction reproduces what the document states — it never interprets. Nothing
- reaches the structured stores here; the confirm route is the only write path."
+ the enhancement fails. Three modes: VISION (empty body) decrypts and scans the stored original (PDF needs an
+ Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: \"text\", text }` structures
+ browser-OCR'd text; STORED (`application/json`) `{ mode: \"stored\" }` structures the document's own stored
+ extracted text (its content index) — the manual recovery for a skipped/failed automatic staging run, 422
+ `documents.inbound.notIndexed` when no stored text exists. Extraction reproduces what the document states — it
+ never interprets. Nothing reaches the structured stores here; the confirm route is the only write path."
parameters:
- name: id
in: path
@@ -12468,7 +12484,7 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/InboundTextExtractRequest"
+ $ref: "#/components/schemas/InboundExtractRequest"
responses:
"200":
description: The document with its newly staged facts.
@@ -12495,9 +12511,11 @@ paths:
description: "Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION,
empty body) or browser-OCR'd text (TEXT, `application/json` `{ mode: \"text\", text }`, opt-in local OCR) and
returns a `{ title, kind, documentDate }` DRAFT for the edit form. AI-consent / rate / budget gated (shares the
- 6/hour extract bucket). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422
- (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title
- is a neutral filing label."
+ per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via
+ `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries
+ the reset instant in `X-RateLimit-Reset` and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips
+ status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured.
+ Never interprets or diagnoses; the title is a neutral filing label."
parameters:
- name: id
in: path
@@ -12629,7 +12647,10 @@ paths:
gated); TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with
no provider egress. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes — no
plaintext body, no plaintext token. Gated on the existing AI consent (no separate per-user toggle). Idempotent;
- re-indexing overwrites in place."
+ re-indexing overwrites in place. A successful index continues into the same lab auto-staging the background
+ worker performs (still-STORED lab-looking document with no facts, both modules on, provider + consent eligible);
+ `labFactsStaged` reports how many facts were staged PENDING for review — confirmation stays the only write into
+ Labs."
parameters:
- name: id
in: path
@@ -17104,29 +17125,37 @@ components:
required:
- mode
- text
- InboundTextExtractRequest:
- type: object
- properties:
- mode:
- type: string
- const: text
- text:
- type: string
- kind:
- type: string
- enum:
- - DOCTOR_REPORT
- - DISCHARGE_LETTER
- - LAB_RESULT
- - IMAGING
- - PRESCRIPTION
- - REFERRAL
- - INSURANCE
- - VACCINATION
- - OTHER
- required:
- - mode
- - text
+ InboundExtractRequest:
+ anyOf:
+ - type: object
+ properties:
+ mode:
+ type: string
+ const: text
+ text:
+ type: string
+ kind:
+ type: string
+ enum:
+ - DOCTOR_REPORT
+ - DISCHARGE_LETTER
+ - LAB_RESULT
+ - IMAGING
+ - PRESCRIPTION
+ - REFERRAL
+ - INSURANCE
+ - VACCINATION
+ - OTHER
+ required:
+ - mode
+ - text
+ - type: object
+ properties:
+ mode:
+ type: string
+ const: stored
+ required:
+ - mode
DocumentChatRequest:
type: object
properties:
@@ -28618,7 +28647,9 @@ components:
anyOf:
- type: string
- type: "null"
- description: Provider that produced the reply (e.g. anthropic, openai, local, refusal); null for user turns.
+ description: Provider that produced the reply (e.g. anthropic, openai, local, refusal). The sentinel `cancelled` marks
+ an empty assistant row closing a turn the client aborted mid-generation (navigation away); clients should
+ render it as an interrupted turn with a retry affordance. Null for user turns.
promptVersion:
anyOf:
- type: string
@@ -38496,10 +38527,16 @@ components:
type: boolean
tokenCount:
type: number
+ labFactsStaged:
+ type: number
+ description: How many lab facts the index run auto-staged for review (0 when the document is not a lab report, staging
+ was not eligible, or facts already exist). Staged facts are PENDING — the confirm route remains the only
+ write into Labs.
required:
- documentId
- indexed
- tokenCount
+ - labFactsStaged
additionalProperties: false
DocumentChatHistoryEnvelope:
type: object
diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md
index 66f03496b..80cc2abc3 100644
--- a/docs/integrations/ai-providers.md
+++ b/docs/integrations/ai-providers.md
@@ -393,6 +393,20 @@ verbose model gets its briefing cut off mid-JSON — the API reports
that case as "AI response was cut off" rather than the generic
invalid-JSON error.
+`DOCUMENT_AI_LIMIT_PER_HOUR` (default `6`, clamped to 1–1000) bounds
+the document-AI actions per user per hour. Read/index, extract,
+suggest and summarise share one bucket, and a slot is only consumed
+when a request actually reaches the provider — a request that fails
+during input preparation (unreadable file, exhausted daily budget,
+malformed body) hands its slot back. Raise it when users batch-read
+many documents against a generous provider budget; lower it on a
+tight one. The 429 names the actual wait (the window is an hour) and
+carries the reset instant in `X-RateLimit-Reset`.
+
+All three variables must also be listed in the compose
+`environment:` whitelist to reach the container; the shipped
+`docker-compose.yml` lists them.
+
## Connection test
`POST /api/ai/test` runs a one-shot probe against the resolved
diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md
index 9d0527047..7d9f00408 100644
--- a/docs/self-hosting/scaling.md
+++ b/docs/self-hosting/scaling.md
@@ -56,6 +56,36 @@ know:
Without a shared staging volume, imports in split mode cannot
complete.
+## Apple Health import memory
+
+The import parses `export.xml` as a stream: the archive is never
+buffered whole, records flush to the database in fixed-size batches,
+and what stays resident scales with the data's calendar span, not its
+record count. Multi-year exports with millions of records parse within
+the default Node.js heap.
+
+Before parsing starts, the worker compares the archive's declared
+uncompressed size against the runtime heap limit. If the limit is too
+low to carry the export — realistically only when
+`--max-old-space-size` was pinned far below the default — the import
+fails immediately with a `failureReason` starting with
+`insufficient_memory`, naming the required and available amounts.
+Raise the limit on the container that runs the worker and re-upload:
+
+```yaml
+# docker-compose.yml
+services:
+ app:
+ environment:
+ NODE_OPTIONS: --max-old-space-size=1024
+```
+
+An import interrupted by a container restart or an out-of-memory kill
+does not stay stuck: the worker reconciles non-terminal import jobs at
+boot and every 15 minutes, flipping orphaned rows to `failed`
+(`interrupted_by_restart`) so the UI offers a retry instead of an
+endless spinner.
+
## Healthchecks
- `app` (web) healthcheck: `wget /api/version` every 30s.
diff --git a/e2e/documents-auto-read.spec.ts b/e2e/documents-auto-read.spec.ts
index d341fbed6..867a72e35 100644
--- a/e2e/documents-auto-read.spec.ts
+++ b/e2e/documents-auto-read.spec.ts
@@ -23,6 +23,7 @@ import {
ensureVaultFixture,
ensureVaultAiFixture,
AI_PROBE_DOC_ID,
+ CONTENT_DOC_ID,
} from "./setup/vault-fixture";
async function fulfilJson(
@@ -249,24 +250,23 @@ test.describe("automatic AI reading — vault per-document contract", () => {
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
});
- test("with auto-read ON the sheet drops the manual AI action row", async ({
+ test("with auto-read ON the sheet drops the AI row only for a read document", async ({
page,
}) => {
- // Auto-read reads every upload with no per-document tap, so the manual
- // Read / Suggest actions are redundant and the WHOLE block collapses
- // (v1.28.22 — no status chrome; provenance lives on the vault card).
+ // Auto-read reads every upload with no per-document tap, so once a
+ // document IS read the manual Read / Suggest actions are redundant and
+ // the WHOLE block collapses (v1.28.22 — no status chrome; provenance
+ // lives on the vault card). `CONTENT_DOC_ID` ships with a content index,
+ // so it is the read-document case.
await mockDocumentCapability(page, { egress: "external", autoRead: true });
- await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
+ await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
- // v1.28.22 — with auto-read ON and nothing pending the WHOLE block
- // collapses (no status chrome; provenance lives on the vault card), so the
- // sheet content starts straight with the document fields. The footer's
- // download action is the loaded-state anchor (it renders only once the
- // document GET resolved), so the absence checks below cannot pass
- // vacuously against the loading skeleton.
+ // The footer's download action is the loaded-state anchor (it renders
+ // only once the document GET resolved), so the absence checks below
+ // cannot pass vacuously against the loading skeleton.
await expect(
sheet.locator('[data-slot="document-download"]'),
).toBeVisible();
@@ -278,4 +278,28 @@ test.describe("automatic AI reading — vault per-document contract", () => {
);
await expect(sheet.locator('[data-slot="assist-suggest"]')).toHaveCount(0);
});
+
+ test("with auto-read ON an unread document keeps its read action", async ({
+ page,
+ }) => {
+ // v1.37.13 — auto-read reads new uploads, but a document that has no
+ // content index yet (upload predates the switch, or its read failed)
+ // still needs a way in: the AI section stays, with the manual read as
+ // the per-document recovery path. Collapsing it here would strand the
+ // document with no route to a read at all. The probe document is seeded
+ // without a content index, so it is exactly that case.
+ await mockDocumentCapability(page, { egress: "external", autoRead: true });
+
+ await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
+ const sheet = page.getByRole("dialog");
+ await expect(sheet).toBeVisible();
+
+ await expect(
+ sheet.locator('[data-slot="document-download"]'),
+ ).toBeVisible();
+ await expect(
+ sheet.locator('[data-slot="document-ai-section"]'),
+ ).toHaveCount(1);
+ await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
+ });
});
diff --git a/messages/de.json b/messages/de.json
index a7340279a..2d115aed0 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2934,6 +2934,10 @@
"railBadge": "Dokument-Chat (abgeschottet)",
"uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut.",
"notFoundError": "Dieser Dokumentlink ist nicht mehr gültig – es wird ein normaler Chat gestartet."
+ },
+ "interrupted": {
+ "note": "Diese Antwort wurde unterbrochen, bevor sie fertig war.",
+ "retry": "Erneut fragen"
}
},
"relativeJustNow": "gerade eben",
@@ -4984,6 +4988,8 @@
"rateLimited": "Zu viele Uploads. Bitte kurz warten und erneut versuchen.",
"tooLarge": "Diese Datei ist zu groß (Grenze 1,5 GB).",
"failed": "Der Import ist fehlgeschlagen.",
+ "failureInterrupted": "Der Import wurde unterbrochen, bevor er fertig war, meist durch einen Server-Neustart oder Speichermangel. Die Daten sind sicher; das Archiv einfach erneut hochladen.",
+ "failureInsufficientMemory": "Das Speicherlimit des Servers ist für ein Archiv dieser Größe zu niedrig. Bitte die Betreiberin oder den Betreiber das Node.js-Heap-Limit erhöhen lassen (NODE_OPTIONS=--max-old-space-size) und das Archiv danach erneut hochladen.",
"rowsImported": "Bisher {count} Einträge importiert",
"doneSummary": "Fertig — {imported} von {read} Datensätzen importiert, {skipped} klinische Datensätze übersprungen.",
"doneNothing": "Aus diesem Archiv wurde nichts importiert: 0 von {read} Datensätzen sind angekommen.",
@@ -5616,14 +5622,10 @@
"profileHint": "Alter und Geschlecht stammen aus deinem Profil und werden automatisch mitgegeben.",
"freeTextLabel": "Über mich (Freitext)",
"conditionsPlaceholder": "Z. B. Bluthochdruck, Typ-2-Diabetes…",
- "allergiesLabel": "Allergien & Unverträglichkeiten",
"allergiesPlaceholder": "Z. B. Laktoseintoleranz, Penicillin-Allergie…",
"focusPlaceholder": "Z. B. behalte meinen Blutdruck im Blick, motiviere mich zu mehr Bewegung…",
- "completeness": "{answered} von {total} beantwortet",
"questionsTitle": "Der Coach hat Rückfragen",
- "questionsOpenCoach": "Im Coach beantworten",
- "recordsLink": "Allergien, Familienanamnese und deine Vorerkrankungen gehören zu deiner Anamnese.",
- "recordsLinkCta": "Anamnese öffnen"
+ "questionsOpenCoach": "Im Coach beantworten"
},
"responseTimeoutHeading": "Antwort-Timeout",
"responseTimeoutBody": "Wie lange auf die Antwort der KI gewartet wird, bevor abgebrochen wird. Erhöhe den Wert für ein lokales oder selbst gehostetes Modell, das beim ersten Aufruf Zeit zum Laden braucht.",
@@ -5651,7 +5653,9 @@
"withdraw": "Einwilligung widerrufen",
"confirmBody": "Die KI-Funktionen sind sofort abgeschaltet. Bereits erfasste Daten werden nicht gelöscht — dafür gibt es Daten & Privatsphäre.",
"confirmWithdraw": "Widerrufen",
- "regrantHint": "Um die KI-Funktionen wieder zu nutzen, schalte das automatische Lesen von Dokumenten ein oder erteile die Einwilligung in der App.",
+ "regrantHint": "Mit einer neuen Einwilligung dürfen die KI-Funktionen deine Gesundheitsdaten wieder an den eingestellten Anbieter senden.",
+ "grant": "Einwilligung erteilen",
+ "grantError": "Die Einwilligung konnte nicht gespeichert werden. Bitte versuche es erneut.",
"error": "Der Widerruf konnte nicht gespeichert werden. Bitte versuche es erneut."
}
},
@@ -9295,6 +9299,12 @@
"savedToast": "Allergie gespeichert",
"updatedToast": "Allergie aktualisiert",
"saveError": "Die Allergie konnte nicht gespeichert werden."
+ },
+ "freeText": {
+ "label": "Freitext-Ergänzung (Allergien & Unverträglichkeiten)",
+ "hint": "Eine Freitext-Ergänzung zur strukturierten Liste oben, für Kontext, den ein einzelner Eintrag nicht abbilden kann.",
+ "savedToast": "Allergie-Notiz gespeichert.",
+ "saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut."
}
},
"family": {
@@ -9560,10 +9570,14 @@
"unavailableAction": "KI-Einstellungen öffnen",
"errorGeneric": "Dokument konnte nicht gelesen werden. Bitte erneut versuchen.",
"errorOcr": "Aus diesem Bild konnte kein Text gelesen werden.",
- "errorRateLimited": "Zu viele KI-Anfragen — bitte in ein paar Minuten erneut versuchen.",
+ "errorRateLimited": "Das stündliche KI-Leselimit ist erreicht. Bitte später erneut versuchen.",
+ "errorRateLimitedWaitOne": "Das stündliche KI-Leselimit ist erreicht. Bitte in etwa einer Minute erneut versuchen.",
+ "errorRateLimitedWaitFew": "Das stündliche KI-Leselimit ist erreicht. Bitte in etwa {count} Minuten erneut versuchen.",
+ "errorRateLimitedWaitOther": "Das stündliche KI-Leselimit ist erreicht. Bitte in etwa {count} Minuten erneut versuchen.",
"errorBudget": "Dein KI-Budget für heute ist aufgebraucht.",
"errorPdfNeedsVision": "Zum Scannen von PDFs wird ein Claude-Vision-Anbieter benötigt.",
"errorFileType": "Dieser Dateityp kann nicht gelesen werden.",
+ "errorNotIndexed": "Lies das Dokument zuerst aus, dann extrahiere.",
"errorLocalOcrDisabled": "Aktiviere zuerst die lokale Texterkennung in den KI-Einstellungen.",
"errorProvider": "Dafür ist kein KI-Anbieter eingerichtet.",
"errorTextImageOnly": "Lokale Texterkennung liest nur Bilder — nicht diese Datei."
@@ -9577,9 +9591,54 @@
},
"ai": {
"readDone": "Die KI hat dein Dokument gelesen.",
+ "readStagedOne": "Die KI hat dein Dokument gelesen. 1 Laborwert wartet auf deine Prüfung.",
+ "readStagedFew": "Die KI hat dein Dokument gelesen. {count} Laborwerte warten auf deine Prüfung.",
+ "readStagedOther": "Die KI hat dein Dokument gelesen. {count} Laborwerte warten auf deine Prüfung.",
"read": "Mit KI auslesen",
"reread": "Erneut lesen",
- "reading": "Liest…"
+ "reading": "Liest…",
+ "notReadYet": "Dieses Dokument wurde noch nicht gelesen."
+ },
+ "review": {
+ "title": "Ausgelesene Werte prüfen",
+ "hint": "Bestätige, was mit dem Dokument übereinstimmt. Nicht angehakte Werte werden verworfen; ohne deine Bestätigung wird nichts gespeichert.",
+ "openOne": "Ausgelesenen Wert prüfen",
+ "openFew": "Ausgelesene Werte prüfen ({count})",
+ "openOther": "Ausgelesene Werte prüfen ({count})",
+ "includeAria": "{value} übernehmen",
+ "type": {
+ "OBSERVATION": "Laborwert",
+ "CONDITION": "Erkrankung",
+ "MEDICATION_STATEMENT": "Medikament"
+ },
+ "statedRange": "angegebener Bereich {range}",
+ "needsCheck": "Prüfe diesen Wert gegen das Dokument und speichere ihn dann.",
+ "verify": "Prüfen",
+ "verifySave": "Wert speichern",
+ "verifyInvalid": "Gib einen Namen und entweder einen Zahlenwert oder ein Textergebnis an, nicht beides.",
+ "verifyFailed": "Die Korrektur konnte nicht gespeichert werden. Versuche es erneut.",
+ "fieldName": "Name",
+ "fieldDate": "Datum",
+ "fieldValue": "Wert",
+ "fieldValueText": "Ergebnis (Text)",
+ "fieldUnit": "Einheit",
+ "fieldDose": "Dosis",
+ "unverifiedNoteOne": "1 Wert muss noch geprüft werden und bleibt offen.",
+ "unverifiedNoteFew": "{count} Werte müssen noch geprüft werden und bleiben offen.",
+ "unverifiedNoteOther": "{count} Werte müssen noch geprüft werden und bleiben offen.",
+ "finish": "Prüfung abschließen",
+ "confirmFailed": "Die Prüfung konnte nicht gespeichert werden. Versuche es erneut.",
+ "savedToastOne": "1 Wert gespeichert.",
+ "savedToastFew": "{count} Werte gespeichert.",
+ "savedToastOther": "{count} Werte gespeichert.",
+ "nothingSavedToast": "Prüfung gespeichert. Es wurden keine Werte übernommen.",
+ "extract": "Laborwerte auslesen",
+ "extracting": "Liest aus…",
+ "extractHint": "Nutzt den bereits ausgelesenen Text dieses Dokuments. Ausgelesene Werte warten auf deine Prüfung; nichts wird automatisch gespeichert.",
+ "extractEmpty": "Aus diesem Dokument konnten keine Werte ausgelesen werden.",
+ "failUnitRequired": "Ein Zahlenwert braucht eine Einheit, bevor er gespeichert werden kann.",
+ "failUnitMismatch": "Die angegebene Einheit passt nicht zur gespeicherten Einheit dieses Markers. Passe sie an und schließe die Prüfung erneut ab.",
+ "failGeneric": "Dieser Wert konnte nicht gespeichert werden."
},
"contentIndex": {
"indexPrompt": "Dokumentinhalte durchsuchbar machen.",
diff --git a/messages/en.json b/messages/en.json
index 953e6a93d..15f1df75d 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2934,6 +2934,10 @@
"railBadge": "Fenced document chat",
"uploadError": "Upload failed. Please try again.",
"notFoundError": "That document link is no longer valid — starting a normal chat instead."
+ },
+ "interrupted": {
+ "note": "This answer was interrupted before it finished.",
+ "retry": "Ask again"
}
},
"relativeJustNow": "just now",
@@ -4984,6 +4988,8 @@
"rateLimited": "Too many uploads. Please wait a moment and try again.",
"tooLarge": "That file is too large (limit 1.5 GB).",
"failed": "The import failed.",
+ "failureInterrupted": "The import was interrupted before it finished, usually by a server restart or an out-of-memory stop. Your data is safe; upload the export again to retry.",
+ "failureInsufficientMemory": "The server's memory limit is too low for an archive this size. Ask whoever runs the server to raise the Node.js heap limit (NODE_OPTIONS=--max-old-space-size), then upload the export again.",
"rowsImported": "{count} rows imported so far",
"doneSummary": "Done — {imported} of {read} records imported, {skipped} clinical records skipped.",
"doneNothing": "Nothing was imported from that archive: 0 of {read} records landed.",
@@ -5616,14 +5622,10 @@
"profileHint": "Age and gender come from your profile and are included automatically.",
"freeTextLabel": "About me (free text)",
"conditionsPlaceholder": "E.g. hypertension, type 2 diabetes…",
- "allergiesLabel": "Allergies & intolerances",
"allergiesPlaceholder": "E.g. lactose intolerance, penicillin allergy…",
"focusPlaceholder": "E.g. keep an eye on my blood pressure, motivate me to move more…",
- "completeness": "{answered} of {total} answered",
"questionsTitle": "The Coach has follow-up questions",
- "questionsOpenCoach": "Answer them in the Coach",
- "recordsLink": "Allergies, family history and your pre-existing conditions are part of your medical history.",
- "recordsLinkCta": "Open medical history"
+ "questionsOpenCoach": "Answer them in the Coach"
},
"responseTimeoutHeading": "Response timeout",
"responseTimeoutBody": "How long to wait for the AI to respond before giving up. Raise it for a local or self-hosted model that needs time to load on the first request.",
@@ -5651,7 +5653,9 @@
"withdraw": "Withdraw consent",
"confirmBody": "The AI features stop working straight away. Nothing already recorded is deleted — use Data & privacy to export or erase your record.",
"confirmWithdraw": "Withdraw",
- "regrantHint": "To use the AI features again, switch on automatic document reading or grant consent from the app.",
+ "regrantHint": "Granting again lets the AI features send your health data to the configured provider.",
+ "grant": "Grant consent",
+ "grantError": "The consent could not be saved. Please try again.",
"error": "The withdrawal could not be saved. Please try again."
}
},
@@ -9295,6 +9299,12 @@
"savedToast": "Allergy saved",
"updatedToast": "Allergy updated",
"saveError": "Couldn't save the allergy."
+ },
+ "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.",
+ "saveError": "Could not save. Please try again."
}
},
"family": {
@@ -9560,10 +9570,14 @@
"unavailableAction": "Open AI settings",
"errorGeneric": "Couldn't read the document. Try again.",
"errorOcr": "Couldn't read text from this image.",
- "errorRateLimited": "Too many AI requests — try again in a few minutes.",
+ "errorRateLimited": "The hourly AI reading limit is reached. Try again later.",
+ "errorRateLimitedWaitOne": "The hourly AI reading limit is reached. Try again in about a minute.",
+ "errorRateLimitedWaitFew": "The hourly AI reading limit is reached. Try again in about {count} minutes.",
+ "errorRateLimitedWaitOther": "The hourly AI reading limit is reached. Try again in about {count} minutes.",
"errorBudget": "Your AI budget for today is used up.",
"errorPdfNeedsVision": "Scanning PDFs needs a Claude vision provider.",
"errorFileType": "This file type can't be read.",
+ "errorNotIndexed": "Read the document first, then extract.",
"errorLocalOcrDisabled": "Turn on local OCR in the AI settings first.",
"errorProvider": "No AI provider is set up for this.",
"errorTextImageOnly": "Local OCR reads images only — not this file."
@@ -9577,9 +9591,54 @@
},
"ai": {
"readDone": "The AI read your document.",
+ "readStagedOne": "The AI read your document. 1 lab value is ready to review.",
+ "readStagedFew": "The AI read your document. {count} lab values are ready to review.",
+ "readStagedOther": "The AI read your document. {count} lab values are ready to review.",
"read": "Read with AI",
"reread": "Read again",
- "reading": "Reading…"
+ "reading": "Reading…",
+ "notReadYet": "This document has not been read yet."
+ },
+ "review": {
+ "title": "Review extracted values",
+ "hint": "Approve what matches the document. Unchecked values are discarded; nothing is saved without your confirmation.",
+ "openOne": "Review extracted value",
+ "openFew": "Review extracted values ({count})",
+ "openOther": "Review extracted values ({count})",
+ "includeAria": "Include {value}",
+ "type": {
+ "OBSERVATION": "Lab value",
+ "CONDITION": "Condition",
+ "MEDICATION_STATEMENT": "Medication"
+ },
+ "statedRange": "stated range {range}",
+ "needsCheck": "Check this value against the document, then save it.",
+ "verify": "Check",
+ "verifySave": "Save value",
+ "verifyInvalid": "Enter a name and either a numeric value or a text result, not both.",
+ "verifyFailed": "The correction couldn't be saved. Try again.",
+ "fieldName": "Name",
+ "fieldDate": "Date",
+ "fieldValue": "Value",
+ "fieldValueText": "Result (text)",
+ "fieldUnit": "Unit",
+ "fieldDose": "Dose",
+ "unverifiedNoteOne": "1 value still needs checking and stays pending.",
+ "unverifiedNoteFew": "{count} values still need checking and stay pending.",
+ "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.",
+ "nothingSavedToast": "Review saved. No values were added.",
+ "extract": "Extract lab values",
+ "extracting": "Extracting…",
+ "extractHint": "Uses the text already read from this document. Extracted values wait for your review; nothing is saved automatically.",
+ "extractEmpty": "No values could be extracted from this document.",
+ "failUnitRequired": "A numeric value needs a unit before it can be saved.",
+ "failUnitMismatch": "The stated unit does not match the saved unit for this marker. Adjust it, then finish the review again.",
+ "failGeneric": "This value could not be saved."
},
"contentIndex": {
"indexPrompt": "Make document contents searchable.",
diff --git a/messages/es.json b/messages/es.json
index 07bd9f645..13c66febd 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2934,6 +2934,10 @@
"railBadge": "Chat de documento aislado",
"uploadError": "Error al subir. Inténtalo de nuevo.",
"notFoundError": "Ese enlace de documento ya no es válido: se iniciará un chat normal."
+ },
+ "interrupted": {
+ "note": "Esta respuesta se interrumpió antes de terminar.",
+ "retry": "Preguntar de nuevo"
}
},
"relativeJustNow": "ahora mismo",
@@ -4984,6 +4988,8 @@
"rateLimited": "Demasiadas subidas. Espera un momento e inténtalo de nuevo.",
"tooLarge": "El archivo es demasiado grande (límite 1,5 GB).",
"failed": "La importación falló.",
+ "failureInterrupted": "La importación se interrumpió antes de terminar, normalmente por un reinicio del servidor o por falta de memoria. Tus datos están a salvo; vuelve a subir el archivo para reintentarlo.",
+ "failureInsufficientMemory": "El límite de memoria del servidor es demasiado bajo para un archivo de este tamaño. Pide a quien administra el servidor que aumente el límite de memoria de Node.js (NODE_OPTIONS=--max-old-space-size) y vuelve a subir el archivo.",
"rowsImported": "{count} filas importadas hasta ahora",
"doneSummary": "Listo: {imported} de {read} registros importados, {skipped} registros clínicos omitidos.",
"doneNothing": "No se importó nada de ese archivo: llegaron 0 de {read} registros.",
@@ -5616,14 +5622,10 @@
"profileHint": "La edad y el género provienen de tu perfil y se incluyen automáticamente.",
"freeTextLabel": "Sobre mí (texto libre)",
"conditionsPlaceholder": "P. ej. hipertensión, diabetes tipo 2…",
- "allergiesLabel": "Alergias e intolerancias",
"allergiesPlaceholder": "P. ej. intolerancia a la lactosa, alergia a la penicilina…",
"focusPlaceholder": "P. ej. vigila mi tensión arterial, motívame a moverme más…",
- "completeness": "{answered} de {total} respondidas",
"questionsTitle": "El Coach tiene preguntas",
- "questionsOpenCoach": "Responderlas en el Coach",
- "recordsLink": "Las alergias, los antecedentes familiares y tus enfermedades preexistentes forman parte de tu historia clínica.",
- "recordsLinkCta": "Abrir historia clínica"
+ "questionsOpenCoach": "Responderlas en el Coach"
},
"responseTimeoutHeading": "Tiempo de espera de la respuesta",
"responseTimeoutBody": "Cuánto esperar a que responda la IA antes de cancelar. Aumenta el valor para un modelo local o autoalojado que necesita tiempo para cargarse en la primera solicitud.",
@@ -5651,7 +5653,9 @@
"withdraw": "Retirar el consentimiento",
"confirmBody": "Las funciones de IA dejan de funcionar de inmediato. No se borra nada de lo ya registrado: usa Datos y privacidad para exportar o eliminar tu historial.",
"confirmWithdraw": "Retirar",
- "regrantHint": "Para volver a usar las funciones de IA, activa la lectura automática de documentos u otorga el consentimiento desde la aplicación.",
+ "regrantHint": "Si vuelves a otorgarlo, las funciones de IA podrán enviar de nuevo tus datos de salud al proveedor configurado.",
+ "grant": "Otorgar el consentimiento",
+ "grantError": "No se pudo guardar el consentimiento. Inténtalo de nuevo.",
"error": "No se pudo guardar la retirada. Inténtalo de nuevo."
}
},
@@ -9295,6 +9299,12 @@
"savedToast": "Alergia guardada",
"updatedToast": "Alergia actualizada",
"saveError": "No se ha podido guardar la alergia."
+ },
+ "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.",
+ "saveError": "No se pudo guardar. Inténtalo de nuevo."
}
},
"family": {
@@ -9560,10 +9570,14 @@
"unavailableAction": "Abrir ajustes de IA",
"errorGeneric": "No se pudo leer el documento. Inténtalo de nuevo.",
"errorOcr": "No se pudo leer texto de esta imagen.",
- "errorRateLimited": "Demasiadas solicitudes de IA: inténtalo en unos minutos.",
+ "errorRateLimited": "Se alcanzó el límite de lecturas de IA por hora. Inténtalo más tarde.",
+ "errorRateLimitedWaitOne": "Se alcanzó el límite de lecturas de IA por hora. Inténtalo en aproximadamente un minuto.",
+ "errorRateLimitedWaitFew": "Se alcanzó el límite de lecturas de IA por hora. Inténtalo en aproximadamente {count} minutos.",
+ "errorRateLimitedWaitOther": "Se alcanzó el límite de lecturas de IA por hora. Inténtalo en aproximadamente {count} minutos.",
"errorBudget": "Tu presupuesto de IA de hoy se ha agotado.",
"errorPdfNeedsVision": "Escanear PDF requiere un proveedor de visión Claude.",
"errorFileType": "Este tipo de archivo no se puede leer.",
+ "errorNotIndexed": "Primero lee el documento y luego extrae.",
"errorLocalOcrDisabled": "Activa primero el OCR local en los ajustes de IA.",
"errorProvider": "No hay ningún proveedor de IA configurado para esto.",
"errorTextImageOnly": "El OCR local solo lee imágenes, no este archivo."
@@ -9577,9 +9591,54 @@
},
"ai": {
"readDone": "La IA leyó tu documento.",
+ "readStagedOne": "La IA leyó tu documento. 1 valor de laboratorio espera tu revisión.",
+ "readStagedFew": "La IA leyó tu documento. {count} valores de laboratorio esperan tu revisión.",
+ "readStagedOther": "La IA leyó tu documento. {count} valores de laboratorio esperan tu revisión.",
"read": "Leer con IA",
"reread": "Leer de nuevo",
- "reading": "Leyendo…"
+ "reading": "Leyendo…",
+ "notReadYet": "Este documento aún no se ha leído."
+ },
+ "review": {
+ "title": "Revisar valores extraídos",
+ "hint": "Aprueba lo que coincida con el documento. Los valores sin marcar se descartan; nada se guarda sin tu confirmación.",
+ "openOne": "Revisar valor extraído",
+ "openFew": "Revisar valores extraídos ({count})",
+ "openOther": "Revisar valores extraídos ({count})",
+ "includeAria": "Incluir {value}",
+ "type": {
+ "OBSERVATION": "Valor de laboratorio",
+ "CONDITION": "Afección",
+ "MEDICATION_STATEMENT": "Medicamento"
+ },
+ "statedRange": "rango indicado {range}",
+ "needsCheck": "Comprueba este valor contra el documento y luego guárdalo.",
+ "verify": "Comprobar",
+ "verifySave": "Guardar valor",
+ "verifyInvalid": "Indica un nombre y un valor numérico o un resultado en texto, no ambos.",
+ "verifyFailed": "No se pudo guardar la corrección. Inténtalo de nuevo.",
+ "fieldName": "Nombre",
+ "fieldDate": "Fecha",
+ "fieldValue": "Valor",
+ "fieldValueText": "Resultado (texto)",
+ "fieldUnit": "Unidad",
+ "fieldDose": "Dosis",
+ "unverifiedNoteOne": "1 valor aún necesita comprobación y queda pendiente.",
+ "unverifiedNoteFew": "{count} valores aún necesitan comprobación y quedan pendientes.",
+ "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.",
+ "nothingSavedToast": "Revisión guardada. No se añadió ningún valor.",
+ "extract": "Extraer valores de laboratorio",
+ "extracting": "Extrayendo…",
+ "extractHint": "Usa el texto ya leído de este documento. Los valores extraídos esperan tu revisión; nada se guarda automáticamente.",
+ "extractEmpty": "No se pudieron extraer valores de este documento.",
+ "failUnitRequired": "Un valor numérico necesita una unidad antes de poder guardarse.",
+ "failUnitMismatch": "La unidad indicada no coincide con la unidad guardada de este marcador. Ajústala y termina la revisión de nuevo.",
+ "failGeneric": "Este valor no se pudo guardar."
},
"contentIndex": {
"indexPrompt": "Haz que el contenido de los documentos sea buscable.",
diff --git a/messages/fr.json b/messages/fr.json
index d9d598454..dd414bccc 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2934,6 +2934,10 @@
"railBadge": "Chat de document cloisonné",
"uploadError": "Échec du téléversement. Veuillez réessayer.",
"notFoundError": "Ce lien de document n'est plus valide : une conversation normale démarre à la place."
+ },
+ "interrupted": {
+ "note": "Cette réponse a été interrompue avant la fin.",
+ "retry": "Reposer la question"
}
},
"relativeJustNow": "à l’instant",
@@ -4984,6 +4988,8 @@
"rateLimited": "Trop d'envois. Patientez un instant et réessayez.",
"tooLarge": "Ce fichier est trop volumineux (limite 1,5 Go).",
"failed": "L'import a échoué.",
+ "failureInterrupted": "L'import a été interrompu avant la fin, généralement par un redémarrage du serveur ou un manque de mémoire. Vos données sont en sécurité ; renvoyez l'archive pour réessayer.",
+ "failureInsufficientMemory": "La limite de mémoire du serveur est trop basse pour une archive de cette taille. Demandez à la personne qui administre le serveur d'augmenter la limite de mémoire de Node.js (NODE_OPTIONS=--max-old-space-size), puis renvoyez l'archive.",
"rowsImported": "{count} lignes importées jusqu'ici",
"doneSummary": "Terminé — {imported} enregistrements sur {read} importés, {skipped} enregistrements cliniques ignorés.",
"doneNothing": "Rien n'a été importé de cette archive : 0 enregistrement sur {read} est arrivé.",
@@ -5616,14 +5622,10 @@
"profileHint": "L'âge et le genre proviennent de votre profil et sont inclus automatiquement.",
"freeTextLabel": "À propos de moi (texte libre)",
"conditionsPlaceholder": "P. ex. hypertension, diabète de type 2…",
- "allergiesLabel": "Allergies et intolérances",
"allergiesPlaceholder": "P. ex. intolérance au lactose, allergie à la pénicilline…",
"focusPlaceholder": "P. ex. surveille ma tension, motive-moi à bouger davantage…",
- "completeness": "{answered} sur {total} renseignés",
"questionsTitle": "Le Coach a des questions",
- "questionsOpenCoach": "Y répondre dans le Coach",
- "recordsLink": "Les allergies, les antécédents familiaux et vos affections préexistantes font partie de vos antécédents médicaux.",
- "recordsLinkCta": "Ouvrir les antécédents médicaux"
+ "questionsOpenCoach": "Y répondre dans le Coach"
},
"responseTimeoutHeading": "Délai de réponse",
"responseTimeoutBody": "Durée d'attente de la réponse de l'IA avant d'abandonner. Augmentez-la pour un modèle local ou auto-hébergé qui a besoin de temps pour se charger à la première requête.",
@@ -5651,7 +5653,9 @@
"withdraw": "Retirer le consentement",
"confirmBody": "Les fonctions IA s'arrêtent immédiatement. Rien de ce qui est déjà enregistré n'est supprimé — utilise Données et confidentialité pour exporter ou effacer ton dossier.",
"confirmWithdraw": "Retirer",
- "regrantHint": "Pour réutiliser les fonctions IA, active la lecture automatique des documents ou donne ton consentement depuis l'application.",
+ "regrantHint": "En le redonnant, les fonctions IA pourront à nouveau transmettre tes données de santé au fournisseur configuré.",
+ "grant": "Donner le consentement",
+ "grantError": "Le consentement n'a pas pu être enregistré. Réessaie.",
"error": "Le retrait n'a pas pu être enregistré. Réessaie."
}
},
@@ -9295,6 +9299,12 @@
"savedToast": "Allergie enregistrée",
"updatedToast": "Allergie mise à jour",
"saveError": "Impossible d'enregistrer l'allergie."
+ },
+ "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.",
+ "saveError": "Impossible d'enregistrer. Veuillez réessayer."
}
},
"family": {
@@ -9560,10 +9570,14 @@
"unavailableAction": "Ouvrir les paramètres d'IA",
"errorGeneric": "Impossible de lire le document. Réessayez.",
"errorOcr": "Impossible de lire le texte de cette image.",
- "errorRateLimited": "Trop de requêtes IA — réessayez dans quelques minutes.",
+ "errorRateLimited": "La limite horaire de lectures IA est atteinte. Réessayez plus tard.",
+ "errorRateLimitedWaitOne": "La limite horaire de lectures IA est atteinte. Réessayez dans environ une minute.",
+ "errorRateLimitedWaitFew": "La limite horaire de lectures IA est atteinte. Réessayez dans environ {count} minutes.",
+ "errorRateLimitedWaitOther": "La limite horaire de lectures IA est atteinte. Réessayez dans environ {count} minutes.",
"errorBudget": "Votre budget IA du jour est épuisé.",
"errorPdfNeedsVision": "La lecture des PDF nécessite un fournisseur de vision Claude.",
"errorFileType": "Ce type de fichier ne peut pas être lu.",
+ "errorNotIndexed": "Lisez d’abord le document, puis extrayez.",
"errorLocalOcrDisabled": "Activez d'abord l'OCR local dans les paramètres d'IA.",
"errorProvider": "Aucun fournisseur d'IA n'est configuré pour cela.",
"errorTextImageOnly": "L'OCR local ne lit que les images, pas ce fichier."
@@ -9577,9 +9591,54 @@
},
"ai": {
"readDone": "L’IA a lu votre document.",
+ "readStagedOne": "L’IA a lu votre document. 1 valeur de laboratoire attend votre vérification.",
+ "readStagedFew": "L’IA a lu votre document. {count} valeurs de laboratoire attendent votre vérification.",
+ "readStagedOther": "L’IA a lu votre document. {count} valeurs de laboratoire attendent votre vérification.",
"read": "Lire avec l’IA",
"reread": "Relire",
- "reading": "Lecture…"
+ "reading": "Lecture…",
+ "notReadYet": "Ce document n’a pas encore été lu."
+ },
+ "review": {
+ "title": "Vérifier les valeurs extraites",
+ "hint": "Approuvez ce qui correspond au document. Les valeurs non cochées sont écartées ; rien n’est enregistré sans votre confirmation.",
+ "openOne": "Vérifier la valeur extraite",
+ "openFew": "Vérifier les valeurs extraites ({count})",
+ "openOther": "Vérifier les valeurs extraites ({count})",
+ "includeAria": "Inclure {value}",
+ "type": {
+ "OBSERVATION": "Valeur de laboratoire",
+ "CONDITION": "Affection",
+ "MEDICATION_STATEMENT": "Médicament"
+ },
+ "statedRange": "plage indiquée {range}",
+ "needsCheck": "Vérifiez cette valeur par rapport au document, puis enregistrez-la.",
+ "verify": "Vérifier",
+ "verifySave": "Enregistrer la valeur",
+ "verifyInvalid": "Indiquez un nom et soit une valeur numérique, soit un résultat en texte, pas les deux.",
+ "verifyFailed": "La correction n’a pas pu être enregistrée. Réessayez.",
+ "fieldName": "Nom",
+ "fieldDate": "Date",
+ "fieldValue": "Valeur",
+ "fieldValueText": "Résultat (texte)",
+ "fieldUnit": "Unité",
+ "fieldDose": "Dose",
+ "unverifiedNoteOne": "1 valeur reste à vérifier et demeure en attente.",
+ "unverifiedNoteFew": "{count} valeurs restent à vérifier et demeurent en attente.",
+ "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.",
+ "nothingSavedToast": "Vérification enregistrée. Aucune valeur n’a été ajoutée.",
+ "extract": "Extraire les valeurs de laboratoire",
+ "extracting": "Extraction…",
+ "extractHint": "Utilise le texte déjà lu de ce document. Les valeurs extraites attendent votre vérification ; rien n’est enregistré automatiquement.",
+ "extractEmpty": "Aucune valeur n’a pu être extraite de ce document.",
+ "failUnitRequired": "Une valeur numérique a besoin d’une unité avant de pouvoir être enregistrée.",
+ "failUnitMismatch": "L’unité indiquée ne correspond pas à l’unité enregistrée pour ce marqueur. Ajustez-la, puis terminez à nouveau la vérification.",
+ "failGeneric": "Cette valeur n’a pas pu être enregistrée."
},
"contentIndex": {
"indexPrompt": "Rendre le contenu des documents consultable.",
diff --git a/messages/it.json b/messages/it.json
index bc486c3f8..7ee83cfc5 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2934,6 +2934,10 @@
"railBadge": "Chat documento isolata",
"uploadError": "Caricamento non riuscito. Riprova.",
"notFoundError": "Quel link al documento non è più valido: verrà avviata una chat normale."
+ },
+ "interrupted": {
+ "note": "Questa risposta è stata interrotta prima di essere completata.",
+ "retry": "Chiedi di nuovo"
}
},
"relativeJustNow": "proprio ora",
@@ -4984,6 +4988,8 @@
"rateLimited": "Troppi caricamenti. Attendi un momento e riprova.",
"tooLarge": "Il file è troppo grande (limite 1,5 GB).",
"failed": "L'importazione non è riuscita.",
+ "failureInterrupted": "L'importazione è stata interrotta prima di finire, di solito per un riavvio del server o per memoria esaurita. I dati sono al sicuro; ricarica l'archivio per riprovare.",
+ "failureInsufficientMemory": "Il limite di memoria del server è troppo basso per un archivio di queste dimensioni. Chiedi a chi gestisce il server di aumentare il limite di memoria di Node.js (NODE_OPTIONS=--max-old-space-size), poi ricarica l'archivio.",
"rowsImported": "{count} righe importate finora",
"doneSummary": "Fatto: {imported} di {read} record importati, {skipped} record clinici saltati.",
"doneNothing": "Da questo archivio non è stato importato nulla: sono arrivati 0 record su {read}.",
@@ -5616,14 +5622,10 @@
"profileHint": "Età e genere provengono dal tuo profilo e vengono inclusi automaticamente.",
"freeTextLabel": "Su di me (testo libero)",
"conditionsPlaceholder": "Es. ipertensione, diabete di tipo 2…",
- "allergiesLabel": "Allergie e intolleranze",
"allergiesPlaceholder": "Es. intolleranza al lattosio, allergia alla penicillina…",
"focusPlaceholder": "Es. tieni d'occhio la mia pressione, motivami a muovermi di più…",
- "completeness": "{answered} su {total} compilati",
"questionsTitle": "Il Coach ha alcune domande",
- "questionsOpenCoach": "Rispondi nel Coach",
- "recordsLink": "Allergie, anamnesi familiare e le tue patologie preesistenti fanno parte della tua anamnesi.",
- "recordsLinkCta": "Apri anamnesi"
+ "questionsOpenCoach": "Rispondi nel Coach"
},
"responseTimeoutHeading": "Timeout della risposta",
"responseTimeoutBody": "Quanto attendere la risposta dell'IA prima di annullare. Aumentalo per un modello locale o self-hosted che ha bisogno di tempo per caricarsi alla prima richiesta.",
@@ -5651,7 +5653,9 @@
"withdraw": "Revoca il consenso",
"confirmBody": "Le funzioni IA si fermano subito. Nulla di quanto già registrato viene eliminato — usa Dati e privacy per esportare o cancellare il tuo archivio.",
"confirmWithdraw": "Revoca",
- "regrantHint": "Per usare di nuovo le funzioni IA, attiva la lettura automatica dei documenti o concedi il consenso dall'app.",
+ "regrantHint": "Concedendolo di nuovo, le funzioni IA potranno inviare ancora i tuoi dati sanitari al provider configurato.",
+ "grant": "Concedi il consenso",
+ "grantError": "Non è stato possibile salvare il consenso. Riprova.",
"error": "Non è stato possibile salvare la revoca. Riprova."
}
},
@@ -9295,6 +9299,12 @@
"savedToast": "Allergia salvata",
"updatedToast": "Allergia aggiornata",
"saveError": "Impossibile salvare l'allergia."
+ },
+ "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.",
+ "saveError": "Impossibile salvare. Riprova."
}
},
"family": {
@@ -9560,10 +9570,14 @@
"unavailableAction": "Apri impostazioni IA",
"errorGeneric": "Impossibile leggere il documento. Riprova.",
"errorOcr": "Impossibile leggere il testo da questa immagine.",
- "errorRateLimited": "Troppe richieste IA — riprova tra qualche minuto.",
+ "errorRateLimited": "Il limite orario di letture IA è stato raggiunto. Riprova più tardi.",
+ "errorRateLimitedWaitOne": "Il limite orario di letture IA è stato raggiunto. Riprova tra circa un minuto.",
+ "errorRateLimitedWaitFew": "Il limite orario di letture IA è stato raggiunto. Riprova tra circa {count} minuti.",
+ "errorRateLimitedWaitOther": "Il limite orario di letture IA è stato raggiunto. Riprova tra circa {count} minuti.",
"errorBudget": "Il tuo budget IA di oggi è esaurito.",
"errorPdfNeedsVision": "La scansione dei PDF richiede un provider di visione Claude.",
"errorFileType": "Questo tipo di file non può essere letto.",
+ "errorNotIndexed": "Leggi prima il documento, poi estrai.",
"errorLocalOcrDisabled": "Attiva prima l'OCR locale nelle impostazioni IA.",
"errorProvider": "Nessun provider IA è configurato per questo.",
"errorTextImageOnly": "L'OCR locale legge solo immagini, non questo file."
@@ -9577,9 +9591,54 @@
},
"ai": {
"readDone": "L’IA ha letto il tuo documento.",
+ "readStagedOne": "L’IA ha letto il tuo documento. 1 valore di laboratorio attende la tua revisione.",
+ "readStagedFew": "L’IA ha letto il tuo documento. {count} valori di laboratorio attendono la tua revisione.",
+ "readStagedOther": "L’IA ha letto il tuo documento. {count} valori di laboratorio attendono la tua revisione.",
"read": "Leggi con l’IA",
"reread": "Leggi di nuovo",
- "reading": "Lettura…"
+ "reading": "Lettura…",
+ "notReadYet": "Questo documento non è ancora stato letto."
+ },
+ "review": {
+ "title": "Rivedi i valori estratti",
+ "hint": "Approva ciò che corrisponde al documento. I valori non selezionati vengono scartati; nulla viene salvato senza la tua conferma.",
+ "openOne": "Rivedi il valore estratto",
+ "openFew": "Rivedi i valori estratti ({count})",
+ "openOther": "Rivedi i valori estratti ({count})",
+ "includeAria": "Includi {value}",
+ "type": {
+ "OBSERVATION": "Valore di laboratorio",
+ "CONDITION": "Condizione",
+ "MEDICATION_STATEMENT": "Farmaco"
+ },
+ "statedRange": "intervallo indicato {range}",
+ "needsCheck": "Controlla questo valore rispetto al documento, poi salvalo.",
+ "verify": "Controlla",
+ "verifySave": "Salva valore",
+ "verifyInvalid": "Indica un nome e un valore numerico oppure un risultato testuale, non entrambi.",
+ "verifyFailed": "La correzione non è stata salvata. Riprova.",
+ "fieldName": "Nome",
+ "fieldDate": "Data",
+ "fieldValue": "Valore",
+ "fieldValueText": "Risultato (testo)",
+ "fieldUnit": "Unità",
+ "fieldDose": "Dose",
+ "unverifiedNoteOne": "1 valore deve ancora essere controllato e resta in sospeso.",
+ "unverifiedNoteFew": "{count} valori devono ancora essere controllati e restano in sospeso.",
+ "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.",
+ "nothingSavedToast": "Revisione salvata. Nessun valore è stato aggiunto.",
+ "extract": "Estrai i valori di laboratorio",
+ "extracting": "Estrazione…",
+ "extractHint": "Usa il testo già letto da questo documento. I valori estratti attendono la tua revisione; nulla viene salvato automaticamente.",
+ "extractEmpty": "Non è stato possibile estrarre valori da questo documento.",
+ "failUnitRequired": "Un valore numerico ha bisogno di un’unità prima di poter essere salvato.",
+ "failUnitMismatch": "L’unità indicata non corrisponde all’unità salvata per questo marcatore. Correggila e concludi di nuovo la revisione.",
+ "failGeneric": "Questo valore non è stato salvato."
},
"contentIndex": {
"indexPrompt": "Rendi ricercabile il contenuto dei documenti.",
diff --git a/messages/pl.json b/messages/pl.json
index dcf8cf7b1..91caae79a 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2934,6 +2934,10 @@
"railBadge": "Odizolowany czat dokumentu",
"uploadError": "Przesyłanie nie powiodło się. Spróbuj ponownie.",
"notFoundError": "Ten link do dokumentu jest już nieprawidłowy – zamiast tego rozpoczynamy zwykły czat."
+ },
+ "interrupted": {
+ "note": "Ta odpowiedź została przerwana przed ukończeniem.",
+ "retry": "Zapytaj ponownie"
}
},
"relativeJustNow": "przed chwilą",
@@ -4984,6 +4988,8 @@
"rateLimited": "Zbyt wiele przesłań. Poczekaj chwilę i spróbuj ponownie.",
"tooLarge": "Ten plik jest za duży (limit 1,5 GB).",
"failed": "Import nie powiódł się.",
+ "failureInterrupted": "Import został przerwany przed zakończeniem, zwykle przez restart serwera lub brak pamięci. Dane są bezpieczne; prześlij archiwum ponownie, aby spróbować jeszcze raz.",
+ "failureInsufficientMemory": "Limit pamięci serwera jest zbyt niski dla archiwum tej wielkości. Poproś osobę administrującą serwerem o zwiększenie limitu pamięci Node.js (NODE_OPTIONS=--max-old-space-size), a następnie prześlij archiwum ponownie.",
"rowsImported": "Zaimportowano dotąd {count} wierszy",
"doneSummary": "Gotowe — zaimportowano {imported} z {read} rekordów, pominięto {skipped} rekordów klinicznych.",
"doneNothing": "Z tego archiwum nic nie zaimportowano: dotarło 0 z {read} rekordów.",
@@ -5616,14 +5622,10 @@
"profileHint": "Wiek i płeć pochodzą z Twojego profilu i są dołączane automatycznie.",
"freeTextLabel": "O mnie (tekst dowolny)",
"conditionsPlaceholder": "Np. nadciśnienie, cukrzyca typu 2…",
- "allergiesLabel": "Alergie i nietolerancje",
"allergiesPlaceholder": "Np. nietolerancja laktozy, alergia na penicylinę…",
"focusPlaceholder": "Np. pilnuj mojego ciśnienia, motywuj mnie do ruchu…",
- "completeness": "Wypełnione: {answered} z {total}",
"questionsTitle": "Coach ma pytania",
- "questionsOpenCoach": "Odpowiedz w Coachu",
- "recordsLink": "Alergie, wywiad rodzinny i Twoje choroby przewlekłe są częścią Twojego wywiadu medycznego.",
- "recordsLinkCta": "Otwórz wywiad medyczny"
+ "questionsOpenCoach": "Odpowiedz w Coachu"
},
"responseTimeoutHeading": "Limit czasu odpowiedzi",
"responseTimeoutBody": "Jak długo czekać na odpowiedź AI, zanim nastąpi przerwanie. Zwiększ wartość dla lokalnego lub samodzielnie hostowanego modelu, który potrzebuje czasu na załadowanie przy pierwszym żądaniu.",
@@ -5651,7 +5653,9 @@
"withdraw": "Wycofaj zgodę",
"confirmBody": "Funkcje AI przestają działać natychmiast. Nic z już zapisanych danych nie zostaje usunięte — użyj sekcji Dane i prywatność, aby wyeksportować lub usunąć swoją historię.",
"confirmWithdraw": "Wycofaj",
- "regrantHint": "Aby ponownie korzystać z funkcji AI, włącz automatyczne czytanie dokumentów lub udziel zgody w aplikacji.",
+ "regrantHint": "Po ponownym udzieleniu zgody funkcje AI znów będą mogły wysyłać Twoje dane zdrowotne do skonfigurowanego dostawcy.",
+ "grant": "Udziel zgody",
+ "grantError": "Nie udało się zapisać zgody. Spróbuj ponownie.",
"error": "Nie udało się zapisać wycofania. Spróbuj ponownie."
}
},
@@ -9295,6 +9299,12 @@
"savedToast": "Alergia zapisana",
"updatedToast": "Alergia zaktualizowana",
"saveError": "Nie udało się zapisać alergii."
+ },
+ "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.",
+ "saveError": "Nie udało się zapisać. Spróbuj ponownie."
}
},
"family": {
@@ -9560,10 +9570,14 @@
"unavailableAction": "Otwórz ustawienia AI",
"errorGeneric": "Nie udało się odczytać dokumentu. Spróbuj ponownie.",
"errorOcr": "Nie udało się odczytać tekstu z tego obrazu.",
- "errorRateLimited": "Zbyt wiele żądań AI — spróbuj za kilka minut.",
+ "errorRateLimited": "Godzinowy limit odczytów AI został osiągnięty. Spróbuj ponownie później.",
+ "errorRateLimitedWaitOne": "Godzinowy limit odczytów AI został osiągnięty. Spróbuj ponownie za około minutę.",
+ "errorRateLimitedWaitFew": "Godzinowy limit odczytów AI został osiągnięty. Spróbuj ponownie za około {count} minuty.",
+ "errorRateLimitedWaitOther": "Godzinowy limit odczytów AI został osiągnięty. Spróbuj ponownie za około {count} minut.",
"errorBudget": "Twój dzisiejszy budżet AI został wyczerpany.",
"errorPdfNeedsVision": "Skanowanie plików PDF wymaga dostawcy wizji Claude.",
"errorFileType": "Tego typu pliku nie można odczytać.",
+ "errorNotIndexed": "Najpierw odczytaj dokument, potem wyodrębnij.",
"errorLocalOcrDisabled": "Najpierw włącz lokalne OCR w ustawieniach AI.",
"errorProvider": "Nie skonfigurowano dostawcy AI do tego celu.",
"errorTextImageOnly": "Lokalne OCR odczytuje tylko obrazy, nie ten plik."
@@ -9577,9 +9591,54 @@
},
"ai": {
"readDone": "AI odczytało Twój dokument.",
+ "readStagedOne": "AI odczytało Twój dokument. 1 wynik laboratoryjny czeka na Twoją weryfikację.",
+ "readStagedFew": "AI odczytało Twój dokument. {count} wyniki laboratoryjne czekają na Twoją weryfikację.",
+ "readStagedOther": "AI odczytało Twój dokument. {count} wyników laboratoryjnych czeka na Twoją weryfikację.",
"read": "Odczytaj z AI",
"reread": "Odczytaj ponownie",
- "reading": "Odczytywanie…"
+ "reading": "Odczytywanie…",
+ "notReadYet": "Ten dokument nie został jeszcze odczytany."
+ },
+ "review": {
+ "title": "Przejrzyj odczytane wartości",
+ "hint": "Zatwierdź to, co zgadza się z dokumentem. Niezaznaczone wartości zostaną odrzucone; nic nie zapisuje się bez Twojego potwierdzenia.",
+ "openOne": "Przejrzyj odczytaną wartość",
+ "openFew": "Przejrzyj odczytane wartości ({count})",
+ "openOther": "Przejrzyj odczytane wartości ({count})",
+ "includeAria": "Uwzględnij {value}",
+ "type": {
+ "OBSERVATION": "Wynik laboratoryjny",
+ "CONDITION": "Schorzenie",
+ "MEDICATION_STATEMENT": "Lek"
+ },
+ "statedRange": "podany zakres {range}",
+ "needsCheck": "Sprawdź tę wartość z dokumentem, a potem ją zapisz.",
+ "verify": "Sprawdź",
+ "verifySave": "Zapisz wartość",
+ "verifyInvalid": "Podaj nazwę oraz wartość liczbową albo wynik tekstowy, nie oba naraz.",
+ "verifyFailed": "Nie udało się zapisać poprawki. Spróbuj ponownie.",
+ "fieldName": "Nazwa",
+ "fieldDate": "Data",
+ "fieldValue": "Wartość",
+ "fieldValueText": "Wynik (tekst)",
+ "fieldUnit": "Jednostka",
+ "fieldDose": "Dawka",
+ "unverifiedNoteOne": "1 wartość wymaga jeszcze sprawdzenia i pozostaje w oczekiwaniu.",
+ "unverifiedNoteFew": "{count} wartości wymagają jeszcze sprawdzenia i pozostają w oczekiwaniu.",
+ "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.",
+ "nothingSavedToast": "Przegląd zapisany. Nie dodano żadnych wartości.",
+ "extract": "Odczytaj wyniki laboratoryjne",
+ "extracting": "Odczytywanie…",
+ "extractHint": "Korzysta z tekstu już odczytanego z tego dokumentu. Odczytane wartości czekają na Twoją weryfikację; nic nie zapisuje się automatycznie.",
+ "extractEmpty": "Z tego dokumentu nie udało się odczytać żadnych wartości.",
+ "failUnitRequired": "Wartość liczbowa potrzebuje jednostki, zanim będzie można ją zapisać.",
+ "failUnitMismatch": "Podana jednostka nie zgadza się z zapisaną jednostką tego wskaźnika. Popraw ją i zakończ przegląd ponownie.",
+ "failGeneric": "Nie udało się zapisać tej wartości."
},
"contentIndex": {
"indexPrompt": "Udostępnij treść dokumentów do wyszukiwania.",
diff --git a/package.json b/package.json
index e225ddb72..c1fb25fd3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.37.12",
+ "version": "1.37.13",
"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",
diff --git a/public/sw.js b/public/sw.js
index 85a97413e..993fcd97f 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -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.7";
+ /* @sw-version-fallback */ "v1.37.13";
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/*`
diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json
index 2a7ab0c02..3bf61318d 100644
--- a/scripts/env-manifest.json
+++ b/scripts/env-manifest.json
@@ -129,6 +129,14 @@
{
"name": "INSIGHTS_MAX_TOKENS",
"purpose": "Daily-briefing output-token ceiling. Default 2500, clamped to 500-8000."
+ },
+ {
+ "name": "INSIGHTS_RATE_LIMIT_PER_HOUR",
+ "purpose": "Per-user hourly ceiling for POST /api/insights/generate. Default 10; values below 1 fall back to the default."
+ },
+ {
+ "name": "DOCUMENT_AI_LIMIT_PER_HOUR",
+ "purpose": "Per-user hourly ceiling for the document-AI actions (read/index, extract, suggest, summarise share one bucket). Default 6, clamped to 1-1000."
}
]
},
diff --git a/src/__tests__/coach-stream-lifecycle-guard.test.ts b/src/__tests__/coach-stream-lifecycle-guard.test.ts
index c014cf97d..466a34f93 100644
--- a/src/__tests__/coach-stream-lifecycle-guard.test.ts
+++ b/src/__tests__/coach-stream-lifecycle-guard.test.ts
@@ -77,6 +77,35 @@ describe("Coach streaming lifecycle", () => {
).toBe(true);
});
+ it("the unmount cleanup records the interrupted conversation before the abort (#781)", () => {
+ const source = read("components/insights/coach-panel/use-coach.ts");
+
+ // A cleanup that writes the resume value under the storage key. Without
+ // it the unmount abort is silent again: the user returns to a blank new
+ // chat with no route back to the interrupted thread.
+ const recordsInterruption =
+ /return\s*\(\)\s*=>\s*\{[\s\S]*?interruptedResumeValue\(\s*active\s*\)[\s\S]*?sessionStorage\.setItem\(\s*COACH_INTERRUPTED_STORAGE_KEY/.test(
+ source,
+ );
+ expect(
+ recordsInterruption,
+ "use-coach.ts must record the interrupted conversation " +
+ "(COACH_INTERRUPTED_STORAGE_KEY) in an unmount cleanup",
+ ).toBe(true);
+
+ // And the recording effect must be defined BEFORE the abort effect so
+ // the flag is written while the send is still marked in flight.
+ const recordIndex = source.indexOf("COACH_INTERRUPTED_STORAGE_KEY,\n");
+ const abortIndex = source.indexOf("abortRef.current?.abort();\n };");
+ expect(recordIndex).toBeGreaterThan(-1);
+ expect(abortIndex).toBeGreaterThan(-1);
+ expect(
+ recordIndex < abortIndex,
+ "the interruption-recording cleanup must be defined before the " +
+ "mount-scoped abort cleanup",
+ ).toBe(true);
+ });
+
it("handleNewChat still resets too — the two paths must not diverge again", () => {
const source = read(
"components/insights/coach-panel/coach-conversation.tsx",
diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts
index a9c42a979..27a745581 100644
--- a/src/__tests__/destructive-control-guard.test.ts
+++ b/src/__tests__/destructive-control-guard.test.ts
@@ -189,7 +189,7 @@ const REGISTRY: DestructiveEntry[] = [
confirm: ["inline-two-step"],
},
{
- file: "components/settings/about-me-section.tsx",
+ file: "components/records/about-me-note-manager.tsx",
destroys: "the free-text self-context note the Coach and briefing read",
recovery: "permanent",
confirm: ["ConfirmButton"],
diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts
index a7a984d9e..8d5ccc2ef 100644
--- a/src/__tests__/success-affordance-guard.test.ts
+++ b/src/__tests__/success-affordance-guard.test.ts
@@ -276,7 +276,8 @@ const PINNED_AFFORDANCES: Record<
"src/components/records/family-history-form.tsx": { "toast.success": 1 },
"src/components/records/family-history-manager.tsx": { "toast.success": 1 },
"src/components/settings/_info-tile.tsx": { "text-success": 1 },
- "src/components/settings/about-me-section.tsx": { "toast.success": 1 },
+ "src/components/records/about-me-note-manager.tsx": { "toast.success": 1 },
+ "src/components/records/allergy-free-text-note.tsx": { "toast.success": 1 },
"src/components/settings/account-section/avatar-section.tsx": {
"text-success": 1,
},
diff --git a/src/app/api/auth/me/diabetes/__tests__/route.test.ts b/src/app/api/auth/me/diabetes/__tests__/route.test.ts
new file mode 100644
index 000000000..5919ff386
--- /dev/null
+++ b/src/app/api/auth/me/diabetes/__tests__/route.test.ts
@@ -0,0 +1,205 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * The declared diabetes flag is the one preference that switches every
+ * glucose surface from the general normal bands to the ADA glycemic goal
+ * bands and reaches the Coach snapshot. The web toggle in the thresholds
+ * section rides this route, so the round trip has to hold: a PATCH writes
+ * the column and echoes the resolved state, and a GET reads it back.
+ */
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ user: {
+ findUnique: vi.fn(),
+ update: vi.fn(),
+ },
+ },
+}));
+
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn().mockResolvedValue({
+ allowed: true,
+ remaining: 59,
+ resetAt: Date.now() + 60_000,
+ }),
+ rateLimitHeaders: () => ({}),
+}));
+
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { GET, PATCH } from "../route";
+import { prisma } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+import { auditLog } from "@/lib/auth/audit";
+import { checkRateLimit } from "@/lib/rate-limit";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "testuser", role: "USER" as const },
+};
+
+function mkGet(): Request {
+ return new Request("http://localhost/api/auth/me/diabetes");
+}
+
+function mkPatch(body: unknown): Request {
+ return new Request("http://localhost/api/auth/me/diabetes", {
+ method: "PATCH",
+ body: JSON.stringify(body),
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("GET /api/auth/me/diabetes", () => {
+ it("rejects an unauthenticated request with 401", async () => {
+ vi.mocked(getSession).mockResolvedValue(null);
+
+ const res = await (GET as (r: Request) => Promise)(mkGet());
+ expect(res.status).toBe(401);
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
+ });
+
+ it("returns false for a user who never declared the flag", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ hasDiabetes: false,
+ } as never);
+
+ const res = await (GET as (r: Request) => Promise)(mkGet());
+ expect(res.status).toBe(200);
+ const env = (await res.json()) as { data: { hasDiabetes: boolean } };
+ expect(env.data.hasDiabetes).toBe(false);
+ });
+
+ it("returns true after the flag was declared", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ hasDiabetes: true,
+ } as never);
+
+ const res = await (GET as (r: Request) => Promise)(mkGet());
+ expect(res.status).toBe(200);
+ const env = (await res.json()) as { data: { hasDiabetes: boolean } };
+ expect(env.data.hasDiabetes).toBe(true);
+ });
+});
+
+describe("PATCH /api/auth/me/diabetes", () => {
+ it("rejects an unauthenticated request with 401", async () => {
+ vi.mocked(getSession).mockResolvedValue(null);
+
+ const res = await (PATCH as (r: Request) => Promise)(
+ mkPatch({ hasDiabetes: true }),
+ );
+ expect(res.status).toBe(401);
+ expect(prisma.user.update).not.toHaveBeenCalled();
+ });
+
+ it("writes the flag on, echoes it back, and audits the transition", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ hasDiabetes: false,
+ } as never);
+ vi.mocked(prisma.user.update).mockResolvedValue({} as never);
+
+ const res = await (PATCH as (r: Request) => Promise)(
+ mkPatch({ hasDiabetes: true }),
+ );
+ expect(res.status).toBe(200);
+ const env = (await res.json()) as { data: { hasDiabetes: boolean } };
+ expect(env.data.hasDiabetes).toBe(true);
+
+ // The write must actually land, scoped to the caller, field-by-field.
+ expect(prisma.user.update).toHaveBeenCalledWith({
+ where: { id: "user-1" },
+ data: { hasDiabetes: true },
+ });
+
+ expect(auditLog).toHaveBeenCalledWith(
+ "user.diabetes.update",
+ expect.objectContaining({
+ userId: "user-1",
+ details: expect.objectContaining({ previous: false, next: true }),
+ }),
+ );
+ });
+
+ it("writes the flag off and echoes the resolved state", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ hasDiabetes: true,
+ } as never);
+ vi.mocked(prisma.user.update).mockResolvedValue({} as never);
+
+ const res = await (PATCH as (r: Request) => Promise)(
+ mkPatch({ hasDiabetes: false }),
+ );
+ expect(res.status).toBe(200);
+ const env = (await res.json()) as { data: { hasDiabetes: boolean } };
+ expect(env.data.hasDiabetes).toBe(false);
+
+ expect(prisma.user.update).toHaveBeenCalledWith({
+ where: { id: "user-1" },
+ data: { hasDiabetes: false },
+ });
+ });
+
+ it("rejects a missing hasDiabetes field with 422", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+
+ const res = await (PATCH as (r: Request) => Promise)(
+ mkPatch({ otherField: true }),
+ );
+ expect(res.status).toBe(422);
+ expect(prisma.user.update).not.toHaveBeenCalled();
+ });
+
+ it("rejects a non-boolean hasDiabetes field with 422", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+
+ const res = await (PATCH as (r: Request) => Promise)(
+ mkPatch({ hasDiabetes: "true" }),
+ );
+ expect(res.status).toBe(422);
+ expect(prisma.user.update).not.toHaveBeenCalled();
+ });
+
+ it("returns 429 when the per-user rate-limit fires", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(checkRateLimit).mockResolvedValueOnce({
+ allowed: false,
+ remaining: 0,
+ resetAt: Date.now() + 30_000,
+ });
+
+ const res = await (PATCH as (r: Request) => Promise)(
+ mkPatch({ hasDiabetes: true }),
+ );
+ expect(res.status).toBe(429);
+ expect(prisma.user.update).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/consent/ai/web/__tests__/route.test.ts b/src/app/api/consent/ai/web/__tests__/route.test.ts
index 4915ca8d4..d646dcb1f 100644
--- a/src/app/api/consent/ai/web/__tests__/route.test.ts
+++ b/src/app/api/consent/ai/web/__tests__/route.test.ts
@@ -22,7 +22,7 @@ vi.mock("next/headers", () => ({
}));
// The mint logic + its TOCTOU close are covered by web-grant.test.ts; the
-// route test stubs it to focus on auth + rate-limit + serialisation.
+// route test stubs it to focus on auth + rate-limit + intent + serialisation.
vi.mock("@/lib/consent/web-grant", () => ({
ensureWebAiConsentReceipt: vi.fn(),
}));
@@ -45,6 +45,22 @@ const SESSION_OK = {
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
+/** The mount heal posts no body and no content-type. */
+function mkHeal(): Request {
+ return new Request("http://localhost/api/consent/ai/web", {
+ method: "POST",
+ });
+}
+
+/** The explicit grant control posts a JSON intent. */
+function mkIntent(body: unknown): Request {
+ return new Request("http://localhost/api/consent/ai/web", {
+ method: "POST",
+ body: JSON.stringify(body),
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(auditLog).mockResolvedValue(undefined);
@@ -54,7 +70,7 @@ beforeEach(() => {
describe("POST /api/consent/ai/web", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
- const res = await POST();
+ const res = await POST(mkHeal());
expect(res.status).toBe(401);
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
});
@@ -66,9 +82,9 @@ describe("POST /api/consent/ai/web", () => {
remaining: 0,
resetAt: Date.now() + 60_000,
});
- const res = await POST();
+ const res = await POST(mkHeal());
expect(res.status).toBe(429);
- // The throttle fires before the heal mint runs.
+ // The throttle fires before the mint runs.
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
});
@@ -79,7 +95,7 @@ describe("POST /api/consent/ai/web", () => {
receipt: { id: "rcpt-web-1" } as never,
});
- const res = await POST();
+ const res = await POST(mkHeal());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { minted: boolean; kind: string };
@@ -95,11 +111,57 @@ describe("POST /api/consent/ai/web", () => {
reason: "already_active",
});
- const res = await POST();
+ const res = await POST(mkHeal());
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { minted: boolean } };
expect(body.data.minted).toBe(false);
// No audit row on the no-op path.
expect(auditLog).not.toHaveBeenCalled();
});
+
+ it("passes a bodyless call to the mint as a heal", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
+ minted: false,
+ reason: "previously_revoked",
+ });
+
+ const res = await POST(mkHeal());
+ expect(res.status).toBe(200);
+ expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith("user-1", "heal");
+ });
+
+ it("passes an explicit affirmative body to the mint as the consent act", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
+ minted: true,
+ receipt: { id: "rcpt-web-2" } as never,
+ });
+
+ const res = await POST(mkIntent({ intent: "affirmative" }));
+ expect(res.status).toBe(200);
+ expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith(
+ "user-1",
+ "affirmative",
+ );
+ // The audit row records that this was the user's own grant, not a heal.
+ expect(auditLog).toHaveBeenCalledWith(
+ "consent.ai.grant",
+ expect.objectContaining({
+ userId: "user-1",
+ details: expect.objectContaining({
+ source: "web",
+ intent: "affirmative",
+ }),
+ }),
+ );
+ });
+
+ it("rejects an unknown intent with 422 and never reaches the mint", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+
+ const res = await POST(mkIntent({ intent: "resurrect" }));
+ expect(res.status).toBe(422);
+ expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
+ });
});
diff --git a/src/app/api/consent/ai/web/route.ts b/src/app/api/consent/ai/web/route.ts
index 4a86bacf9..29014097e 100644
--- a/src/app/api/consent/ai/web/route.ts
+++ b/src/app/api/consent/ai/web/route.ts
@@ -1,26 +1,41 @@
/**
- * v1.16.13 — web AI-consent grant (heal endpoint).
+ * v1.16.13 — web AI-consent grant.
*
* POST /api/consent/ai/web
- * Mints an `ai_full` consent receipt for the calling web user if none
- * is active, mirroring the iOS master-grant. Idempotent — a user with
- * an active `ai_full` receipt is a no-op. The web AI-settings surface
- * calls this on mount (the web equivalent of the iOS shell-mount heal)
- * so existing web accounts gain a receipt without a re-consent step and
- * stop hitting the server-managed consent gate's no-key fallback.
+ * Mints an `ai_full` consent receipt for the calling web user.
+ * Idempotent — a user with an active `ai_full` receipt is a no-op.
+ * The body decides what a missing receipt means:
+ *
+ * no body (heal) The AI-settings surface calls this on mount
+ * (the web equivalent of the iOS shell-mount
+ * heal) so an account that predates the consent
+ * gate gains a receipt without a re-consent
+ * step. A past revocation stands; the heal
+ * never resurrects a withdrawn consent.
+ * `{ intent: "affirmative" }` The explicit grant control on the same
+ * surface. This IS the user's consent act, so
+ * it may supersede an earlier revocation
+ * exactly as the first grant would.
*
* Revocation stays on `DELETE /api/consent/ai/latest`; this route only
* grants. The full CRUD (explicit grant with a signed artefact, reader,
* revoke) lives in `../route.ts` + `../latest/route.ts`.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
-import { apiError, apiSuccess } from "@/lib/api-response";
+import {
+ apiError,
+ apiSuccess,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
+import type { WebConsentGrantIntent } from "@/lib/consent/web-grant";
+import { webConsentGrantBody } from "@/lib/validations/consent";
-export const POST = apiHandler(async () => {
+export const POST = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
// The web AI-settings surface calls this on every mount; the bucket is
@@ -37,14 +52,39 @@ export const POST = apiHandler(async () => {
});
}
- const result = await ensureWebAiConsentReceipt(user.id, "heal");
+ // The mount heal posts no body; only the explicit grant control sends one.
+ // Absence of a JSON body therefore reads as a heal, and a present body is
+ // Zod-validated so a malformed intent is a 422, never a silent heal.
+ let intent: WebConsentGrantIntent = "heal";
+ const contentType = req.headers.get("content-type") ?? "";
+ if (contentType.includes("application/json")) {
+ const { data: body, error: jsonError } = await safeJson(req, {
+ maxBytes: 1024,
+ });
+ if (jsonError) return jsonError;
+ const parsed = webConsentGrantBody.safeParse(body);
+ if (!parsed.success) {
+ annotate({ action: { name: "consent.ai.web-grant.invalid_shape" } });
+ return returnAllZodIssues(parsed.error, 422);
+ }
+ intent = parsed.data.intent;
+ }
+
+ const result = await ensureWebAiConsentReceipt(user.id, intent);
if (result.minted) {
// Audit trail parity with the iOS-driven POST /api/consent/ai grant so
- // the legal team can reconstruct web-originated consent. Fire-and-forget.
+ // the legal team can reconstruct web-originated consent; `intent`
+ // separates a migration heal from the user's own re-grant click.
+ // Fire-and-forget.
auditLog("consent.ai.grant", {
userId: user.id,
- details: { kind: "ai_full", source: "web", receiptId: result.receipt.id },
+ details: {
+ kind: "ai_full",
+ source: "web",
+ intent,
+ receiptId: result.receipt.id,
+ },
}).catch(() => {});
}
@@ -52,6 +92,7 @@ export const POST = apiHandler(async () => {
action: { name: "consent.ai.web-grant" },
meta: {
minted: result.minted,
+ intent,
...(result.minted ? { receiptId: result.receipt.id } : {}),
},
});
diff --git a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts
index b3a264742..c5b3ba303 100644
--- a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts
+++ b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts
@@ -8,16 +8,31 @@ import { NextRequest } from "next/server";
* 422s on the ENHANCEMENT only — the already-stored document is left intact
* (no fact-staging transaction, no status flip). This is the inverse of the
* old behaviour where the absence of a provider blocked the upload itself.
+ *
+ * STORED mode — `{ mode: "stored" }` structures the document's OWN stored
+ * extracted text (its content index) into staged facts: the manual recovery
+ * for a skipped/failed automatic staging run. Pins that the stored text is
+ * the input (the encrypted original is never touched — no re-upload), that
+ * no stored text 422s (`documents.inbound.notIndexed`) before any bucket
+ * charge, that the shared document-AI bucket applies, and that the
+ * approved-facts re-extraction refusal covers this mode too.
*/
-vi.mock("@/lib/db", () => ({
- prisma: {
- inboundDocument: { findFirst: vi.fn(), update: vi.fn() },
- extractedFact: { deleteMany: vi.fn(), count: vi.fn() },
- user: { findUnique: vi.fn() },
- $transaction: vi.fn(),
- },
-}));
+vi.mock("@/lib/db", () => {
+ const tx = {
+ extractedFact: { deleteMany: vi.fn() },
+ inboundDocument: { update: vi.fn(), findUniqueOrThrow: vi.fn() },
+ };
+ return {
+ prisma: {
+ inboundDocument: { findFirst: vi.fn(), update: vi.fn() },
+ extractedFact: { deleteMany: vi.fn(), count: vi.fn() },
+ user: { findUnique: vi.fn() },
+ $transaction: vi.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
+ __tx: tx,
+ },
+ };
+});
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((s: string) => `v1.${s}`),
@@ -28,6 +43,19 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: vi.fn(() => "{}"),
}));
+vi.mock("@/lib/documents/store", () => ({
+ decryptDocumentContent: vi.fn(() => Buffer.from([1, 2, 3])),
+ encryptFactData: vi.fn(() => new Uint8Array([1])),
+ encryptFactProvenance: vi.fn(() => new Uint8Array([2])),
+ serialiseDocumentDetail: vi.fn(() => ({ id: "doc-1", facts: [] })),
+}));
+vi.mock("@/lib/documents/content-index", () => ({
+ loadDocumentChatText: vi.fn(),
+}));
+vi.mock("@/lib/labs/ocr-upload", () => ({
+ detectOcrMimeType: vi.fn(() => "image/png"),
+}));
+
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
resolveDocumentTextProvider: vi.fn(),
@@ -54,6 +82,7 @@ vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 5, resetAt: Date.now() }),
+ refundRateLimit: vi.fn().mockResolvedValue(undefined),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -76,8 +105,28 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
+import { auditLog } from "@/lib/auth/audit";
import { requireModuleEnabled } from "@/lib/modules/gate";
-import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { loadDocumentChatText } from "@/lib/documents/content-index";
+import { runInboundExtraction } from "@/lib/documents/extract";
+import { decryptDocumentContent } from "@/lib/documents/store";
+import {
+ resolveDocumentTextProvider,
+ resolveDocumentVisionProvider,
+} from "@/lib/documents/provider-order";
+
+const tx = (
+ prisma as unknown as {
+ __tx: {
+ extractedFact: { deleteMany: ReturnType };
+ inboundDocument: {
+ update: ReturnType;
+ findUniqueOrThrow: ReturnType;
+ };
+ };
+ }
+).__tx;
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -94,6 +143,17 @@ function visionReq(id: string): NextRequest {
);
}
+function storedReq(id: string): NextRequest {
+ return new NextRequest(
+ new URL(`http://localhost/api/documents/inbound/${id}/extract`),
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ mode: "stored" }),
+ },
+ );
+}
+
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
@@ -106,6 +166,11 @@ beforeEach(() => {
status: "STORED",
} as never);
vi.mocked(prisma.extractedFact.count).mockResolvedValue(0 as never);
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 5,
+ resetAt: Date.now() + 60 * 60 * 1000,
+ });
});
describe("POST /api/documents/inbound/[id]/extract", () => {
@@ -161,3 +226,96 @@ describe("POST /api/documents/inbound/[id]/extract", () => {
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
+
+describe("POST /api/documents/inbound/[id]/extract — stored mode", () => {
+ beforeEach(() => {
+ vi.mocked(loadDocumentChatText).mockResolvedValue({
+ text: "Hemoglobin 13.9 g/dL reference range 12-16",
+ source: "verbatim",
+ } as never);
+ vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
+ pick: {
+ providerType: "anthropic",
+ entry: { providerType: "anthropic", instance: {} },
+ },
+ } as never);
+ vi.mocked(runInboundExtraction).mockResolvedValue({
+ providerType: "anthropic",
+ reportDate: "2026-08-01",
+ facts: [
+ {
+ factType: "OBSERVATION",
+ confidence: 0.95,
+ needsReview: false,
+ data: { label: "Hemoglobin", value: 13.9, unit: "g/dL" },
+ provenance: {
+ sourceText: "",
+ anchored: false,
+ sourceOffset: null,
+ page: null,
+ confidence: 0.95,
+ },
+ },
+ ],
+ } as never);
+ tx.inboundDocument.findUniqueOrThrow.mockResolvedValue({
+ id: "doc-1",
+ facts: [{ id: "fact-1" }],
+ } as never);
+ });
+
+ it("structures the stored text and stages PENDING facts, never touching the original", async () => {
+ const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
+ expect(res.status).toBe(200);
+ // The stored extracted text is the input…
+ expect(runInboundExtraction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ocrText: "Hemoglobin 13.9 g/dL reference range 12-16",
+ }),
+ );
+ // …and the encrypted original is never read — no re-upload, no re-scan.
+ expect(decryptDocumentContent).not.toHaveBeenCalled();
+ // Staged through the guarded transaction (prior PENDING leftovers cleared,
+ // status flipped) — nothing here writes a structured store.
+ expect(tx.extractedFact.deleteMany).toHaveBeenCalled();
+ expect(tx.inboundDocument.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ status: "EXTRACTED" }),
+ }),
+ );
+ expect(auditLog).toHaveBeenCalledWith(
+ "documents.inbound.extract",
+ expect.objectContaining({
+ details: expect.objectContaining({ mode: "stored" }),
+ }),
+ );
+ });
+
+ it("422s (notIndexed) without stored text, before any bucket charge", async () => {
+ vi.mocked(loadDocumentChatText).mockResolvedValue(null as never);
+ const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as { meta?: { errorCode?: string } };
+ expect(body.meta?.errorCode).toBe("documents.inbound.notIndexed");
+ expect(checkRateLimit).not.toHaveBeenCalled();
+ expect(runInboundExtraction).not.toHaveBeenCalled();
+ });
+
+ it("consumes the shared document-AI bucket honestly", async () => {
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: false,
+ remaining: 0,
+ resetAt: Date.now() + 30 * 60_000,
+ });
+ const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
+ expect(res.status).toBe(429);
+ expect(runInboundExtraction).not.toHaveBeenCalled();
+ });
+
+ it("refuses once any fact is APPROVED, like every other extract mode", async () => {
+ vi.mocked(prisma.extractedFact.count).mockResolvedValue(1 as never);
+ const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
+ expect(res.status).toBe(409);
+ expect(runInboundExtraction).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts
index c5c124c4d..93de5f7e9 100644
--- a/src/app/api/documents/inbound/[id]/extract/route.ts
+++ b/src/app/api/documents/inbound/[id]/extract/route.ts
@@ -9,11 +9,14 @@
* facts — but now against a row that already exists. Absent a provider this 422s
* the ENHANCEMENT only; the stored document is untouched and remains filed.
*
- * Two modes, dispatched on content-type (mirroring the original upload):
+ * Three modes:
* - VISION (no JSON body): decrypt the stored original, re-derive its MIME,
* and run the vision-capable provider over it.
* - TEXT (application/json, opt-in local OCR): `{ mode: "text", text }` — the
* browser OCR'd the document locally and posts only the text to structure.
+ * - STORED (application/json): `{ mode: "stored" }` — structure the
+ * document's own stored extracted text (its content index), the manual
+ * recovery for a skipped/failed automatic staging run. No re-upload.
*
* The document is UNTRUSTED (prompt-injection): the server never acts on an
* instruction inside it. The staged facts land PENDING for the mandatory
@@ -55,21 +58,25 @@ import {
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
} from "@/lib/documents/provider-order";
+import {
+ checkDocumentAiRateLimit,
+ documentAiRateLimited,
+ DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
+ refundDocumentAiSlot,
+} from "@/lib/documents/ai-route-support";
+import { loadDocumentChatText } from "@/lib/documents/content-index";
import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
-import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
-import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
+import {
+ inboundStoredExtractSchema,
+ inboundTextExtractSchema,
+} from "@/lib/validations/inbound-documents";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
-/** Inbound extractions are expensive (vision) / metered (text). 6/hour. */
-const EXTRACT_LIMIT_PER_HOUR = 6;
-const EXTRACT_WINDOW_MS = 60 * 60 * 1000;
-const TEXT_BODY_MAX_BYTES = 512 * 1024;
-
/** A loaded, owner-scoped, still-extractable document row. */
type LoadedDocument = {
id: string;
@@ -177,7 +184,16 @@ export const POST = apiHandler(
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
- return handleTextExtract(request, user.id, document);
+ // One body read for both JSON modes, BEFORE any bucket charge — a
+ // malformed body never needs a refund because nothing was charged yet.
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
+ });
+ if (jsonError) return jsonError;
+ if (inboundStoredExtractSchema.safeParse(body).success) {
+ return handleStoredExtract(request, user.id, document);
+ }
+ return handleTextExtract(request, user.id, document, body);
}
return handleVisionExtract(request, user.id, document);
},
@@ -188,6 +204,7 @@ async function handleTextExtract(
request: NextRequest,
userId: string,
document: LoadedDocument,
+ body: unknown,
): Promise {
const row = await prisma.user.findUnique({
where: { id: userId },
@@ -213,28 +230,12 @@ async function handleTextExtract(
surface: "insights",
});
- const rl = await checkRateLimit(
- `documents-inbound:${userId}`,
- EXTRACT_LIMIT_PER_HOUR,
- EXTRACT_WINDOW_MS,
- );
- if (!rl.allowed) {
- const response = apiError("Too many extractions. Try again later.", 429, {
- errorCode: "documents.inbound.rateLimited",
- });
- for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
- response.headers.set(k, v);
- }
- return response;
- }
-
- const { data: body, error: jsonError } = await safeJson(request, {
- maxBytes: TEXT_BODY_MAX_BYTES,
- });
- if (jsonError) return jsonError;
+ const rl = await checkDocumentAiRateLimit(userId);
+ if (!rl.allowed) return documentAiRateLimited(rl);
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
+ await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
@@ -248,6 +249,7 @@ async function handleTextExtract(
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
+ await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
@@ -296,6 +298,103 @@ async function handleTextExtract(
}
}
+/**
+ * STORED mode — structure the document's OWN stored extracted text (the
+ * content index the read/index step produced) into staged facts.
+ *
+ * This is the manual recovery for a skipped or failed automatic staging run:
+ * the same text-structuring pass the auto worker performs, but user-triggered
+ * from the document detail. No re-upload, no second read of the original, no
+ * browser OCR — so the local-OCR opt-in is not required here. Everything else
+ * runs the full gauntlet: provider resolve → consent → rate limit (charged
+ * only when the provider is actually dispatched) → budget → stage PENDING for
+ * the mandatory review-then-confirm step. Nothing is committed here.
+ */
+async function handleStoredExtract(
+ request: NextRequest,
+ userId: string,
+ document: LoadedDocument,
+): Promise {
+ const chat = await loadDocumentChatText(userId, document.id);
+ if (!chat || !chat.text.trim()) {
+ return apiError("Read the document first, then extract.", 422, {
+ errorCode: "documents.inbound.notIndexed",
+ });
+ }
+
+ const { pick } = await resolveDocumentTextProvider(userId);
+ if (!pick) {
+ return apiError("No AI provider is configured", 422, {
+ errorCode: "documents.inbound.providerUnsupported",
+ });
+ }
+
+ await assertDocumentEgressConsent({
+ userId,
+ providerType: pick.providerType,
+ surface: "insights",
+ });
+
+ const rl = await checkDocumentAiRateLimit(userId);
+ if (!rl.allowed) return documentAiRateLimited(rl);
+
+ const dateKey = buildDateKey();
+ const reservation = await reserveBudget(
+ userId,
+ AI_BUDGETS.ocrExtractText.maxTokens,
+ dateKey,
+ resolveDailyCap([{ providerType: pick.entry.providerType }]),
+ );
+ if (!reservation.allowed) {
+ await refundDocumentAiSlot(userId);
+ return apiError("Your AI usage budget for today is reached.", 429, {
+ errorCode: "documents.inbound.budgetExceeded",
+ });
+ }
+
+ try {
+ const result = await runInboundExtraction({
+ provider: pick.entry.instance,
+ providerType: pick.providerType,
+ ocrText: chat.text,
+ });
+ await reconcileSpend(
+ userId,
+ reservation.reserved,
+ reservation.reserved,
+ dateKey,
+ );
+
+ const updated = await stageExtraction(document.id, userId, result);
+
+ await auditLog("documents.inbound.extract", {
+ userId,
+ ipAddress: getClientIp(request),
+ details: {
+ documentId: document.id,
+ facts: updated.facts.length,
+ mode: "stored",
+ },
+ });
+
+ return apiSuccess(serialiseDocumentDetail(updated, updated.facts));
+ } catch (err) {
+ await reconcileSpend(userId, reservation.reserved, 0, dateKey);
+ if (err instanceof InboundExtractError) {
+ return apiError("Couldn't read the document. Try a clearer copy.", 422, {
+ errorCode: "documents.inbound.extractFailed",
+ });
+ }
+ annotate({
+ action: { name: "documents.inbound.extractFailed" },
+ meta: { reason: "provider_error", mode: "stored" },
+ });
+ return apiError("Couldn't read the document. Try a clearer copy.", 502, {
+ errorCode: "documents.inbound.extractFailed",
+ });
+ }
+}
+
/** VISION mode — run the stored original through the vision provider. */
async function handleVisionExtract(
request: NextRequest,
@@ -315,23 +414,12 @@ async function handleVisionExtract(
surface: "insights",
});
- const rl = await checkRateLimit(
- `documents-inbound:${userId}`,
- EXTRACT_LIMIT_PER_HOUR,
- EXTRACT_WINDOW_MS,
- );
- if (!rl.allowed) {
- const response = apiError("Too many extractions. Try again later.", 429, {
- errorCode: "documents.inbound.rateLimited",
- });
- for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
- response.headers.set(k, v);
- }
- return response;
- }
+ const rl = await checkDocumentAiRateLimit(userId);
+ if (!rl.allowed) return documentAiRateLimited(rl);
// Decrypt the stored original and re-derive its MIME from the bytes (never
- // trust a stored label for the provider call).
+ // trust a stored label for the provider call). Every miss below happens
+ // before the provider dispatch, so the charged slot goes back.
let buffer: Buffer;
try {
buffer = decryptDocumentContent(
@@ -339,6 +427,7 @@ async function handleVisionExtract(
document.contentCodec,
);
} catch {
+ await refundDocumentAiSlot(userId);
return apiError("Couldn't read the stored document.", 422, {
errorCode: "documents.inbound.extractFailed",
});
@@ -346,6 +435,7 @@ async function handleVisionExtract(
const mime = detectOcrMimeType(buffer);
if (!mime) {
+ await refundDocumentAiSlot(userId);
return apiError(
"This document can't be scanned. Use local OCR (text mode).",
422,
@@ -354,6 +444,7 @@ async function handleVisionExtract(
}
if (mime === "application/pdf" && !pick.pdfSupported) {
+ await refundDocumentAiSlot(userId);
return apiError(
"PDF scanning needs a Claude vision provider; use local OCR instead.",
422,
@@ -369,6 +460,7 @@ async function handleVisionExtract(
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
+ await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
diff --git a/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts
index c132d5d6b..5b6540011 100644
--- a/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts
+++ b/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts
@@ -5,6 +5,12 @@ import { NextRequest } from "next/server";
* Content-index route (Document vault P2). Pins: vision path transcribes then
* upserts the index; text path indexes posted OCR text with no provider;
* provider-gated on vision; only the index sibling is written (never facts).
+ *
+ * Bucket honesty: the route charges the shared document-AI bucket up front
+ * (so a 429 stays cheap) but a slot must reflect work actually performed —
+ * a 429 names the reset instant in `meta.retryAt`, a preparation failure
+ * before any provider dispatch refunds the charged slot, and a successful
+ * dispatch keeps it.
*/
vi.mock("@/lib/db", () => ({
@@ -32,6 +38,11 @@ vi.mock("@/lib/documents/content-index", () => ({
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
}));
+vi.mock("@/lib/documents/auto-stage-labs", () => ({
+ maybeAutoStageLabFacts: vi
+ .fn()
+ .mockResolvedValue({ staged: false, reason: "not-lab" }),
+}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
@@ -47,7 +58,11 @@ vi.mock("@/lib/modules/gate", () => ({
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
- rateLimitHeaders: () => ({}),
+ refundRateLimit: vi.fn().mockResolvedValue(undefined),
+ rateLimitHeaders: (result: { remaining?: number; resetAt?: number }) => ({
+ "X-RateLimit-Remaining": String(result.remaining ?? 0),
+ "X-RateLimit-Reset": new Date(result.resetAt ?? 0).toISOString(),
+ }),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -69,7 +84,11 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
+import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs";
+import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
+import { reserveBudget } from "@/lib/ai/coach/budget";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
+import { decryptDocumentContent } from "@/lib/documents/store";
import { transcribeDocument } from "@/lib/documents/describe";
import { upsertContentIndex } from "@/lib/documents/content-index";
@@ -95,9 +114,30 @@ const textReq = (id: string, text: string) =>
},
);
+const PICK = {
+ chain: [{ providerType: "anthropic", instance: {} }],
+ pick: {
+ entry: { providerType: "anthropic", instance: {} },
+ providerType: "anthropic",
+ pdfSupported: true,
+ },
+};
+
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 5,
+ resetAt: Date.now() + 60 * 60 * 1000,
+ });
+ vi.mocked(reserveBudget).mockResolvedValue({
+ allowed: true,
+ reserved: 1,
+ } as never);
+ vi.mocked(decryptDocumentContent).mockReturnValue(
+ Buffer.from([1, 2, 3]) as never,
+ );
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-1",
kind: "OTHER",
@@ -110,14 +150,7 @@ beforeEach(() => {
describe("POST /api/documents/inbound/[id]/index", () => {
it("vision: transcribes then upserts the index, never facts", async () => {
- vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
- chain: [{ providerType: "anthropic", instance: {} }],
- pick: {
- entry: { providerType: "anthropic", instance: {} },
- providerType: "anthropic",
- pdfSupported: true,
- },
- } as never);
+ vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
vi.mocked(transcribeDocument).mockResolvedValue({
text: "hemoglobin 14.2 leukocytes normal",
} as never);
@@ -176,3 +209,111 @@ describe("POST /api/documents/inbound/[id]/index", () => {
expect(upsertContentIndex).not.toHaveBeenCalled();
});
});
+
+describe("POST /api/documents/inbound/[id]/index — bucket honesty", () => {
+ beforeEach(() => {
+ vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
+ vi.mocked(transcribeDocument).mockResolvedValue({
+ text: "hemoglobin 14.2 leukocytes normal",
+ } as never);
+ });
+
+ it("returns 429 with the reset instant in meta.retryAt when the bucket is exhausted", async () => {
+ const resetAt = Date.now() + 47 * 60_000;
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: false,
+ remaining: 0,
+ resetAt,
+ });
+ const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
+ expect(res.status).toBe(429);
+ const body = (await res.json()) as {
+ meta?: { errorCode?: string; retryAt?: string };
+ };
+ expect(body.meta?.errorCode).toBe("documents.inbound.rateLimited");
+ expect(body.meta?.retryAt).toBe(new Date(resetAt).toISOString());
+ expect(refundRateLimit).not.toHaveBeenCalled();
+ });
+
+ it("refunds the charged slot when input preparation fails before dispatch", async () => {
+ vi.mocked(decryptDocumentContent).mockImplementation(() => {
+ throw new Error("bad codec");
+ });
+ const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
+ expect(res.status).toBe(422);
+ expect(refundRateLimit).toHaveBeenCalledTimes(1);
+ expect(refundRateLimit).toHaveBeenCalledWith("documents-inbound:user-1");
+ expect(transcribeDocument).not.toHaveBeenCalled();
+ });
+
+ it("refunds the charged slot when the daily budget refuses before dispatch", async () => {
+ vi.mocked(reserveBudget).mockResolvedValue({
+ allowed: false,
+ reserved: 0,
+ } as never);
+ const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
+ expect(res.status).toBe(429);
+ const body = (await res.json()) as { meta?: { errorCode?: string } };
+ expect(body.meta?.errorCode).toBe("documents.inbound.budgetExceeded");
+ expect(refundRateLimit).toHaveBeenCalledTimes(1);
+ expect(transcribeDocument).not.toHaveBeenCalled();
+ });
+
+ it("keeps the slot once the provider was actually dispatched", async () => {
+ const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
+ expect(res.status).toBe(200);
+ expect(transcribeDocument).toHaveBeenCalledTimes(1);
+ expect(refundRateLimit).not.toHaveBeenCalled();
+ });
+});
+
+describe("POST /api/documents/inbound/[id]/index — staging continuation", () => {
+ beforeEach(() => {
+ vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
+ vi.mocked(transcribeDocument).mockResolvedValue({
+ text: "hemoglobin 14.2 reference range 12-16",
+ } as never);
+ });
+
+ it("a manual read continues into the same lab staging the auto worker performs", async () => {
+ vi.mocked(maybeAutoStageLabFacts).mockResolvedValue({
+ staged: true,
+ facts: 4,
+ } as never);
+ const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
+ expect(res.status).toBe(200);
+ expect(maybeAutoStageLabFacts).toHaveBeenCalledWith("user-1", "doc-1");
+ const body = await res.json();
+ expect(body.data.labFactsStaged).toBe(4);
+ });
+
+ it("reports zero staged facts as an honest zero, never an error", async () => {
+ vi.mocked(maybeAutoStageLabFacts).mockResolvedValue({
+ staged: false,
+ reason: "not-lab",
+ } as never);
+ const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data.labFactsStaged).toBe(0);
+ });
+
+ it("a staging failure never fails the index that just succeeded", async () => {
+ vi.mocked(maybeAutoStageLabFacts).mockRejectedValue(new Error("boom"));
+ const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.data).toMatchObject({ indexed: true, labFactsStaged: 0 });
+ });
+
+ it("does not reach staging when the index itself was refused", async () => {
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: false,
+ remaining: 0,
+ resetAt: Date.now() + 60_000,
+ });
+ const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
+ expect(res.status).toBe(429);
+ expect(maybeAutoStageLabFacts).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts
index 665a93d05..072271263 100644
--- a/src/app/api/documents/inbound/[id]/index/route.ts
+++ b/src/app/api/documents/inbound/[id]/index/route.ts
@@ -39,14 +39,15 @@ import {
} from "@/lib/ai/coach/budget";
import { auditLog } from "@/lib/auth/audit";
import {
- DOCUMENT_AI_BUCKET,
- DOCUMENT_AI_LIMIT_PER_HOUR,
+ checkDocumentAiRateLimit,
+ documentAiRateLimited,
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
- DOCUMENT_AI_WINDOW_MS,
loadOwnedDocument,
prepareVisionInput,
+ refundDocumentAiSlot,
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
+import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs";
import { upsertContentIndex } from "@/lib/documents/content-index";
import {
DocumentDescribeError,
@@ -56,23 +57,12 @@ import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
-import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
-function rateLimited(rl: Awaited>): Response {
- const response = apiError("Too many requests. Try again later.", 429, {
- errorCode: "documents.inbound.rateLimited",
- });
- for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
- response.headers.set(k, v);
- }
- return response;
-}
-
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -112,7 +102,17 @@ async function finishIndex(
action: { name: "documents.contentIndex.upsert" },
meta: { documentId, source, tokens: tokenCount },
});
- return apiSuccess({ documentId, indexed: true, tokenCount });
+ // A manual read continues into the SAME lab staging the automatic index
+ // worker performs, so a skipped or failed auto run is recoverable per
+ // document without a re-upload. Every guard lives inside the helper (both
+ // modules on, still STORED with no facts, provider + consent, looks like a
+ // lab report) — a non-lab document is a tagged no-op, and a staging failure
+ // never fails the index that just succeeded.
+ const staging = await maybeAutoStageLabFacts(userId, documentId).catch(
+ () => null,
+ );
+ const labFactsStaged = staging?.staged === true ? staging.facts : 0;
+ return apiSuccess({ documentId, indexed: true, tokenCount, labFactsStaged });
}
/** TEXT mode — index browser-OCR'd text (no provider egress). */
@@ -131,19 +131,19 @@ async function handleTextIndex(
});
}
- const rl = await checkRateLimit(
- `${DOCUMENT_AI_BUCKET}:${userId}`,
- DOCUMENT_AI_LIMIT_PER_HOUR,
- DOCUMENT_AI_WINDOW_MS,
- );
- if (!rl.allowed) return rateLimited(rl);
+ const rl = await checkDocumentAiRateLimit(userId);
+ if (!rl.allowed) return documentAiRateLimited(rl);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
});
- if (jsonError) return jsonError;
+ if (jsonError) {
+ await refundDocumentAiSlot(userId);
+ return jsonError;
+ }
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
+ await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
@@ -177,15 +177,13 @@ async function handleVisionIndex(
surface: "insights",
});
- const rl = await checkRateLimit(
- `${DOCUMENT_AI_BUCKET}:${userId}`,
- DOCUMENT_AI_LIMIT_PER_HOUR,
- DOCUMENT_AI_WINDOW_MS,
- );
- if (!rl.allowed) return rateLimited(rl);
+ const rl = await checkDocumentAiRateLimit(userId);
+ if (!rl.allowed) return documentAiRateLimited(rl);
const vision = await prepareVisionInput(document, pick.pdfSupported);
if (!vision.ok) {
+ // Preparation failed before any provider dispatch — the slot goes back.
+ await refundDocumentAiSlot(userId);
if (vision.reason === "pdfNeedsAnthropic") {
return apiError(
"PDF scanning needs a Claude vision provider; use local OCR instead.",
@@ -213,6 +211,7 @@ async function handleVisionIndex(
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
+ await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts
index 79a939fb2..94f435641 100644
--- a/src/app/api/documents/inbound/[id]/suggest/route.ts
+++ b/src/app/api/documents/inbound/[id]/suggest/route.ts
@@ -36,12 +36,12 @@ import {
type DocumentSuggestion,
} from "@/lib/documents/assist";
import {
- DOCUMENT_AI_BUCKET,
- DOCUMENT_AI_LIMIT_PER_HOUR,
+ checkDocumentAiRateLimit,
+ documentAiRateLimited,
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
- DOCUMENT_AI_WINDOW_MS,
loadOwnedDocument,
prepareVisionInput,
+ refundDocumentAiSlot,
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import {
@@ -51,23 +51,12 @@ import {
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
-import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
-function rateLimited(rl: Awaited>): Response {
- const response = apiError("Too many requests. Try again later.", 429, {
- errorCode: "documents.inbound.rateLimited",
- });
- for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
- response.headers.set(k, v);
- }
- return response;
-}
-
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -146,19 +135,19 @@ async function handleTextSuggest(
surface: "insights",
});
- const rl = await checkRateLimit(
- `${DOCUMENT_AI_BUCKET}:${userId}`,
- DOCUMENT_AI_LIMIT_PER_HOUR,
- DOCUMENT_AI_WINDOW_MS,
- );
- if (!rl.allowed) return rateLimited(rl);
+ const rl = await checkDocumentAiRateLimit(userId);
+ if (!rl.allowed) return documentAiRateLimited(rl);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
});
- if (jsonError) return jsonError;
+ if (jsonError) {
+ await refundDocumentAiSlot(userId);
+ return jsonError;
+ }
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
+ await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
@@ -172,6 +161,7 @@ async function handleTextSuggest(
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
+ await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
@@ -226,15 +216,13 @@ async function handleVisionSuggest(
surface: "insights",
});
- const rl = await checkRateLimit(
- `${DOCUMENT_AI_BUCKET}:${userId}`,
- DOCUMENT_AI_LIMIT_PER_HOUR,
- DOCUMENT_AI_WINDOW_MS,
- );
- if (!rl.allowed) return rateLimited(rl);
+ const rl = await checkDocumentAiRateLimit(userId);
+ if (!rl.allowed) return documentAiRateLimited(rl);
const vision = await prepareVisionInput(document, pick.pdfSupported);
if (!vision.ok) {
+ // Preparation failed before any provider dispatch — the slot goes back.
+ await refundDocumentAiSlot(userId);
if (vision.reason === "pdfNeedsAnthropic") {
return apiError(
"PDF scanning needs a Claude vision provider; use local OCR instead.",
@@ -262,6 +250,7 @@ async function handleVisionSuggest(
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
+ await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts
index 9003afbe4..55fbea7ec 100644
--- a/src/app/api/documents/inbound/[id]/summary/route.ts
+++ b/src/app/api/documents/inbound/[id]/summary/route.ts
@@ -38,12 +38,12 @@ import {
} from "@/lib/ai/coach/budget";
import { auditLog } from "@/lib/auth/audit";
import {
- DOCUMENT_AI_BUCKET,
- DOCUMENT_AI_LIMIT_PER_HOUR,
+ checkDocumentAiRateLimit,
+ documentAiRateLimited,
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
- DOCUMENT_AI_WINDOW_MS,
loadOwnedDocument,
prepareVisionInput,
+ refundDocumentAiSlot,
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import {
@@ -62,7 +62,6 @@ import {
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
-import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
DOCUMENT_SUMMARY_MODES,
inboundTextExtractSchema,
@@ -76,16 +75,6 @@ export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
-function rateLimited(rl: Awaited>): Response {
- const response = apiError("Too many requests. Try again later.", 429, {
- errorCode: "documents.inbound.rateLimited",
- });
- for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
- response.headers.set(k, v);
- }
- return response;
-}
-
type SummaryPersistenceTarget = {
userId: string;
documentId: string;
@@ -295,19 +284,19 @@ async function handleTextSummary(
});
}
- const rl = await checkRateLimit(
- `${DOCUMENT_AI_BUCKET}:${userId}`,
- DOCUMENT_AI_LIMIT_PER_HOUR,
- DOCUMENT_AI_WINDOW_MS,
- );
- if (!rl.allowed) return rateLimited(rl);
+ const rl = await checkDocumentAiRateLimit(userId);
+ if (!rl.allowed) return documentAiRateLimited(rl);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
});
- if (jsonError) return jsonError;
+ if (jsonError) {
+ await refundDocumentAiSlot(userId);
+ return jsonError;
+ }
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
+ await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
@@ -326,15 +315,22 @@ async function handleTextSummary(
const { pick } = await resolveDocumentTextProvider(userId);
if (!pick) {
+ await refundDocumentAiSlot(userId);
return apiError("No AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
- await assertDocumentEgressConsent({
- userId,
- providerType: pick.providerType,
- surface: "insights",
- });
+ try {
+ await assertDocumentEgressConsent({
+ userId,
+ providerType: pick.providerType,
+ surface: "insights",
+ });
+ } catch (err) {
+ // Refused before any dispatch — the slot goes back with the refusal.
+ await refundDocumentAiSlot(userId);
+ throw err;
+ }
const dateKey = buildDateKey();
const reservation = await reserveBudget(
@@ -344,6 +340,7 @@ async function handleTextSummary(
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
+ await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
@@ -394,15 +391,13 @@ async function handleVisionSummary(
surface: "insights",
});
- const rl = await checkRateLimit(
- `${DOCUMENT_AI_BUCKET}:${userId}`,
- DOCUMENT_AI_LIMIT_PER_HOUR,
- DOCUMENT_AI_WINDOW_MS,
- );
- if (!rl.allowed) return rateLimited(rl);
+ const rl = await checkDocumentAiRateLimit(userId);
+ if (!rl.allowed) return documentAiRateLimited(rl);
const vision = await prepareVisionInput(document, pick.pdfSupported);
if (!vision.ok) {
+ // Preparation failed before any provider dispatch — the slot goes back.
+ await refundDocumentAiSlot(userId);
if (vision.reason === "pdfNeedsAnthropic") {
return apiError(
"PDF scanning needs a Claude vision provider; use local OCR instead.",
@@ -430,6 +425,7 @@ async function handleVisionSummary(
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
+ await refundDocumentAiSlot(userId);
return apiError("Your AI usage budget for today is reached.", 429, {
errorCode: "documents.inbound.budgetExceeded",
});
diff --git a/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts b/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts
new file mode 100644
index 000000000..593a9fb7a
--- /dev/null
+++ b/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts
@@ -0,0 +1,246 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+/**
+ * #781 — a client that navigates away mid-generation aborts the SSE fetch.
+ * The route must close the turn honestly: persist an EMPTY assistant marker
+ * with `providerType: "cancelled"` so the conversation shows the interrupted
+ * turn (and the client can offer a retry), instead of leaving the user
+ * message dangling unanswered. The reservation is refunded in full, and the
+ * abort is never misreported as a provider failure.
+ *
+ * Route-level on purpose: the abort branch lives in `route.ts`'s catch, a
+ * place no pure-module test can see (the #591 lesson).
+ */
+
+const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}';
+
+vi.mock("@/lib/api-handler", () => ({
+ apiHandler: 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 }),
+}));
+const { annotate } = vi.hoisted(() => ({ annotate: vi.fn() }));
+vi.mock("@/lib/logging/context", () => ({
+ annotate,
+ getEvent: vi.fn(() => null),
+}));
+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" })) },
+ workout: { findFirst: vi.fn(async () => null) },
+ },
+}));
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn(async () => ({ allowed: true })),
+}));
+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(),
+}));
+vi.mock("@/lib/ai/consent-guard", () => ({
+ assertConsentForChain: vi.fn(async () => undefined),
+}));
+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/persistence", () => ({
+ appendMessage: vi.fn(async () => ({ id: "m1" })),
+ createConversation: vi.fn(async () => ({ id: "c1" })),
+ fetchConversationWithMessages: vi.fn(),
+ 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),
+}));
+
+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-08-13"),
+ reserveBudget,
+ reconcileSpend,
+ resolveDailyCap: vi.fn(() => 2_000_000),
+}));
+
+vi.mock("@/lib/ai/coach/refusal", () => ({
+ detectRefusal: vi.fn(() => ({ refuse: false })),
+}));
+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),
+}));
+
+vi.mock("@/lib/ai/coach/tools", () => ({
+ COACH_TOOL_DEFS: [{ name: "get_metric_series" }],
+ MAX_ROUNDS: 3,
+ buildCoachDataInventory: vi.fn(async () => ({
+ entries: [],
+ restMode: false,
+ cycleEnabled: false,
+ window: "last30days",
+ probeScope: { sources: ["bp"], window: "last30days" },
+ })),
+ renderDataInventory: vi.fn(() => "DATA INVENTORY"),
+ renderFocusHint: vi.fn(() => ""),
+ buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"),
+ runCoachToolLoop: vi.fn(),
+}));
+
+vi.mock("@/lib/ai/coach/keyvalues", () => ({
+ parseKeyValuesSentinel: vi.fn(),
+}));
+vi.mock("@/lib/ai/coach/suggest-reminder", () => ({
+ parseSuggestReminder: vi.fn(),
+}));
+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 }));
+vi.mock("@/lib/sse/create-stream", () => ({
+ createSseStream: (
+ producer: (c: {
+ signal: { aborted: boolean };
+ enqueue: () => void;
+ }) => void | Promise,
+ ) => {
+ sse.done = Promise.resolve(
+ producer({ signal: { aborted: true }, enqueue: () => {} }),
+ );
+ return new ReadableStream();
+ },
+}));
+
+import { POST } from "../route";
+
+const post = POST as unknown as (req: Request) => Promise;
+
+function abortedChatReq(body: Record): Request {
+ const controller = new AbortController();
+ const req = new Request("http://localhost/api/insights/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ });
+ // The body is read synchronously by the handler before the provider call;
+ // aborting here models the client tearing the connection down while the
+ // provider is generating (the runner rejects below).
+ controller.abort();
+ return req;
+}
+
+function abortError(): Error {
+ const err = new Error("This operation was aborted");
+ err.name = "AbortError";
+ return err;
+}
+
+describe("coach chat — client abort mid-generation (#781)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 });
+ // No-tools chain so the turn runs the streaming runner (which rejects
+ // with the abort the torn-down connection produces).
+ resolveProviderChain.mockResolvedValue([
+ { providerType: "local", instance: { supportsTools: false } },
+ ]);
+ runStreamingRawCompletionWithFallback.mockRejectedValue(abortError());
+ });
+
+ it("persists an empty assistant marker with providerType 'cancelled'", async () => {
+ const res = await post(abortedChatReq({ message: "How is my BP?" }));
+ expect(res.status).toBe(200);
+ await sse.done;
+
+ const calls = (appendMessage as ReturnType).mock.calls;
+ // The user turn is persisted first, then the cancelled marker.
+ const roles = calls.map((c) => (c[0] as { role: string }).role);
+ expect(roles).toEqual(["user", "assistant"]);
+ const marker = calls[1][0] as {
+ role: string;
+ content: string;
+ providerType: string | null;
+ };
+ expect(marker.providerType).toBe("cancelled");
+ expect(marker.content).toBe("");
+ });
+
+ it("refunds the full reservation and annotates the abort as cancelled, not a provider failure", async () => {
+ await post(abortedChatReq({ message: "How is my BP?" }));
+ await sse.done;
+
+ expect(reconcileSpend).toHaveBeenCalledWith("u1", 3000, 0, "2026-08-13");
+ const actions = annotate.mock.calls
+ .map((c) => (c[0] as { action?: { name?: string } }).action?.name)
+ .filter(Boolean);
+ expect(actions).toContain("insights.coach.cancelled");
+ expect(actions).not.toContain("insights.coach.providerFailed");
+ });
+});
diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts
index 2f03c16ae..d10447086 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -325,10 +325,16 @@ async function handleChatRequest(request: NextRequest): Promise {
}
workingConversationId = existing.id;
priorSummary = existing.summary ?? null;
- priorTurns = existing.messages.map((m) => ({
- role: m.role,
- content: m.content,
- }));
+ // #781 — cancelled-turn markers (`providerType: "cancelled"`, empty body)
+ // are UI rows, not conversation content: keep them out of the provider
+ // transcript so an interrupted turn adds no empty assistant line to the
+ // prompt. They stay in the DTO the client renders.
+ priorTurns = existing.messages
+ .filter((m) => m.providerType !== "cancelled")
+ .map((m) => ({
+ role: m.role,
+ content: m.content,
+ }));
// Ledger cross-turn sources (D3-safe): user-authored numbers + the
// persisted tool figures of prior turns. Assistant PROSE is never read.
priorUserMessages = existing.messages
@@ -761,6 +767,35 @@ async function handleChatRequest(request: NextRequest): Promise {
await reconcileSpend(userId, reservation.reserved, 0, reqDateKey).catch(
() => {},
);
+ // #781 — the client walked away mid-generation. The request's abort
+ // signal is threaded into every provider call, so the teardown surfaces
+ // here as an abort-shaped failure with `request.signal` already flipped.
+ // Close the turn honestly instead of letting the user message dangle
+ // unanswered: persist an EMPTY assistant marker tagged
+ // `providerType: "cancelled"` (the same channel "refusal" and "nudge"
+ // already use for non-provider rows) so the thread shows the turn as
+ // interrupted and offers a retry on reload. The content is empty by
+ // construction — nothing guarded was produced, and anything the client
+ // ever SAW was persisted before the first token frame left, so no
+ // unguarded partial text is ever written. The reservation was refunded
+ // above; a retry pays exactly what a fresh turn pays.
+ if (request.signal.aborted) {
+ await appendMessage({
+ conversationId: workingConversationId,
+ role: "assistant",
+ content: "",
+ providerType: "cancelled",
+ promptVersion: PROMPT_VERSION,
+ }).catch(() => {
+ // Marker persistence is best-effort — a failure leaves the
+ // dangling user turn, which is exactly the pre-#781 state.
+ });
+ annotate({
+ action: { name: "insights.coach.cancelled" },
+ meta: { conversationId: workingConversationId },
+ });
+ return { ok: false, code: "coach.cancelled" };
+ }
if (err instanceof AllProvidersFailedError) {
annotate({
action: { name: "insights.coach.providerFailed" },
@@ -1241,6 +1276,17 @@ async function handleChatRequest(request: NextRequest): Promise {
outcome = await produceReply();
} catch (err) {
clearInterval(heartbeat);
+ // #781 — a client disconnect can also surface as an abort-shaped error
+ // on a path `produceReply`'s own catch does not own (e.g. a teardown
+ // rejection out of persistence). That is not a defect and nobody is
+ // listening: skip the GlitchTip forward and the error frame.
+ if (request.signal.aborted) {
+ annotate({
+ action: { name: "insights.coach.cancelled" },
+ meta: { conversationId: workingConversationId, lateAbort: true },
+ });
+ return;
+ }
// The stream is already open, so we cannot 500: a tagged provider error
// surfaces a graceful `coach.provider.*` frame; anything else degrades to
// a generic unavailable frame. A genuine defect is still annotated.
diff --git a/src/app/coach/__tests__/page-interrupted-resume.test.tsx b/src/app/coach/__tests__/page-interrupted-resume.test.tsx
new file mode 100644
index 000000000..c01191e27
--- /dev/null
+++ b/src/app/coach/__tests__/page-interrupted-resume.test.tsx
@@ -0,0 +1,129 @@
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+/**
+ * #781 — returning to `/coach` after a navigation aborted a streaming reply
+ * must land the user back IN the interrupted conversation, not on a blank
+ * new chat. The unmount cleanup in `use-coach.ts` records the interrupted
+ * conversation under `COACH_INTERRUPTED_STORAGE_KEY`; this pins the page's
+ * consumption side:
+ *
+ * - a stored conversation id seeds `initialConversationId` (same prop the
+ * `?c=` deep-link drives);
+ * - the `"latest"` sentinel (a first-turn abort whose id never reached the
+ * client) flips `autoOpenMostRecent` instead;
+ * - every EXPLICIT entry intent (`?c=`, `?c=new`, `?doc=`, `?ask=`) wins
+ * over the resume.
+ *
+ * Same probe pattern as `page-launch-params.test.tsx`: the conversation
+ * surface is stubbed and the resolved props are asserted.
+ */
+
+const conversationProps = vi.fn();
+
+vi.mock("@/components/insights/coach-panel/coach-conversation", () => ({
+ CoachConversation: (props: Record) => {
+ conversationProps(props);
+ return ;
+ },
+}));
+
+const searchParams = { current: new URLSearchParams() };
+vi.mock("next/navigation", () => ({
+ useSearchParams: () => searchParams.current,
+ useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
+}));
+
+vi.mock("@/lib/insights/coach-launch-context", () => ({
+ useCoachLaunch: () => ({ askCoach: vi.fn() }),
+}));
+vi.mock("@/hooks/use-feature-flags", () => ({
+ useFeatureFlags: () => ({ coach: true }),
+}));
+vi.mock("@/hooks/use-disable-coach", () => ({
+ useDisableCoach: () => false,
+}));
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({ user: {}, isLoading: false }),
+}));
+vi.mock("@/hooks/use-record-capabilities", () => ({
+ useRecordCapabilities: () => ({ inSharedRecord: false }),
+}));
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({ data: undefined }),
+}));
+vi.mock("@/lib/api/api-fetch", () => ({ apiGet: vi.fn() }));
+
+import CoachPageClient from "../page-client";
+import { COACH_INTERRUPTED_STORAGE_KEY } from "@/components/insights/coach-panel/use-coach";
+
+// The vitest environment is `node` (SSR-only tests) — there is no `window`.
+// The page reads the flag through `window.sessionStorage` behind a
+// `typeof window` guard, so stub the minimal surface it touches.
+const store = new Map();
+vi.stubGlobal("window", {
+ sessionStorage: {
+ getItem: (k: string) => store.get(k) ?? null,
+ setItem: (k: string, v: string) => void store.set(k, v),
+ removeItem: (k: string) => void store.delete(k),
+ },
+});
+
+function renderWith(query: string): Record {
+ searchParams.current = new URLSearchParams(query);
+ renderToStaticMarkup();
+ const calls = conversationProps.mock.calls;
+ return calls[calls.length - 1][0] as Record;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ store.clear();
+});
+
+describe("/coach — interrupted-conversation resume (#781)", () => {
+ it("reopens the interrupted conversation recorded by the aborted stream", () => {
+ store.set(COACH_INTERRUPTED_STORAGE_KEY, "conv-42");
+ const props = renderWith("");
+ expect(props.initialConversationId).toBe("conv-42");
+ });
+
+ it("resolves a first-turn abort (sentinel 'latest') via most-recent", () => {
+ store.set(COACH_INTERRUPTED_STORAGE_KEY, "latest");
+ const props = renderWith("");
+ expect(props.initialConversationId).toBe(null);
+ expect(props.autoOpenMostRecent).toBe(true);
+ });
+
+ it("without a recorded interruption the default new-chat hero stands", () => {
+ const props = renderWith("");
+ expect(props.initialConversationId).toBe(null);
+ expect(props.autoOpenMostRecent).toBe(false);
+ });
+
+ it("an explicit ?c= deep-link wins over the resume", () => {
+ store.set(COACH_INTERRUPTED_STORAGE_KEY, "conv-42");
+ const props = renderWith("c=conv-7");
+ expect(props.initialConversationId).toBe("conv-7");
+ });
+
+ it("an explicit ?c=new (fresh chat) wins over the resume", () => {
+ store.set(COACH_INTERRUPTED_STORAGE_KEY, "conv-42");
+ const props = renderWith("c=new");
+ expect(props.initialConversationId).toBe(null);
+ expect(props.autoOpenMostRecent).toBe(false);
+ });
+
+ it("an explicit ?ask= hand-off wins over the resume", () => {
+ store.set(COACH_INTERRUPTED_STORAGE_KEY, "conv-42");
+ const props = renderWith("ask=How%20did%20I%20sleep");
+ expect(props.initialConversationId).toBe(null);
+ });
+
+ it("a ?doc= scoped chat wins over the resume", () => {
+ store.set(COACH_INTERRUPTED_STORAGE_KEY, "conv-42");
+ const props = renderWith("doc=doc-1");
+ expect(props.initialConversationId).toBe(null);
+ expect(props.initialDocumentId).toBe("doc-1");
+ });
+});
diff --git a/src/app/coach/page-client.tsx b/src/app/coach/page-client.tsx
index 0b8bb28ce..cef46e764 100644
--- a/src/app/coach/page-client.tsx
+++ b/src/app/coach/page-client.tsx
@@ -1,10 +1,11 @@
"use client";
-import { Suspense, useEffect } from "react";
+import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
+import { COACH_INTERRUPTED_STORAGE_KEY } from "@/components/insights/coach-panel/use-coach";
import type { CoachNudgeStatus } from "@/components/insights/layout-coach-fab";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
@@ -118,6 +119,47 @@ function CoachPageBody() {
: null;
const seedPrefill = freshChat ? searchParams.get("ask") : null;
+ // #781 — a navigation away mid-reply recorded the interrupted conversation
+ // (see `COACH_INTERRUPTED_STORAGE_KEY` in `use-coach.ts`). Returning to the
+ // Coach reopens that thread — where the persisted `cancelled` marker shows
+ // the turn as interrupted and offers the retry — instead of landing on a
+ // blank new chat. Read once per mount (lazy state, SSR-guarded), cleared in
+ // an effect so it is consumed exactly once. Every EXPLICIT entry intent
+ // wins over the resume: a `?c=` thread or `?c=new`, a `?doc=`/`?workout=`
+ // scoped chat, and a `?scope=`/`?ask=` hand-off all carry their own
+ // destination.
+ const [interruptedResume] = useState(() => {
+ if (typeof window === "undefined") return null;
+ try {
+ return window.sessionStorage.getItem(COACH_INTERRUPTED_STORAGE_KEY);
+ } catch {
+ return null;
+ }
+ });
+ useEffect(() => {
+ if (interruptedResume === null) return;
+ try {
+ window.sessionStorage.removeItem(COACH_INTERRUPTED_STORAGE_KEY);
+ } catch {
+ // Best-effort clear; a stale flag only re-opens a conversation once.
+ }
+ }, [interruptedResume]);
+ const interruptedEligible =
+ rawC === null &&
+ rawDoc === null &&
+ rawWorkout === null &&
+ launchScope === null &&
+ !seedPrefill;
+ const interruptedId =
+ interruptedEligible && interruptedResume && interruptedResume !== "latest"
+ ? interruptedResume
+ : null;
+ // A first-turn abort never learned its conversation id (it rides the SSE
+ // `done` frame) — resolve via the server-authoritative most-recent thread,
+ // which the aborted turn's server-side persistence has just bumped to the top.
+ const resumeInterruptedLatest =
+ interruptedEligible && interruptedResume === "latest";
+
// C1 exception — an unread coach-initiated message opens the most-recent
// conversation (which holds that proactive turn). Only consulted when the
// entry did not pin a specific thread or ask for a fresh chat.
@@ -144,7 +186,9 @@ function CoachPageBody() {
` open so the first
// turn is created + sent through the hardened fenced document endpoint.
initialDocumentId={seedDocumentId}
@@ -153,9 +197,10 @@ function CoachPageBody() {
// cross-surface hand-off carried in the URL.
launchScope={launchScope}
prefill={seedPrefill}
- // Default is the new-chat hero; only resume most-recent for the
- // unread coach-initiated exception.
- autoOpenMostRecent={hasUnreadCoachMessage}
+ // Default is the new-chat hero; resume most-recent only for the
+ // unread coach-initiated exception and the interrupted first-turn
+ // resume (#781, whose conversation id never reached the client).
+ autoOpenMostRecent={hasUnreadCoachMessage || resumeInterruptedLatest}
/>
);
}
diff --git a/src/components/documents/__tests__/document-ai-panels.test.tsx b/src/components/documents/__tests__/document-ai-panels.test.tsx
index ea24d4aa4..53748f937 100644
--- a/src/components/documents/__tests__/document-ai-panels.test.tsx
+++ b/src/components/documents/__tests__/document-ai-panels.test.tsx
@@ -153,7 +153,7 @@ describe("", () => {
output="summary"
result={null}
isPending
- errorKey={null}
+ errorText={null}
onClose={noop}
/>,
);
@@ -167,7 +167,7 @@ describe("", () => {
output="summary"
result={{ summary: "A lipid panel dated 1 March 2026." }}
isPending={false}
- errorKey={null}
+ errorText={null}
onClose={noop}
/>,
);
@@ -180,7 +180,7 @@ describe("", () => {
output="text"
result={{ text: "LDL 3.1 mmol/L" }}
isPending={false}
- errorKey={null}
+ errorText={null}
onClose={noop}
/>,
);
@@ -195,7 +195,7 @@ describe("", () => {
output="summary"
result={null}
isPending={false}
- errorKey="documents.assist.errorGeneric"
+ errorText="Couldn't read the document. Try again."
onClose={noop}
/>,
);
@@ -209,7 +209,7 @@ describe("", () => {
output="summary"
result={{ summary: "A lipid panel dated 1 March 2026." }}
isPending={false}
- errorKey={null}
+ errorText={null}
onClose={noop}
/>,
);
diff --git a/src/components/documents/__tests__/document-ai-section.test.tsx b/src/components/documents/__tests__/document-ai-section.test.tsx
index e8768e73d..9eecdbefb 100644
--- a/src/components/documents/__tests__/document-ai-section.test.tsx
+++ b/src/components/documents/__tests__/document-ai-section.test.tsx
@@ -159,16 +159,50 @@ describe("", () => {
expect(html).not.toContain('data-slot="document-read-ai"');
});
- it("hides the manual AI action row when auto-read is on, keeping status", () => {
- const html = render(base({ aiEnabled: true, autoReadEnabled: true }));
- // Auto-read reads on upload — the manual per-document actions are gone.
- expect(html).not.toContain('data-slot="document-read-ai"');
+ it("collapses entirely when auto-read is on and the document is read", () => {
+ const html = render(
+ base({
+ aiEnabled: true,
+ autoReadEnabled: true,
+ hasContentIndex: true,
+ contentIndexSource: "vision",
+ }),
+ );
+ // v1.28.22 — with auto-read ON, the document read, and nothing pending
+ // the WHOLE block collapses: no chrome, no status pill; the sheet content
+ // starts straight with the document fields under the preview.
+ expect(html).toBe("");
+ });
+
+ it("keeps the read action for an unread document even with auto-read on", () => {
+ // Auto-read promises reading on upload — but a document with no content
+ // index never got that read (it may predate the preference, or the pass
+ // did not complete). Collapsing here would leave the document with no
+ // path to a read at all; the recovery action must stay visible.
+ const html = render(
+ base({ aiEnabled: true, autoReadEnabled: true, hasContentIndex: false }),
+ );
+ expect(html).toContain('data-slot="document-read-ai"');
+ expect(html).toContain("Read with AI");
+ expect(html).toContain('data-slot="document-unread-note"');
+ expect(html).toContain("This document has not been read yet.");
+ // The auto-read promise still covers the extras: no suggest / summarise.
expect(html).not.toContain('data-slot="assist-suggest"');
expect(html).not.toContain("Summarise");
expect(html).not.toContain("Show extracted text");
- // v1.28.22 — with auto-read ON and nothing pending the WHOLE block
- // collapses: no chrome, no status pill; the sheet content starts straight
- // with the document fields under the preview.
+ });
+
+ it("still collapses an unread document when indexing is unavailable", () => {
+ // No transport can index here, so there is no action to recover with —
+ // an empty chrome box would be the only thing left to render.
+ const html = render(
+ base({
+ aiEnabled: true,
+ autoReadEnabled: true,
+ hasContentIndex: false,
+ indexEnabled: false,
+ }),
+ );
expect(html).toBe("");
});
diff --git a/src/components/documents/__tests__/document-facts-review.test.tsx b/src/components/documents/__tests__/document-facts-review.test.tsx
new file mode 100644
index 000000000..a90b630b5
--- /dev/null
+++ b/src/components/documents/__tests__/document-facts-review.test.tsx
@@ -0,0 +1,154 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import type {
+ ExtractedFactDto,
+ InboundDocumentDetailDto,
+} from "@/lib/validations/inbound-documents";
+
+import { DocumentFactsSection } from "../document-facts-review";
+
+/**
+ * The staged-facts block's honest states, pinned by static render:
+ *
+ * - a document with PENDING facts shows the review action (the control the
+ * detail view never had — its absence forced a re-upload through Labs);
+ * - a lab-filed, already-read document with nothing staged offers the
+ * stored-text extract (recovery for a skipped automatic run);
+ * - a delegate inside somebody else's record sees neither (staging and
+ * confirming write to the owner's record);
+ * - a fully confirmed document renders nothing — the surface stays calm.
+ */
+
+function render(node: React.ReactNode) {
+ const queryClient = new QueryClient();
+ return renderToStaticMarkup(
+
+ {node}
+ ,
+ );
+}
+
+function fact(overrides: Partial = {}): ExtractedFactDto {
+ return {
+ id: "fact-1",
+ factType: "OBSERVATION",
+ status: "PENDING",
+ confidence: 0.95,
+ needsReview: false,
+ data: {
+ label: "Hemoglobin",
+ code: null,
+ codeSystem: null,
+ value: 13.9,
+ valueText: null,
+ unit: "g/dL",
+ referenceLow: 12,
+ referenceHigh: 16,
+ referenceText: "12-16",
+ effectiveDate: "2026-08-01",
+ },
+ provenance: {
+ sourceText: "Hemoglobin 13.9 g/dL",
+ anchored: true,
+ sourceOffset: 10,
+ page: 0,
+ confidence: 0.95,
+ },
+ committedRecordId: null,
+ committedRecordType: null,
+ ...overrides,
+ };
+}
+
+function doc(
+ overrides: Partial = {},
+): InboundDocumentDetailDto {
+ return {
+ id: "doc-1",
+ kind: "LAB_RESULT",
+ title: "Blood panel",
+ filename: "panel.pdf",
+ mimeType: "application/pdf",
+ byteSize: 1000,
+ status: "EXTRACTED",
+ providerType: "anthropic",
+ reportDate: "2026-08-01",
+ documentDate: null,
+ errorReason: null,
+ factCount: 1,
+ pendingCount: 1,
+ conditionLinks: [],
+ encounterLinks: [],
+ servingClass: "inline",
+ hasContentIndex: true,
+ contentIndexSource: "vision",
+ hasThumbnail: false,
+ createdAt: "2026-08-01T10:00:00.000Z",
+ updatedAt: "2026-08-01T10:00:00.000Z",
+ facts: [fact()],
+ summary: null,
+ summaryGeneratedAt: null,
+ summaryState: "NONE",
+ ...overrides,
+ };
+}
+
+const baseProps = {
+ canManage: true,
+ aiEnabled: true,
+ labsModuleEnabled: true,
+};
+
+describe("", () => {
+ it("shows the review action for a lab document with pending facts", () => {
+ const html = render();
+ expect(html).toContain('data-slot="document-facts-review-open"');
+ expect(html).toContain("Review extracted value");
+ });
+
+ it("offers the stored-text extract for a read lab document with nothing staged", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-slot="document-facts-extract"');
+ expect(html).toContain("Extract lab values");
+ // Honest about the mechanism: stored text, review-first, no auto-save.
+ expect(html).toContain("nothing is saved automatically");
+ });
+
+ it("withdraws the extract offer once facts were approved", () => {
+ const html = render(
+ ,
+ );
+ expect(html).not.toContain('data-slot="document-facts-extract"');
+ expect(html).not.toContain('data-slot="document-facts-review-open"');
+ });
+
+ it("renders nothing for a delegate inside somebody else's record", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toBe("");
+ });
+
+ it("renders nothing for an unread non-lab document", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toBe("");
+ });
+});
diff --git a/src/components/documents/__tests__/use-document-assist.test.ts b/src/components/documents/__tests__/use-document-assist.test.ts
new file mode 100644
index 000000000..0a661bc7f
--- /dev/null
+++ b/src/components/documents/__tests__/use-document-assist.test.ts
@@ -0,0 +1,59 @@
+/**
+ * The honest-wait derivation for a document-AI 429. The routes mirror the
+ * bucket's reset instant into `meta.retryAt`; the client turns it into "try
+ * again in about N minutes". This pins the mapping: only a document-AI
+ * rate-limit error yields minutes, the floor is one minute, and anything
+ * malformed falls back to null (the generic copy).
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { ApiError } from "@/lib/api/api-fetch";
+
+import { documentAiRetryMinutes } from "../use-document-assist";
+
+const NOW = new Date("2026-08-12T12:00:00.000Z").getTime();
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(NOW);
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+function rateLimited(retryAt?: unknown): ApiError {
+ return new ApiError("Too many document AI requests this hour.", 429, {
+ errorCode: "documents.inbound.rateLimited",
+ ...(retryAt !== undefined ? { retryAt } : {}),
+ });
+}
+
+describe("documentAiRetryMinutes", () => {
+ it("derives whole minutes from the reset instant, rounding up", () => {
+ const err = rateLimited(new Date(NOW + 42.4 * 60_000).toISOString());
+ expect(documentAiRetryMinutes(err)).toBe(43);
+ });
+
+ it("floors at one minute for a reset only seconds away", () => {
+ const err = rateLimited(new Date(NOW + 20_000).toISOString());
+ expect(documentAiRetryMinutes(err)).toBe(1);
+ });
+
+ it("returns null when the error carries no usable instant", () => {
+ expect(documentAiRetryMinutes(rateLimited())).toBeNull();
+ expect(documentAiRetryMinutes(rateLimited("soon"))).toBeNull();
+ expect(documentAiRetryMinutes(rateLimited(42))).toBeNull();
+ });
+
+ it("returns null for any other error class", () => {
+ expect(
+ documentAiRetryMinutes(
+ new ApiError("Budget", 429, {
+ errorCode: "documents.inbound.budgetExceeded",
+ }),
+ ),
+ ).toBeNull();
+ expect(documentAiRetryMinutes(new Error("network"))).toBeNull();
+ });
+});
diff --git a/src/components/documents/document-ai-panels.tsx b/src/components/documents/document-ai-panels.tsx
index 43f08e1a0..919711f4c 100644
--- a/src/components/documents/document-ai-panels.tsx
+++ b/src/components/documents/document-ai-panels.tsx
@@ -202,13 +202,14 @@ export function DocumentSummaryPanel({
output,
result,
isPending,
- errorKey,
+ errorText,
onClose,
}: {
output: DocumentSummaryMode;
result: DocumentDescribeResult | null;
isPending: boolean;
- errorKey: string | null;
+ /** Resolved user-facing error sentence (already translated), or null. */
+ errorText: string | null;
onClose: () => void;
}) {
const { t } = useTranslations();
@@ -247,9 +248,9 @@ export function DocumentSummaryPanel({
- ) : errorKey ? (
+ ) : errorText ? (
- {t(errorKey)}
+ {errorText}
) : body !== null ? (
void;
suggestPending: boolean;
+ /** Resolved user-facing error sentence (already translated), or null. */
suggestErrorKey: string | null;
onSummarise: (output: DocumentSummaryMode) => void;
summaryPending: boolean;
@@ -100,6 +106,7 @@ export function DocumentAiSection({
onDismissSuggestion: () => void;
summaryOutput: DocumentSummaryMode | null;
summaryResult: DocumentDescribeResult | null;
+ /** Resolved user-facing error sentence (already translated), or null. */
summaryErrorKey: string | null;
onCloseSummary: () => void;
hasContentIndex: boolean;
@@ -117,12 +124,16 @@ export function DocumentAiSection({
// v1.28.22 — the block carries NO header/status chrome any more (the
// provenance pill moved to the vault card's meta row; auto-read makes an
- // in-sheet "read by AI" banner redundant). With auto-read ON and nothing
- // pending to review, nothing below would render — collapse the whole box so
- // the content under the preview starts straight with the fields.
+ // in-sheet "read by AI" banner redundant). With auto-read ON, the document
+ // read, and nothing pending to review, nothing below would render —
+ // collapse the whole box so the content under the preview starts straight
+ // with the fields. An unindexed document does NOT collapse (unless indexing
+ // is unavailable on this transport): it still needs its read, and this
+ // section is the only place that action lives.
if (
aiEnabled &&
autoReadEnabled &&
+ (hasContentIndex || !indexEnabled) &&
!suggestErrorKey &&
!suggestion &&
!summaryOutput
@@ -191,11 +202,40 @@ export function DocumentAiSection({
{t("documents.summary.showText")}
+ ) : !hasContentIndex && indexEnabled ? (
+ // Auto-read is ON but this document has no content index: the
+ // automatic pass never reached it or did not complete. The
+ // suggest / summarise extras stay hidden (auto-read's promise
+ // covers them), but the read itself must stay reachable.
+
+
+ {t("documents.ai.notReadYet")}
+
+
+
) : null}
{suggestErrorKey ? (
- {t(suggestErrorKey)}
+ {suggestErrorKey}
) : null}
@@ -217,7 +257,7 @@ export function DocumentAiSection({
output={summaryOutput}
result={summaryResult}
isPending={summaryPending}
- errorKey={summaryErrorKey}
+ errorText={summaryErrorKey}
onClose={onCloseSummary}
/>
) : null}
diff --git a/src/components/documents/document-ai-transport.ts b/src/components/documents/document-ai-transport.ts
index f1399a902..a698e0c2a 100644
--- a/src/components/documents/document-ai-transport.ts
+++ b/src/components/documents/document-ai-transport.ts
@@ -98,12 +98,24 @@ export async function runDocumentAi(opts: {
});
}
+/** The index route's result, including the lab-staging continuation. */
+export interface DocumentIndexResult {
+ indexed: boolean;
+ tokenCount: number;
+ /**
+ * How many lab facts the read continued into staging (PENDING, for the
+ * review step). 0 when the document is not a lab report or staging was not
+ * eligible. Confirmation stays the only write into Labs.
+ */
+ labFactsStaged: number;
+}
+
/** Populate / refresh one document's content index over the chosen transport. */
export function runDocumentIndex(opts: {
mode: DocumentAiMode;
target: DocumentAiTarget;
-}): Promise<{ indexed: boolean; tokenCount: number }> {
- return runDocumentAi<{ indexed: boolean; tokenCount: number }>({
+}): Promise {
+ return runDocumentAi({
path: `/api/documents/inbound/${opts.target.documentId}/index`,
mode: opts.mode,
target: opts.target,
diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx
index 2d947998b..fd37ddc07 100644
--- a/src/components/documents/document-detail-sheet.tsx
+++ b/src/components/documents/document-detail-sheet.tsx
@@ -80,6 +80,7 @@ import {
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
+import { DocumentFactsSection } from "./document-facts-review";
import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion";
import { DocumentSummaryBlock } from "./document-summary-block";
import type { DocumentAiTarget } from "./document-ai-transport";
@@ -87,8 +88,8 @@ import { DocumentShareSheet } from "./document-share-sheet";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import { useIndexDocument } from "./use-content-index";
import {
- documentAiErrorKey,
useDocumentAiCapability,
+ useDocumentAiErrorText,
useDocumentsAutoAiRead,
useDocumentSummary,
useSuggestDetails,
@@ -338,7 +339,7 @@ export function DocumentDetailSheet({
/** Whether content indexing is available (from `usage.contentIndex.enabled`). */
contentIndexEnabled?: boolean;
}) {
- const { t, locale } = useTranslations();
+ const { t, tCount, locale } = useTranslations();
const format = useFormatters();
const queryClient = useQueryClient();
// v1.28.52 (Documents R3) — the vault "Ask the Coach" action opens the REAL
@@ -363,9 +364,13 @@ export function DocumentDetailSheet({
// deep-link — see the labs page's own "no per-value relation" comment),
// so this links to the labs list rather than a single biomarker; still a
// real jump where there was none before.
+ //
+ // APPROVED only: the link claims values that exist in Labs, and a PENDING
+ // fact is not there yet — it belongs to the review block above, which is
+ // the control that turns it into one.
const labFactCount =
doc?.facts.filter(
- (fact) => fact.factType === "OBSERVATION" && fact.status !== "REJECTED",
+ (fact) => fact.factType === "OBSERVATION" && fact.status === "APPROVED",
).length ?? 0;
const { user } = useAuth();
const { canManage } = useRecordCapabilities();
@@ -393,6 +398,9 @@ export function DocumentDetailSheet({
);
const suggest = useSuggestDetails();
const summary = useDocumentSummary();
+ // Resolves an AI error to final copy — a 429 names the actual wait, derived
+ // from the reset instant the route mirrors into the error meta.
+ const aiErrorText = useDocumentAiErrorText();
// Stored generation has an independent mutation state so transient
// Summarise/Show text requests cannot change this block's spinner or result.
const storedSummary = useDocumentSummary();
@@ -593,7 +601,7 @@ export function DocumentDetailSheet({
queryKey: queryKeys.inboundDocument(documentId),
});
},
- onError: (error) => toast.error(t(documentAiErrorKey(error))),
+ onError: (error) => toast.error(aiErrorText(error)),
},
);
};
@@ -603,8 +611,16 @@ export function DocumentDetailSheet({
indexDoc.mutate(
{ mode: aiMode, target: aiTarget },
{
- onSuccess: () => toast.success(t("documents.ai.readDone")),
- onError: (error) => toast.error(t(documentAiErrorKey(error))),
+ // A read that continued into lab staging says so — the staged values
+ // are waiting in the review block, not silently in a queue. Staged
+ // facts stay PENDING; confirmation remains the only write into Labs.
+ onSuccess: (result) =>
+ toast.success(
+ result.labFactsStaged > 0
+ ? tCount("documents.ai.readStaged", result.labFactsStaged)
+ : t("documents.ai.readDone"),
+ ),
+ onError: (error) => toast.error(aiErrorText(error)),
},
);
};
@@ -836,7 +852,7 @@ export function DocumentDetailSheet({
onSuggest={runSuggest}
suggestPending={suggest.isPending}
suggestErrorKey={
- suggest.isError ? documentAiErrorKey(suggest.error) : null
+ suggest.isError ? aiErrorText(suggest.error) : null
}
onSummarise={runSummary}
summaryPending={summary.isPending}
@@ -851,7 +867,7 @@ export function DocumentDetailSheet({
summaryOutput={summaryOutput}
summaryResult={summary.data ?? null}
summaryErrorKey={
- summary.isError ? documentAiErrorKey(summary.error) : null
+ summary.isError ? aiErrorText(summary.error) : null
}
onCloseSummary={() => {
setSummaryOutput(null);
@@ -1087,6 +1103,18 @@ export function DocumentDetailSheet({
) : null}
+ {/* Staged-facts review + the stored-text extract recovery. The
+ existing extract/confirm chain finally gets its control on
+ the document itself: pending facts open the review, a read
+ lab document with nothing staged offers "Extract lab
+ values" over its stored text — never a re-upload. */}
+
+
{labsModuleEnabled && labFactCount > 0 ? (
void;
+ /** A per-fact commit refusal from the last confirm attempt, or null. */
+ failure: string | null;
+}) {
+ const { t } = useTranslations();
+ const format = useFormatters();
+ const editFact = useEditFact();
+ const [verifying, setVerifying] = useState(false);
+ const [draft, setDraft] = useState(() => draftFor(fact));
+ const [draftInvalid, setDraftInvalid] = useState(false);
+
+ const date = factDate(fact);
+ const typeLabel = t(`documents.review.type.${fact.factType}`);
+ const isObservation = fact.factType === "OBSERVATION";
+
+ const saveVerification = () => {
+ const edit = buildEdit(fact, draft);
+ if (!edit) {
+ setDraftInvalid(true);
+ return;
+ }
+ setDraftInvalid(false);
+ editFact.mutate(
+ { documentId, factId: fact.id, edit },
+ {
+ onSuccess: () => {
+ setVerifying(false);
+ // The refetched fact arrives with needsReview cleared; include it —
+ // the person just asserted these values.
+ onCheckedChange(true);
+ },
+ },
+ );
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/documents/use-content-index.ts b/src/components/documents/use-content-index.ts
index bfbe9d8ca..3d9434cac 100644
--- a/src/components/documents/use-content-index.ts
+++ b/src/components/documents/use-content-index.ts
@@ -23,13 +23,14 @@ import {
runDocumentIndex,
type DocumentAiMode,
type DocumentAiTarget,
+ type DocumentIndexResult,
} from "./document-ai-transport";
/** Index / re-index one document for content search. */
export function useIndexDocument() {
const queryClient = useQueryClient();
return useMutation<
- { indexed: boolean; tokenCount: number },
+ DocumentIndexResult,
Error,
{ mode: DocumentAiMode; target: DocumentAiTarget }
>({
diff --git a/src/components/documents/use-document-assist.ts b/src/components/documents/use-document-assist.ts
index da5ae7814..d87a56274 100644
--- a/src/components/documents/use-document-assist.ts
+++ b/src/components/documents/use-document-assist.ts
@@ -13,9 +13,12 @@
* prefills; the summary is transient. The only mutation is the caller applying
* a reviewed draft through the existing edit-on-commit machinery.
*/
+import { useCallback } from "react";
+
import { useMutation, useQuery } from "@tanstack/react-query";
import { apiGet, ApiError } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type {
DocumentAiCapabilityDto,
@@ -129,6 +132,45 @@ export function useDocumentSummary() {
* `meta.errorCode`; client-side precondition failures carry a `reason`. The
* default is the calm "try again" message — never a raw provider string.
*/
+/**
+ * The actual wait, in whole minutes, behind a document-AI 429. The routes
+ * mirror the bucket's reset instant into the error meta (`retryAt`, ISO 8601);
+ * the window is an hour, so a fixed "try again in a few minutes" understated
+ * the wait for most of it. Null when the error is not a document-AI rate-limit
+ * response or carries no usable instant — the caller falls back to the generic
+ * copy. Floored at one minute: a reset seconds away still reads as "about
+ * 1 minute", never "0 minutes".
+ */
+export function documentAiRetryMinutes(err: unknown): number | null {
+ if (!(err instanceof ApiError)) return null;
+ if (err.meta?.errorCode !== "documents.inbound.rateLimited") return null;
+ const raw = err.meta?.retryAt;
+ if (typeof raw !== "string") return null;
+ const at = Date.parse(raw);
+ if (!Number.isFinite(at)) return null;
+ return Math.max(1, Math.ceil((at - Date.now()) / 60_000));
+}
+
+/**
+ * Resolve a document-AI error to the user-facing sentence. Rate-limited
+ * responses derive their copy from the ACTUAL reset instant ("try again in
+ * about N minutes"); everything else maps through `documentAiErrorKey`. The
+ * result is final text — callers render or toast it as-is.
+ */
+export function useDocumentAiErrorText(): (err: unknown) => string {
+ const { t, tCount } = useTranslations();
+ return useCallback(
+ (err: unknown) => {
+ const minutes = documentAiRetryMinutes(err);
+ if (minutes !== null) {
+ return tCount("documents.assist.errorRateLimitedWait", minutes);
+ }
+ return t(documentAiErrorKey(err));
+ },
+ [t, tCount],
+ );
+}
+
export function documentAiErrorKey(err: unknown): string {
if (err instanceof DocumentAssistClientError) {
switch (err.reason) {
@@ -156,6 +198,8 @@ export function documentAiErrorKey(err: unknown): string {
return "documents.assist.errorLocalOcrDisabled";
case "documents.inbound.providerUnsupported":
return "documents.assist.errorProvider";
+ case "documents.inbound.notIndexed":
+ return "documents.assist.errorNotIndexed";
default:
return "documents.assist.errorGeneric";
}
diff --git a/src/components/documents/use-document-facts.ts b/src/components/documents/use-document-facts.ts
new file mode 100644
index 000000000..497724761
--- /dev/null
+++ b/src/components/documents/use-document-facts.ts
@@ -0,0 +1,113 @@
+"use client";
+
+/**
+ * Client hooks for the review-then-confirm chain on a stored document's
+ * staged facts — the missing UI leg of the server chain that already existed:
+ *
+ * extract (`POST …/extract`, incl. `{ mode: "stored" }` over the document's
+ * own indexed text) → PENDING `ExtractedFact` rows → optional per-fact edit
+ * (`PATCH …/facts/{factId}`, clears `needsReview`) → confirm
+ * (`POST …/confirm`) — the ONLY write into the structured stores.
+ *
+ * Nothing here commits on its own. The confirm mutation carries the explicit
+ * approve/reject decisions the person made; a fact without a decision stays
+ * pending on the server.
+ */
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+import { apiPatch, apiPost } from "@/lib/api/api-fetch";
+import { invalidateKeys, queryKeys } from "@/lib/query-keys";
+import type {
+ ExtractedFactDto,
+ InboundDocumentDetailDto,
+ InboundFactEdit,
+} from "@/lib/validations/inbound-documents";
+
+/** The confirm route's per-batch outcome (per-fact misses never fail it). */
+export interface ConfirmFactsResult {
+ approved: { factId: string; recordType: string; recordId: string }[];
+ rejected: string[];
+ needsReview: string[];
+ failed: { factId: string; reason: string }[];
+}
+
+/**
+ * Structure the document's own stored extracted text into staged facts —
+ * the manual recovery when the automatic staging run was skipped or failed.
+ * Stages PENDING facts only; the review + confirm steps stay mandatory.
+ * A provider call is slow, so the request opts out of the 15 s default.
+ */
+export function useStoredExtract() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ documentId }) =>
+ apiPost(
+ `/api/documents/inbound/${documentId}/extract`,
+ { mode: "stored" },
+ { signal: AbortSignal.timeout(120_000) },
+ ),
+ onSuccess: (_data, { documentId }) => {
+ void invalidateKeys(queryClient, [
+ queryKeys.inboundDocument(documentId),
+ queryKeys.documents(),
+ ]);
+ },
+ });
+}
+
+/**
+ * Correct a staged fact before approval. A successful edit clears
+ * `needsReview` server-side — the values become user-asserted and the fact
+ * turns approvable. The fact's resource type can never change.
+ */
+export function useEditFact() {
+ const queryClient = useQueryClient();
+ return useMutation<
+ ExtractedFactDto,
+ Error,
+ { documentId: string; factId: string; edit: InboundFactEdit }
+ >({
+ mutationFn: ({ documentId, factId, edit }) =>
+ apiPatch(
+ `/api/documents/inbound/${documentId}/facts/${factId}`,
+ edit,
+ ),
+ onSuccess: (_data, { documentId }) => {
+ void invalidateKeys(queryClient, [queryKeys.inboundDocument(documentId)]);
+ },
+ });
+}
+
+/**
+ * Commit the review decisions. Approved facts land in their structured
+ * stores (labs / conditions / medications) through the server's existing
+ * field-by-field commit; rejected facts are discarded. Invalidates every
+ * read the commit can touch, plus the document itself (status + fact states).
+ */
+export function useConfirmFacts() {
+ const queryClient = useQueryClient();
+ return useMutation<
+ ConfirmFactsResult,
+ Error,
+ {
+ documentId: string;
+ decisions: { factId: string; action: "approve" | "reject" }[];
+ }
+ >({
+ mutationFn: ({ documentId, decisions }) =>
+ apiPost(
+ `/api/documents/inbound/${documentId}/confirm`,
+ { decisions },
+ ),
+ onSuccess: (_data, { documentId }) => {
+ void invalidateKeys(queryClient, [
+ queryKeys.inboundDocument(documentId),
+ queryKeys.documents(),
+ queryKeys.labResults(),
+ queryKeys.biomarkers(),
+ queryKeys.illness(),
+ queryKeys.medications(),
+ ]);
+ },
+ });
+}
diff --git a/src/components/insights/coach-panel/__tests__/chat-bubble-cancelled.test.tsx b/src/components/insights/coach-panel/__tests__/chat-bubble-cancelled.test.tsx
new file mode 100644
index 000000000..0d3db1a7e
--- /dev/null
+++ b/src/components/insights/coach-panel/__tests__/chat-bubble-cancelled.test.tsx
@@ -0,0 +1,96 @@
+import { describe, it, expect, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+
+/**
+ * #781 — the persisted cancelled-turn marker (an EMPTY assistant row with
+ * `providerType: "cancelled"`) renders as a quiet interrupted note with a
+ * retry, never as an empty assistant bubble. SSR harness mirrors
+ * `chat-bubble-unverified-notice.test.tsx`.
+ */
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({
+ user: { id: "u1", username: "tester", role: "USER", avatarUrl: null },
+ isAuthenticated: true,
+ isLoading: false,
+ }),
+}));
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({ data: undefined }),
+ useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }),
+}));
+
+import { MessageThread } from "../message-thread";
+import type {
+ CoachConversationDetailDTO,
+ CoachMessageDTO,
+} from "@/lib/ai/coach/types";
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+function conversationWith(
+ messages: Array>,
+): CoachConversationDetailDTO {
+ return {
+ id: "c1",
+ title: "Test",
+ createdAt: "2026-08-13T10:00:00.000Z",
+ updatedAt: "2026-08-13T10:00:00.000Z",
+ messageCount: messages.length,
+ fenced: false,
+ attachments: [],
+ attachmentCount: 0,
+ documentTitle: null,
+ messages: messages.map((m, i) => ({
+ id: `m${i}`,
+ role: "user",
+ content: "",
+ createdAt: "2026-08-13T10:00:00.000Z",
+ metricSource: null,
+ providerType: null,
+ promptVersion: null,
+ tokensUsed: null,
+ model: null,
+ ...m,
+ })),
+ };
+}
+
+const interruptedThread = conversationWith([
+ { role: "user", content: "How is my blood pressure trending?" },
+ { role: "assistant", content: "", providerType: "cancelled" },
+]);
+
+describe("chat bubble — cancelled-turn marker (#781)", () => {
+ it("renders the interrupted note with a retry action, muted, no alarm colour", () => {
+ const html = render(
+ undefined}
+ />,
+ );
+ expect(html).toContain('data-slot="coach-bubble-cancelled"');
+ expect(html).toContain("This answer was interrupted before it finished.");
+ // The retry resubmits the preceding user question — the thread resolved
+ // one, so the affordance is present.
+ expect(html).toContain('data-slot="coach-interrupted-retry"');
+ expect(html).toContain("Ask again");
+ // Muted meta, not an alarm: the marker must not paint destructive/warning.
+ const markerHtml = html.slice(html.indexOf("coach-bubble-cancelled"));
+ expect(markerHtml).not.toContain("text-destructive");
+ expect(markerHtml).not.toContain("text-warning");
+ // No empty assistant prose bubble alongside the marker.
+ expect(html).not.toContain('data-slot="coach-bubble-assistant"');
+ });
+
+ it("omits the retry when the surface supplies no regenerate handler", () => {
+ const html = render();
+ expect(html).toContain('data-slot="coach-bubble-cancelled"');
+ expect(html).not.toContain('data-slot="coach-interrupted-retry"');
+ });
+});
diff --git a/src/components/insights/coach-panel/__tests__/use-coach-interrupted.test.ts b/src/components/insights/coach-panel/__tests__/use-coach-interrupted.test.ts
new file mode 100644
index 000000000..191829bdf
--- /dev/null
+++ b/src/components/insights/coach-panel/__tests__/use-coach-interrupted.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from "vitest";
+
+import { interruptedResumeValue } from "../use-coach";
+
+/**
+ * #781 — the write side of the interrupted-conversation resume. The unmount
+ * cleanup records `interruptedResumeValue(...)` under
+ * `COACH_INTERRUPTED_STORAGE_KEY` before aborting an in-flight stream; the
+ * `/coach` page's consumption side is pinned in
+ * `src/app/coach/__tests__/page-interrupted-resume.test.tsx`.
+ */
+describe("interruptedResumeValue (#781)", () => {
+ it("records the conversation id for an in-flight turn on an existing thread", () => {
+ expect(
+ interruptedResumeValue({ conversationId: "conv-42", inProgress: true }),
+ ).toBe("conv-42");
+ });
+
+ it("records the 'latest' sentinel for an in-flight FIRST turn (id unknown)", () => {
+ expect(
+ interruptedResumeValue({ conversationId: null, inProgress: true }),
+ ).toBe("latest");
+ });
+
+ it("records nothing for a settled turn — unmount after completion is not an interruption", () => {
+ expect(
+ interruptedResumeValue({ conversationId: "conv-42", inProgress: false }),
+ ).toBe(null);
+ expect(
+ interruptedResumeValue({ conversationId: null, inProgress: false }),
+ ).toBe(null);
+ });
+});
diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx
index 36b85c097..475c537e9 100644
--- a/src/components/insights/coach-panel/chat-bubble.tsx
+++ b/src/components/insights/coach-panel/chat-bubble.tsx
@@ -21,6 +21,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
@@ -567,6 +568,48 @@ function ChatBubbleImpl({
);
}
+ // #781 — the persisted cancelled-turn marker (an EMPTY assistant row the
+ // server writes when the client aborts mid-generation, e.g. navigating
+ // away). Rendered as a quiet interrupted note — muted meta text per
+ // UI-STANDARDS §3, never an alarm colour: nothing failed, the user left —
+ // plus a retry that resubmits the question that produced the turn (the
+ // regenerate closure, so the resend runs through the normal send path and
+ // every budget/rate gate). No provenance, feedback, copy, or token footer:
+ // there is no reply to act on.
+ if (providerType === "cancelled") {
+ return (
+
+
+
+
+
+
+ {t("insights.coach.interrupted.note")}
+
+ {onRegenerate && (
+
+ )}
+
+
+ );
+ }
+
// v1.4.25 W5 — map server-emitted error codes to specific Coach
// i18n keys. Distinct user-quota and provider-rate-limit copy so the
// user understands daily-cap (resets at UTC midnight) vs. transient
diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts
index 67db1df37..532d58f60 100644
--- a/src/components/insights/coach-panel/use-coach.ts
+++ b/src/components/insights/coach-panel/use-coach.ts
@@ -586,6 +586,33 @@ export interface CoachOptimisticUserMessage {
conversationId: string | null;
}
+/**
+ * #781 — sessionStorage key recording a turn interrupted by navigation.
+ * Written by the unmount cleanup in `useSendCoachMessage` when a stream is
+ * still in flight; consumed (and cleared) by the `/coach` page on its next
+ * mount so the user lands back IN the interrupted conversation instead of a
+ * blank new chat. The value is the conversation id, or the sentinel
+ * `"latest"` when the aborted turn was creating a brand-new conversation —
+ * its id only travels on the SSE `done` frame, which never arrived, so the
+ * page resolves it via the server-authoritative most-recent thread instead.
+ */
+export const COACH_INTERRUPTED_STORAGE_KEY = "coach-interrupted-conversation";
+
+/**
+ * #781 — the resume value the unmount cleanup records, or null when nothing
+ * should be recorded. Pure so the decision is unit-testable without mounting
+ * the hook (this repo's tests are SSR-only): a settled turn records nothing,
+ * an in-flight turn records its conversation id, and an in-flight FIRST turn
+ * (id not yet known) records the `"latest"` sentinel.
+ */
+export function interruptedResumeValue(active: {
+ conversationId: string | null;
+ inProgress: boolean;
+}): string | null {
+ if (!active.inProgress) return null;
+ return active.conversationId ?? "latest";
+}
+
const EMPTY_STREAMING: CoachStreamingMessage = {
content: "",
metricSource: null,
@@ -742,6 +769,48 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
optsRef.current = opts;
}, [opts]);
+ // #781 — the send in flight at this moment, for the unmount cleanup below.
+ // A ref (not state): the cleanup closure must read the live value, and the
+ // bookkeeping must never re-render the surface.
+ const activeSendRef = useRef<{
+ conversationId: string | null;
+ inProgress: boolean;
+ }>({ conversationId: null, inProgress: false });
+
+ // #781 — the unmount abort below stays (it is the budget guard), but it
+ // must not be SILENT any more: record which conversation was interrupted so
+ // the next `/coach` mount reopens it (the server closes the turn with a
+ // persisted `cancelled` marker on its side), and drop the cached
+ // conversation + rail copies so the return visit refetches the
+ // just-advanced server state instead of serving a pre-abort cache entry.
+ // Its own effect, defined BEFORE the abort effect: within one component
+ // React runs unmount cleanups in definition order, and the bookkeeping
+ // must read `activeSendRef` while the send is still marked in flight
+ // (the abort's `finally` only clears the flag on a later microtask
+ // regardless, so either order is safe — this one is merely honest).
+ useEffect(() => {
+ return () => {
+ const active = activeSendRef.current;
+ const resumeValue = interruptedResumeValue(active);
+ if (resumeValue === null) return;
+ try {
+ window.sessionStorage.setItem(
+ COACH_INTERRUPTED_STORAGE_KEY,
+ resumeValue,
+ );
+ } catch {
+ // Storage can throw (private mode / quota) — the persisted
+ // cancelled marker still surfaces the interruption in history.
+ }
+ if (active.conversationId) {
+ queryClient.invalidateQueries({
+ queryKey: QUERY_KEYS.one(active.conversationId),
+ });
+ }
+ queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() });
+ };
+ }, [queryClient]);
+
// Abort any in-flight stream when the hook unmounts. The drawer works around
// this via `handleOpenChange`, but the full-page `/coach` surface passes no
// `registerReset`, so navigating away mid-stream left the SSE reader loop
@@ -764,254 +833,269 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
-
- // v1.4.25 W5 — surface the user's message in the thread BEFORE
- // the "Thinking…" placeholder paints. Without this the send hook
- // only exposed the streaming assistant bubble, so the first
- // thing the user saw was the placeholder followed by their own
- // message landing on the next refetch — the maintainer flagged the
- // order as confusing on the suggested-prompt chips.
- setOptimisticUser({
- localId: `local-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
- content: params.message,
+ // #781 — mark the send in flight for the unmount cleanup. The whole
+ // body runs inside try/finally so EVERY exit (offline pre-check, fetch
+ // failure, error envelope, stream end, abort) clears the flag — an
+ // unmount after a settled turn must never record an interruption. The
+ // marker object is captured locally so a superseding send (which
+ // replaces `activeSendRef.current` with its own marker) is never
+ // cleared by the superseded send's finally.
+ const sendMarker = {
conversationId: params.conversationId ?? null,
- });
- setStreaming({
- content: "",
- metricSource: null,
- suggestion: null,
- suggestedAction: null,
inProgress: true,
- messageId: null,
- errorCode: null,
- usage: null,
- });
-
- // v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
- // user gets the offline-specific copy immediately rather than
- // waiting for the fetch to fail with a generic network error.
- if (typeof navigator !== "undefined" && navigator.onLine === false) {
- setStreaming({
- content: "",
- metricSource: null,
- suggestion: null,
- suggestedAction: null,
- inProgress: false,
- messageId: null,
- errorCode: "coach.network",
- usage: null,
- });
- return null;
- }
-
- // v1.28.51 — resolve the target BEFORE the fetch. A doc-scoped turn
- // resolves to the hardened fenced document endpoint; everything else to
- // the Coach tool route. This is the prompt-injection fence's client edge.
- const target = resolveCoachSendTarget(params);
-
- // apiFetchRaw: the chat POST streams SSE — the envelope helpers
- // would buffer and unwrap a body that never carries the envelope.
- let response: Response;
+ };
+ activeSendRef.current = sendMarker;
try {
- response = await apiFetchRaw(target.url, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Accept: "text/event-stream",
- },
- body: target.body,
- signal: controller.signal,
+ // v1.4.25 W5 — surface the user's message in the thread BEFORE
+ // the "Thinking…" placeholder paints. Without this the send hook
+ // only exposed the streaming assistant bubble, so the first
+ // thing the user saw was the placeholder followed by their own
+ // message landing on the next refetch — the maintainer flagged the
+ // order as confusing on the suggested-prompt chips.
+ setOptimisticUser({
+ localId: `local-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
+ content: params.message,
+ conversationId: params.conversationId ?? null,
});
- } catch (err) {
- if ((err as Error).name === "AbortError") return null;
setStreaming({
content: "",
metricSource: null,
suggestion: null,
suggestedAction: null,
- inProgress: false,
+ inProgress: true,
messageId: null,
- errorCode: "coach.network",
+ errorCode: null,
usage: null,
});
- return null;
- }
- if (!response.body) {
- setStreaming({
- content: "",
- metricSource: null,
- suggestion: null,
- suggestedAction: null,
- inProgress: false,
- messageId: null,
- errorCode: `coach.http.${response.status}`,
- usage: null,
- });
- return null;
- }
-
- // Even on `!response.ok`, the route may have emitted a structured
- // `error` frame in an SSE body (e.g. 4xx/5xx + text/event-stream).
- // Falling back to `coach.http.` would discard the richer
- // `errorCode`. Read the stream and let the parser route the frame
- // through the same path as the success case.
- const contentType = response.headers.get("Content-Type") ?? "";
- const isEventStream = contentType
- .toLowerCase()
- .startsWith("text/event-stream");
- if (!response.ok && !isEventStream) {
- // v1.4.25 W5 — for JSON errors the apiHandler envelope carries
- // a structured `error` code (e.g. `coach.budget.exceeded`).
- // Surface the structured code directly so the drawer can show
- // the right copy + toast variant; fall back to the generic
- // `coach.http.` only when parsing the envelope fails.
- let structured: string | null = null;
- try {
- const envelope = (await response.clone().json()) as {
- error?: unknown;
- };
- if (typeof envelope?.error === "string") {
- structured = envelope.error;
- }
- } catch {
- // body was not JSON; fall through to the http-status fallback
+ // v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
+ // user gets the offline-specific copy immediately rather than
+ // waiting for the fetch to fail with a generic network error.
+ if (typeof navigator !== "undefined" && navigator.onLine === false) {
+ setStreaming({
+ content: "",
+ metricSource: null,
+ suggestion: null,
+ suggestedAction: null,
+ inProgress: false,
+ messageId: null,
+ errorCode: "coach.network",
+ usage: null,
+ });
+ return null;
}
- setStreaming({
- content: "",
- metricSource: null,
- suggestion: null,
- suggestedAction: null,
- inProgress: false,
- messageId: null,
- errorCode: structured ?? `coach.http.${response.status}`,
- usage: null,
- });
- // v1.16.4 — KEEP the optimistic user bubble: a rejected turn
- // (budget gate, 4xx) has no persisted twin coming, and dropping
- // the bubble left the error standing next to an empty thread.
- // The content-equality dedupe in still
- // suppresses the optimistic copy if a persisted twin lands.
- return null;
- }
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
- let resolvedConversationId: string | null = null;
- let collectedContent = "";
- let collectedProvenance: CoachProvenance | null = null;
- let collectedSuggestion: CoachSuggestion | null = null;
- let collectedSuggestedAction: CoachSuggestedAction | null = null;
- let lastError: string | null = null;
- let messageId: string | null = null;
- let collectedUsage: CoachUsage | null = null;
+ // v1.28.51 — resolve the target BEFORE the fetch. A doc-scoped turn
+ // resolves to the hardened fenced document endpoint; everything else to
+ // the Coach tool route. This is the prompt-injection fence's client edge.
+ const target = resolveCoachSendTarget(params);
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- const text = decoder.decode(value, { stream: true });
- const { events, rest } = parseSseChunk(buffer, text);
- buffer = rest;
- for (const evt of events) {
- switch (evt.type) {
- case "token":
- collectedContent += evt.token;
- setStreaming((prev) => ({
- ...prev,
- content: prev.content + evt.token,
- }));
- break;
- case "provenance":
- collectedProvenance = evt.metricSource;
- setStreaming((prev) => ({
- ...prev,
- metricSource: evt.metricSource,
- }));
- break;
- case "suggestion":
- collectedSuggestion = evt.suggestion;
- setStreaming((prev) => ({
- ...prev,
- suggestion: evt.suggestion,
- }));
- break;
- case "suggestedAction":
- collectedSuggestedAction = evt.suggestedAction;
- setStreaming((prev) => ({
- ...prev,
- suggestedAction: evt.suggestedAction,
- }));
- break;
- case "reasoning":
- // v1.19.1 — the thinking disclosure was removed, so the
- // additive `reasoning` frame is accepted off the wire and
- // dropped. Kept as an explicit no-op case so the wire frame
- // stays a recognised type rather than slipping to a default.
- break;
- case "done":
- resolvedConversationId = evt.conversationId;
- messageId = evt.messageId;
- // v1.18.9 — additive per-turn usage envelope.
- collectedUsage = evt.usage ?? null;
- break;
- case "error":
- lastError = evt.code;
- break;
+ // apiFetchRaw: the chat POST streams SSE — the envelope helpers
+ // would buffer and unwrap a body that never carries the envelope.
+ let response: Response;
+ try {
+ response = await apiFetchRaw(target.url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "text/event-stream",
+ },
+ body: target.body,
+ signal: controller.signal,
+ });
+ } catch (err) {
+ if ((err as Error).name === "AbortError") return null;
+ setStreaming({
+ content: "",
+ metricSource: null,
+ suggestion: null,
+ suggestedAction: null,
+ inProgress: false,
+ messageId: null,
+ errorCode: "coach.network",
+ usage: null,
+ });
+ return null;
+ }
+
+ if (!response.body) {
+ setStreaming({
+ content: "",
+ metricSource: null,
+ suggestion: null,
+ suggestedAction: null,
+ inProgress: false,
+ messageId: null,
+ errorCode: `coach.http.${response.status}`,
+ usage: null,
+ });
+ return null;
+ }
+
+ // Even on `!response.ok`, the route may have emitted a structured
+ // `error` frame in an SSE body (e.g. 4xx/5xx + text/event-stream).
+ // Falling back to `coach.http.` would discard the richer
+ // `errorCode`. Read the stream and let the parser route the frame
+ // through the same path as the success case.
+ const contentType = response.headers.get("Content-Type") ?? "";
+ const isEventStream = contentType
+ .toLowerCase()
+ .startsWith("text/event-stream");
+ if (!response.ok && !isEventStream) {
+ // v1.4.25 W5 — for JSON errors the apiHandler envelope carries
+ // a structured `error` code (e.g. `coach.budget.exceeded`).
+ // Surface the structured code directly so the drawer can show
+ // the right copy + toast variant; fall back to the generic
+ // `coach.http.` only when parsing the envelope fails.
+ let structured: string | null = null;
+ try {
+ const envelope = (await response.clone().json()) as {
+ error?: unknown;
+ };
+ if (typeof envelope?.error === "string") {
+ structured = envelope.error;
+ }
+ } catch {
+ // body was not JSON; fall through to the http-status fallback
+ }
+ setStreaming({
+ content: "",
+ metricSource: null,
+ suggestion: null,
+ suggestedAction: null,
+ inProgress: false,
+ messageId: null,
+ errorCode: structured ?? `coach.http.${response.status}`,
+ usage: null,
+ });
+ // v1.16.4 — KEEP the optimistic user bubble: a rejected turn
+ // (budget gate, 4xx) has no persisted twin coming, and dropping
+ // the bubble left the error standing next to an empty thread.
+ // The content-equality dedupe in still
+ // suppresses the optimistic copy if a persisted twin lands.
+ return null;
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let resolvedConversationId: string | null = null;
+ let collectedContent = "";
+ let collectedProvenance: CoachProvenance | null = null;
+ let collectedSuggestion: CoachSuggestion | null = null;
+ let collectedSuggestedAction: CoachSuggestedAction | null = null;
+ let lastError: string | null = null;
+ let messageId: string | null = null;
+ let collectedUsage: CoachUsage | null = null;
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ const text = decoder.decode(value, { stream: true });
+ const { events, rest } = parseSseChunk(buffer, text);
+ buffer = rest;
+ for (const evt of events) {
+ switch (evt.type) {
+ case "token":
+ collectedContent += evt.token;
+ setStreaming((prev) => ({
+ ...prev,
+ content: prev.content + evt.token,
+ }));
+ break;
+ case "provenance":
+ collectedProvenance = evt.metricSource;
+ setStreaming((prev) => ({
+ ...prev,
+ metricSource: evt.metricSource,
+ }));
+ break;
+ case "suggestion":
+ collectedSuggestion = evt.suggestion;
+ setStreaming((prev) => ({
+ ...prev,
+ suggestion: evt.suggestion,
+ }));
+ break;
+ case "suggestedAction":
+ collectedSuggestedAction = evt.suggestedAction;
+ setStreaming((prev) => ({
+ ...prev,
+ suggestedAction: evt.suggestedAction,
+ }));
+ break;
+ case "reasoning":
+ // v1.19.1 — the thinking disclosure was removed, so the
+ // additive `reasoning` frame is accepted off the wire and
+ // dropped. Kept as an explicit no-op case so the wire frame
+ // stays a recognised type rather than slipping to a default.
+ break;
+ case "done":
+ resolvedConversationId = evt.conversationId;
+ messageId = evt.messageId;
+ // v1.18.9 — additive per-turn usage envelope.
+ collectedUsage = evt.usage ?? null;
+ break;
+ case "error":
+ lastError = evt.code;
+ break;
+ }
}
}
- }
- // Final flush of any trailing frame in the residual buffer.
- const tail = parseSseChunk(buffer, "\n\n");
- for (const evt of tail.events) {
- if (evt.type === "done") {
- resolvedConversationId = evt.conversationId;
- messageId = evt.messageId;
- collectedUsage = evt.usage ?? null;
- } else if (evt.type === "error") {
- lastError = evt.code;
+ // Final flush of any trailing frame in the residual buffer.
+ const tail = parseSseChunk(buffer, "\n\n");
+ for (const evt of tail.events) {
+ if (evt.type === "done") {
+ resolvedConversationId = evt.conversationId;
+ messageId = evt.messageId;
+ collectedUsage = evt.usage ?? null;
+ } else if (evt.type === "error") {
+ lastError = evt.code;
+ }
+ }
+ } catch (err) {
+ if ((err as Error).name !== "AbortError") {
+ lastError = "coach.stream";
}
}
- } catch (err) {
- if ((err as Error).name !== "AbortError") {
- lastError = "coach.stream";
- }
- }
- setStreaming({
- content: collectedContent,
- metricSource: collectedProvenance,
- suggestion: collectedSuggestion,
- suggestedAction: collectedSuggestedAction,
- inProgress: false,
- messageId,
- errorCode: lastError,
- usage: collectedUsage,
- });
-
- if (resolvedConversationId) {
- // Invalidate the freshly-persisted conversation + the rail so
- // the drawer's next mount picks up the encrypted-on-disk shape
- // and the rail orders the thread to the top.
- queryClient.invalidateQueries({
- queryKey: QUERY_KEYS.one(resolvedConversationId),
+ setStreaming({
+ content: collectedContent,
+ metricSource: collectedProvenance,
+ suggestion: collectedSuggestion,
+ suggestedAction: collectedSuggestedAction,
+ inProgress: false,
+ messageId,
+ errorCode: lastError,
+ usage: collectedUsage,
});
- queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() });
- optsRef.current.onDone?.(resolvedConversationId);
+
+ if (resolvedConversationId) {
+ // Invalidate the freshly-persisted conversation + the rail so
+ // the drawer's next mount picks up the encrypted-on-disk shape
+ // and the rail orders the thread to the top.
+ queryClient.invalidateQueries({
+ queryKey: QUERY_KEYS.one(resolvedConversationId),
+ });
+ queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() });
+ optsRef.current.onDone?.(resolvedConversationId);
+ }
+ // v1.4.25 W5 — drop the optimistic user bubble; the persisted
+ // twin is on its way via the invalidate-refetch above.
+ // v1.16.4 — but only when a twin IS coming (the turn resolved a
+ // conversation) or the turn succeeded outright. An errored turn
+ // without a conversation id persisted nothing; dropping the
+ // bubble then erased the user's message next to the error bubble.
+ // The content-equality dedupe in still suppresses
+ // the optimistic copy if a persisted twin does land later.
+ if (resolvedConversationId || !lastError) {
+ setOptimisticUser(null);
+ }
+ return resolvedConversationId;
+ } finally {
+ sendMarker.inProgress = false;
}
- // v1.4.25 W5 — drop the optimistic user bubble; the persisted
- // twin is on its way via the invalidate-refetch above.
- // v1.16.4 — but only when a twin IS coming (the turn resolved a
- // conversation) or the turn succeeded outright. An errored turn
- // without a conversation id persisted nothing; dropping the
- // bubble then erased the user's message next to the error bubble.
- // The content-equality dedupe in still suppresses
- // the optimistic copy if a persisted twin does land later.
- if (resolvedConversationId || !lastError) {
- setOptimisticUser(null);
- }
- return resolvedConversationId;
},
[queryClient],
);
diff --git a/src/components/settings/__tests__/about-me-clear-scope.test.tsx b/src/components/records/__tests__/about-me-clear-scope.test.tsx
similarity index 86%
rename from src/components/settings/__tests__/about-me-clear-scope.test.tsx
rename to src/components/records/__tests__/about-me-clear-scope.test.tsx
index 60bfef5cf..24b396442 100644
--- a/src/components/settings/__tests__/about-me-clear-scope.test.tsx
+++ b/src/components/records/__tests__/about-me-clear-scope.test.tsx
@@ -1,5 +1,10 @@
/**
- * Settings → AI "About me": the Clear control cannot take the allergy line.
+ * Anamnese "About me" note: the Clear control cannot take the allergy line.
+ *
+ * #159 — the panel moved from the account settings into the Anamnese as
+ * `about-me-note-manager.tsx` (note) + `allergy-free-text-note.tsx` (the
+ * free-text allergy line, under the structured AllergyManager). The scoped
+ * clear is unchanged and stays pinned here.
*
* The control used to fire `save.mutate({ aboutMe: "", allergies: "" })` from
* a bare `onClick`. One tap wiped a person's free-text note AND their
@@ -81,7 +86,8 @@ vi.mock("@/components/ui/confirm-button", () => ({
ConfirmDialog: () => null,
}));
-const { AboutMeSection } = await import("../about-me-section");
+const { AboutMeNoteManager } = await import("../about-me-note-manager");
+const { AllergyFreeTextNote } = await import("../allergy-free-text-note");
const STORED: AboutMePayload = {
aboutMe: "Shift work, training for a half marathon.",
@@ -102,7 +108,7 @@ function render(seed: Partial = {}) {
return renderToStaticMarkup(
-
+ ,
);
@@ -171,10 +177,21 @@ describe("clearing allergies stays possible, in the field itself", () => {
it("renders the allergy textarea, editable, carrying the stored value", () => {
// Removing the field from the Clear payload only removes the untargeted
// route. The targeted one has to still be there, or scoping the control
- // would have taken a capability away rather than a footgun.
- const html = render();
+ // would have taken a capability away rather than a footgun. #159 — the
+ // field lives in its own component under the structured allergy list now.
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+ client.setQueryData(queryKeys.coachAboutMe(), STORED);
+ const html = renderToStaticMarkup(
+
+
+
+
+ ,
+ );
const field = html.slice(
- html.indexOf('data-testid="settings-about-me-allergies"'),
+ html.indexOf('data-testid="records-allergy-free-text"'),
);
const openTag = field.slice(0, field.indexOf(">"));
expect(openTag).not.toContain("disabled");
diff --git a/src/components/records/about-me-note-manager.tsx b/src/components/records/about-me-note-manager.tsx
new file mode 100644
index 000000000..d53bc11d7
--- /dev/null
+++ b/src/components/records/about-me-note-manager.tsx
@@ -0,0 +1,220 @@
+"use client";
+
+/**
+ * #159 — the "About me" free-text note, at home in the Anamnese
+ * (medical-history) section.
+ *
+ * v1.15.20 introduced the note under Settings → AI, v1.18.1 moved it to the
+ * account profile, v1.25.12 moved the chronic conditions + Coach focus out of
+ * it into the Anamnese. This completes the move: the note itself is personal
+ * medical context the Coach and the daily briefing read, so it lives with
+ * conditions, allergies and family history as one coherent medical history.
+ * The card header (title/description) is rendered by `anamnesis-section.tsx`,
+ * matching the neighbouring managers.
+ *
+ * Data path is unchanged: the note is part of the self-context payload
+ * (`GET`/`PUT /api/coach/about-me`), read and written through the same
+ * `queryKeys.coachAboutMe()` query- and mutation-key the rest of the app
+ * uses. The PUT sends only the field this editor owns (plus the base token),
+ * so conditions / focus / allergies stay untouched by contract.
+ *
+ * The "Clear" control writes `aboutMe: ""` and NOTHING else — the allergies
+ * field is omitted from the payload so the stored allergy line survives (the
+ * v1.32-era scoped-clear lesson; see `about-me-clear-scope.test.tsx`).
+ *
+ * Plain text only — every value renders exclusively through inputs and React
+ * text children; no markdown anywhere.
+ */
+import { useState } from "react";
+import Link from "next/link";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Loader2, MessageCircleQuestion, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
+import { SettingsInfoTile } from "@/components/settings/_info-tile";
+import { Label } from "@/components/ui/label";
+import { ConfirmButton } from "@/components/ui/confirm-button";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+import { cn } from "@/lib/utils";
+import { apiGet, apiPut } from "@/lib/api/api-fetch";
+import { withBaseToken, isConflict } from "@/lib/api/optimistic-token";
+
+const FALLBACK_MAX_CHARS = 4000;
+
+interface AboutMeData {
+ aboutMe: string | null;
+ conditions: string | null;
+ allergies: string | null;
+ coachFocus: string | null;
+ pendingQuestions: string[];
+ updatedAt: string | null;
+ maxChars: number;
+ fieldMaxChars: number;
+}
+
+const FIELD_CLASSES =
+ "border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none";
+
+export function AboutMeNoteManager() {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+ const [draft, setDraft] = useState(null);
+
+ const query = useQuery({
+ queryKey: queryKeys.coachAboutMe(),
+ queryFn: () => apiGet("/api/coach/about-me"),
+ });
+
+ const savedNote = query.data?.aboutMe ?? "";
+ const maxChars = query.data?.maxChars ?? FALLBACK_MAX_CHARS;
+ const pendingQuestions = query.data?.pendingQuestions ?? [];
+
+ const value = draft ?? savedNote;
+ const dirty = value !== savedNote;
+ // The clear control only ever touches the note, so it only appears when
+ // there is a note to clear.
+ const hasNote = savedNote.length > 0;
+
+ const save = useMutation({
+ mutationKey: queryKeys.coachAboutMe(),
+ // Send only the note. `conditions` / `coachFocus` / `allergies` are
+ // omitted so the server preserves them (they are edited in their own
+ // cards). v1.32.21 (R5a) — echo the base token this edit was based on so
+ // a concurrent write to the same self-context 409s instead of silently
+ // overwriting the newer state.
+ mutationFn: async (input: { aboutMe: string }) => {
+ return apiPut(
+ "/api/coach/about-me",
+ withBaseToken(input, query.data?.updatedAt ?? undefined),
+ );
+ },
+ onSuccess: (next) => {
+ const cleared = !next.aboutMe;
+ toast.success(
+ cleared
+ ? t("settings.ai.aboutMe.clearedToast")
+ : t("settings.ai.aboutMe.savedToast"),
+ );
+ setDraft(null);
+ queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.coachAboutMeQuestions(),
+ });
+ },
+ onError: (err) => {
+ // v1.32.21 (R5a) — a 409 means the stored self-context advanced since
+ // this edit was based. Refetch so the token advances, KEEP the draft so
+ // the user can re-save, nudge gently.
+ if (isConflict(err)) {
+ queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
+ toast.message(t("common.conflictReloaded"));
+ return;
+ }
+ toast.error(t("settings.ai.aboutMe.saveError"));
+ },
+ });
+
+ const disabled = query.isLoading || save.isPending;
+
+ return (
+
+ );
+}
diff --git a/src/components/records/allergy-free-text-note.tsx b/src/components/records/allergy-free-text-note.tsx
new file mode 100644
index 000000000..1db0536eb
--- /dev/null
+++ b/src/components/records/allergy-free-text-note.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+/**
+ * #159 — the free-text allergies / intolerances line, mounted directly under
+ * the structured `AllergyManager` in the SAME Anamnese card.
+ *
+ * The structured list is the record; this line is its labelled free-text
+ * SUPPLEMENT (context the structured rows cannot carry — "reacts within
+ * minutes", "family suspects a cross-allergy"). The label + the one-sentence
+ * hint say exactly that, so the two inputs never read as duplicates. It used
+ * to live in the account-settings "About me" panel; the placement moved here
+ * so both allergy inputs sit in one place.
+ *
+ * Data path is unchanged: the line is part of the self-context payload
+ * (`GET`/`PUT /api/coach/about-me`), read and written through the same
+ * `queryKeys.coachAboutMe()` query- and mutation-key the rest of the app
+ * uses. The PUT sends only this field (plus the base token), so the note /
+ * conditions / focus stay untouched by contract. Clearing the line is done in
+ * the field itself — a deliberate act on the thing being emptied.
+ *
+ * Plain text only — the value renders exclusively through the input; no
+ * markdown anywhere.
+ */
+import { useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Loader2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
+import { Label } from "@/components/ui/label";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+import { cn } from "@/lib/utils";
+import { apiGet, apiPut } from "@/lib/api/api-fetch";
+import { withBaseToken, isConflict } from "@/lib/api/optimistic-token";
+
+const FALLBACK_FIELD_MAX_CHARS = 500;
+
+interface AboutMeData {
+ aboutMe: string | null;
+ conditions: string | null;
+ allergies: string | null;
+ coachFocus: string | null;
+ updatedAt: string | null;
+ fieldMaxChars: number;
+}
+
+const FIELD_CLASSES =
+ "border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none";
+
+export function AllergyFreeTextNote() {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+ const [draft, setDraft] = useState(null);
+
+ const query = useQuery({
+ queryKey: queryKeys.coachAboutMe(),
+ queryFn: () => apiGet("/api/coach/about-me"),
+ });
+
+ const savedLine = query.data?.allergies ?? "";
+ const fieldMaxChars = query.data?.fieldMaxChars ?? FALLBACK_FIELD_MAX_CHARS;
+ const value = draft ?? savedLine;
+ const dirty = value !== savedLine;
+
+ const save = useMutation({
+ mutationKey: queryKeys.coachAboutMe(),
+ // Send only the allergies line — omitted fields are preserved server-side
+ // by the PUT contract. v1.32.21 (R5a) — echo the base token so a
+ // concurrent self-context write 409s instead of silently losing.
+ mutationFn: async (input: { allergies: string }) => {
+ return apiPut(
+ "/api/coach/about-me",
+ withBaseToken(input, query.data?.updatedAt ?? undefined),
+ );
+ },
+ onSuccess: () => {
+ toast.success(t("records.allergies.freeText.savedToast"));
+ setDraft(null);
+ queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.coachAboutMeQuestions(),
+ });
+ },
+ onError: (err) => {
+ if (isConflict(err)) {
+ queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
+ toast.message(t("common.conflictReloaded"));
+ return;
+ }
+ toast.error(t("records.allergies.freeText.saveError"));
+ },
+ });
+
+ const disabled = query.isLoading || save.isPending;
+
+ return (
+
+
+ {/* One sentence naming the difference: the structured rows above are
+ the record; this line is free-text context supplementing them. */}
+
+ {t("records.allergies.freeText.hint")}
+
+
+ );
+}
diff --git a/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx b/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx
new file mode 100644
index 000000000..972fcab94
--- /dev/null
+++ b/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx
@@ -0,0 +1,119 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { AnamnesisSection } from "../anamnesis-section";
+
+/**
+ * #159 — the "About me" note and the free-text allergies line moved from the
+ * account settings into the Anamnese. Two halves, and both matter:
+ *
+ * 1. PRESENCE — the Anamnese renders the note as its own card and mounts
+ * the free-text allergy supplement in the SAME card as the structured
+ * AllergyManager, directly under it.
+ * 2. ABSENCE — the account section carries no about-me mount any more. The
+ * section is too prop- and hook-heavy for an SSR render here, so the
+ * absence half is structural over the source (the established
+ * `destructive-controls-confirm.test.tsx` pattern): no import, no
+ * element, no about-me slot.
+ */
+
+const moduleState = vi.hoisted(() => ({
+ coach: true,
+ insights: true,
+}));
+
+vi.mock("@/hooks/use-module-enabled", () => ({
+ useModuleEnabled: (key: "coach" | "insights") => moduleState[key],
+}));
+vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
+ AiProfileInclusionManager: () => null,
+}));
+vi.mock("@/components/records/conditions-manager", () => ({
+ ConditionsManager: () => null,
+}));
+vi.mock("@/components/records/health-profile-facts-manager", () => ({
+ HealthProfileFactsManager: () => null,
+}));
+vi.mock("@/components/records/allergy-manager", () => ({
+ AllergyManager: () => ,
+}));
+vi.mock("@/components/records/allergy-free-text-note", () => ({
+ AllergyFreeTextNote: () => ,
+}));
+vi.mock("@/components/records/about-me-note-manager", () => ({
+ AboutMeNoteManager: () => ,
+}));
+vi.mock("@/components/records/family-history-manager", () => ({
+ FamilyHistoryManager: () => null,
+}));
+vi.mock("@/components/records/emergency-profile-manager", () => ({
+ EmergencyProfileManager: () => null,
+}));
+
+function renderSection(): string {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ moduleState.coach = true;
+ moduleState.insights = true;
+});
+
+describe("Anamnese — the About-me note card (#159)", () => {
+ it("renders the note as its own card in the medical history", () => {
+ const html = renderSection();
+ expect(html).toContain('data-testid="records-about-me-card"');
+ expect(html).toContain('data-slot="about-me-note-probe"');
+ expect(html).toContain("About me");
+ });
+
+ it("mounts the free-text allergy supplement in the allergies card, under the structured list", () => {
+ const html = renderSection();
+ const managerAt = html.indexOf('data-slot="allergy-manager-probe"');
+ const noteAt = html.indexOf('data-slot="allergy-free-text-probe"');
+ expect(managerAt).toBeGreaterThan(-1);
+ expect(noteAt).toBeGreaterThan(-1);
+ // Directly under the structured list, same card: the supplement follows
+ // the manager, and no card boundary (a new SettingsCard opens with its
+ // header h2) sits between the two.
+ expect(noteAt).toBeGreaterThan(managerAt);
+ const between = html.slice(managerAt, noteAt);
+ expect(between).not.toContain("
{
+ moduleState.coach = false;
+ moduleState.insights = false;
+ const html = renderSection();
+ expect(html).not.toContain('data-testid="records-about-me-card"');
+ // The allergy supplement is NOT AI-gated — the line is medical history
+ // whichever surfaces read it, exactly like the structured list.
+ expect(html).toContain('data-slot="allergy-free-text-probe"');
+ });
+
+ it("insights alone keeps the note available (the daily briefing reads it)", () => {
+ moduleState.coach = false;
+ const html = renderSection();
+ expect(html).toContain('data-testid="records-about-me-card"');
+ });
+});
+
+describe("Account settings — the About-me card is gone (#159)", () => {
+ it("the account section carries no about-me mount any more", () => {
+ const source = readFileSync(
+ join(process.cwd(), "src/components/settings/account-section/index.tsx"),
+ "utf8",
+ );
+ expect(source).not.toContain("AboutMeSection");
+ expect(source).not.toContain("about-me-section");
+ expect(source).not.toContain("AboutMeNoteManager");
+ expect(source).not.toContain('data-testid="settings-about-me-card"');
+ });
+});
diff --git a/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx b/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx
index e2446e0af..126b4197b 100644
--- a/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx
+++ b/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx
@@ -24,6 +24,12 @@ vi.mock("@/components/records/health-profile-facts-manager", () => ({
vi.mock("@/components/records/allergy-manager", () => ({
AllergyManager: () => null,
}));
+vi.mock("@/components/records/allergy-free-text-note", () => ({
+ AllergyFreeTextNote: () => null,
+}));
+vi.mock("@/components/records/about-me-note-manager", () => ({
+ AboutMeNoteManager: () => ,
+}));
vi.mock("@/components/records/family-history-manager", () => ({
FamilyHistoryManager: () => null,
}));
diff --git a/src/components/settings/__tests__/destructive-controls-confirm.test.tsx b/src/components/settings/__tests__/destructive-controls-confirm.test.tsx
index c3c67147f..e8a75c382 100644
--- a/src/components/settings/__tests__/destructive-controls-confirm.test.tsx
+++ b/src/components/settings/__tests__/destructive-controls-confirm.test.tsx
@@ -51,10 +51,11 @@ const CONTROLS: { file: string; slots: string[] }[] = [
file: "components/settings/trusted-devices-card.tsx",
slots: ["revoke-trusted-device", "revoke-all-trusted-devices"],
},
- // The allergy wipe. Scoped to the note as well as gated — see
- // `about-me-clear-scope.test.tsx` for the payload half.
+ // The note wipe. Scoped to the note as well as gated — see
+ // `about-me-clear-scope.test.tsx` for the payload half. #159 — the panel
+ // moved to the Anamnese as `about-me-note-manager.tsx`.
{
- file: "components/settings/about-me-section.tsx",
+ file: "components/records/about-me-note-manager.tsx",
slots: ["settings-about-me-clear"],
},
{
diff --git a/src/components/settings/__tests__/import-panel.test.tsx b/src/components/settings/__tests__/import-panel.test.tsx
index e4a1b50a6..c2a1be65d 100644
--- a/src/components/settings/__tests__/import-panel.test.tsx
+++ b/src/components/settings/__tests__/import-panel.test.tsx
@@ -27,7 +27,10 @@ vi.mock("next/navigation", () => ({
}));
import type { Locale } from "@/lib/i18n/config";
-import { AppleHealthEstimateWarning } from "../import-panel/apple-health-import-card";
+import {
+ AppleHealthEstimateWarning,
+ appleHealthFailureKind,
+} from "../import-panel/apple-health-import-card";
import { I18nProvider } from "@/lib/i18n/context";
import {
ImportPanel,
@@ -116,6 +119,40 @@ describe("", () => {
);
});
+// (issue #775) — the worker writes two machine-readable failure
+// reasons (the reconcile's `interrupted_by_restart` code and the memory
+// preflight's `insufficient_memory` prefix). The card must translate
+// them instead of echoing a raw code at the user; honest free-text
+// reasons keep passing through verbatim.
+describe("appleHealthFailureKind", () => {
+ it("maps the reconcile code onto translated copy", () => {
+ expect(appleHealthFailureKind("interrupted_by_restart")).toBe(
+ "interrupted",
+ );
+ });
+
+ it("maps the memory-preflight refusal onto translated copy", () => {
+ expect(
+ appleHealthFailureKind(
+ "insufficient_memory: export.xml declares 8192 MiB uncompressed; " +
+ "parsing it needs roughly 320 MiB of heap but the Node.js heap " +
+ "limit is 256 MiB.",
+ ),
+ ).toBe("insufficientMemory");
+ });
+
+ it("passes honest free-text reasons through verbatim", () => {
+ expect(
+ appleHealthFailureKind("Import staging file is no longer available"),
+ ).toBe("raw");
+ });
+
+ it("returns null when the job has no failure reason", () => {
+ expect(appleHealthFailureKind(null)).toBeNull();
+ expect(appleHealthFailureKind("")).toBeNull();
+ });
+});
+
describe("EXAMPLE_IMPORT payload", () => {
it("round-trips through JSON and stays parseable", () => {
const serialised = JSON.stringify(EXAMPLE_IMPORT, null, 2);
diff --git a/src/components/settings/__tests__/settings-read-error-family.test.tsx b/src/components/settings/__tests__/settings-read-error-family.test.tsx
index 06b07fea9..9181bf587 100644
--- a/src/components/settings/__tests__/settings-read-error-family.test.tsx
+++ b/src/components/settings/__tests__/settings-read-error-family.test.tsx
@@ -37,7 +37,8 @@ import { SecurityActivityCard } from "../security-activity-card";
import { TrustedDevicesCard } from "../trusted-devices-card";
import { CoachMemorySection } from "../coach-memory-section";
import { CoachRemindersSection } from "../coach-reminders-section";
-import { AboutMeSection } from "../about-me-section";
+import { AboutMeNoteManager } from "@/components/records/about-me-note-manager";
+import { AllergyFreeTextNote } from "@/components/records/allergy-free-text-note";
function renderCard(node: React.ReactNode) {
return renderToStaticMarkup(
@@ -51,7 +52,8 @@ const cases: Array<[string, () => React.ReactNode]> = [
["TrustedDevicesCard", () => ],
["CoachMemorySection", () => ],
["CoachRemindersSection", () => ],
- ["AboutMeSection", () => ],
+ ["AboutMeNoteManager", () => ],
+ ["AllergyFreeTextNote", () => ],
];
describe("settings read-error family — a read failure is foreground and recoverable", () => {
diff --git a/src/components/settings/about-me-section.tsx b/src/components/settings/about-me-section.tsx
deleted file mode 100644
index 38fcb3acf..000000000
--- a/src/components/settings/about-me-section.tsx
+++ /dev/null
@@ -1,353 +0,0 @@
-"use client";
-
-/**
- * v1.15.20 — Settings → AI "About me" panel (free text only).
- * v1.16.0 — structured self-context: alongside the free text the user
- * notes allergies / intolerances. All fields are encrypted at rest;
- * age/gender stay on the profile and are merged into the prompt
- * server-side — the panel says so instead of asking again.
- *
- * v1.25.12 — the pre-existing / chronic conditions and the "what should the
- * Coach watch" focus moved out of this panel into Settings → Anamnese, where
- * they sit with allergies + family history as one medical history. Editing them
- * has a single home there now; this panel keeps the free-text note and the
- * allergies line. The store is unchanged — every field is still part of the
- * `/api/coach/about-me` payload; the PUT here simply omits the two conditions
- * fields, which are preserved server-side.
- *
- * GET /api/coach/about-me — full structured payload + pending questions
- * PUT /api/coach/about-me — writes aboutMe (empty string clears) + allergies;
- * conditions / coachFocus are omitted here and left untouched
- *
- * After a save the server derives up to 3 clarifying questions (AI
- * when a provider + budget allow, deterministic hints otherwise); the
- * panel surfaces them in a quiet callout and the Coach composer
- * renders them as tappable chips.
- *
- * Plain text only — every value renders exclusively through inputs and
- * React text children; no markdown anywhere.
- *
- * The "Clear" control writes `aboutMe: ""` and nothing else. It used to send
- * `allergies: ""` alongside, so one untargeted tap wiped the allergy line as
- * well — while the toast it raised said "About-me note cleared", which is what
- * the control looks like it does and what a person reading it expects. The
- * allergy line is the entry a clinician reads first, so the control is scoped
- * rather than merely gated: the field is omitted from the payload, and the PUT
- * leaves an omitted field untouched by contract. Clearing allergies is done in
- * the allergies field itself, which is a deliberate act on the thing being
- * emptied. The confirmation on top of that guards the note.
- */
-import { useState } from "react";
-import Link from "next/link";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- Loader2,
- MessageCircleQuestion,
- NotebookPen,
- Trash2,
-} from "lucide-react";
-import { toast } from "sonner";
-
-import { Button } from "@/components/ui/button";
-import { QueryErrorRow } from "@/components/ui/query-error-row";
-import { SettingsInfoTile } from "@/components/settings/_info-tile";
-import { Label } from "@/components/ui/label";
-import { ConfirmButton } from "@/components/ui/confirm-button";
-import { SettingsCard } from "@/components/settings/settings-card";
-import { SettingsCardHeader } from "@/components/settings/_card-header";
-import { useTranslations } from "@/lib/i18n/context";
-import { queryKeys } from "@/lib/query-keys";
-import { cn } from "@/lib/utils";
-import { apiGet, apiPut } from "@/lib/api/api-fetch";
-import { withBaseToken, isConflict } from "@/lib/api/optimistic-token";
-
-const FALLBACK_MAX_CHARS = 4000;
-const FALLBACK_FIELD_MAX_CHARS = 500;
-
-interface AboutMeData {
- aboutMe: string | null;
- conditions: string | null;
- allergies: string | null;
- coachFocus: string | null;
- pendingQuestions: string[];
- updatedAt: string | null;
- maxChars: number;
- fieldMaxChars: number;
-}
-
-type StructuredKey = "allergies";
-
-async function fetchAboutMe(): Promise {
- return apiGet("/api/coach/about-me");
-}
-
-const FIELD_CLASSES =
- "border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none";
-
-export function AboutMeSection({
- isAuthenticated,
-}: {
- isAuthenticated: boolean;
-}) {
- const { t } = useTranslations();
- const queryClient = useQueryClient();
- const [drafts, setDrafts] = useState<{
- aboutMe: string | null;
- allergies: string | null;
- }>({ aboutMe: null, allergies: null });
-
- const query = useQuery({
- queryKey: queryKeys.coachAboutMe(),
- queryFn: fetchAboutMe,
- enabled: isAuthenticated,
- });
-
- const saved = {
- aboutMe: query.data?.aboutMe ?? "",
- allergies: query.data?.allergies ?? "",
- };
- const maxChars = query.data?.maxChars ?? FALLBACK_MAX_CHARS;
- const fieldMaxChars = query.data?.fieldMaxChars ?? FALLBACK_FIELD_MAX_CHARS;
- const pendingQuestions = query.data?.pendingQuestions ?? [];
-
- const value = {
- aboutMe: drafts.aboutMe ?? saved.aboutMe,
- allergies: drafts.allergies ?? saved.allergies,
- };
- const dirty =
- value.aboutMe !== saved.aboutMe || value.allergies !== saved.allergies;
- // The clear control only ever touches the note, so it only appears when
- // there is a note to clear.
- const hasNote = saved.aboutMe.length > 0;
-
- // Quiet completeness meter: one segment per answered field. Reads
- // the SAVED state (not the draft) so it only moves on persistence.
- const answered = [saved.allergies, saved.aboutMe].filter(
- (v) => v.length > 0,
- ).length;
-
- const save = useMutation({
- mutationKey: queryKeys.coachAboutMe(),
- // Send only the two fields this panel owns. `conditions` / `coachFocus`
- // are omitted so the server preserves them (they are edited under Anamnese).
- // v1.32.21 (R5a) — echo the base token this edit was based on so a
- // concurrent write to the same self-context (another tab, or Coach adopt)
- // 409s instead of silently overwriting the newer state.
- // `allergies` is optional on purpose: the clear control omits it so the
- // stored allergy line survives, per the PUT's omitted-field contract.
- mutationFn: async (input: { aboutMe: string; allergies?: string }) => {
- return apiPut(
- "/api/coach/about-me",
- withBaseToken(input, query.data?.updatedAt ?? undefined),
- );
- },
- onSuccess: (next) => {
- const cleared = !next.aboutMe;
- toast.success(
- cleared
- ? t("settings.ai.aboutMe.clearedToast")
- : t("settings.ai.aboutMe.savedToast"),
- );
- setDrafts({ aboutMe: null, allergies: null });
- queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
- queryClient.invalidateQueries({
- queryKey: queryKeys.coachAboutMeQuestions(),
- });
- },
- onError: (err) => {
- // v1.32.21 (R5a) — explicit-Save disposition: a 409 means the stored
- // self-context advanced since this edit was based. Refetch so the token
- // advances, KEEP the drafts so the user can re-save, nudge gently.
- if (isConflict(err)) {
- queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
- toast.message(t("common.conflictReloaded"));
- return;
- }
- toast.error(t("settings.ai.aboutMe.saveError"));
- },
- });
-
- const disabled = !isAuthenticated || query.isLoading || save.isPending;
-
- function structuredField(
- key: StructuredKey,
- label: string,
- placeholder: string,
- ) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
- {/* Completeness — two quiet segments, no percentage shouting. */}
-
-
- {/* v1.25.12 — cross-link to the Anamnese (medical-history) home, where the
- chronic conditions the Coach watches now live alongside allergies +
- family history as one coherent medical history. */}
-
-
- );
-}
diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx
index 795852dd0..7f7fc2828 100644
--- a/src/components/settings/account-section/index.tsx
+++ b/src/components/settings/account-section/index.tsx
@@ -25,7 +25,6 @@ import {
} from "@/lib/profile/height-unit-display";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
-import { AboutMeSection } from "@/components/settings/about-me-section";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TimezonePicker } from "@/components/settings/timezone-picker";
@@ -525,12 +524,10 @@ export function AccountSection() {
Medikamente settings section, where every medication-specific
preference now lives. */}
- {/* v1.18.1 (D8) — the AI "About me" context lives under Profil, placed
- before Zyklus-Tracking. It is personal medical context (conditions,
- allergies, what the Coach should watch) the daily briefing + Coach
- read, so it belongs with the account profile rather than the AI
- provider-configuration screen. */}
-
+ {/* #159 — the AI "About me" note and the free-text allergies line moved
+ to Settings → Anamnese: they are personal medical context, so they
+ sit with conditions, allergies and family history as one medical
+ history (`anamnesis-section.tsx`). */}
{/* Cycle tracking is a module, and the Modules hub carries its switch.
The second enable surface that used to sit here asked the same
diff --git a/src/components/settings/ai/__tests__/ai-consent-card.test.tsx b/src/components/settings/ai/__tests__/ai-consent-card.test.tsx
index 01a622c6f..444d2d2f2 100644
--- a/src/components/settings/ai/__tests__/ai-consent-card.test.tsx
+++ b/src/components/settings/ai/__tests__/ai-consent-card.test.tsx
@@ -50,7 +50,7 @@ describe("AiConsentCard", () => {
expect(html).toContain("Withdraw consent");
});
- it("reports a withdrawal and stops offering it again", () => {
+ it("reports a withdrawal, stops offering it again, and offers the grant", () => {
const html = renderWith({
id: "rcpt-1",
kind: "ai_full",
@@ -60,6 +60,11 @@ describe("AiConsentCard", () => {
expect(html).toContain("Withdrawn.");
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
+ // Withdrawal must be as reversible as it was easy: the same surface
+ // carries the explicit grant control, and its hint says what granting
+ // lets the app do.
+ expect(html).toContain('data-slot="ai-consent-grant"');
+ expect(html).toContain("Grant consent");
});
it("treats no receipt at all as consent not given", () => {
@@ -67,6 +72,18 @@ describe("AiConsentCard", () => {
expect(html).toContain("Withdrawn.");
expect(html).not.toContain('data-slot="ai-consent-withdraw"');
+ expect(html).toContain('data-slot="ai-consent-grant"');
+ });
+
+ it("never shows the grant control while consent stands", () => {
+ const html = renderWith({
+ id: "rcpt-1",
+ kind: "ai_full",
+ signedAt: "2026-07-01T08:00:00.000Z",
+ revokedAt: null,
+ });
+
+ expect(html).not.toContain('data-slot="ai-consent-grant"');
});
it("renders nothing for a signed-out visitor", () => {
diff --git a/src/components/settings/ai/ai-consent-card.tsx b/src/components/settings/ai/ai-consent-card.tsx
index 7cca9f1e9..ac0f4ce51 100644
--- a/src/components/settings/ai/ai-consent-card.tsx
+++ b/src/components/settings/ai/ai-consent-card.tsx
@@ -10,11 +10,14 @@
* surface, not behind a support request.
*
* The card shows the standing decision in plain words and offers the one
- * move that changes it. Withdrawing takes effect immediately — the consent
- * gate fails closed without an active receipt, so every AI surface falls
- * back to its no-consent state on the next call. Nothing already stored is
- * deleted by this; that is the export/erase path in Data & Privacy, and the
- * copy says so rather than implying more than it does.
+ * move that changes it: withdraw while consent stands, grant while it does
+ * not. Withdrawing takes effect immediately: the consent gate fails closed
+ * without an active receipt, so every AI surface falls back to its
+ * no-consent state on the next call. Granting posts an affirmative intent,
+ * which is the consent act itself and therefore may supersede an earlier
+ * revocation; the silent mount heal never does. Nothing already stored is
+ * deleted by a withdrawal; that is the export/erase path in Data & Privacy,
+ * and the copy says so rather than implying more than it does.
* ──────────────────────────────────────────────────────────────── */
import { useState } from "react";
@@ -75,6 +78,29 @@ export function AiConsentCard({
},
});
+ const grant = useMutation({
+ mutationKey: queryKeys.aiConsentReceipt("ai_full"),
+ mutationFn: async () => {
+ // The affirmative intent marks this as the user's own consent act,
+ // the only web path that may lift a standing revocation. The silent
+ // mount heal posts no body and cannot.
+ const res = await apiFetchRaw("/api/consent/ai/web", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ intent: "affirmative" }),
+ });
+ if (!res.ok) throw new Error("grant failed");
+ },
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({
+ queryKey: queryKeys.aiConsentReceipt("ai_full"),
+ });
+ await queryClient.invalidateQueries({
+ queryKey: queryKeys.insightsProviderChain(),
+ });
+ },
+ });
+
// Say nothing until the state is known: a card that flashes "withdrawn"
// before the receipt arrives would misreport the user's own decision.
if (!isAuthenticated || isLoading) return null;
@@ -144,9 +170,24 @@ export function AiConsentCard({
)
) : (
-
+ ) : null}
);
}
diff --git a/src/components/settings/anamnesis-section.tsx b/src/components/settings/anamnesis-section.tsx
index 46377330f..f1151a053 100644
--- a/src/components/settings/anamnesis-section.tsx
+++ b/src/components/settings/anamnesis-section.tsx
@@ -22,6 +22,7 @@ import {
ClipboardList,
EyeOff,
HeartPulse,
+ NotebookPen,
ShieldAlert,
Siren,
Users,
@@ -33,6 +34,8 @@ import { useTranslations } from "@/lib/i18n/context";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { AllergyManager } from "@/components/records/allergy-manager";
+import { AllergyFreeTextNote } from "@/components/records/allergy-free-text-note";
+import { AboutMeNoteManager } from "@/components/records/about-me-note-manager";
import { ConditionsManager } from "@/components/records/conditions-manager";
import { EmergencyProfileManager } from "@/components/records/emergency-profile-manager";
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
@@ -66,6 +69,28 @@ export function AnamnesisSection() {
)}
+
+ {/* #159 — the "About me" free-text note moved here from the account
+ settings: it is personal medical context the Coach and the daily
+ briefing read, so it belongs in the medical history. Same
+ AI-surface gate as the inclusion card above — the note only feeds
+ the AI surfaces, so with both disabled there is nothing to feed. */}
+ {(coachEnabled || insightsEnabled) && (
+
+
+
+
+ )}
+
+ {/* #159 — the free-text supplement to the structured list above,
+ moved here from the account-settings "About me" panel so both
+ allergy inputs live in one card. */}
+
diff --git a/src/components/settings/import-panel/apple-health-import-card.tsx b/src/components/settings/import-panel/apple-health-import-card.tsx
index 32b3577ce..fd4e13d97 100644
--- a/src/components/settings/import-panel/apple-health-import-card.tsx
+++ b/src/components/settings/import-panel/apple-health-import-card.tsx
@@ -42,6 +42,24 @@ interface JobStatus {
failureReason: string | null;
}
+/**
+ * Classify a worker `failureReason` for display. The worker writes two
+ * machine-readable reasons — the reconcile's bare `interrupted_by_restart`
+ * code and the memory preflight's `insufficient_memory`-prefixed string
+ * (issue #775) — which must surface as translated, actionable copy
+ * instead of a raw code. Anything else is honest English free text from
+ * the parse and passes through verbatim. Exported for the SSR contract
+ * suite.
+ */
+export function appleHealthFailureKind(
+ reason: string | null,
+): "interrupted" | "insufficientMemory" | "raw" | null {
+ if (reason === null || reason.length === 0) return null;
+ if (reason === "interrupted_by_restart") return "interrupted";
+ if (reason.startsWith("insufficient_memory")) return "insufficientMemory";
+ return "raw";
+}
+
export function AppleHealthEstimateWarning({ days }: { days: number }) {
const { t } = useTranslations();
return (
@@ -160,6 +178,23 @@ export function AppleHealthImportCard() {
const isFailed = status?.status === "failed";
const busy = uploading || isRunning;
const percent = status?.progress?.percent ?? null;
+ const failureText = (() => {
+ const reason = status?.failureReason ?? null;
+ switch (appleHealthFailureKind(reason)) {
+ case "interrupted":
+ return t(
+ "settings.sections.export.import.appleHealth.failureInterrupted",
+ );
+ case "insufficientMemory":
+ return t(
+ "settings.sections.export.import.appleHealth.failureInsufficientMemory",
+ );
+ case "raw":
+ return reason;
+ default:
+ return null;
+ }
+ })();
const ecg = status?.result?.ecg;
const ecgWritten = (ecg?.imported ?? 0) + (ecg?.updated ?? 0);
@@ -302,7 +337,7 @@ export function AppleHealthImportCard() {
/>
{uploadError ??
- status?.failureReason ??
+ failureText ??
t("settings.sections.export.import.appleHealth.failed")}
diff --git a/src/lib/ai/coach/conversation-summary.ts b/src/lib/ai/coach/conversation-summary.ts
index 074fc7c9d..41d7306b5 100644
--- a/src/lib/ai/coach/conversation-summary.ts
+++ b/src/lib/ai/coach/conversation-summary.ts
@@ -182,6 +182,9 @@ export async function refreshConversationSummary(
// fail-closed like every other decrypt in the Coach stack.
return [];
}
+ // #781 — skip empty bodies: a cancelled-turn marker (an empty
+ // assistant row closing an aborted turn) carries no content to fold.
+ if (!content.trim()) return [];
return [{ role: m.role === "assistant" ? "assistant" : "user", content }];
});
diff --git a/src/lib/ai/coach/facts.ts b/src/lib/ai/coach/facts.ts
index 265598d64..8d3921c19 100644
--- a/src/lib/ai/coach/facts.ts
+++ b/src/lib/ai/coach/facts.ts
@@ -255,6 +255,9 @@ async function loadRecentTurns(
for (const m of [...row.messages].reverse()) {
const content = decryptOrNull(m.encryptedContent);
if (content === null) continue;
+ // #781 — skip empty bodies: a cancelled-turn marker (an empty assistant
+ // row closing an aborted turn) carries nothing to extract from.
+ if (!content.trim()) continue;
turns.push({ role: m.role, content });
}
return turns;
diff --git a/src/lib/ai/coach/plans.ts b/src/lib/ai/coach/plans.ts
index 070335bfe..cade32d18 100644
--- a/src/lib/ai/coach/plans.ts
+++ b/src/lib/ai/coach/plans.ts
@@ -276,6 +276,9 @@ async function loadRecentTurns(
for (const m of [...row.messages].reverse()) {
const content = decryptOrNull(m.encryptedContent);
if (content === null) continue;
+ // #781 — skip empty bodies: a cancelled-turn marker (an empty assistant
+ // row closing an aborted turn) carries nothing to extract from.
+ if (!content.trim()) continue;
turns.push({ role: m.role, content });
}
return turns;
diff --git a/src/lib/analytics/score/__tests__/reader.test.ts b/src/lib/analytics/score/__tests__/reader.test.ts
index 3a68a6356..bafbffde0 100644
--- a/src/lib/analytics/score/__tests__/reader.test.ts
+++ b/src/lib/analytics/score/__tests__/reader.test.ts
@@ -1,7 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
-import { readActivityDays, readFastingGlucoseMeasurements } from "../reader";
+import {
+ computeUserHealthScore,
+ readActivityDays,
+ readFastingGlucoseMeasurements,
+} from "../reader";
function dbWithRows(rows: unknown[]) {
return {
@@ -107,6 +111,51 @@ describe("score reader bounds and source parity", () => {
expect(result.days).toEqual([{ day: "2026-07-02", value: 1_000 }]);
});
+ it("reads an empty labs set as not_tracked, never read_failed", async () => {
+ // A labs-enabled account with no lab rows has honestly recorded
+ // nothing: the read SUCCEEDED and found an empty set. `read_failed`
+ // is reserved for a read that threw — showing it here would offer a
+ // Retry that can never help.
+ const db = {
+ measurement: { findMany: vi.fn().mockResolvedValue([]) },
+ mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
+ labResult: { findMany: vi.fn().mockResolvedValue([]) },
+ dismissedPriorityItem: { findUnique: vi.fn().mockResolvedValue(null) },
+ } as unknown as PrismaClient;
+
+ const report = await computeUserHealthScore({
+ prisma: db,
+ userId: "user-1",
+ now: new Date("2026-07-28T12:00:00.000Z"),
+ profile: {
+ dateOfBirth: new Date("1985-07-09"),
+ heightCm: 178,
+ timezone: "Europe/Berlin",
+ sourcePriorityJson: null,
+ thresholdsJson: null,
+ },
+ modules: {
+ glucose: true,
+ labs: true,
+ sleep: true,
+ mentalHealth: true,
+ },
+ healthScoreConfigJson: null,
+ bpTargets: null,
+ bpEnvelope: null,
+ bpEnvelopePriorWeek: null,
+ bpEnvelopePriorTwoWeeks: null,
+ });
+
+ for (const id of ["GLYCAEMIA", "LIPIDS"] as const) {
+ const pillar = report.pillars.find((p) => p.id === id)!;
+ expect(pillar.result.status).toBe("insufficient");
+ expect(
+ pillar.result.status === "insufficient" && pillar.result.reason,
+ ).toBe("not_tracked");
+ }
+ });
+
it("excludes source writers outside the current 28-day score window", async () => {
const db = dbWithRows([
stepRow("2026-06-15T09:00:00.000Z", 8_000, "watch", "APPLE_HEALTH"),
diff --git a/src/lib/analytics/score/reader.ts b/src/lib/analytics/score/reader.ts
index 22e636641..989ce5b44 100644
--- a/src/lib/analytics/score/reader.ts
+++ b/src/lib/analytics/score/reader.ts
@@ -443,8 +443,8 @@ function scoreInputsFor(args: {
biomarker: {
name: string;
unit: string;
- referenceLow: number | null;
- referenceHigh: number | null;
+ lowerBound: number | null;
+ upperBound: number | null;
} | null;
}>
>;
@@ -597,9 +597,8 @@ function scoreInputsFor(args: {
marker: row.biomarker?.name ?? row.analyte,
value: row.value!,
unit: row.unit || row.biomarker?.unit || "",
- referenceLow: row.referenceLow ?? row.biomarker?.referenceLow ?? null,
- referenceHigh:
- row.referenceHigh ?? row.biomarker?.referenceHigh ?? null,
+ referenceLow: row.referenceLow ?? row.biomarker?.lowerBound ?? null,
+ referenceHigh: row.referenceHigh ?? row.biomarker?.upperBound ?? null,
panel: row.panel,
at: row.takenAt,
source: row.source,
@@ -708,12 +707,18 @@ export async function computeUserHealthScore(
panel: true,
takenAt: true,
source: true,
+ // The catalog's range columns are `lowerBound` / `upperBound`
+ // (`lower_bound` / `upper_bound`), not the LabResult's own
+ // `reference*` names. Prisma rejects a select against names a
+ // model does not have at query-validation time — before any
+ // row is read — so getting this wrong fails EVERY labs read,
+ // not just rows that use the catalog fallback.
biomarker: {
select: {
name: true,
unit: true,
- referenceLow: true,
- referenceHigh: true,
+ lowerBound: true,
+ upperBound: true,
},
},
},
diff --git a/src/lib/documents/__tests__/ai-route-support-limit.test.ts b/src/lib/documents/__tests__/ai-route-support-limit.test.ts
new file mode 100644
index 000000000..d2a4b1110
--- /dev/null
+++ b/src/lib/documents/__tests__/ai-route-support-limit.test.ts
@@ -0,0 +1,94 @@
+/**
+ * The document-AI hourly ceiling is an operator setting
+ * (`DOCUMENT_AI_LIMIT_PER_HOUR`), not a constant. This pins the resolver's
+ * contract: unset / non-numeric falls back to the default 6, a numeric value
+ * takes effect, and the clamp keeps a typo from zeroing the surface out
+ * (floor 1) or removing the cap entirely (ceiling 1000).
+ *
+ * Also pins the shared 429 builder: the response mirrors the bucket's reset
+ * instant into `meta.retryAt` so the client can name the ACTUAL wait instead
+ * of a fixed "a few minutes".
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ inboundDocument: { findFirst: vi.fn() },
+ $queryRaw: vi.fn(),
+ $executeRaw: vi.fn(),
+ },
+}));
+vi.mock("@/lib/documents/store", () => ({
+ decryptDocumentContent: vi.fn(),
+}));
+vi.mock("@/lib/documents/rasterize-pdf", () => ({
+ rasterizePdf: vi.fn(),
+}));
+vi.mock("@/lib/labs/ocr-upload", () => ({
+ detectOcrMimeType: vi.fn(),
+}));
+
+import {
+ documentAiRateLimited,
+ resolveDocumentAiLimitPerHour,
+} from "../ai-route-support";
+
+const ORIGINAL = process.env.DOCUMENT_AI_LIMIT_PER_HOUR;
+
+beforeEach(() => {
+ delete process.env.DOCUMENT_AI_LIMIT_PER_HOUR;
+});
+
+afterEach(() => {
+ if (ORIGINAL === undefined) delete process.env.DOCUMENT_AI_LIMIT_PER_HOUR;
+ else process.env.DOCUMENT_AI_LIMIT_PER_HOUR = ORIGINAL;
+});
+
+describe("resolveDocumentAiLimitPerHour", () => {
+ it("defaults to 6 when the variable is unset", () => {
+ expect(resolveDocumentAiLimitPerHour()).toBe(6);
+ });
+
+ it("takes an operator value from the environment", () => {
+ process.env.DOCUMENT_AI_LIMIT_PER_HOUR = "12";
+ expect(resolveDocumentAiLimitPerHour()).toBe(12);
+ });
+
+ it("falls back to the default on a non-numeric value", () => {
+ process.env.DOCUMENT_AI_LIMIT_PER_HOUR = "plenty";
+ expect(resolveDocumentAiLimitPerHour()).toBe(6);
+ });
+
+ it("floors at 1 so a zero/negative typo cannot zero the surface out", () => {
+ process.env.DOCUMENT_AI_LIMIT_PER_HOUR = "0";
+ expect(resolveDocumentAiLimitPerHour()).toBe(1);
+ process.env.DOCUMENT_AI_LIMIT_PER_HOUR = "-4";
+ expect(resolveDocumentAiLimitPerHour()).toBe(1);
+ });
+
+ it("caps at 1000 so a typo cannot remove the ceiling entirely", () => {
+ process.env.DOCUMENT_AI_LIMIT_PER_HOUR = "500000";
+ expect(resolveDocumentAiLimitPerHour()).toBe(1000);
+ });
+});
+
+describe("documentAiRateLimited", () => {
+ it("mirrors the reset instant into meta.retryAt and the headers", async () => {
+ const resetAt = Date.now() + 42 * 60_000;
+ const response = documentAiRateLimited({
+ allowed: false,
+ remaining: 0,
+ resetAt,
+ });
+ expect(response.status).toBe(429);
+ expect(response.headers.get("X-RateLimit-Reset")).toBe(
+ new Date(resetAt).toISOString(),
+ );
+ const body = (await response.json()) as {
+ error: string | null;
+ meta?: { errorCode?: string; retryAt?: string };
+ };
+ expect(body.meta?.errorCode).toBe("documents.inbound.rateLimited");
+ expect(body.meta?.retryAt).toBe(new Date(resetAt).toISOString());
+ });
+});
diff --git a/src/lib/documents/ai-route-support.ts b/src/lib/documents/ai-route-support.ts
index a974ad5ab..e294000fe 100644
--- a/src/lib/documents/ai-route-support.ts
+++ b/src/lib/documents/ai-route-support.ts
@@ -9,16 +9,88 @@
*/
import { Buffer } from "node:buffer";
+import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import { decryptDocumentContent } from "@/lib/documents/store";
import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
+import {
+ checkRateLimit,
+ rateLimitHeaders,
+ refundRateLimit,
+ type RateLimitResult,
+} from "@/lib/rate-limit";
+
+/**
+ * The document-AI actions (extract / suggest / summary / index) share one
+ * per-user hourly egress bucket. The ceiling is operator-tunable via
+ * `DOCUMENT_AI_LIMIT_PER_HOUR` (default 6, clamped to 1–1000 so a typo can
+ * neither zero the surface out nor remove the cap entirely) — same posture as
+ * `INSIGHTS_RATE_LIMIT_PER_HOUR` on the briefing route. Unset / non-numeric
+ * falls back to the default.
+ */
+const DOCUMENT_AI_LIMIT_DEFAULT = 6;
+const DOCUMENT_AI_LIMIT_MIN = 1;
+const DOCUMENT_AI_LIMIT_MAX = 1000;
+
+export function resolveDocumentAiLimitPerHour(): number {
+ const raw = process.env.DOCUMENT_AI_LIMIT_PER_HOUR;
+ if (!raw) return DOCUMENT_AI_LIMIT_DEFAULT;
+ const parsed = Number.parseInt(raw, 10);
+ if (!Number.isFinite(parsed)) return DOCUMENT_AI_LIMIT_DEFAULT;
+ return Math.min(
+ DOCUMENT_AI_LIMIT_MAX,
+ Math.max(DOCUMENT_AI_LIMIT_MIN, parsed),
+ );
+}
-/** The three AI actions share the extract route's 6/hour egress bucket. */
-export const DOCUMENT_AI_LIMIT_PER_HOUR = 6;
export const DOCUMENT_AI_WINDOW_MS = 60 * 60 * 1000;
/** The shared rate-limit bucket key prefix (identical to the extract route). */
export const DOCUMENT_AI_BUCKET = "documents-inbound";
+
+/** Charge one slot of the shared per-user document-AI bucket. */
+export async function checkDocumentAiRateLimit(
+ userId: string,
+): Promise {
+ return checkRateLimit(
+ `${DOCUMENT_AI_BUCKET}:${userId}`,
+ resolveDocumentAiLimitPerHour(),
+ DOCUMENT_AI_WINDOW_MS,
+ );
+}
+
+/**
+ * Give a charged slot back when the request failed BEFORE the metered work
+ * (provider dispatch, or the local index/echo work on the no-egress paths).
+ * The bucket is charged early so a 429 stays cheap, but a slot must reflect
+ * work actually performed — a malformed body, an unreadable original, or an
+ * exhausted budget dispatched nothing and burns no allowance. Best-effort:
+ * a failed refund never masks the response the caller is about to return.
+ */
+export async function refundDocumentAiSlot(userId: string): Promise {
+ try {
+ await refundRateLimit(`${DOCUMENT_AI_BUCKET}:${userId}`);
+ } catch {
+ // The original (error) response matters more than the bookkeeping.
+ }
+}
+
+/**
+ * The shared 429 for the document-AI bucket. Carries the standard
+ * `X-RateLimit-*` headers AND mirrors the reset instant into the error meta
+ * (`retryAt`, ISO 8601) so the client can tell the user the ACTUAL wait —
+ * the window is an hour, not "a few minutes".
+ */
+export function documentAiRateLimited(rl: RateLimitResult): Response {
+ const response = apiError("Too many document AI requests this hour.", 429, {
+ errorCode: "documents.inbound.rateLimited",
+ retryAt: new Date(rl.resetAt).toISOString(),
+ });
+ for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
+ response.headers.set(k, v);
+ }
+ return response;
+}
/** Cap on a browser-OCR text body (mirrors the extract text mode). */
export const DOCUMENT_AI_TEXT_BODY_MAX_BYTES = 512 * 1024;
diff --git a/src/lib/import/__tests__/unzip-export-xml.test.ts b/src/lib/import/__tests__/unzip-export-xml.test.ts
index 3863f79b8..10046cbaf 100644
--- a/src/lib/import/__tests__/unzip-export-xml.test.ts
+++ b/src/lib/import/__tests__/unzip-export-xml.test.ts
@@ -142,6 +142,30 @@ describe("extractExportXml", () => {
expect(extracted.toString("utf8")).toBe(payload.toString("utf8"));
});
+ // (issue #775) — the preflight hook sees the declared sizes before
+ // any extraction work starts; throwing there must abort the extract
+ // with the hook's error.
+ it("aborts before extraction when the preflight hook throws", async () => {
+ const payload = Buffer.from(
+ ``,
+ );
+ const zip = buildMinimalZip("apple_health_export/export.xml", payload);
+ const tmp = mkdtempSync(join(tmpdir(), "healthlog-unzip-"));
+ const zipPath = join(tmp, "export.zip");
+ writeFileSync(zipPath, zip);
+
+ let seenDeclaredBytes = -1;
+ await expect(
+ extractExportXml(zipPath, {
+ preflight: ({ declaredXmlBytes }) => {
+ seenDeclaredBytes = declaredXmlBytes;
+ throw new Error("insufficient_memory: preflight refused");
+ },
+ }),
+ ).rejects.toThrow(/insufficient_memory/);
+ expect(seenDeclaredBytes).toBe(payload.length);
+ });
+
it("throws when the export.xml member is missing", async () => {
const payload = Buffer.from("noop");
const zip = buildMinimalZip("other-file.txt", payload);
diff --git a/src/lib/import/unzip-export-xml.ts b/src/lib/import/unzip-export-xml.ts
index 6379ce0ec..3a45b91a3 100644
--- a/src/lib/import/unzip-export-xml.ts
+++ b/src/lib/import/unzip-export-xml.ts
@@ -19,39 +19,34 @@
* - Zip64 — supported for the central directory record (Apple
* exports easily push past the 4 GB limit on multi-year accounts).
*
- * v1.32.1 (issue #588) — extracting the member used to run through
- * `inflateRawSync()` on a single in-memory buffer holding the WHOLE
- * inflated `export.xml` (up to `MAX_DECOMPRESSED_BYTES`) at the same
- * time the compressed-archive buffer was still resident, then
- * `writeFileSync()`'d the result in one blocking call. `extractExportXml`
- * runs on the same event loop as the web server in the default
- * single-container deployment (`src/instrumentation.ts` starts the
- * pg-boss worker in-process), so that synchronous path could pin the
- * Node main thread for minutes on a real multi-year export — freezing
- * every other request the app was serving — while briefly needing
- * roughly 2x the decompressed size in JS heap. A user hitting this saw
- * an import that never leaves "Unpacking the archive…": the phase
- * label is written to `ImportJob.status` BEFORE extraction starts and
- * only advances once `extractExportXml()` returns, so a process that
- * dies mid-extraction (OOM, restart) before that return leaves the row
- * silently stuck with no failure ever recorded. The member now streams
- * through `zlib.createInflateRaw()` into a file write stream —
- * decompression work moves off the JS event loop onto the libuv
- * threadpool in bounded chunks, and peak memory is bounded by the
- * chunk size rather than the whole decompressed payload. The
- * `MAX_DECOMPRESSED_BYTES` zip-bomb defence moves with it: the
- * streaming inflate API has no `maxOutputLength` option, so a
- * byte-counting transform enforces the cap on the real output as it
- * flows.
+ * v1.32.1 (issue #588) moved the member extraction from a synchronous
+ * whole-buffer `inflateRawSync()` onto a streamed inflate pipeline.
+ * (issue #775) finishes the job for the archive itself: the extractor
+ * used to `readFileSync()` the ENTIRE `export.zip` into one Buffer —
+ * hundreds of MB resident for the whole unpack phase on a real
+ * multi-year export, on top of everything else the worker holds. The
+ * archive is now read through a file handle: the EOCD is located in a
+ * bounded tail read (≤ 64 KiB + 22 bytes), the central directory is
+ * read as one size-capped slice, and the member bytes flow through a
+ * byte-range `createReadStream` → inflate → byte-cap → file pipeline.
+ * Peak memory is bounded by stream buffer sizes and the (small)
+ * central directory, independent of archive size.
*
* Locks per `.planning/research/v1434-r-1-xml-import.md` §6.1.
*/
-import { readFileSync, createWriteStream, statSync, unlinkSync } from "node:fs";
+import {
+ createReadStream,
+ createWriteStream,
+ statSync,
+ unlinkSync,
+} from "node:fs";
+import { open, type FileHandle } from "node:fs/promises";
import { createInflateRaw } from "node:zlib";
-import { Readable, Transform } from "node:stream";
+import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
+import { randomBytes } from "node:crypto";
/** Signature bytes for the End-Of-Central-Directory record. */
const EOCD_SIGNATURE = 0x06054b50;
@@ -78,8 +73,17 @@ const MAX_DECOMPRESSED_BYTES = 8 * 1024 * 1024 * 1024;
*/
const MAX_COMPRESSION_RATIO = 200;
-/** Chunk size fed into the inflate/write pipeline per `write()` call. */
-const STREAM_CHUNK_BYTES = 1 * 1024 * 1024;
+/**
+ * Cap on the central-directory slice the extractor is willing to hold
+ * in memory. A real Apple export's central directory is a few KB (one
+ * XML member + optional ECG CSVs, ~100 bytes per entry); 64 MiB gives
+ * six orders of magnitude of headroom while refusing a forged EOCD
+ * that advertises a multi-GB directory purely to force an allocation.
+ */
+const MAX_CENTRAL_DIRECTORY_BYTES = 64 * 1024 * 1024;
+
+/** EOCD is 22 bytes plus an up-to-64 KiB trailing comment. */
+const MAX_EOCD_SEARCH = 22 + 0x10000;
/** A single entry within the archive's central directory. */
interface CentralDirectoryEntry {
@@ -104,6 +108,31 @@ export interface UnzipResult {
otherMembers: { name: string; bytes: number }[];
}
+/**
+ * Central-directory metadata for the `export.xml` member, surfaced to
+ * the optional preflight hook BEFORE any extraction work starts. The
+ * declared sizes come from the archive and are attacker-controlled;
+ * they are suitable for a resource preflight, not for trust decisions
+ * (the byte-counting cap enforces the real output).
+ */
+export interface ExportXmlPreflightInfo {
+ /** Uncompressed size of export.xml as declared by the archive. */
+ declaredXmlBytes: number;
+ /** Compressed size of the export.xml member inside the archive. */
+ compressedXmlBytes: number;
+}
+
+export interface ExtractExportXmlOptions {
+ /**
+ * Called once with the member's declared sizes after the central
+ * directory is read but before extraction begins. Throwing here
+ * aborts the extraction with the thrown error — the worker uses it
+ * to refuse an import the runtime demonstrably cannot carry, before
+ * minutes of inflate work are spent.
+ */
+ preflight?: (info: ExportXmlPreflightInfo) => void | Promise;
+}
+
/**
* Walk the central directory of `archivePath` and write the
* `apple_health_export/export.xml` member out to a temp file.
@@ -112,75 +141,247 @@ export interface UnzipResult {
*/
export async function extractExportXml(
archivePath: string,
+ options: ExtractExportXmlOptions = {},
): Promise {
- const buf = readFileSync(archivePath);
- const entries = readCentralDirectory(buf);
+ const handle = await open(archivePath, "r");
+ try {
+ const stat = await handle.stat();
+ const fileSize = Number(stat.size);
+ if (!Number.isSafeInteger(fileSize)) {
+ throw new Error("Archive is too large to address safely");
+ }
+ const entries = await readCentralDirectoryFromFile(handle, fileSize);
- const exportXmlEntry = entries.find(
- (e) => e.fileName.endsWith("/export.xml") || e.fileName === "export.xml",
- );
- if (!exportXmlEntry) {
- throw new Error(
- "Archive is missing the `apple_health_export/export.xml` member" +
- " — is this a valid Apple Health export.zip?",
+ const exportXmlEntry = entries.find(
+ (e) => e.fileName.endsWith("/export.xml") || e.fileName === "export.xml",
);
- }
+ if (!exportXmlEntry) {
+ throw new Error(
+ "Archive is missing the `apple_health_export/export.xml` member" +
+ " — is this a valid Apple Health export.zip?",
+ );
+ }
+ if (
+ exportXmlEntry.compressionMethod !== 0 &&
+ exportXmlEntry.compressionMethod !== 8
+ ) {
+ throw new Error(
+ `Unsupported ZIP compression method ${exportXmlEntry.compressionMethod}` +
+ " for export.xml (expected 0=stored or 8=deflate)",
+ );
+ }
+
+ // Pre-flight zip-bomb defence. The central directory's advertised
+ // `uncompressedSize` is attacker-controlled (a malicious archive can
+ // lie), so this catches honest-but-oversized payloads early; the
+ // load-bearing defence is the byte-counting cap inside
+ // `streamEntryToFile()` which trips on the actual inflate output.
+ if (exportXmlEntry.uncompressedSize > MAX_DECOMPRESSED_BYTES) {
+ throw new Error(
+ `export.xml declares an uncompressed size of ${exportXmlEntry.uncompressedSize} bytes` +
+ ` — refusing to extract (cap is ${MAX_DECOMPRESSED_BYTES} bytes).`,
+ );
+ }
+ if (
+ exportXmlEntry.compressedSize > 0 &&
+ exportXmlEntry.uncompressedSize / exportXmlEntry.compressedSize >
+ MAX_COMPRESSION_RATIO
+ ) {
+ throw new Error(
+ `export.xml advertises a ${(
+ exportXmlEntry.uncompressedSize / exportXmlEntry.compressedSize
+ ).toFixed(0)}× compression ratio (cap is ${MAX_COMPRESSION_RATIO}×)` +
+ " — refusing as a suspected zip bomb.",
+ );
+ }
+
+ if (options.preflight) {
+ await options.preflight({
+ declaredXmlBytes: exportXmlEntry.uncompressedSize,
+ compressedXmlBytes: exportXmlEntry.compressedSize,
+ });
+ }
+
+ // Unguessable temp name: the extraction target lives in the shared
+ // system tmpdir, and a predictable name (timestamp + Math.random) is
+ // what a local pre-creation or symlink attack needs.
+ const xmlPath = join(
+ tmpdir(),
+ `healthlog-import-${randomBytes(12).toString("hex")}.xml`,
+ );
+ const xmlBytes = await streamEntryToFile(
+ handle,
+ archivePath,
+ fileSize,
+ exportXmlEntry,
+ xmlPath,
+ );
+
+ const otherMembers = entries
+ .filter((e) => e !== exportXmlEntry)
+ .map((e) => ({ name: e.fileName, bytes: e.uncompressedSize }));
+
+ return {
+ xmlPath,
+ xmlBytes,
+ otherMembers,
+ };
+ } finally {
+ await handle.close();
+ }
+}
+
+/** Read exactly `length` bytes at `position` or throw on truncation. */
+async function readExactly(
+ handle: FileHandle,
+ length: number,
+ position: number,
+): Promise {
if (
- exportXmlEntry.compressionMethod !== 0 &&
- exportXmlEntry.compressionMethod !== 8
+ !Number.isSafeInteger(length) ||
+ !Number.isSafeInteger(position) ||
+ length < 0 ||
+ position < 0
) {
- throw new Error(
- `Unsupported ZIP compression method ${exportXmlEntry.compressionMethod}` +
- " for export.xml (expected 0=stored or 8=deflate)",
- );
+ throw new Error("Invalid ZIP read bounds");
}
-
- // Pre-flight zip-bomb defence. The central directory's advertised
- // `uncompressedSize` is attacker-controlled (a malicious archive can
- // lie), so this catches honest-but-oversized payloads early; the
- // load-bearing defence is the byte-counting cap inside
- // `streamEntryToFile()` which trips on the actual inflate output.
- if (exportXmlEntry.uncompressedSize > MAX_DECOMPRESSED_BYTES) {
- throw new Error(
- `export.xml declares an uncompressed size of ${exportXmlEntry.uncompressedSize} bytes` +
- ` — refusing to extract (cap is ${MAX_DECOMPRESSED_BYTES} bytes).`,
+ const buffer = Buffer.allocUnsafe(length);
+ let offset = 0;
+ while (offset < length) {
+ const read = await handle.read(
+ buffer,
+ offset,
+ length - offset,
+ position + offset,
);
+ if (read.bytesRead === 0) {
+ throw new Error("Truncated ZIP archive");
+ }
+ offset += read.bytesRead;
}
- if (
- exportXmlEntry.compressedSize > 0 &&
- exportXmlEntry.uncompressedSize / exportXmlEntry.compressedSize >
- MAX_COMPRESSION_RATIO
- ) {
- throw new Error(
- `export.xml advertises a ${(
- exportXmlEntry.uncompressedSize / exportXmlEntry.compressedSize
- ).toFixed(0)}× compression ratio (cap is ${MAX_COMPRESSION_RATIO}×)` +
- " — refusing as a suspected zip bomb.",
- );
- }
-
- const xmlPath = join(
- tmpdir(),
- `healthlog-import-${Date.now()}-${Math.random().toString(36).slice(2, 10)}.xml`,
- );
- const xmlBytes = await streamEntryToFile(buf, exportXmlEntry, xmlPath);
-
- const otherMembers = entries
- .filter((e) => e !== exportXmlEntry)
- .map((e) => ({ name: e.fileName, bytes: e.uncompressedSize }));
-
- return {
- xmlPath,
- xmlBytes,
- otherMembers,
- };
+ return buffer;
}
/**
- * Walk the ZIP central directory and return one descriptor per member.
- * Exported for unit testing the central-directory parser independently
- * of the file-extraction path.
+ * EOCD fields after Zip64 resolution — enough to locate and size the
+ * central directory.
+ */
+interface EocdSummary {
+ entryCount: number;
+ centralDirOffset: number;
+ centralDirSize: number;
+}
+
+/**
+ * Resolve the (possibly Zip64) EOCD from a tail slice. `readAt` supplies
+ * absolute-offset reads for the Zip64 locator/record, which can sit
+ * outside the tail slice on a large archive; the buffer-based caller
+ * passes a same-buffer reader.
+ */
+async function resolveEocd(
+ tail: Buffer,
+ tailFileOffset: number,
+ eocdOffsetInTail: number,
+ readAt: (length: number, position: number) => Promise,
+): Promise {
+ let centralDirSize = tail.readUInt32LE(eocdOffsetInTail + 12);
+ let centralDirOffset = tail.readUInt32LE(eocdOffsetInTail + 16);
+ let entryCount = tail.readUInt16LE(eocdOffsetInTail + 10);
+
+ // Zip64 handling — when any of the three count/offset fields is the
+ // 0xFFFFFFFF / 0xFFFF sentinel, the real value lives in the Zip64
+ // EOCD record located by walking back through the locator.
+ if (
+ centralDirOffset === 0xffffffff ||
+ centralDirSize === 0xffffffff ||
+ entryCount === 0xffff
+ ) {
+ const locatorFileOffset = tailFileOffset + eocdOffsetInTail - 20;
+ if (locatorFileOffset < 0) {
+ throw new Error("Zip64 sentinels present but Zip64 locator missing");
+ }
+ const locator = await readAt(20, locatorFileOffset);
+ if (locator.readUInt32LE(0) !== ZIP64_EOCD_LOCATOR) {
+ throw new Error("Zip64 sentinels present but Zip64 locator missing");
+ }
+ const zip64EocdOffset = Number(locator.readBigUInt64LE(8));
+ const zip64Eocd = await readAt(56, zip64EocdOffset);
+ if (zip64Eocd.readUInt32LE(0) !== ZIP64_EOCD_RECORD) {
+ throw new Error("Zip64 EOCD record signature mismatch");
+ }
+ entryCount = Number(zip64Eocd.readBigUInt64LE(32));
+ centralDirSize = Number(zip64Eocd.readBigUInt64LE(40));
+ centralDirOffset = Number(zip64Eocd.readBigUInt64LE(48));
+ }
+
+ return { entryCount, centralDirOffset, centralDirSize };
+}
+
+/**
+ * Locate and parse the central directory through the file handle,
+ * without ever holding more than the tail slice + the central
+ * directory itself in memory.
+ */
+async function readCentralDirectoryFromFile(
+ handle: FileHandle,
+ fileSize: number,
+): Promise {
+ if (fileSize < 22) {
+ throw new Error("Could not locate ZIP End-Of-Central-Directory record");
+ }
+ const tailLength = Math.min(fileSize, MAX_EOCD_SEARCH);
+ const tailFileOffset = fileSize - tailLength;
+ const tail = await readExactly(handle, tailLength, tailFileOffset);
+ const eocdOffsetInTail = locateEocd(tail);
+ if (eocdOffsetInTail === -1) {
+ throw new Error("Could not locate ZIP End-Of-Central-Directory record");
+ }
+
+ const summary = await resolveEocd(
+ tail,
+ tailFileOffset,
+ eocdOffsetInTail,
+ (length, position) => readExactly(handle, length, position),
+ );
+ validateCentralDirectoryBounds(summary, fileSize);
+
+ const centralDir = await readExactly(
+ handle,
+ summary.centralDirSize,
+ summary.centralDirOffset,
+ );
+ return parseCentralDirectory(
+ centralDir,
+ summary.centralDirOffset,
+ summary.entryCount,
+ );
+}
+
+function validateCentralDirectoryBounds(
+ summary: EocdSummary,
+ fileSize: number,
+): void {
+ if (summary.centralDirSize > MAX_CENTRAL_DIRECTORY_BYTES) {
+ throw new Error(
+ `ZIP central directory declares ${summary.centralDirSize} bytes` +
+ ` — refusing (cap is ${MAX_CENTRAL_DIRECTORY_BYTES} bytes).`,
+ );
+ }
+ if (
+ summary.centralDirOffset < 0 ||
+ summary.centralDirSize < 0 ||
+ summary.centralDirOffset + summary.centralDirSize > fileSize
+ ) {
+ throw new Error("ZIP central directory bounds exceed the archive");
+ }
+}
+
+/**
+ * Walk the ZIP central directory of a fully-buffered archive and return
+ * one descriptor per member. Kept as a Buffer-based API for unit tests
+ * and small in-memory archives; the production extraction path reads
+ * the directory through a file handle instead of buffering the archive.
*/
export function readCentralDirectory(buf: Buffer): CentralDirectoryEntry[] {
const eocdOffset = locateEocd(buf);
@@ -188,13 +389,26 @@ export function readCentralDirectory(buf: Buffer): CentralDirectoryEntry[] {
throw new Error("Could not locate ZIP End-Of-Central-Directory record");
}
+ // The whole archive is in `buf`, so absolute-offset reads are slices.
+ const resolved = resolveEocdSync(buf, eocdOffset);
+ validateCentralDirectoryBounds(resolved, buf.length);
+ const centralDir = buf.subarray(
+ resolved.centralDirOffset,
+ resolved.centralDirOffset + resolved.centralDirSize,
+ );
+ return parseCentralDirectory(
+ centralDir,
+ resolved.centralDirOffset,
+ resolved.entryCount,
+ );
+}
+
+/** Synchronous EOCD/Zip64 resolution over a fully-buffered archive. */
+function resolveEocdSync(buf: Buffer, eocdOffset: number): EocdSummary {
let centralDirSize = buf.readUInt32LE(eocdOffset + 12);
let centralDirOffset = buf.readUInt32LE(eocdOffset + 16);
let entryCount = buf.readUInt16LE(eocdOffset + 10);
- // Zip64 handling — when any of the three count/offset fields is the
- // 0xFFFFFFFF / 0xFFFF sentinel, the real value lives in the Zip64
- // EOCD record located by walking back through the locator.
if (
centralDirOffset === 0xffffffff ||
centralDirSize === 0xffffffff ||
@@ -215,30 +429,47 @@ export function readCentralDirectory(buf: Buffer): CentralDirectoryEntry[] {
centralDirSize = Number(buf.readBigUInt64LE(zip64EocdOffset + 40));
centralDirOffset = Number(buf.readBigUInt64LE(zip64EocdOffset + 48));
}
+ return { entryCount, centralDirOffset, centralDirSize };
+}
+/**
+ * Parse central-directory entries from a slice that starts at the
+ * directory's first byte. `baseOffset` is the slice's absolute file
+ * offset, used only for error reporting symmetry — entry offsets in
+ * the records themselves are already absolute.
+ */
+function parseCentralDirectory(
+ centralDir: Buffer,
+ baseOffset: number,
+ entryCount: number,
+): CentralDirectoryEntry[] {
+ void baseOffset;
const entries: CentralDirectoryEntry[] = [];
- let cursor = centralDirOffset;
- const end = centralDirOffset + centralDirSize;
+ let cursor = 0;
+ const end = centralDir.length;
while (cursor < end && entries.length < entryCount) {
- if (buf.readUInt32LE(cursor) !== CENTRAL_FILE_HEADER) {
+ if (cursor + 46 > end) {
+ throw new Error(`Central directory entry ${entries.length} is truncated`);
+ }
+ if (centralDir.readUInt32LE(cursor) !== CENTRAL_FILE_HEADER) {
throw new Error(
`Central directory entry ${entries.length} has wrong signature`,
);
}
- const compressionMethod = buf.readUInt16LE(cursor + 10);
- const flags = buf.readUInt16LE(cursor + 8);
+ const compressionMethod = centralDir.readUInt16LE(cursor + 10);
+ const flags = centralDir.readUInt16LE(cursor + 8);
if (flags & 0x0001) {
throw new Error("Encrypted ZIP entries are not supported");
}
- const compressedSize32 = buf.readUInt32LE(cursor + 20);
- const uncompressedSize32 = buf.readUInt32LE(cursor + 24);
- const fileNameLen = buf.readUInt16LE(cursor + 28);
- const extraLen = buf.readUInt16LE(cursor + 30);
- const commentLen = buf.readUInt16LE(cursor + 32);
- const localHeaderOffset32 = buf.readUInt32LE(cursor + 42);
+ const compressedSize32 = centralDir.readUInt32LE(cursor + 20);
+ const uncompressedSize32 = centralDir.readUInt32LE(cursor + 24);
+ const fileNameLen = centralDir.readUInt16LE(cursor + 28);
+ const extraLen = centralDir.readUInt16LE(cursor + 30);
+ const commentLen = centralDir.readUInt16LE(cursor + 32);
+ const localHeaderOffset32 = centralDir.readUInt32LE(cursor + 42);
- const fileName = buf
- .slice(cursor + 46, cursor + 46 + fileNameLen)
+ const fileName = centralDir
+ .subarray(cursor + 46, cursor + 46 + fileNameLen)
.toString("utf8");
let compressedSize = compressedSize32;
@@ -255,21 +486,21 @@ export function readCentralDirectory(buf: Buffer): CentralDirectoryEntry[] {
let extraCursor = extraStart;
const extraEnd = extraStart + extraLen;
while (extraCursor + 4 <= extraEnd) {
- const headerId = buf.readUInt16LE(extraCursor);
- const dataSize = buf.readUInt16LE(extraCursor + 2);
+ const headerId = centralDir.readUInt16LE(extraCursor);
+ const dataSize = centralDir.readUInt16LE(extraCursor + 2);
if (headerId === 0x0001) {
// Zip64 extended-info extra field
let dCursor = extraCursor + 4;
if (uncompressedSize === 0xffffffff) {
- uncompressedSize = Number(buf.readBigUInt64LE(dCursor));
+ uncompressedSize = Number(centralDir.readBigUInt64LE(dCursor));
dCursor += 8;
}
if (compressedSize === 0xffffffff) {
- compressedSize = Number(buf.readBigUInt64LE(dCursor));
+ compressedSize = Number(centralDir.readBigUInt64LE(dCursor));
dCursor += 8;
}
if (localHeaderOffset === 0xffffffff) {
- localHeaderOffset = Number(buf.readBigUInt64LE(dCursor));
+ localHeaderOffset = Number(centralDir.readBigUInt64LE(dCursor));
dCursor += 8;
}
break;
@@ -292,17 +523,6 @@ export function readCentralDirectory(buf: Buffer): CentralDirectoryEntry[] {
return entries;
}
-/**
- * Split a Buffer into fixed-size slices (no copying — `subarray` views
- * share the backing memory) so the inflate/write pipeline processes the
- * entry in bounded chunks instead of one giant `write()` call.
- */
-function* chunkBuffer(buf: Buffer, size: number): Generator {
- for (let offset = 0; offset < buf.length; offset += size) {
- yield buf.subarray(offset, Math.min(offset + size, buf.length));
- }
-}
-
/**
* Byte-counting passthrough that refuses to forward more than `limit`
* bytes — the streaming equivalent of `inflateRawSync`'s
@@ -334,38 +554,53 @@ export function createByteCap(limit: number): Transform {
/**
* Stream a single ZIP entry's bytes out to `destPath`, decompressing
- * through `zlib.createInflateRaw()` when needed. Runs entirely off the
- * JS main thread (stream I/O + the libuv-threadpool-backed zlib
- * binding), so a multi-GB member no longer blocks the event loop the
- * way the old `inflateRawSync()` + `writeFileSync()` pair did. Returns
- * the number of bytes written.
+ * through `zlib.createInflateRaw()` when needed. The compressed bytes
+ * are read through a byte-range file stream rather than an archive-wide
+ * buffer, so peak memory is bounded by stream buffer sizes regardless
+ * of archive size, and the decompression work runs off the JS event
+ * loop on the libuv threadpool. Returns the number of bytes written.
*/
async function streamEntryToFile(
- buf: Buffer,
+ handle: FileHandle,
+ archivePath: string,
+ fileSize: number,
entry: CentralDirectoryEntry,
destPath: string,
): Promise {
- const local = entry.localHeaderOffset;
- if (buf.readUInt32LE(local) !== LOCAL_FILE_HEADER) {
+ const local = await readExactly(handle, 30, entry.localHeaderOffset);
+ if (local.readUInt32LE(0) !== LOCAL_FILE_HEADER) {
throw new Error(
`Local file header for ${entry.fileName} has wrong signature`,
);
}
- const localFileNameLen = buf.readUInt16LE(local + 26);
- const localExtraLen = buf.readUInt16LE(local + 28);
- const dataStart = local + 30 + localFileNameLen + localExtraLen;
- const compressed = buf.subarray(dataStart, dataStart + entry.compressedSize);
+ const localFileNameLen = local.readUInt16LE(26);
+ const localExtraLen = local.readUInt16LE(28);
+ const dataStart =
+ entry.localHeaderOffset + 30 + localFileNameLen + localExtraLen;
+ if (dataStart + entry.compressedSize > fileSize) {
+ throw new Error(
+ `Member data for ${entry.fileName} exceeds the archive bounds`,
+ );
+ }
- const source = Readable.from(chunkBuffer(compressed, STREAM_CHUNK_BYTES));
const dest = createWriteStream(destPath);
const cap = createByteCap(MAX_DECOMPRESSED_BYTES);
try {
- if (entry.compressionMethod === 0) {
- await pipeline(source, cap, dest);
+ if (entry.compressedSize === 0) {
+ // Empty member — nothing to stream; still create the file.
+ await pipeline([], cap, dest);
} else {
- // Method 8 — DEFLATE, streamed through the async zlib Transform.
- await pipeline(source, createInflateRaw(), cap, dest);
+ const source = createReadStream(archivePath, {
+ start: dataStart,
+ end: dataStart + entry.compressedSize - 1,
+ });
+ if (entry.compressionMethod === 0) {
+ await pipeline(source, cap, dest);
+ } else {
+ // Method 8 — DEFLATE, streamed through the async zlib Transform.
+ await pipeline(source, createInflateRaw(), cap, dest);
+ }
}
} catch (err) {
// A mid-stream failure (byte cap trip, corrupt deflate stream) can
@@ -388,7 +623,7 @@ async function streamEntryToFile(
* the Health export but we tolerate up to 64 KB just in case.
*/
function locateEocd(buf: Buffer): number {
- const searchStart = Math.max(0, buf.length - (22 + 0x10000));
+ const searchStart = Math.max(0, buf.length - MAX_EOCD_SEARCH);
for (let i = buf.length - 22; i >= searchStart; i--) {
if (buf.readUInt32LE(i) === EOCD_SIGNATURE) return i;
}
diff --git a/src/lib/jobs/__tests__/apple-health-import-memory-preflight.test.ts b/src/lib/jobs/__tests__/apple-health-import-memory-preflight.test.ts
new file mode 100644
index 000000000..2e871db9d
--- /dev/null
+++ b/src/lib/jobs/__tests__/apple-health-import-memory-preflight.test.ts
@@ -0,0 +1,62 @@
+/**
+ * (issue #775) Memory preflight for the Apple Health import. The
+ * streaming parse keeps memory bounded, so the preflight is generous —
+ * it exists to refuse only the truly impossible combination (a tiny
+ * pinned heap against a huge declared export) with an actionable
+ * reason, instead of letting the worker die mid-parse and leave the
+ * job stuck in a running state.
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ IMPORT_MEMORY_PREFLIGHT_BASE_BYTES,
+ IMPORT_MEMORY_PREFLIGHT_XML_DIVISOR,
+ INSUFFICIENT_MEMORY_REASON_PREFIX,
+ importMemoryPreflightReason,
+} from "../apple-health-import-worker";
+
+const MIB = 1024 * 1024;
+const GIB = 1024 * MIB;
+
+describe("importMemoryPreflightReason", () => {
+ it("passes a large real-world export against any default heap", () => {
+ // A 3 GiB export.xml (238 MB export.zip scale) against a 1 GiB
+ // default heap — the reported self-hoster setup — must NOT be
+ // refused: the streaming parse carries it.
+ expect(importMemoryPreflightReason(3 * GIB, 1 * GIB)).toBeNull();
+ });
+
+ it("passes the largest extractable export against the default heap", () => {
+ // 8 GiB is the archive-level extraction cap; the model needs
+ // ~320 MiB for it, far below any default heap.
+ expect(importMemoryPreflightReason(8 * GIB, 1 * GIB)).toBeNull();
+ });
+
+ it("refuses when the pinned heap cannot carry the declared export", () => {
+ const reason = importMemoryPreflightReason(8 * GIB, 256 * MIB);
+ expect(reason).not.toBeNull();
+ expect(reason).toMatch(
+ new RegExp(`^${INSUFFICIENT_MEMORY_REASON_PREFIX}: `),
+ );
+ // The reason must be actionable for the operator: name the knob.
+ expect(reason).toContain("NODE_OPTIONS=--max-old-space-size=");
+ // And honest about the numbers involved.
+ expect(reason).toContain("8192 MiB uncompressed");
+ expect(reason).toContain("256 MiB");
+ });
+
+ it("scales the requirement with the declared XML size", () => {
+ const declared = 4 * GIB;
+ const required =
+ IMPORT_MEMORY_PREFLIGHT_BASE_BYTES +
+ Math.ceil(declared / IMPORT_MEMORY_PREFLIGHT_XML_DIVISOR);
+ expect(importMemoryPreflightReason(declared, required)).toBeNull();
+ expect(importMemoryPreflightReason(declared, required - 1)).not.toBeNull();
+ });
+
+ it("stays comfortably under the 1000-char failureReason column slice", () => {
+ const reason = importMemoryPreflightReason(8 * GIB, 1);
+ expect(reason).not.toBeNull();
+ expect((reason as string).length).toBeLessThan(500);
+ });
+});
diff --git a/src/lib/jobs/__tests__/document-content-index-backfill.test.ts b/src/lib/jobs/__tests__/document-content-index-backfill.test.ts
index dff362c26..b0817c6ff 100644
--- a/src/lib/jobs/__tests__/document-content-index-backfill.test.ts
+++ b/src/lib/jobs/__tests__/document-content-index-backfill.test.ts
@@ -18,6 +18,9 @@ vi.mock("@/lib/documents/store", () => ({
vi.mock("@/lib/labs/ocr-upload", () => ({
detectOcrMimeType: vi.fn(() => "image/png"),
}));
+vi.mock("@/lib/documents/rasterize-pdf", () => ({
+ rasterizePdf: vi.fn(),
+}));
vi.mock("@/lib/documents/describe", () => ({
transcribeDocument: vi.fn().mockResolvedValue({ text: "glucose 90" }),
DocumentDescribeError: class DocumentDescribeError extends Error {},
@@ -42,6 +45,9 @@ vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
import { runContentIndexBackfillForUser } from "../document-content-index-backfill";
import { prisma } from "@/lib/db";
+import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
+import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
+import { transcribeDocument } from "@/lib/documents/describe";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import {
assertDocumentEgressConsent,
@@ -86,7 +92,12 @@ describe("runContentIndexBackfillForUser", () => {
pick: null,
} as never);
const result = await runContentIndexBackfillForUser("user-1");
- expect(result).toEqual({ indexed: 0, reason: "no-provider" });
+ expect(result).toEqual({
+ indexed: 0,
+ skipped: 0,
+ failed: 0,
+ reason: "no-provider",
+ });
expect(upsertContentIndex).not.toHaveBeenCalled();
});
@@ -96,7 +107,12 @@ describe("runContentIndexBackfillForUser", () => {
new ConsentRequiredError("insights" as never),
);
const result = await runContentIndexBackfillForUser("user-1");
- expect(result).toEqual({ indexed: 0, reason: "no-consent" });
+ expect(result).toEqual({
+ indexed: 0,
+ skipped: 0,
+ failed: 0,
+ reason: "no-consent",
+ });
expect(upsertContentIndex).not.toHaveBeenCalled();
});
@@ -110,10 +126,89 @@ describe("runContentIndexBackfillForUser", () => {
] as never);
const result = await runContentIndexBackfillForUser("user-1");
- expect(result).toEqual({ indexed: 2, reason: "ok" });
+ expect(result).toEqual({
+ indexed: 2,
+ skipped: 0,
+ failed: 0,
+ reason: "ok",
+ });
expect(upsertContentIndex).toHaveBeenCalledTimes(2);
});
+ it("includes PDFs as candidates for an image-input provider and rasterises them", async () => {
+ // The per-document authority (`prepareVisionInput`) renders a PDF to page
+ // images when the provider cannot take PDFs natively. The candidate MIME
+ // prefilter must not be stricter than that, or "index all documents"
+ // silently leaves every PDF out for exactly those providers.
+ vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
+ chain: [{ providerType: "codex", instance: {} }],
+ pick: {
+ entry: { providerType: "codex", instance: {} },
+ providerType: "codex",
+ pdfSupported: false,
+ },
+ } as never);
+ vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
+ { id: "pdf-1" },
+ ] as never);
+ vi.mocked(detectOcrMimeType).mockReturnValueOnce(
+ "application/pdf" as never,
+ );
+ vi.mocked(rasterizePdf).mockResolvedValue({
+ ok: true,
+ images: [{ mediaType: "image/png", dataBase64: "cGFnZQ==" }],
+ } as never);
+
+ const result = await runContentIndexBackfillForUser("user-1");
+
+ // The discovery walk asked for PDFs despite pdfSupported=false…
+ const where = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]?.[0]
+ ?.where as { mimeType: { in: string[] } };
+ expect(where.mimeType.in).toContain("application/pdf");
+ // …and the document reached `prepareVisionInput`, which rasterised it.
+ expect(transcribeDocument).toHaveBeenCalledWith(
+ expect.objectContaining({
+ images: [{ mediaType: "image/png", dataBase64: "cGFnZQ==" }],
+ documents: [],
+ }),
+ );
+ expect(result).toEqual({
+ indexed: 1,
+ skipped: 0,
+ failed: 0,
+ reason: "ok",
+ });
+ });
+
+ it("counts a PDF the rasteriser cannot render as skipped, not failed", async () => {
+ vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
+ chain: [{ providerType: "codex", instance: {} }],
+ pick: {
+ entry: { providerType: "codex", instance: {} },
+ providerType: "codex",
+ pdfSupported: false,
+ },
+ } as never);
+ vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
+ { id: "pdf-1" },
+ ] as never);
+ vi.mocked(detectOcrMimeType).mockReturnValueOnce(
+ "application/pdf" as never,
+ );
+ vi.mocked(rasterizePdf).mockResolvedValue({ ok: false } as never);
+
+ const result = await runContentIndexBackfillForUser("user-1");
+ // No provider call happened, so this is a skip (retryable later), and the
+ // summary says so instead of folding it into silence.
+ expect(transcribeDocument).not.toHaveBeenCalled();
+ expect(result).toEqual({
+ indexed: 0,
+ skipped: 1,
+ failed: 0,
+ reason: "ok",
+ });
+ });
+
it("stops when the daily budget is reached (resumable)", async () => {
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValueOnce([
@@ -128,7 +223,12 @@ describe("runContentIndexBackfillForUser", () => {
.mockResolvedValue({ allowed: true, reserved: 1 } as never);
const result = await runContentIndexBackfillForUser("user-1");
- expect(result).toEqual({ indexed: 1, reason: "budget-reached" });
+ expect(result).toEqual({
+ indexed: 1,
+ skipped: 0,
+ failed: 0,
+ reason: "budget-reached",
+ });
expect(upsertContentIndex).toHaveBeenCalledTimes(1);
});
});
diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts
index 542ecb941..60cc8b8e9 100644
--- a/src/lib/jobs/apple-health-import-worker.ts
+++ b/src/lib/jobs/apple-health-import-worker.ts
@@ -25,6 +25,7 @@
* Locks per `.planning/research/v1434-r-1-xml-import.md` §5.1.
*/
import { unlinkSync } from "node:fs";
+import { getHeapStatistics } from "node:v8";
import type { PrismaClient } from "@/generated/prisma/client";
import { prisma, toJson } from "@/lib/db";
import type { Job } from "pg-boss";
@@ -115,6 +116,59 @@ const APPLE_HEALTH_ECG_MAX_SAMPLES = 2_000_000;
/** Concurrency cap per worker host. */
export const APPLE_HEALTH_IMPORT_CONCURRENCY = 1;
+/**
+ * (issue #775) Memory preflight for the parse. The streaming pipeline
+ * keeps per-record work off the heap, so what remains resident scales
+ * with data SPAN (cumulative day-buckets, per-type counters, batch
+ * buffers), not record count — a small fraction of the XML size. The
+ * model below is deliberately generous: a fixed base for parser +
+ * batches + framework, plus 1/64 of the declared XML size for the
+ * span-bounded fold state. An 8 GiB export (the archive-level cap)
+ * needs ~320 MiB under this model — any default Node heap clears it.
+ * The preflight only refuses the truly impossible, e.g. an operator
+ * who pinned --max-old-space-size far below that, and it refuses
+ * BEFORE minutes of unpack work and with an actionable reason instead
+ * of an OOM-killed worker and a job stuck in "running".
+ */
+export const IMPORT_MEMORY_PREFLIGHT_BASE_BYTES = 192 * 1024 * 1024;
+export const IMPORT_MEMORY_PREFLIGHT_XML_DIVISOR = 64;
+
+/**
+ * Machine-readable prefix of the preflight refusal. The web UI keys
+ * its translated message off this prefix (mirroring the bare
+ * `interrupted_by_restart` code the reconcile writes); the rest of the
+ * string stays honest English for the status API and logs.
+ */
+export const INSUFFICIENT_MEMORY_REASON_PREFIX = "insufficient_memory";
+
+const MIB = 1024 * 1024;
+
+/**
+ * Returns `null` when the runtime heap can carry the declared export,
+ * otherwise the `failureReason` string for the refused import.
+ * `heapLimitBytes` is injectable for tests; production reads the real
+ * V8 limit, which reflects `NODE_OPTIONS=--max-old-space-size`.
+ */
+export function importMemoryPreflightReason(
+ declaredXmlBytes: number,
+ heapLimitBytes: number = getHeapStatistics().heap_size_limit,
+): string | null {
+ const requiredBytes =
+ IMPORT_MEMORY_PREFLIGHT_BASE_BYTES +
+ Math.ceil(declaredXmlBytes / IMPORT_MEMORY_PREFLIGHT_XML_DIVISOR);
+ if (heapLimitBytes >= requiredBytes) return null;
+ const requiredMib = Math.ceil(requiredBytes / MIB);
+ const suggestedMib = Math.max(512, requiredMib);
+ return (
+ `${INSUFFICIENT_MEMORY_REASON_PREFIX}: export.xml declares ` +
+ `${Math.ceil(declaredXmlBytes / MIB)} MiB uncompressed; parsing it needs ` +
+ `roughly ${requiredMib} MiB of heap but the Node.js heap limit is ` +
+ `${Math.floor(heapLimitBytes / MIB)} MiB. Raise it with ` +
+ `NODE_OPTIONS=--max-old-space-size=${suggestedMib} on the container ` +
+ `that runs the worker, then upload the export again.`
+ );
+}
+
/**
* v1.28.33 (issue #486) — job-level pg-boss overrides for the import
* sends. The queue defaults (retryLimit 2, expireInSeconds 900) are
@@ -325,7 +379,15 @@ export async function handleAppleHealthImport(
elapsedMs: 0,
});
- const unzip = await extractExportXml(uploadPath);
+ const unzip = await extractExportXml(uploadPath, {
+ // Refuse an export the heap demonstrably cannot carry BEFORE the
+ // multi-minute unpack, with an actionable reason instead of an
+ // OOM-killed worker (issue #775).
+ preflight: ({ declaredXmlBytes }) => {
+ const reason = importMemoryPreflightReason(declaredXmlBytes);
+ if (reason) throw new Error(reason);
+ },
+ });
extractedXmlPath = unzip.xmlPath;
// Phase 2 + 3: parsing + upserting (the parser tracks both
diff --git a/src/lib/jobs/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts
index fa5f17f05..8ddd81594 100644
--- a/src/lib/jobs/document-content-index-backfill.ts
+++ b/src/lib/jobs/document-content-index-backfill.ts
@@ -15,10 +15,12 @@
*
* Bounded + resumable: an id-cursor forward walk over the not-yet-indexed set,
* capped at `MAX_DOCS_PER_RUN` provider calls per job and stopped the moment the
- * daily AI budget is reached. Only vision-indexable MIME types (image + PDF when
- * the provider reads PDFs) are candidates, so the walk converges — an
- * un-indexable file never re-queues work. A re-run picks up where budget /
- * the cap left off because already-indexed documents drop out of the set.
+ * daily AI budget is reached. Only vision-indexable MIME types (images + PDFs —
+ * `prepareVisionInput` passes a PDF natively when the provider reads PDFs and
+ * rasterises it to page images otherwise) are candidates, so the walk
+ * converges — an un-indexable file never re-queues work. A re-run picks up
+ * where budget / the cap left off because already-indexed documents drop out
+ * of the set.
*
* The queue MUST be registered in the maintenance registrar
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it.
@@ -66,6 +68,14 @@ export interface ContentIndexBackfillPayload {
export interface ContentIndexBackfillSummary {
indexed: number;
+ /**
+ * Documents visited but not indexable this run (gone by load time,
+ * undecryptable, or a PDF no rasteriser could render). Refunded, never
+ * billed, and left un-indexed for a later run.
+ */
+ skipped: number;
+ /** Documents whose provider call failed. Refunded; a later run retries. */
+ failed: number;
reason: "ok" | "no-provider" | "no-consent" | "budget-reached";
}
@@ -78,7 +88,8 @@ export async function runContentIndexBackfillForUser(
userId: string,
): Promise {
const { pick } = await resolveDocumentVisionProvider(userId);
- if (!pick) return { indexed: 0, reason: "no-provider" };
+ if (!pick)
+ return { indexed: 0, skipped: 0, failed: 0, reason: "no-provider" };
try {
await assertDocumentEgressConsent({
userId,
@@ -87,17 +98,23 @@ export async function runContentIndexBackfillForUser(
});
} catch (err) {
if (err instanceof ConsentRequiredError) {
- return { indexed: 0, reason: "no-consent" };
+ return { indexed: 0, skipped: 0, failed: 0, reason: "no-consent" };
}
throw err;
}
- const candidateMimes = pick.pdfSupported
- ? [...IMAGE_MIMES, "application/pdf"]
- : [...IMAGE_MIMES];
+ // PDFs are always candidates: `prepareVisionInput` passes them natively
+ // when the provider reads PDFs and rasterises them to page images
+ // otherwise — the same path the per-document index route takes. The
+ // prefilter here only trims the walk; it must never be stricter than
+ // that per-document authority, or an image-input provider silently
+ // leaves every PDF out of "index all documents".
+ const candidateMimes = [...IMAGE_MIMES, "application/pdf"];
const dailyCap = resolveDailyCap([{ providerType: pick.entry.providerType }]);
let indexed = 0;
+ let skipped = 0;
+ let failed = 0;
let reason: ContentIndexBackfillSummary["reason"] = "ok";
let cursor: string | null = null;
@@ -135,13 +152,17 @@ export async function runContentIndexBackfillForUser(
const document = await loadOwnedDocument(userId, id);
if (!document) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
+ skipped += 1;
continue;
}
const vision = await prepareVisionInput(document, pick.pdfSupported);
if (!vision.ok) {
- // Not vision-indexable (should be filtered by MIME, but guard) — refund
- // and move the cursor past it so the walk keeps converging.
+ // Not vision-indexable after all (undecryptable, unreadable bytes,
+ // or a PDF the rasteriser could not render) — refund and move on so
+ // the walk keeps converging. Counted as skipped, not failed: no
+ // provider call happened.
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
+ skipped += 1;
continue;
}
@@ -170,6 +191,7 @@ export async function runContentIndexBackfillForUser(
// A provider miss on one document must not abort the batch — refund and
// leave it un-indexed (a later run retries it).
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
+ failed += 1;
}
}
@@ -179,9 +201,9 @@ export async function runContentIndexBackfillForUser(
annotate({
action: { name: "documents.contentIndex.backfill" },
- meta: { indexed, reason },
+ meta: { indexed, skipped, failed, reason },
});
- return { indexed, reason };
+ return { indexed, skipped, failed, reason };
}
/**
diff --git a/src/lib/jobs/job-outcome.ts b/src/lib/jobs/job-outcome.ts
index 72e390f2b..8120255e9 100644
--- a/src/lib/jobs/job-outcome.ts
+++ b/src/lib/jobs/job-outcome.ts
@@ -75,7 +75,9 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([
"dispatched_period_soon",
"document_purge_deleted",
"documents",
+ "documents_failed",
"documents_indexed",
+ "documents_skipped",
"dose_changes_migrated",
"downstream_failed",
"drained",
diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts
index 4a815dc03..b12a8207f 100644
--- a/src/lib/jobs/reminder/register-maintenance.ts
+++ b/src/lib/jobs/reminder/register-maintenance.ts
@@ -1148,6 +1148,8 @@ export async function registerMaintenanceQueues(
let users = 0;
let skipped = 0;
let documentsIndexed = 0;
+ let documentsSkipped = 0;
+ let documentsFailed = 0;
for (const job of jobs) {
const { userId } = job.data;
if (!userId) {
@@ -1155,13 +1157,19 @@ export async function registerMaintenanceQueues(
continue;
}
try {
- const { indexed, reason } =
- await runContentIndexBackfillForUser(userId);
+ const {
+ indexed,
+ skipped: docsSkipped,
+ failed: docsFailed,
+ reason,
+ } = await runContentIndexBackfillForUser(userId);
users++;
documentsIndexed += indexed;
+ documentsSkipped += docsSkipped;
+ documentsFailed += docsFailed;
workerLog(
"info",
- `[document-content-index-backfill] user=${userId} indexed=${indexed} reason=${reason}`,
+ `[document-content-index-backfill] user=${userId} indexed=${indexed} skipped=${docsSkipped} failed=${docsFailed} reason=${reason}`,
);
} catch (err) {
recordError();
@@ -1178,6 +1186,8 @@ export async function registerMaintenanceQueues(
users,
skipped,
documents_indexed: documentsIndexed,
+ documents_skipped: documentsSkipped,
+ documents_failed: documentsFailed,
});
},
);
diff --git a/src/lib/measurements/__tests__/import-large-export-memory.test.ts b/src/lib/measurements/__tests__/import-large-export-memory.test.ts
new file mode 100644
index 000000000..941843fa6
--- /dev/null
+++ b/src/lib/measurements/__tests__/import-large-export-memory.test.ts
@@ -0,0 +1,409 @@
+/**
+ * (issue #775) — bounded-memory regression proof for the Apple Health
+ * import. A self-hoster's 238 MB export.zip with ~11 million records
+ * exhausted the default 1 GB Node heap: the cumulative fold hashed the
+ * raw `device` attribute (which embeds a volatile HKDevice runtime
+ * address) into its per-day source buckets, fragmenting the map toward
+ * one entry per record, and the extractor buffered the whole archive.
+ *
+ * This suite generates a synthetic archive on the fly — hundreds of
+ * thousands of records streamed into a temp ZIP, never checked in and
+ * never held in memory whole — and pushes it through the REAL pipeline
+ * (extractExportXml → streamParseExportXml), sampling
+ * `process.memoryUsage().heapUsed` throughout. The heap-growth bound it
+ * asserts is a small fraction of the archive's decompressed size; both
+ * historical failure modes (unbatched row accumulation, per-record
+ * source buckets) blow past it.
+ */
+import { describe, expect, it, vi } from "vitest";
+import {
+ createWriteStream,
+ mkdtempSync,
+ rmSync,
+ statSync,
+ unlinkSync,
+} from "node:fs";
+import { open } from "node:fs/promises";
+import { once } from "node:events";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import type { Writable } from "node:stream";
+import { setFlagsFromString } from "node:v8";
+import { runInNewContext } from "node:vm";
+
+// Plain async stubs, deliberately NOT vi.fn(): a vi.fn() retains every
+// call's arguments, and the arrival emitter receives every inserted row
+// — the harness itself would hold hundreds of thousands of rows live
+// and drown the very measurement this test exists to take.
+vi.mock("@/lib/arrivals/measurement-emit", () => ({
+ emitInsertedMeasurementArrivals: async () => undefined,
+}));
+
+vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
+ maybeEnqueueMorningRefresh: async () => undefined,
+}));
+
+vi.mock("@/lib/arrivals/emit-shared", () => ({
+ emitDataArrival: async () => undefined,
+}));
+
+import type { PrismaClient } from "@/generated/prisma/client";
+import { extractExportXml } from "@/lib/import/unzip-export-xml";
+import { streamParseExportXml } from "../import-apple-health-export";
+
+/** Record volume — large enough that unbounded accumulation is obvious. */
+const STEP_RECORDS = 500_000;
+const SPOT_RECORDS = 200_000;
+const TOTAL_RECORDS = STEP_RECORDS + SPOT_RECORDS;
+/** Distinct local days the cumulative records spread across. */
+const STEP_DAYS = 700;
+
+/**
+ * Heap-growth ceiling for the whole run. The decompressed fixture is
+ * several times this bound (asserted below), so the parse provably does
+ * not scale its heap with the data volume. Both watched failure modes
+ * exceed it: 700k unflushed prepared rows and 500k fragmented source
+ * buckets each retain well over this amount.
+ */
+const MAX_HEAP_GROWTH_BYTES = 48 * 1024 * 1024;
+
+/** The fixture must dwarf the heap bound for the assertion to mean much. */
+const MIN_XML_BYTES = 2 * MAX_HEAP_GROWTH_BYTES;
+
+/**
+ * The heap samples must measure LIVE state, not garbage a lazy major GC
+ * has not collected yet — the test runner's multi-GB heap lets V8 defer
+ * collection long enough to drown the signal in transient allocations.
+ * Vitest does not run with `--expose-gc`, so obtain a `gc()` handle via
+ * the documented late-flag route and collect before measuring.
+ */
+setFlagsFromString("--expose-gc");
+const forceGc: () => void = runInNewContext("gc");
+setFlagsFromString("--no-expose-gc");
+
+function pad(n: number, width: number): string {
+ return String(n).padStart(width, "0");
+}
+
+/** Apple's export timestamp format: `YYYY-MM-DD HH:MM:SS +0100`. */
+function appleDate(dayIndex: number, secondOfDay: number): string {
+ const base = Date.UTC(2020, 0, 1) + dayIndex * 86_400_000;
+ const d = new Date(base + secondOfDay * 1000);
+ return (
+ `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1, 2)}-${pad(d.getUTCDate(), 2)} ` +
+ `${pad(d.getUTCHours(), 2)}:${pad(d.getUTCMinutes(), 2)}:${pad(d.getUTCSeconds(), 2)} +0000`
+ );
+}
+
+async function writeChunk(stream: Writable, chunk: string): Promise {
+ if (!stream.write(chunk)) {
+ await once(stream, "drain");
+ }
+}
+
+/**
+ * Stream a synthetic `export.xml` to disk. Cumulative step records carry
+ * a fresh HKDevice runtime address per record — exactly what Apple's
+ * export writer produces and what fragmented the source buckets before
+ * the fix. Spot body-mass rows interleave so the spot batch flush path
+ * runs continuously.
+ */
+async function writeSyntheticExportXml(xmlPath: string): Promise {
+ const stream = createWriteStream(xmlPath);
+ await writeChunk(
+ stream,
+ `\n\n` +
+ ` \n`,
+ );
+
+ let steps = 0;
+ let spots = 0;
+ let buffer: string[] = [];
+ for (let i = 0; i < TOTAL_RECORDS; i += 1) {
+ // Interleave 5 cumulative records for every 2 spot records.
+ const wantStep = i % 7 < 5 && steps < STEP_RECORDS;
+ if (wantStep || spots >= SPOT_RECORDS) {
+ const day = steps % STEP_DAYS;
+ const second = 21_600 + (steps % 43_200);
+ const start = appleDate(day, second);
+ const end = appleDate(day, second + 60);
+ // A unique runtime address per record, as Apple serialises it.
+ const device =
+ `<<HKDevice: 0x${(0x280000000 + steps).toString(16)}>, ` +
+ `name:iPhone, manufacturer:Apple Inc., model:iPhone, ` +
+ `hardware:iPhone14,2, software:17.4>`;
+ buffer.push(
+ `\n`,
+ );
+ steps += 1;
+ } else {
+ const day = spots % STEP_DAYS;
+ const second = spots % 86_340;
+ const start = appleDate(day, second);
+ const end = appleDate(day, second + 30);
+ buffer.push(
+ `\n`,
+ );
+ spots += 1;
+ }
+ if (buffer.length >= 2_000) {
+ await writeChunk(stream, buffer.join(""));
+ buffer = [];
+ }
+ }
+ if (buffer.length > 0) await writeChunk(stream, buffer.join(""));
+ await writeChunk(stream, `\n`);
+ stream.end();
+ await once(stream, "finish");
+}
+
+/**
+ * Wrap an on-disk file into a single-member STORED (method 0) ZIP by
+ * streaming the payload — the archive is never buffered whole. The
+ * extractor does not verify CRC-32, so the fixture writes 0 there.
+ */
+async function wrapFileInStoredZip(
+ payloadPath: string,
+ zipPath: string,
+ memberName: string,
+): Promise {
+ const payloadBytes = statSync(payloadPath).size;
+ const nameBuf = Buffer.from(memberName, "utf8");
+
+ const localHeader = Buffer.alloc(30);
+ localHeader.writeUInt32LE(0x04034b50, 0);
+ localHeader.writeUInt16LE(20, 4);
+ localHeader.writeUInt16LE(0, 6);
+ localHeader.writeUInt16LE(0, 8); // method 0 = stored
+ localHeader.writeUInt32LE(0, 14); // crc32 — not verified by the extractor
+ localHeader.writeUInt32LE(payloadBytes, 18);
+ localHeader.writeUInt32LE(payloadBytes, 22);
+ localHeader.writeUInt16LE(nameBuf.length, 26);
+
+ const cdh = Buffer.alloc(46);
+ cdh.writeUInt32LE(0x02014b50, 0);
+ cdh.writeUInt16LE(20, 4);
+ cdh.writeUInt16LE(20, 6);
+ cdh.writeUInt16LE(0, 10); // method 0
+ cdh.writeUInt32LE(0, 16); // crc32
+ cdh.writeUInt32LE(payloadBytes, 20);
+ cdh.writeUInt32LE(payloadBytes, 24);
+ cdh.writeUInt16LE(nameBuf.length, 28);
+ cdh.writeUInt32LE(0, 42); // local header offset
+
+ const eocd = Buffer.alloc(22);
+ eocd.writeUInt32LE(0x06054b50, 0);
+ eocd.writeUInt16LE(1, 8);
+ eocd.writeUInt16LE(1, 10);
+ eocd.writeUInt32LE(46 + nameBuf.length, 12);
+ eocd.writeUInt32LE(30 + nameBuf.length + payloadBytes, 16);
+
+ const out = createWriteStream(zipPath);
+ out.write(localHeader);
+ out.write(nameBuf);
+ const payload = await open(payloadPath, "r");
+ try {
+ for await (const chunk of payload.createReadStream()) {
+ if (!out.write(chunk)) await once(out, "drain");
+ }
+ } finally {
+ // createReadStream closes the handle on end.
+ }
+ out.write(cdh);
+ out.write(nameBuf);
+ out.write(eocd);
+ out.end();
+ await once(out, "finish");
+}
+
+/**
+ * Discarding Prisma stand-in: counts writes, retains nothing, so any
+ * heap growth the sampler sees belongs to the pipeline under test.
+ */
+function makeDiscardingPrisma(sample: () => void) {
+ let nextId = 1;
+ let measurementInserts = 0;
+ let workoutInserts = 0;
+ let cumulativeCreates = 0;
+
+ const fake = {
+ counters: {
+ get measurementInserts() {
+ return measurementInserts;
+ },
+ get workoutInserts() {
+ return workoutInserts;
+ },
+ get cumulativeCreates() {
+ return cumulativeCreates;
+ },
+ },
+ $queryRaw: async () => [],
+ $executeRawUnsafe: async () => 0,
+ $transaction: async (
+ callback: (tx: unknown) => Promise,
+ ): Promise => callback(fake),
+ measurement: {
+ create: async ({ data }: { data: Record }) => {
+ cumulativeCreates += 1;
+ sample();
+ return {
+ id: `m${nextId++}`,
+ type: data.type,
+ value: data.value,
+ unit: data.unit,
+ measuredAt: data.measuredAt,
+ externalId: data.externalId,
+ deletedAt: null,
+ syncVersion: 1,
+ aggregationProvenance: data.aggregationProvenance ?? null,
+ };
+ },
+ createManyAndReturn: async ({
+ data,
+ }: {
+ data: Array>;
+ }) => {
+ measurementInserts += data.length;
+ sample();
+ return data.map((row) => ({
+ id: `m${nextId++}`,
+ type: row.type,
+ measuredAt: row.measuredAt,
+ externalId: row.externalId,
+ }));
+ },
+ findMany: async () => [],
+ findFirst: async () => null,
+ update: async () => {
+ throw new Error("unexpected measurement.update in insert-only run");
+ },
+ },
+ workout: {
+ createManyAndReturn: async ({
+ data,
+ }: {
+ data: Array>;
+ }) => {
+ workoutInserts += data.length;
+ sample();
+ return data.map((row) => ({
+ id: `w${nextId++}`,
+ startedAt: row.startedAt,
+ externalId: row.externalId,
+ }));
+ },
+ update: async () => {
+ throw new Error("unexpected workout.update in insert-only run");
+ },
+ },
+ };
+ return fake;
+}
+
+describe("large-export bounded-memory regression (issue #775)", () => {
+ it(
+ "parses a multi-hundred-MB archive with bounded heap growth",
+ { timeout: 180_000 },
+ async () => {
+ const tmp = mkdtempSync(join(tmpdir(), "healthlog-large-import-"));
+ const xmlSourcePath = join(tmp, "export.xml");
+ const zipPath = join(tmp, "export.zip");
+ let extractedXmlPath: string | null = null;
+
+ try {
+ await writeSyntheticExportXml(xmlSourcePath);
+ await wrapFileInStoredZip(
+ xmlSourcePath,
+ zipPath,
+ "apple_health_export/export.xml",
+ );
+ // The generator's own garbage must not count against the parse.
+ unlinkSync(xmlSourcePath);
+
+ // Two measurement channels:
+ // - `measureLive()` collects first, so the reading reflects
+ // retained state only. It runs on every progress tick —
+ // ticks fire throughout the parse whether or not batches
+ // flush, which is exactly where an accumulation regression
+ // shows up as live memory.
+ // - the flush-path `sample()` stays GC-free (a collection per
+ // flush would dominate runtime) and feeds the informational
+ // raw peak in the log line.
+ let peakLiveHeap = 0;
+ let peakRawHeap = 0;
+ let liveMeasurements = 0;
+ const measureLive = (): void => {
+ forceGc();
+ liveMeasurements += 1;
+ const used = process.memoryUsage().heapUsed;
+ if (used > peakLiveHeap) peakLiveHeap = used;
+ };
+ const sample = (): void => {
+ const used = process.memoryUsage().heapUsed;
+ if (used > peakRawHeap) peakRawHeap = used;
+ };
+ const prisma = makeDiscardingPrisma(sample);
+
+ forceGc();
+ const baselineHeapUsed = process.memoryUsage().heapUsed;
+ peakLiveHeap = baselineHeapUsed;
+ peakRawHeap = baselineHeapUsed;
+
+ const unzip = await extractExportXml(zipPath);
+ extractedXmlPath = unzip.xmlPath;
+ measureLive();
+ expect(unzip.xmlBytes).toBeGreaterThan(MIN_XML_BYTES);
+
+ const result = await streamParseExportXml({
+ xmlPath: unzip.xmlPath,
+ userId: "user-large-import",
+ userTimezone: "Europe/Berlin",
+ prisma: prisma as unknown as PrismaClient,
+ onProgress: () => {
+ measureLive();
+ },
+ });
+ measureLive();
+
+ const liveGrowth = peakLiveHeap - baselineHeapUsed;
+ // Surface the measurement in the run log so regressions in the
+ // margin are visible before they become failures.
+ console.info(
+ `[large-import] xmlBytes=${unzip.xmlBytes} ` +
+ `baselineHeap=${baselineHeapUsed} peakLiveHeap=${peakLiveHeap} ` +
+ `liveGrowth=${liveGrowth} rawPeak=${peakRawHeap} ` +
+ `liveMeasurements=${liveMeasurements}`,
+ );
+ // The progress hook must actually have sampled mid-parse, or
+ // the bound would be vacuously green.
+ expect(liveMeasurements).toBeGreaterThan(10);
+ expect(liveGrowth).toBeLessThan(MAX_HEAP_GROWTH_BYTES);
+
+ // Semantics stay intact while memory stays bounded: every
+ // record was read, every spot row flushed, and the cumulative
+ // fold produced one estimate row per (type, day) — not one per
+ // record or per runtime device address.
+ expect(result.totals.recordsRead).toBe(TOTAL_RECORDS);
+ expect(prisma.counters.measurementInserts).toBe(SPOT_RECORDS);
+ expect(prisma.counters.cumulativeCreates).toBe(STEP_DAYS);
+ expect(result.cumulativeEstimates.days).toBe(STEP_DAYS);
+ expect(result.cumulativeEstimates.rows).toBe(STEP_DAYS);
+ } finally {
+ if (extractedXmlPath) {
+ try {
+ unlinkSync(extractedXmlPath);
+ } catch {
+ // already gone
+ }
+ }
+ rmSync(tmp, { recursive: true, force: true });
+ }
+ },
+ );
+});
diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts
index 31d0b7dc2..5b03a0169 100644
--- a/src/lib/measurements/import-apple-health-export.ts
+++ b/src/lib/measurements/import-apple-health-export.ts
@@ -21,6 +21,7 @@
*/
import { createReadStream } from "node:fs";
import { createHash } from "node:crypto";
+import { StringDecoder } from "node:string_decoder";
import { Writable } from "node:stream";
import { pipeline } from "node:stream/promises";
import sax from "sax";
@@ -203,6 +204,25 @@ const UNATTRIBUTED_CUMULATIVE_SOURCE_HASH = createHash("sha256")
.update(JSON.stringify(["unattributed"]))
.digest("hex");
+/**
+ * (issue #775) Apple serialises the `device` attribute from the live
+ * HKDevice object, embedding its runtime address:
+ * `<, name:Apple Watch, …>`. That address is not
+ * stable across the export enumeration — the same physical device shows
+ * up under many addresses in one archive. Hashing it verbatim fragments
+ * the per-day source buckets toward one bucket per RECORD: an 11-million
+ * record export grows the cumulative fold map past the default 1 GB Node
+ * heap and kills the worker mid-parse. Stripping the volatile address
+ * before hashing restores the documented invariant (state bounded by
+ * actual source cardinality) — the stable parts (device name, model,
+ * hardware, software) still keep genuinely different devices apart.
+ */
+const HK_DEVICE_RUNTIME_ADDRESS = /0x[0-9a-f]+/gi;
+
+function stableDeviceIdentity(device: string | undefined): string | undefined {
+ return device?.replace(HK_DEVICE_RUNTIME_ADDRESS, "0x0");
+}
+
/**
* Hash the source tuple used only to keep overlapping export.xml contributors
* separate. Records without source metadata share one stable bucket so parser
@@ -217,7 +237,7 @@ export function hashCumulativeSourceIdentity(
const sourceTuple = [
sourceName?.trim() ?? "",
sourceVersion?.trim() ?? "",
- device?.trim() ?? "",
+ stableDeviceIdentity(device)?.trim() ?? "",
];
if (!sourceTuple.some(Boolean)) {
return UNATTRIBUTED_CUMULATIVE_SOURCE_HASH;
@@ -1013,10 +1033,16 @@ export async function streamParseExportXml(
let lastProgressEmitAt = 0;
const PROGRESS_EMIT_INTERVAL_MS = 250;
+ // A read chunk can end mid-way through a multi-byte UTF-8 sequence
+ // (umlauts in source names are routine); a bare `toString("utf8")`
+ // per chunk would decay those split characters to U+FFFD. The
+ // StringDecoder carries the partial sequence across chunk boundaries.
+ const utf8Decoder = new StringDecoder("utf8");
+
const sink = new Writable({
write(chunk: Buffer, _enc, callback) {
try {
- parser.write(chunk.toString("utf8"));
+ parser.write(utf8Decoder.write(chunk));
if (pendingError) {
callback(pendingError);
return;
@@ -1051,6 +1077,8 @@ export async function streamParseExportXml(
},
final(callback) {
try {
+ const tail = utf8Decoder.end();
+ if (tail.length > 0) parser.write(tail);
parser.close();
callback();
} catch (err) {
diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts
index 814cdfbf0..9e0336e3e 100644
--- a/src/lib/openapi/routes/coach.ts
+++ b/src/lib/openapi/routes/coach.ts
@@ -540,7 +540,7 @@ const coachMessageSchema = z
.string()
.nullable()
.describe(
- "Provider that produced the reply (e.g. anthropic, openai, local, refusal); null for user turns.",
+ "Provider that produced the reply (e.g. anthropic, openai, local, refusal). The sentinel `cancelled` marks an empty assistant row closing a turn the client aborted mid-generation (navigation away); clients should render it as an interrupted turn with a retry affordance. Null for user turns.",
),
promptVersion: z
.string()
diff --git a/src/lib/openapi/routes/consent.ts b/src/lib/openapi/routes/consent.ts
index 3de58f9a2..3fbe3be20 100644
--- a/src/lib/openapi/routes/consent.ts
+++ b/src/lib/openapi/routes/consent.ts
@@ -13,8 +13,10 @@
*
* - POST /api/consent/ai — explicit grant with a signed artefact (iOS).
* - POST /api/consent/ai/web — idempotent `ai_full` grant for the web
- * client (mints if none active; heals
- * existing accounts on the AI-settings mount).
+ * client. Without a body it is the AI-settings
+ * mount heal (never lifts a revocation); with
+ * `intent: "affirmative"` it is the explicit
+ * grant control on the same surface.
* - GET /api/consent/ai/latest — latest active receipt(s).
* - DELETE /api/consent/ai/latest — revoke the latest receipt (all kinds
* when `kind` is omitted — the master OFF).
@@ -22,7 +24,11 @@
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
-import { consentKindEnum, consentPostBody } from "@/lib/validations/consent";
+import {
+ consentKindEnum,
+ consentPostBody,
+ webConsentGrantBody,
+} from "@/lib/validations/consent";
import { dataEnvelope, stdResponses } from "./shared";
consentPostBody.meta({
@@ -31,6 +37,12 @@ consentPostBody.meta({
"Explicit AI-consent grant. `artefact` is an opaque signed receipt (base64 PDF or JWT, ≤ 64 KB UTF-8 bytes); `signedAt` is an ISO-8601 instant. Always appends a fresh row — re-granting after a revoke mints a new receipt.",
});
+webConsentGrantBody.meta({
+ id: "WebConsentGrantBody",
+ description:
+ 'Optional. Omitting the body (or sending `intent: "heal"`) is the AI-settings mount heal: it mints only for an account with no `ai_full` consent history at all, so a standing revocation is never resurrected. `intent: "affirmative"` is the user\'s own grant action and may supersede an earlier revocation, exactly as a first grant would.',
+});
+
const consentReceiptResponse = z
.object({
id: z.string(),
@@ -106,7 +118,13 @@ export const consentPaths: NonNullable = {
tags: ["Consent"],
summary: "Grant web AI consent (idempotent ai_full)",
description:
- "Mint an `ai_full` consent receipt for the calling web user if none is active; a no-op when one already exists. Mirrors the iOS master grant; the web AI-settings surface calls this on mount so existing web accounts on a shared-key deployment gain a receipt without a re-consent step. Revocation flows through DELETE /api/consent/ai/latest.",
+ 'Mint an `ai_full` consent receipt for the calling web user if none is active; a no-op when one already exists. Mirrors the iOS master grant. Without a body (the mount heal) it grants only to an account with no consent history, so a revocation stands until the user grants again; with `intent: "affirmative"` (the explicit grant control) it records the user\'s own consent act, which may supersede an earlier revocation. Revocation flows through DELETE /api/consent/ai/latest.',
+ requestBody: {
+ required: false,
+ content: {
+ "application/json": { schema: webConsentGrantBody },
+ },
+ },
responses: {
"200": {
description: "Grant outcome.",
diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts
index 3d4afe2be..5c52046f8 100644
--- a/src/lib/openapi/routes/documents.ts
+++ b/src/lib/openapi/routes/documents.ts
@@ -720,7 +720,7 @@ export const inboundDocumentPaths: NonNullable = {
tags: ["Documents"],
summary: "Extract facts from a stored document",
description:
- 'Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the stored original and stages STRUCTURED FACTS for review. AI-consent / rate / budget gated. With no provider configured this returns 422 (`documents.inbound.providerUnsupported`) — the stored document is untouched, only the enhancement fails. Two modes by content-type: VISION (empty body) decrypts and scans the stored original (PDF needs an Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: "text", text }` structures browser-OCR\'d text. Extraction reproduces what the document states — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path.',
+ 'Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the stored original and stages STRUCTURED FACTS for review. AI-consent / rate / budget gated. With no provider configured this returns 422 (`documents.inbound.providerUnsupported`) — the stored document is untouched, only the enhancement fails. Three modes: VISION (empty body) decrypts and scans the stored original (PDF needs an Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: "text", text }` structures browser-OCR\'d text; STORED (`application/json`) `{ mode: "stored" }` structures the document\'s own stored extracted text (its content index) — the manual recovery for a skipped/failed automatic staging run, 422 `documents.inbound.notIndexed` when no stored text exists. Extraction reproduces what the document states — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path.',
parameters: [
{
name: "id",
@@ -734,12 +734,17 @@ export const inboundDocumentPaths: NonNullable = {
content: {
"application/json": {
schema: z
- .object({
- mode: z.literal("text"),
- text: z.string(),
- kind: kindEnum.optional(),
- })
- .meta({ id: "InboundTextExtractRequest" }),
+ .union([
+ z.object({
+ mode: z.literal("text"),
+ text: z.string(),
+ kind: kindEnum.optional(),
+ }),
+ z.object({
+ mode: z.literal("stored"),
+ }),
+ ])
+ .meta({ id: "InboundExtractRequest" }),
},
},
},
@@ -769,7 +774,7 @@ export const inboundDocumentPaths: NonNullable = {
tags: ["Documents"],
summary: "Suggest filing metadata (drafts only)",
description:
- 'Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR\'d text (TEXT, `application/json` `{ mode: "text", text }`, opt-in local OCR) and returns a `{ title, kind, documentDate }` DRAFT for the edit form. AI-consent / rate / budget gated (shares the 6/hour extract bucket). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title is a neutral filing label.',
+ 'Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR\'d text (TEXT, `application/json` `{ mode: "text", text }`, opt-in local OCR) and returns a `{ title, kind, documentDate }` DRAFT for the edit form. AI-consent / rate / budget gated (shares the per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries the reset instant in `X-RateLimit-Reset` and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title is a neutral filing label.',
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "string" } },
],
@@ -888,7 +893,7 @@ export const inboundDocumentPaths: NonNullable = {
tags: ["Documents"],
summary: "Build / refresh the content-search index",
description:
- "Populates or refreshes one document's content-search index so search matches INSIDE its body. VISION (empty body) decrypts the stored original and runs one provider transcription (AI-consent / rate / budget gated); TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with no provider egress. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes — no plaintext body, no plaintext token. Gated on the existing AI consent (no separate per-user toggle). Idempotent; re-indexing overwrites in place.",
+ "Populates or refreshes one document's content-search index so search matches INSIDE its body. VISION (empty body) decrypts the stored original and runs one provider transcription (AI-consent / rate / budget gated); TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with no provider egress. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes — no plaintext body, no plaintext token. Gated on the existing AI consent (no separate per-user toggle). Idempotent; re-indexing overwrites in place. A successful index continues into the same lab auto-staging the background worker performs (still-STORED lab-looking document with no facts, both modules on, provider + consent eligible); `labFactsStaged` reports how many facts were staged PENDING for review — confirmation stays the only write into Labs.",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "string" } },
],
@@ -915,6 +920,11 @@ export const inboundDocumentPaths: NonNullable = {
documentId: z.string(),
indexed: z.boolean(),
tokenCount: z.number(),
+ labFactsStaged: z
+ .number()
+ .describe(
+ "How many lab facts the index run auto-staged for review (0 when the document is not a lab report, staging was not eligible, or facts already exist). Staged facts are PENDING — the confirm route remains the only write into Labs.",
+ ),
})
.meta({ id: "DocumentIndexResponse" }),
"DocumentIndexEnvelope",
diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts
index d72de6da5..b8dccc0a5 100644
--- a/src/lib/rate-limit.ts
+++ b/src/lib/rate-limit.ts
@@ -52,6 +52,30 @@ export async function checkRateLimit(
};
}
+/**
+ * Give one slot back to a bucket whose charge turned out to be premature.
+ *
+ * A caller that charges the bucket up front (to keep the 429 cheap) but then
+ * fails BEFORE doing the metered work refunds the slot so the user's allowance
+ * reflects work actually performed, not attempts. Scoped to the live window:
+ * once `reset_at` has passed the counter is stale and a decrement would bleed
+ * into the NEXT window's budget, so an expired row is left untouched. Never
+ * drops below zero (concurrent refunds after a window flip are a no-op).
+ *
+ * Known slack: if the charge's window expires and a new request opens the
+ * NEXT window before a late refund lands, the refund decrements that fresh
+ * window instead — at most one extra slot there, only on failure paths that
+ * never reached the metered work. Accepted; carrying the charge's `reset_at`
+ * through every failure path is not worth one slot of slack.
+ */
+export async function refundRateLimit(key: string): Promise {
+ await prisma.$executeRaw`
+ UPDATE rate_limits
+ SET count = GREATEST(count - 1, 0)
+ WHERE key = ${key} AND reset_at >= NOW()
+ `;
+}
+
/**
* Rate limit response headers.
*/
diff --git a/src/lib/validations/consent.ts b/src/lib/validations/consent.ts
index e02ef2731..d0d4f1fb6 100644
--- a/src/lib/validations/consent.ts
+++ b/src/lib/validations/consent.ts
@@ -52,6 +52,22 @@ export const consentPostBody = z.object({
export type ConsentPostBody = z.infer;
+/**
+ * Optional body for `POST /api/consent/ai/web`. The AI-settings mount posts
+ * no body at all (a heal: mints only for an account with no consent history);
+ * the explicit re-grant control on the same surface posts
+ * `{ intent: "affirmative" }`, the user's own consent act, which may
+ * supersede an earlier revocation the way any first grant would. The enum
+ * must stay in lockstep with `WebConsentGrantIntent` in
+ * `src/lib/consent/web-grant.ts`; the route narrows the parsed value to that
+ * type, so a drift fails the typecheck.
+ */
+export const webConsentGrantBody = z.object({
+ intent: z.enum(["heal", "affirmative"]).default("heal"),
+});
+
+export type WebConsentGrantBodyInput = z.input;
+
/**
* Query schema for both `/api/consent/ai/latest` GET and DELETE.
*
diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts
index 7f325bf07..2c8c70a25 100644
--- a/src/lib/validations/inbound-documents.ts
+++ b/src/lib/validations/inbound-documents.ts
@@ -488,6 +488,22 @@ export const inboundTextExtractSchema = z.object({
export type InboundTextExtractInput = z.infer;
+/**
+ * The stored-text extract body: structure the document's OWN stored extracted
+ * text (the content index the read/index step already produced) into staged
+ * facts — no re-upload, no second read of the original. The document detail's
+ * "Extract lab values" action posts this when an automatic staging run was
+ * skipped or failed; the server refuses with `documents.inbound.notIndexed`
+ * when there is no stored text to structure.
+ */
+export const inboundStoredExtractSchema = z.object({
+ mode: z.literal("stored"),
+});
+
+export type InboundStoredExtractInput = z.infer<
+ typeof inboundStoredExtractSchema
+>;
+
// ─── Library: store / edit / list ──────────────────────────────────────────
/** A bare YYYY-MM-DD date string (no time component). */
diff --git a/tests/integration/health-score-labs-pillars.test.ts b/tests/integration/health-score-labs-pillars.test.ts
new file mode 100644
index 000000000..00c1be13a
--- /dev/null
+++ b/tests/integration/health-score-labs-pillars.test.ts
@@ -0,0 +1,188 @@
+/**
+ * v1.37.13 — Health Score GLYCAEMIA + LIPIDS pillars through a real
+ * Postgres round trip of `GET /api/analytics`.
+ *
+ * The score reader joins each LabResult to its linked Biomarker for the
+ * canonical name, unit and reference bounds. The Biomarker columns are
+ * `lowerBound` / `upperBound`; a select that asks the relation for the
+ * LabResult's own `referenceLow` / `referenceHigh` names is rejected by
+ * the Prisma client at query-validation time — before any row is read —
+ * which turns EVERY labs read into `read_failed` and parks both pillars
+ * on "Data could not be loaded" permanently, Retry included. A mocked
+ * client cannot catch that class, so these cases run the real join.
+ *
+ * Two contracts:
+ *
+ * 1. A linked HbA1c reading and a lipid reading whose reference range
+ * lives ONLY on the biomarker catalog row (the per-row bounds left
+ * NULL) score both pillars — the join delivers name, unit and the
+ * catalog bounds.
+ * 2. A labs-enabled account with no lab rows reads as `not_tracked` —
+ * an honest "nothing recorded", never `read_failed`, which is
+ * reserved for a read that genuinely failed.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+process.env.ENCRYPTION_KEY ??=
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => {
+ cookieJar.set(name, value);
+ },
+ delete: (name: string) => {
+ cookieJar.delete(name);
+ },
+ })),
+ };
+});
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+interface PillarEnvelope {
+ id: string;
+ result: {
+ status: "ok" | "insufficient";
+ reason?: string;
+ value?: { score: number; observed: { value: number } };
+ };
+}
+
+interface AnalyticsEnvelope {
+ data: {
+ healthScore: { pillars: PillarEnvelope[] } | null;
+ } | null;
+}
+
+async function seedSession(username: string) {
+ const prisma = getPrismaClient();
+ const user = await prisma.user.create({
+ data: {
+ username,
+ email: `${username}@example.test`,
+ role: "USER",
+ heightCm: 178,
+ dateOfBirth: new Date("1985-07-09"),
+ },
+ });
+ const session = await prisma.session.create({
+ data: {
+ userId: user.id,
+ expiresAt: new Date(Date.now() + 60_000),
+ },
+ });
+ cookieJar.set("healthlog_session", session.id);
+ return user;
+}
+
+async function fetchScorePillars() {
+ const { GET } = await import("@/app/api/analytics/route");
+ const res = await (GET as (req: Request) => Promise)(
+ new Request("http://localhost/api/analytics"),
+ );
+ expect(res.status).toBe(200);
+ const env = (await res.json()) as AnalyticsEnvelope;
+ const pillars = env.data!.healthScore!.pillars;
+ return new Map(pillars.map((p) => [p.id, p]));
+}
+
+describe("GET /api/analytics — labs-fed score pillars (biomarker join)", () => {
+ it("scores GLYCAEMIA and LIPIDS from lab rows joined to their biomarker", async () => {
+ const user = await seedSession("hs-labs-join");
+ const prisma = getPrismaClient();
+ const takenAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+
+ const hba1c = await prisma.biomarker.create({
+ data: {
+ userId: user.id,
+ name: "HbA1c",
+ unit: "%",
+ lowerBound: 5,
+ upperBound: 5.6,
+ },
+ });
+ await prisma.labResult.create({
+ data: {
+ userId: user.id,
+ biomarkerId: hba1c.id,
+ analyte: "HbA1c",
+ value: 5.2,
+ unit: "%",
+ takenAt,
+ },
+ });
+
+ // The lipid row deliberately carries NO per-row reference bounds: the
+ // range the pillar grades against must arrive through the biomarker
+ // join, which is exactly the leg the wrong select severed.
+ const ldl = await prisma.biomarker.create({
+ data: {
+ userId: user.id,
+ name: "LDL Cholesterol",
+ unit: "mg/dL",
+ upperBound: 130,
+ },
+ });
+ await prisma.labResult.create({
+ data: {
+ userId: user.id,
+ biomarkerId: ldl.id,
+ analyte: "ldl",
+ value: 110,
+ unit: "mg/dL",
+ takenAt,
+ },
+ });
+
+ const byId = await fetchScorePillars();
+
+ const glycaemia = byId.get("GLYCAEMIA")!;
+ expect(glycaemia.result.reason).not.toBe("read_failed");
+ expect(glycaemia.result.status).toBe("ok");
+ expect(glycaemia.result.value!.observed.value).toBe(5.2);
+
+ const lipids = byId.get("LIPIDS")!;
+ expect(lipids.result.reason).not.toBe("read_failed");
+ expect(lipids.result.status).toBe("ok");
+ expect(lipids.result.value!.score).toBeGreaterThan(0);
+ });
+
+ it("reads not_tracked, never read_failed, when a labs account has no rows", async () => {
+ await seedSession("hs-labs-empty");
+
+ const byId = await fetchScorePillars();
+
+ const glycaemia = byId.get("GLYCAEMIA")!;
+ expect(glycaemia.result.status).toBe("insufficient");
+ expect(glycaemia.result.reason).toBe("not_tracked");
+
+ const lipids = byId.get("LIPIDS")!;
+ expect(lipids.result.status).toBe("insufficient");
+ expect(lipids.result.reason).toBe("not_tracked");
+ });
+});