diff --git a/CHANGELOG.md b/CHANGELOG.md index ef877f577..6238b2556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +## [1.27.17] — 2026-07-07 — The document vault + +### Added + +- A document vault: an opt-in module (Settings → Modules) that stores the paper trail a household accumulates — doctor letters, discharge summaries, lab and imaging reports, prescriptions, referrals, insurance letters, vaccination records — encrypted at rest in one searchable timeline, with no folders to maintain. +- Upload by drag-and-drop anywhere on the page, paste from the clipboard, or the file picker; multiple files upload in parallel with per-file progress and instant optimistic entries. Files are classified by their actual bytes, not their extension: PDFs and photos (JPEG, PNG, WebP, GIF) preview inline; Office, text, CSV, RTF, TIFF, and HEIC files are stored verbatim and served only as an opaque download; executables, HTML/SVG, and archives are refused at upload. Nothing a user uploads can execute in the app's origin. +- Find a document in seconds: filter by type, by linked condition, or by year, or search titles and filenames — every filter lives in the URL, so a view is shareable and the back button restores it. Each document opens in a detail view with inline preview, editable title, type, and date, and links to the conditions it belongs to; the illness journal shows and links its documents both ways. +- Bulk selection (shift-click, long-press on touch) with bulk type assignment, condition linking, and delete; delete is undo-able with a 30-day grace before a nightly job reclaims the space. Full keyboard navigation and screen-reader labelling throughout. +- Operators tune two limits on the admin settings page: a per-file cap (default 25 MB, hard ceiling 100 MB) and a per-account quota (default 1 GB, with an optional per-user override). Documents live in Postgres, so a normal database backup includes them; the off-host JSON backup deliberately stays data-only. + ## [1.27.16] — 2026-07-07 — Offline that survives, and a faster first paint ### Fixed diff --git a/README.md b/README.md index d00619af4..cc627a54f 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig - **Every vital on one timeline.** Weight, blood pressure, pulse, glucose, body composition, sleep, SpO₂, respiratory rate, mood, and cycle tracking — charted with trends, moving averages, and clinical reference ranges (ESH 2023, ADA 2024), overridable with the targets your clinician set. - **Clinical signals, opt-in and additive.** Validated self-checks for mental wellbeing and sleep (WHO-5, PHQ-9, GAD-7, Sleep Condition Indicator — official questionnaire wording, calm crisis-resource handling), grip strength, a 0–10 pain score, waist and body measurements, and a longevity lab panel — each added through one signal registry, so a new signal wires into charts, correlations, the coach, the FHIR map, and the API at once. -- **Records, structured.** Allergies and family history as encrypted structured records that flow into the doctor report, plus lab values you key in or scan from a printed report — OCR extracts the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. (A general document vault is on the roadmap; immunization records are deliberately out of scope.) +- **Records, structured.** Allergies and family history as encrypted structured records that flow into the doctor report, plus lab values you key in or scan from a printed report — OCR extracts the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. (Immunization records are deliberately out of scope.) +- **A document vault for the paper trail.** An opt-in module stores every letter, report, and scan encrypted at rest — PDFs and photos preview inline, Office and text files stay download-only, and nothing user-uploaded can ever execute in the app. One searchable timeline with type, condition, and year filters, drag-and-drop and paste-to-upload, bulk filing, undo-able delete with a 30-day grace, and per-file/per-account limits the operator tunes. - **Context that explains the numbers.** An opt-in environmental module folds weather, daylight, and temperature in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point. - **Your devices, one server.** Withings, WHOOP, Oura and Polar sync over OAuth2, Google Health connects Fitbit and Pixel Watch through your own Google Cloud client, Nightscout streams CGM glucose, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap. - **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2794af751..08a21319b 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.27.16 + version: 1.27.17 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -8669,20 +8669,39 @@ paths: maxLength: 100 description: Case-insensitive search over title + filename. - name: kind + in: query + required: false + style: form + explode: true + schema: + type: array + items: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - REFERRAL + - INSURANCE + - VACCINATION + - OTHER + description: Category filter — OR inside the facet. Repeat the parameter (or send a comma-separated value). + - name: episodeId in: query required: false schema: type: string - enum: - - DOCTOR_REPORT - - DISCHARGE_LETTER - - LAB_RESULT - - IMAGING - - PRESCRIPTION - - INSURANCE - - VACCINATION - - OTHER - description: Category filter. + description: Only documents linked to this illness/condition episode. + - name: year + in: query + required: false + schema: + type: integer + minimum: 1900 + maximum: 9999 + description: Only documents whose filing date falls in this calendar year (UTC). Takes precedence over from/to. - name: from in: query required: false @@ -8746,9 +8765,16 @@ paths: summary: Store a document (no extraction) description: "STORE-ONLY upload. Stores the raw document ENCRYPTED at rest with `status: STORED` and runs NO extraction — provider-free, no AI consent / budget / egress. A file is always filable, even with no document-scan provider - configured. `multipart/form-data`: a `file` (JPEG/PNG/WebP/PDF, validated by magic-byte MIME sniff, ≤ 12 MiB) - plus optional `title`, `kind`, and `documentDate` (YYYY-MM-DD) form fields. AI extraction is a separate opt-in - action — see `POST /api/documents/inbound/{id}/extract`." + configured. `multipart/form-data`: a `file` plus optional `title`, `kind`, `documentDate` (YYYY-MM-DD), and + repeated `episodeIds` form fields (pre-link to the caller's illness/condition episodes). Accepted types + (magic-byte sniffed, never the wire Content-Type): PDF/JPEG/PNG/WebP/GIF render inline; Office + (docx/xlsx/pptx/doc/xls/ppt), text/CSV/Markdown/RTF, TIFF, HEIC/HEIF, XML/JSON are stored verbatim and served + download-only. HEIC is stored as-is but attachment-only — prefer transcoding to JPEG client-side for inline + preview parity. Error contract: `413` with `meta.reason = \"fileTooLarge\"` (+ `maxFileBytes`) or + `\"quotaExceeded\"` (+ `quotaBytes`, `usedBytes`); `415` with `meta.reason = \"unsupportedType\"`. A same-user + duplicate (same bytes) returns 200 + `meta.duplicate: true` with the existing row — not an error. + `Idempotency-Key` honoured. Read `GET /api/documents/inbound/usage` for the effective limits before offering an + upload. AI extraction is a separate opt-in action — see `POST /api/documents/inbound/{id}/extract`." requestBody: required: true content: @@ -8759,7 +8785,7 @@ paths: file: type: string format: binary - description: The document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type. + description: The document. Validated by magic-byte MIME sniff, not the wire Content-Type. title: description: Optional user label. type: string @@ -8772,6 +8798,7 @@ paths: - LAB_RESULT - IMAGING - PRESCRIPTION + - REFERRAL - INSURANCE - VACCINATION - OTHER @@ -8779,9 +8806,21 @@ paths: description: Optional user filing date (YYYY-MM-DD). Defaults to the upload day when omitted so the library sort/filter/display stay aligned. type: string + episodeIds: + description: Optional condition pre-links (repeat the field per id). Every id must be a live episode of the caller. + type: array + items: + type: string required: - file responses: + "200": + description: "Duplicate — the same bytes are already stored: the existing row, with `meta.duplicate: true` at the + envelope level." + content: + application/json: + schema: + $ref: "#/components/schemas/InboundDocumentEnvelope" "201": description: The stored document. content: @@ -8789,6 +8828,99 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentEnvelope" "401": *a1 + "413": + description: "Too large: `meta.reason` is `fileTooLarge` (with `maxFileBytes`) or `quotaExceeded` (with `quotaBytes` + + `usedBytes`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": + description: Unsupported or unidentifiable type (`meta.reason = "unsupportedType"`). Executables, HTML/SVG, and generic + archives are always refused. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/documents/inbound/usage: + get: + tags: + - Documents + summary: Storage usage + effective limits + description: "The caller's vault usage and effective limits: `usedBytes` (every non-purged row — tombstones inside the + 30-day undo grace still count), `quotaBytes` (per-user override ?? instance default), `maxFileBytes` + (admin-tunable per-file cap), `acceptedExtensions` for picker `accept` lists, and `linkedEpisodes` (episodes + carrying at least one live document link — the condition-filter chips). Read this before offering an upload." + responses: + "200": + description: Usage + limits. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentUsageEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/documents/inbound/bulk: + post: + tags: + - Documents + summary: Bulk action over documents + description: Applies one action to up to 100 owner-scoped documents and returns a per-id result array — a partial + failure never aborts the batch. `error` is `notFound` (unknown / foreign / tombstoned where liveness is + required) or `conflict` (restore blocked by a live duplicate of the same bytes). + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + ids: + minItems: 1 + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + action: + type: string + enum: + - setKind + - linkEpisode + - unlinkEpisode + - delete + - restore + kind: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - REFERRAL + - INSURANCE + - VACCINATION + - OTHER + episodeId: + type: string + minLength: 1 + maxLength: 40 + required: + - ids + - action + responses: + "200": + description: Per-id outcomes. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentBulkEnvelope" + "401": *a1 "422": *a3 "429": *a4 /api/documents/inbound/{id}: @@ -8845,6 +8977,7 @@ paths: - LAB_RESULT - IMAGING - PRESCRIPTION + - REFERRAL - INSURANCE - VACCINATION - OTHER @@ -8853,6 +8986,13 @@ paths: - type: string pattern: ^\d{4}-\d{2}-\d{2}$ - type: "null" + episodeIds: + maxItems: 20 + type: array + items: + type: string + minLength: 1 + maxLength: 40 responses: "200": description: The updated document. @@ -8885,16 +9025,14 @@ paths: "401": *a1 "422": *a3 "429": *a4 - /api/documents/inbound/{id}/original: - get: + /api/documents/inbound/{id}/restore: + post: tags: - Documents - summary: View / download the original document - description: "Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under - `InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. Uploads are - constrained at the store path to the inline-safe set (JPEG / PNG / WebP / PDF), so the document is always served - `Content-Disposition: inline` for in-tab rendering; a non-ASCII filename is carried via RFC 5987 `filename*`. - Fails closed (500) on a decrypt error — it never returns the ciphertext." + summary: Restore a discarded document + description: Clears the soft-delete tombstone within the 30-day undo grace. 409 when the tombstone is already purged + (the undo window is over) or when an identical live document blocks the restore (`meta.reason = + "duplicateExists"`). Restoring an already-live document is a no-op success. parameters: - name: id in: path @@ -8903,7 +9041,42 @@ paths: type: string responses: "200": - description: The decrypted original document bytes with the stored MIME type. + description: The restored document + its staged facts. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundRestoreEnvelope" + "401": *a1 + "409": + description: "Not restorable: purged past the grace window, or an identical live document exists." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/documents/inbound/{id}/original: + get: + tags: + - Documents + summary: View / download the original document + description: "Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under + `InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. Serving + posture follows the document's `servingClass`: inline types (PDF/JPEG/PNG/WebP/GIF) come with their true + Content-Type, `Content-Disposition: inline`, `nosniff`, and a `Content-Security-Policy: sandbox` header; + attachment types (Office/text/TIFF/HEIC/XML/JSON) ALWAYS come as `application/octet-stream` + + `Content-Disposition: attachment` + `nosniff` — never inline. A non-ASCII filename is carried via RFC 5987 + `filename*`. Fails closed (500) on a decrypt error — it never returns the ciphertext." + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: The decrypted original document bytes. Inline types carry the stored MIME type; attachment types are served + as application/octet-stream. content: application/pdf: schema: @@ -8913,6 +9086,10 @@ paths: schema: type: string format: binary + application/octet-stream: + schema: + type: string + format: binary "401": *a1 "422": *a3 "429": *a4 @@ -10698,6 +10875,8 @@ components: type: boolean mcp: type: boolean + inboundDocuments: + type: boolean mentalHealth: type: boolean additionalProperties: false @@ -11256,6 +11435,7 @@ components: - LAB_RESULT - IMAGING - PRESCRIPTION + - REFERRAL - INSURANCE - VACCINATION - OTHER @@ -25699,6 +25879,7 @@ components: - LAB_RESULT - IMAGING - PRESCRIPTION + - REFERRAL - INSURANCE - VACCINATION - OTHER @@ -25743,6 +25924,15 @@ components: type: number pendingCount: type: number + conditionLinks: + type: array + items: + $ref: "#/components/schemas/DocumentConditionLink" + servingClass: + type: string + enum: + - inline + - attachment createdAt: type: string updatedAt: @@ -25761,13 +25951,29 @@ components: - errorReason - factCount - pendingCount + - conditionLinks + - servingClass - createdAt - updatedAt additionalProperties: false description: A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until - extraction runs). + extraction runs). `servingClass` says how `/original` delivers the file — render inline (`inline`) or + download-only (`attachment`); render/download by it, never by MIME guess. Treat an unknown `kind` value as OTHER + when decoding. + DocumentConditionLink: + type: object + properties: + episodeId: + type: string + name: + type: string + required: + - episodeId + - name + additionalProperties: false + description: A link to one of the caller's illness/condition episodes. `name` is the episode's user-facing label. InboundDocumentEnvelope: type: object properties: @@ -25785,6 +25991,97 @@ components: - data - error additionalProperties: false + DocumentUsageEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentUsage" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentUsage: + type: object + properties: + usedBytes: + type: number + quotaBytes: + type: number + maxFileBytes: + type: number + acceptedExtensions: + type: array + items: + type: string + linkedEpisodes: + type: array + items: + type: object + properties: + episodeId: + type: string + name: + type: string + required: + - episodeId + - name + additionalProperties: false + required: + - usedBytes + - quotaBytes + - maxFileBytes + - acceptedExtensions + - linkedEpisodes + additionalProperties: false + DocumentBulkEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentBulkResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentBulkResponse: + type: object + properties: + results: + type: array + items: + type: object + properties: + id: + type: string + ok: + type: boolean + error: + anyOf: + - type: string + - type: "null" + required: + - id + - ok + - error + additionalProperties: false + required: + - results + additionalProperties: false InboundDocumentDetailEnvelope: type: object properties: @@ -25815,6 +26112,7 @@ components: - LAB_RESULT - IMAGING - PRESCRIPTION + - REFERRAL - INSURANCE - VACCINATION - OTHER @@ -25859,6 +26157,15 @@ components: type: number pendingCount: type: number + conditionLinks: + type: array + items: + $ref: "#/components/schemas/DocumentConditionLink" + servingClass: + type: string + enum: + - inline + - attachment createdAt: type: string updatedAt: @@ -25881,6 +26188,8 @@ components: - errorReason - factCount - pendingCount + - conditionLinks + - servingClass - createdAt - updatedAt - facts @@ -25993,6 +26302,23 @@ components: - data - error additionalProperties: false + InboundRestoreEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InboundDocumentDetail" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false InboundExtractEnvelope: type: object properties: diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts new file mode 100644 index 000000000..9d9cfb339 --- /dev/null +++ b/e2e/documents.spec.ts @@ -0,0 +1,583 @@ +/** + * Document vault — the release acceptance suite. + * + * Pins the doctor-flow script (three independent routes to one document: + * type chip, condition chip, text search), the URL deep-link contract + * (`?kind` / `?episode` / `?year` / `?doc`), delete→undo, the bulk flows + * (50-item kind change, partial failure, the >100 client-side chunking), + * keyboard-only navigation, page-wide drop + clipboard paste intake, the + * duplicate-upload contract, the admin per-file cap (commit-on-Enter + + * rejection copy naming the limit), and axe scans over the timeline, the + * open detail sheet, and bulk mode. + * + * Fixture: `e2e/setup/vault-fixture.ts` seeds the 60-document corpus (plus + * the "Knie" episode and the linked MRT trio) straight through Postgres — + * idempotent, so the desktop and mobile projects can both (re-)run it. + * Mutating tests operate on their own seeded namespaces or on documents + * they create, never on the shared doctor-flow corpus. + */ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test, type Page } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + ensureVaultFixture, + seedNamespaceDocs, + KNIE_EPISODE_ID, + MRT_DOC_ID, +} from "./setup/vault-fixture"; + +const DEFAULT_MAX_FILE_BYTES = 26_214_400; + +/** Scroll the shell's scroll container until `locator` is visible (the + * timeline is virtualized — off-window rows are not in the DOM at all). */ +async function scrollUntilVisible( + page: Page, + locator: ReturnType, + maxSteps = 40, +): Promise { + for (let i = 0; i < maxSteps; i++) { + if (await locator.isVisible().catch(() => false)) return; + await page + .locator("#main-content") + .evaluate((el) => el.scrollBy(0, el.clientHeight)); + await page.waitForTimeout(150); + } + await expect(locator).toBeVisible(); +} + +/** The invisible whole-card open button for a document titled `title`. */ +function openButton(page: Page, title: string) { + return page.getByRole("button", { name: `Open ${title}` }).first(); +} + +/** The card checkbox for a document titled `title`. */ +function selectBox(page: Page, title: string) { + return page.getByRole("checkbox", { name: `Select ${title}` }).first(); +} + +/** Upload `bytes` through the picker input and return when queued. */ +async function uploadViaPicker( + page: Page, + name: string, + bytes: Buffer, + mimeType = "application/pdf", +): Promise { + await page + .locator('[data-slot="document-upload-zone"] input[type="file"]') + .setInputFiles([{ name, mimeType, buffer: bytes }]); +} + +function uniquePdf(marker: string): Buffer { + return Buffer.from( + `%PDF-1.4\n%${marker}-${Date.now()}-${Math.random()}\n1 0 obj<>endobj\n%%EOF\n`, + "utf8", + ); +} + +async function runAxe(page: Page) { + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + const blocking = results.violations.filter( + (v) => v.impact === "serious" || v.impact === "critical", + ); + if (blocking.length > 0) { + // Pretty-print so failures are actionable in CI logs. + console.log( + "axe violations:\n" + + blocking + .map( + (v) => + ` - [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes + .map((n) => n.target.join(" ")) + .join("\n ")}`, + ) + .join("\n"), + ); + } + return blocking; +} + +test.describe("document vault", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeAll(async () => { + await ensureVaultFixture(); + }); + + // ── §1A — the 30-second doctor flow, three independent routes ───────── + + test("doctor flow A: the type chip reaches the MRT report", async ({ + page, + }) => { + await page.goto("/documents"); + await expect( + page.getByRole("heading", { name: "Documents" }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Imaging", exact: true }).click(); + // Autumn 2025 sits below the newest sections — scroll the window down. + await scrollUntilVisible(page, openButton(page, "MRT Knie")); + await openButton(page, "MRT Knie").click(); + + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + await expect(sheet.getByText("MRT Knie").first()).toBeVisible(); + // Inline preview (Class A PDF) mounts against the serve route. + await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 }); + }); + + test("doctor flow B: the condition chip reaches the MRT report", async ({ + page, + }) => { + await page.goto("/documents"); + await page + .locator('[data-slot="document-filter-bar"]') + .getByRole("button", { name: "Knie", exact: true }) + .click(); + + // The Knie filter narrows the corpus to the linked MRT trio. + await expect(openButton(page, "MRT Knie")).toBeVisible(); + await expect(openButton(page, "MRT Aufnahme 1")).toBeVisible(); + await openButton(page, "MRT Knie").click(); + await expect(page.getByRole("dialog")).toBeVisible(); + }); + + test("doctor flow C: text search reaches the MRT report", async ({ + page, + }) => { + await page.goto("/documents"); + await page.getByRole("searchbox", { name: "Search documents" }).fill("MRT"); + // 200 ms debounce → URL → refetch. + await expect(openButton(page, "MRT Knie")).toBeVisible(); + await openButton(page, "MRT Knie").click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(page).toHaveURL(/q=MRT/); + }); + + // ── URL deep links ───────────────────────────────────────────────────── + + test("deep links land filtered: ?kind, ?year, ?episode, ?doc", async ({ + page, + }) => { + await page.goto("/documents?kind=IMAGING&year=2025"); + await expect( + page.getByRole("button", { name: "Imaging", exact: true }), + ).toHaveAttribute("aria-pressed", "true"); + await expect( + page.getByRole("button", { name: "2025", exact: true }), + ).toHaveAttribute("aria-pressed", "true"); + await scrollUntilVisible(page, openButton(page, "MRT Knie")); + + await page.goto(`/documents?episode=${KNIE_EPISODE_ID}`); + await expect(openButton(page, "MRT Knie")).toBeVisible(); + await expect(openButton(page, "MRT Aufnahme 2")).toBeVisible(); + + // `?doc=` opens the detail sheet; closing it strips the param. + await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&doc=${MRT_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + await expect(sheet.getByText("MRT Knie").first()).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(sheet).not.toBeVisible(); + await expect(page).not.toHaveURL(/doc=/); + }); + + test("the condition page links into the vault and back", async ({ page }) => { + await page.goto(`/illness/${KNIE_EPISODE_ID}`); + const card = page.locator('[data-slot="episode-documents-card"]'); + await expect(card).toBeVisible(); + await card.getByRole("link", { name: /MRT Knie/ }).click(); + // Lands on the vault, episode-filtered, with the detail sheet open. + await expect(page).toHaveURL(/\/documents\?episode=/); + await expect(page.getByRole("dialog")).toBeVisible(); + }); + + // ── Delete → undo ────────────────────────────────────────────────────── + + test("delete then undo restores the document", async ({ page }, testInfo) => { + // Unique per run — a stable name would leave a same-named twin from a + // previous run in the corpus and break the disappearance assertion. + const title = `undo-probe-${testInfo.project.name}-${Date.now()}`; + await page.goto("/documents"); + await uploadViaPicker(page, `${title}.pdf`, uniquePdf(title)); + await page.getByRole("searchbox", { name: "Search documents" }).fill(title); + await expect(openButton(page, `${title}.pdf`)).toBeVisible(); + + await openButton(page, `${title}.pdf`).click(); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + await sheet.getByRole("button", { name: "Delete" }).click(); + + // Undo toast → restore → the card returns. + await expect(page.getByText("Document deleted.")).toBeVisible(); + await expect(openButton(page, `${title}.pdf`)).not.toBeVisible(); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(openButton(page, `${title}.pdf`)).toBeVisible(); + }); + + // ── Bulk flows (desktop-only: shift-click ranges are a pointer flow) ── + + test("bulk: 50-item kind change round-trips", async ({ page, isMobile }) => { + test.skip(isMobile, "range selection is a desktop pointer flow"); + test.setTimeout(90_000); + await seedNamespaceDocs("bulkfifty", 50); + + // Shared filing date → the namespace orders by id desc: "050" first, + // "001" last. Anchor on the first card, shift-click the last. + await page.goto("/documents?q=bulkfifty"); + await expect(openButton(page, "bulkfifty 050")).toBeVisible({ + timeout: 15_000, + }); + await selectBox(page, "bulkfifty 050").click(); + await scrollUntilVisible(page, openButton(page, "bulkfifty 001")); + await selectBox(page, "bulkfifty 001").click({ modifiers: ["Shift"] }); + + const bar = page.locator('[data-slot="document-bulk-bar"]'); + await expect(bar).toBeVisible(); + await expect(bar.getByText("50 selected")).toBeVisible(); + + await bar.getByRole("button", { name: "Change type" }).click(); + await page.getByRole("menuitem", { name: "Referral" }).click(); + await expect(page.getByText("50 document(s) updated")).toBeVisible({ + timeout: 15_000, + }); + + // Server state: all 50 now carry the new kind. + const res = await page.request.get( + "/api/documents/inbound?q=bulkfifty&kind=REFERRAL&limit=100", + ); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.data.documents).toHaveLength(50); + }); + + test("bulk: a partial failure surfaces per-id results", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "bulk selection is a desktop pointer flow"); + await seedNamespaceDocs("bulkpf", 5); + + await page.goto("/documents?q=bulkpf"); + for (let i = 1; i <= 5; i++) { + const box = selectBox(page, `bulkpf 00${i}`); + await scrollUntilVisible(page, box); + await box.click(); + } + await expect(page.getByText("5 selected")).toBeVisible(); + + // Kill one of the five behind the selection's back (tombstone), so the + // bulk call reports exactly one per-id failure. + const list = await page.request.get( + "/api/documents/inbound?q=bulkpf&limit=10", + ); + const victim = (await list.json()).data.documents.find( + (d: { title: string }) => d.title === "bulkpf 003", + ); + const del = await page.request.delete( + `/api/documents/inbound/${victim.id}`, + ); + expect(del.status()).toBe(200); + + const bar = page.locator('[data-slot="document-bulk-bar"]'); + await bar.getByRole("button", { name: "Change type" }).click(); + await page.getByRole("menuitem", { name: "Insurance" }).click(); + await expect(page.getByText("1 of 5 could not be updated")).toBeVisible(); + }); + + test("bulk: a selection above 100 chunks into two requests", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "range selection is a desktop pointer flow"); + test.setTimeout(120_000); + await seedNamespaceDocs("bulkchunk", 101); + + // Newest-first by id tiebreak: "101" renders first, "001" last. Walk + // the infinite query to the end (pages of 50) so the range can span + // all 101, then anchor + shift-click. + await page.goto("/documents?q=bulkchunk"); + await expect(openButton(page, "bulkchunk 101")).toBeVisible(); + await selectBox(page, "bulkchunk 101").click(); + await scrollUntilVisible(page, openButton(page, "bulkchunk 001"), 80); + await selectBox(page, "bulkchunk 001").click({ modifiers: ["Shift"] }); + await expect(page.getByText("101 selected")).toBeVisible(); + + let bulkPosts = 0; + page.on("request", (req) => { + if ( + req.method() === "POST" && + req.url().includes("/api/documents/inbound/bulk") + ) { + bulkPosts += 1; + } + }); + + const bar = page.locator('[data-slot="document-bulk-bar"]'); + await bar.getByRole("button", { name: "Change type" }).click(); + await page.getByRole("menuitem", { name: "Vaccination" }).click(); + await expect(page.getByText("101 document(s) updated")).toBeVisible({ + timeout: 15_000, + }); + expect(bulkPosts).toBe(2); + }); + + // ── Keyboard-only doctor flow ────────────────────────────────────────── + + test("keyboard-only: search, roving grid, Enter opens, Escape closes", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "keyboard navigation is a desktop flow"); + await page.goto("/documents"); + await expect( + page.getByRole("heading", { name: "Documents" }), + ).toBeVisible(); + + // `/` focuses the search from anywhere. + await page.keyboard.press("/"); + await expect( + page.getByRole("searchbox", { name: "Search documents" }), + ).toBeFocused(); + await page.keyboard.type("MRT"); + await expect(openButton(page, "MRT Knie")).toBeVisible(); + + // Tab from the search into the grid's single roving slot (bounded walk + // across the chip rail), then arrows move the active card. + let reachedGrid = false; + for (let i = 0; i < 40; i++) { + await page.keyboard.press("Tab"); + const slot = await page.evaluate( + () => document.activeElement?.getAttribute("data-slot") ?? "", + ); + if (slot === "document-open") { + reachedGrid = true; + break; + } + } + expect(reachedGrid).toBe(true); + + // Arrow keys move the roving slot; the focus lands asynchronously + // (the target row may mount on the next virtualizer paint) — poll. + const activeLabel = () => + page.evaluate( + () => document.activeElement?.getAttribute("aria-label") ?? "", + ); + const first = await activeLabel(); + await page.keyboard.press("ArrowRight"); + await expect.poll(activeLabel).not.toBe(first); + await page.keyboard.press("ArrowLeft"); + await expect.poll(activeLabel).toBe(first); + + await page.keyboard.press("Enter"); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + // Wait for the preview to finish loading — the sheet hands focus back + // to the dialog after Chromium's PDF viewer grabs it, so Escape works + // regardless of when the user presses it. + await expect(dialog.locator("iframe")).toBeVisible(); + await page.waitForTimeout(250); + await page.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); + }); + + // ── Touch: long-press selects (mobile project only) ──────────────────── + + test("long-press selects a card on touch", async ({ page, isMobile }) => { + test.skip(!isMobile, "long-press is the touch selection gesture"); + await page.goto("/documents?q=MRT"); + const button = openButton(page, "MRT Knie"); + await expect(button).toBeVisible(); + + await button.dispatchEvent("touchstart"); + await page.waitForTimeout(700); + await button.dispatchEvent("touchend"); + + await expect(page.locator('[data-slot="document-bulk-bar"]')).toBeVisible(); + await expect(page.getByText("1 selected")).toBeVisible(); + }); + + // ── Page-wide drop + clipboard paste intake ──────────────────────────── + + test("dropping a file anywhere on the page uploads it", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "OS file drag is a desktop gesture"); + await page.goto("/documents"); + await expect( + page.getByRole("heading", { name: "Documents" }), + ).toBeVisible(); + + const marker = `drop-probe-${Date.now()}`; + const dataTransfer = await page.evaluateHandle( + ([name, content]) => { + const dt = new DataTransfer(); + dt.items.add( + new File([content], `${name}.pdf`, { type: "application/pdf" }), + ); + return dt; + }, + [marker, `%PDF-1.4\n%${marker}\n%%EOF\n`] as const, + ); + + await page.dispatchEvent("body", "dragenter", { dataTransfer }); + await expect( + page.locator('[data-slot="document-drop-overlay"]'), + ).toBeVisible(); + await page.dispatchEvent("body", "drop", { dataTransfer }); + await expect( + page.locator('[data-slot="document-drop-overlay"]'), + ).not.toBeVisible(); + + // The stored card lands in the timeline once the upload settles. + await page + .getByRole("searchbox", { name: "Search documents" }) + .fill(marker); + await expect(openButton(page, `${marker}.pdf`)).toBeVisible({ + timeout: 10_000, + }); + }); + + test("pasting a file from the clipboard uploads it", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "clipboard file paste is a desktop gesture"); + await page.goto("/documents"); + await expect( + page.getByRole("heading", { name: "Documents" }), + ).toBeVisible(); + + const marker = `paste-probe-${Date.now()}`; + await page.evaluate( + ([name, content]) => { + const dt = new DataTransfer(); + dt.items.add( + new File([content], `${name}.pdf`, { type: "application/pdf" }), + ); + const event = new ClipboardEvent("paste", { + clipboardData: dt, + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(event); + }, + [marker, `%PDF-1.4\n%${marker}\n%%EOF\n`] as const, + ); + + await page + .getByRole("searchbox", { name: "Search documents" }) + .fill(marker); + await expect(openButton(page, `${marker}.pdf`)).toBeVisible({ + timeout: 10_000, + }); + }); + + test("a duplicate upload highlights the existing row instead of storing twice", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "runs once on the desktop project (shared bucket)"); + await page.goto("/documents"); + const marker = `dup-probe-${Date.now()}`; + const bytes = uniquePdf(marker); + + await uploadViaPicker(page, `${marker}.pdf`, bytes); + await page + .getByRole("searchbox", { name: "Search documents" }) + .fill(marker); + await expect(openButton(page, `${marker}.pdf`)).toBeVisible(); + + await uploadViaPicker(page, `${marker}-copy.pdf`, bytes); + await expect( + page.getByText("Already stored — highlighting the existing document."), + ).toBeVisible(); + // One row, not two — the copy never landed. + await expect(openButton(page, `${marker}-copy.pdf`)).not.toBeVisible(); + }); + + // ── Admin cap: commit-on-Enter + rejection copy naming the limit ────── + + test("a 1 MB admin cap rejects an oversized upload with the limit in the copy", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "admin surface is a desktop flow"); + try { + await page.goto("/admin/general"); + const capInput = page.getByRole("spinbutton", { + name: "Document upload limit", + }); + await expect(capInput).toBeEnabled(); + await capInput.fill("1"); + const saved = page.waitForResponse( + (res) => + res.url().includes("/api/admin/settings") && + res.request().method() === "PUT", + ); + await capInput.press("Enter"); + expect((await saved).status()).toBe(200); + + await page.goto("/documents"); + await expect( + page.getByRole("heading", { name: "Documents" }), + ).toBeVisible(); + // ~1.2 MB — over the 1 MB cap, far under the default. + const big = Buffer.concat([ + Buffer.from("%PDF-1.4\n"), + Buffer.alloc(1_200_000, 0x20), + ]); + await uploadViaPicker(page, "oversized-scan.pdf", big); + await expect( + page.getByText("This file is larger than the 1 MB limit."), + ).toBeVisible(); + } finally { + const restore = await page.request.put("/api/admin/settings", { + data: { documentMaxFileBytes: DEFAULT_MAX_FILE_BYTES }, + }); + expect(restore.status()).toBe(200); + } + }); + + // ── Accessibility ────────────────────────────────────────────────────── + + test("axe: the vault timeline has no serious or critical violations", async ({ + page, + }) => { + await page.goto("/documents"); + await expect( + page.locator('[data-slot="document-card"]').first(), + ).toBeVisible({ timeout: 15_000 }); + await page.waitForLoadState("networkidle"); + const blocking = await runAxe(page); + expect(blocking).toHaveLength(0); + }); + + test("axe: the open detail sheet has no serious or critical violations", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "scanned once on the desktop project"); + await page.goto(`/documents?doc=${MRT_DOC_ID}`); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.waitForLoadState("networkidle"); + const blocking = await runAxe(page); + expect(blocking).toHaveLength(0); + }); + + test("axe: bulk mode has no serious or critical violations", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "scanned once on the desktop project"); + await page.goto("/documents?q=MRT"); + await expect(openButton(page, "MRT Knie")).toBeVisible(); + await selectBox(page, "MRT Knie").click(); + await expect(page.locator('[data-slot="document-bulk-bar"]')).toBeVisible(); + const blocking = await runAxe(page); + expect(blocking).toHaveLength(0); + }); +}); diff --git a/e2e/setup/vault-fixture.ts b/e2e/setup/vault-fixture.ts new file mode 100644 index 000000000..9cd1538ba --- /dev/null +++ b/e2e/setup/vault-fixture.ts @@ -0,0 +1,301 @@ +/** + * Document-vault e2e fixture — seeded straight through Postgres (`pg`, the + * same transport `global-setup.ts` uses) because the doctor-flow script + * needs 60 documents and the upload endpoint's per-user rate bucket is + * 60/hour: seeding through the API would burn the whole budget before the + * first genuine upload test runs, and the two Playwright projects (desktop + * + mobile) each re-run the seed. + * + * The seed is deterministic and idempotent: + * - fixed row ids (`e2evault…`), upserted with `ON CONFLICT (id) DO + * UPDATE` so a re-run self-heals whatever a previous run mutated; + * - per-document unique plaintext (an id marker inside the bytes) so the + * `(user_id, content_sha256)` live-dedupe partial unique index never + * trips across rows; + * - blobs encrypted with the SAME binary2 layout the server writes + * (`[0x02][keyIdLen][keyId][iv 12][tag 16][ciphertext]`, AES-256-GCM + * under the env key), so the serve route decrypts and previews them + * like real uploads. + * + * Corpus (mirrors the plan's §1A doctor-flow seed): 60 documents across + * 3 years, one IllnessEpisode "Knie", an MRI report (IMAGING, "MRT Knie", + * last autumn, linked to Knie) plus two adjacent image documents on the + * same date and link. `seedNamespaceDocs` adds disposable per-test + * namespaces (bulk flows) that never collide with the doctor-flow corpus. + */ +import { createCipheriv, createHash, randomBytes } from "node:crypto"; + +import pg from "pg"; + +import { E2E_USER } from "./global-setup"; + +// ─── binary2 codec (mirrors src/lib/crypto.ts encryptBytes) ──────────────── + +function resolveActiveKey(): { id: string; key: Buffer } { + const mapRaw = process.env.ENCRYPTION_KEYS; + if (mapRaw) { + const map = JSON.parse(mapRaw) as Record; + const id = + process.env.ENCRYPTION_ACTIVE_KEY_ID ?? Object.keys(map)[0] ?? "v1"; + const hex = map[id]; + if (!hex) { + throw new Error(`[vault-fixture] key id '${id}' not in ENCRYPTION_KEYS`); + } + return { id, key: Buffer.from(hex, "hex") }; + } + const legacy = process.env.ENCRYPTION_KEY; + if (!legacy) { + throw new Error( + "[vault-fixture] neither ENCRYPTION_KEYS nor ENCRYPTION_KEY is set", + ); + } + return { id: "v1", key: Buffer.from(legacy, "hex") }; +} + +function encryptBinary2(plaintext: Buffer): Buffer { + const { id, key } = resolveActiveKey(); + const keyId = Buffer.from(id, "ascii"); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([ + Buffer.from([0x02, keyId.byteLength]), + keyId, + iv, + tag, + ct, + ]); +} + +// ─── Deterministic tiny documents ─────────────────────────────────────────── + +/** Minimal single-page PDF; `marker` lands in a comment so every doc's bytes + * (and therefore sha256) are unique. */ +function tinyPdf(marker: string): Buffer { + return Buffer.from( + `%PDF-1.4\n%${marker}\n1 0 obj<>endobj\n` + + `2 0 obj<>endobj\n` + + `3 0 obj<>endobj\n` + + `xref\n0 4\ntrailer<>\n%%EOF\n`, + "utf8", + ); +} + +const PNG_1X1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==", + "base64", +); + +/** 1×1 PNG + a trailing marker (decoders ignore bytes past IEND). */ +function tinyPng(marker: string): Buffer { + return Buffer.concat([PNG_1X1, Buffer.from(marker, "utf8")]); +} + +// ─── Fixture identity ─────────────────────────────────────────────────────── + +export const KNIE_EPISODE_ID = "e2evaultknie000000000001"; +export const MRT_DOC_ID = "e2evaultmrt0000000000001"; +/** Filing date of the MRT report — "last autumn" relative to the fixture. */ +const MRT_DOC_DATE = "2025-10-14"; + +const FIXTURE_KINDS = [ + "DOCTOR_REPORT", + "LAB_RESULT", + "PRESCRIPTION", + "IMAGING", + "OTHER", + "DISCHARGE_LETTER", + "INSURANCE", +] as const; + +interface DocRow { + id: string; + kind: string; + title: string; + filename: string; + mimeType: string; + documentDate: string; // YYYY-MM-DD + bytes: Buffer; +} + +async function getUserId(pool: pg.Pool): Promise { + const res = await pool.query<{ id: string }>( + "SELECT id FROM users WHERE username = $1", + [E2E_USER.username], + ); + const id = res.rows[0]?.id; + if (!id) { + throw new Error( + "[vault-fixture] e2e user not seeded — global-setup must run first", + ); + } + return id; +} + +async function upsertDocs( + pool: pg.Pool, + userId: string, + docs: DocRow[], +): Promise { + for (const doc of docs) { + const encrypted = encryptBinary2(doc.bytes); + const sha = createHash("sha256").update(doc.bytes).digest("hex"); + await pool.query( + `INSERT INTO inbound_documents + (id, user_id, kind, title, filename, mime_type, byte_size, + content_encrypted, content_codec, content_sha256, status, + document_date, created_at, updated_at, deleted_at) + VALUES ($1, $2, $3::inbound_document_kind, $4, $5, $6, $7, + $8, 'binary2', $9, 'STORED', + $10::timestamp, NOW(), NOW(), NULL) + ON CONFLICT (id) DO UPDATE SET + kind = EXCLUDED.kind, + title = EXCLUDED.title, + document_date = EXCLUDED.document_date, + status = 'STORED', + deleted_at = NULL, + updated_at = NOW()`, + [ + doc.id, + userId, + doc.kind, + doc.title, + doc.filename, + doc.mimeType, + doc.bytes.byteLength, + encrypted, + sha, + `${doc.documentDate}T00:00:00Z`, + ], + ); + } +} + +/** + * Enable the vault module for the seeded e2e user and plant the doctor-flow + * corpus. Safe to call from every spec's beforeAll — every statement + * upserts. + */ +export async function ensureVaultFixture(): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + const userId = await getUserId(pool); + + // Opt-in module: merge, never overwrite other module preferences. + await pool.query( + `UPDATE users + SET module_preferences_json = + COALESCE(module_preferences_json, '{}'::jsonb) + || '{"inboundDocuments": true}'::jsonb + WHERE id = $1`, + [userId], + ); + + // The "Knie" condition episode the MRT report files under. + await pool.query( + `INSERT INTO illness_episodes + (id, user_id, label, type, lifecycle, onset_at, created_at, updated_at) + VALUES ($1, $2, 'Knie', 'INJURY', 'ACUTE', '2025-09-01T00:00:00Z', NOW(), NOW()) + ON CONFLICT (id) DO UPDATE SET + label = EXCLUDED.label, deleted_at = NULL, updated_at = NOW()`, + [KNIE_EPISODE_ID, userId], + ); + + // 60 background documents spread over 36 months (newest ~current). + const docs: DocRow[] = []; + for (let i = 0; i < 60; i++) { + const monthsBack = Math.floor((i * 36) / 60); + const anchor = new Date(Date.UTC(2026, 5 - monthsBack, 1 + (i % 25))); + const id = `e2evaultdoc${String(i).padStart(12, "0")}`; + docs.push({ + id, + kind: FIXTURE_KINDS[i % FIXTURE_KINDS.length], + title: `Befund ${String(i + 1).padStart(2, "0")}`, + filename: `befund-${i + 1}.pdf`, + mimeType: "application/pdf", + documentDate: anchor.toISOString().slice(0, 10), + bytes: tinyPdf(id), + }); + } + // The MRT report + two adjacent images, same date, all linked to Knie. + docs.push({ + id: MRT_DOC_ID, + kind: "IMAGING", + title: "MRT Knie", + filename: "mrt-knie.pdf", + mimeType: "application/pdf", + documentDate: MRT_DOC_DATE, + bytes: tinyPdf(MRT_DOC_ID), + }); + for (const n of [1, 2]) { + docs.push({ + id: `e2evaultimg000000000000${n}`, + kind: "IMAGING", + title: `MRT Aufnahme ${n}`, + filename: `mrt-aufnahme-${n}.png`, + mimeType: "image/png", + documentDate: MRT_DOC_DATE, + bytes: tinyPng(`e2evaultimg${n}`), + }); + } + await upsertDocs(pool, userId, docs); + + // Condition links for the three MRT-day documents. + const linked = [ + MRT_DOC_ID, + "e2evaultimg0000000000001", + "e2evaultimg0000000000002", + ]; + for (const [i, docId] of linked.entries()) { + await pool.query( + `INSERT INTO document_condition_links + (id, document_id, episode_id, user_id, created_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (document_id, episode_id) DO NOTHING`, + [`e2evaultlink00000000000${i + 1}`, docId, KNIE_EPISODE_ID, userId], + ); + } + } finally { + await pool.end(); + } +} + +/** + * Seed a disposable, namespaced batch of OTHER-kind PDF documents for the + * bulk-flow specs (`title`/`filename` carry the prefix so a search filter + * isolates exactly this namespace). Idempotent; re-runs reset kind/date and + * clear tombstones so a previous run's mutations never leak into this one. + */ +export async function seedNamespaceDocs( + prefix: string, + count: number, +): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + const userId = await getUserId(pool); + const docs: DocRow[] = []; + for (let i = 0; i < count; i++) { + const id = `e2e${prefix}${String(i).padStart(8, "0")}`; + docs.push({ + id, + kind: "OTHER", + title: `${prefix} ${String(i + 1).padStart(3, "0")}`, + filename: `${prefix}-${i + 1}.pdf`, + mimeType: "application/pdf", + // One shared filing date: the timeline then orders the namespace by + // the id tiebreak (desc), so `${prefix} ` renders first and + // `${prefix} 001` last — a deterministic range-selection span. + documentDate: "2026-05-01", + bytes: tinyPdf(id), + }); + } + await upsertDocs(pool, userId, docs); + } finally { + await pool.end(); + } +} diff --git a/messages/de.json b/messages/de.json index 0afda8c76..c7ec2d512 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5077,7 +5077,8 @@ "error": "Export fehlgeschlagen ({code}).", "labs": "Laborwerte", "allergies": "Allergien & Unverträglichkeiten", - "familyHistory": "Familienanamnese" + "familyHistory": "Familienanamnese", + "documentsLink": "Dokumente zum Zeitraum" }, "globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen", "globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.", @@ -5935,7 +5936,14 @@ "mfaEnforcedOffHint": "Einen zweiten Faktor für diesen Nutzer verlangen.", "copyIp": "IP-Adresse kopieren", "ipCopied": "IP-Adresse kopiert", - "copyFailed": "Kopieren fehlgeschlagen." + "copyFailed": "Kopieren fehlgeschlagen.", + "documentMaxFile": "Upload-Limit für Dokumente", + "documentMaxFileDescription": "Maximale Dateigröße im Dokumenten-Bestand (1–100 MB).", + "documentQuota": "Speicherkontingent für Dokumente", + "documentQuotaDescription": "Standard-Speicherplatz pro Konto für Dokumente (0,1–1024 GB).", + "userDocumentQuota": "Abweichendes Dokumenten-Kontingent", + "userDocumentQuotaDefault": "Standard", + "userDocumentQuotaHint": "Leer lassen, um den Instanz-Standard zu verwenden." }, "achievements": { "title": "Erfolge", @@ -7338,7 +7346,8 @@ "extractFailed": "Der Befund konnte nicht gelesen werden. Versuche ein schärferes Foto.", "pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch." }, - "loadMoreReadings": "Weitere Werte laden" + "loadMoreReadings": "Weitere Werte laden", + "documentsLink": "Befund-Dokumente" }, "modules": { "cycle": { @@ -7403,7 +7412,7 @@ }, "inboundDocuments": { "label": "Dokumente", - "description": "Speichere deine medizinischen Dokumente; optional ihre Angaben auslesen und prüfen, bevor sie gespeichert werden. Standardmäßig aus." + "description": "Bewahre jedes medizinische Schreiben, jeden Befund und Scan an einem verschlüsselten, durchsuchbaren Ort auf — verknüpft mit deinen Erkrankungen. Standardmäßig aus." }, "mentalHealth": { "label": "Seelisches Wohlbefinden", @@ -8015,19 +8024,8 @@ }, "documents": { "title": "Dokumente", - "subtitle": "Speichere deine medizinischen Dokumente und prüfe, was darin steht.", - "upload": { - "label": "Dokument hinzufügen", - "kindLabel": "Kategorie", - "button": "Hochladen", - "uploading": "Wird hochgeladen…", - "fileLabel": "Datei (erforderlich)", - "titleLabel": "Titel (optional)", - "titlePlaceholder": "z. B. Kardiologie-Bericht", - "dateLabel": "Dokumentdatum (optional)", - "fileRequired": "Wähle eine Datei zum Hochladen.", - "accepts": "JPEG, PNG, WebP oder PDF · bis 12 MB" - }, + "subtitle": "Jeder Brief, Befund und Scan an einem durchsuchbaren Ort — verschlüsselt gespeichert.", + "pageUpload": "Hochladen", "kind": { "DOCTOR_REPORT": "Arztbericht", "DISCHARGE_LETTER": "Entlassungsbrief", @@ -8035,124 +8033,112 @@ "LAB_RESULT": "Laborbefund", "IMAGING": "Bildgebung", "PRESCRIPTION": "Rezept", + "REFERRAL": "Überweisung", "INSURANCE": "Versicherung", "VACCINATION": "Impfung" }, - "list": { - "empty": "Noch keine Dokumente.", - "noResults": "Keine Dokumente passen zu deinen Filtern.", - "loadMore": "Mehr laden", - "loadingMore": "Wird geladen…", - "pending": "{count} zu prüfen", - "facts": "{count} Angaben", - "clearFilters": "Filter zurücksetzen", - "loadError": "Deine Dokumente konnten nicht geladen werden." + "filter": { + "searchLabel": "Dokumente durchsuchen", + "searchPlaceholder": "Titel oder Dateiname suchen…", + "groupLabel": "Dokumente filtern", + "typeGroup": "Dokumenttyp", + "conditionGroup": "Erkrankung", + "yearGroup": "Jahr", + "clear": "Filter zurücksetzen" }, - "status": { - "EXTRACTING": "Wird gelesen", - "EXTRACTED": "Bereit zur Prüfung", - "FAILED": "Konnte nicht gelesen werden", - "CONFIRMED": "Geprüft", - "DISCARDED": "Verworfen", - "STORED": "Gespeichert" + "card": { + "untitled": "Unbenanntes Dokument", + "selectLabel": "{title} auswählen", + "openLabel": "{title} öffnen", + "attachmentBadge": "Nur Download", + "uploading": "Wird hochgeladen…", + "dismissFailed": "Fehlgeschlagenen Upload ausblenden" }, - "review": { - "title": "Erkannte Angaben prüfen", - "confirm": "Bestätigte Angaben speichern", - "confirming": "Wird gespeichert…", - "needsReview": "Prüfung nötig", - "approve": "Übernehmen", - "reject": "Ablehnen", - "edit": "Bearbeiten", - "cancel": "Abbrechen", - "save": "Speichern", - "needsReviewHint": "Bearbeite diese Angabe, um sie zu übernehmen.", - "confidenceLabel": "Erkennungssicherheit", - "commitError": { - "unitMismatch": "die Einheit stimmt nicht mit der gespeicherten überein", - "unitRequired": "ein Zahlenwert braucht eine Einheit", - "generic": "es konnte nicht gespeichert werden" - } - }, - "empty": { - "noFacts": "Nichts mehr zu prüfen." - }, - "factType": { - "CONDITION": "Diagnose", - "OBSERVATION": "Wert", - "MEDICATION_STATEMENT": "Medikament" - }, - "fields": { - "label": "Name", - "value": "Wert", - "unit": "Einheit", - "date": "Datum", - "dose": "Dosis" + "error": { + "fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.", + "quotaExceeded": "Der Speicher ist voll — {used} von {quota} belegt.", + "unsupportedType": "Dieser Dateityp wird nicht unterstützt.", + "purged": "Dieses Dokument wurde endgültig entfernt und kann nicht wiederhergestellt werden.", + "duplicateExists": "Ein identisches Dokument ist bereits gespeichert.", + "rateLimited": "Zu viele Uploads — versuche es in ein paar Minuten erneut.", + "generic": "Upload fehlgeschlagen. Bitte erneut versuchen." }, "toast": { - "uploaded": "Dokument gespeichert.", - "uploadFailed": "Dokument konnte nicht gespeichert werden.", - "confirmed": "Bestätigte Angaben gespeichert.", - "confirmedPartial": "Gespeichert, aber {count} konnten nicht gespeichert werden — {reason}.", - "confirmFailed": "Deine Entscheidungen konnten nicht gespeichert werden.", - "saved": "Angabe aktualisiert.", - "discarded": "Dokument verworfen.", - "extracted": "Angaben ausgelesen. Prüfe sie unten.", - "extractFailed": "Angaben konnten nicht ausgelesen werden.", - "localOcrDisabled": "Aktiviere lokale OCR in den Einstellungen, um dieses Dokument auf dem Gerät zu lesen.", - "updated": "Dokument aktualisiert.", - "updateFailed": "Dokument konnte nicht aktualisiert werden.", - "discardFailed": "Dokument konnte nicht verworfen werden.", - "uploadTooLarge": "Datei ist zu groß (max. 12 MB).", - "uploadFileType": "Lade eine JPEG-, PNG-, WebP- oder PDF-Datei hoch.", - "uploadRateLimited": "Zu viele Uploads. Versuch es später erneut.", - "uploadInvalidMetadata": "Prüfe Titel, Kategorie oder Datum und versuch es erneut." + "duplicate": "Bereits gespeichert — das vorhandene Dokument wird hervorgehoben.", + "deleted": "Dokument gelöscht.", + "restoreFailed": "Das Dokument konnte nicht wiederhergestellt werden." }, - "toolbar": { - "searchLabel": "Suche", - "searchPlaceholder": "Titel oder Dateiname", - "kindFilterLabel": "Kategorie", - "allKinds": "Alle Kategorien", - "sortLabel": "Sortieren nach", - "sort": { - "documentDate": "Dokumentdatum", - "createdAt": "Hinzugefügt am", - "title": "Titel" - }, - "orderAsc": "Aufsteigend", - "orderDesc": "Absteigend", - "fromLabel": "Von", - "toLabel": "Bis" + "timeline": { + "loadingMore": "Mehr wird geladen…", + "listLabel": "Dokumenten-Zeitleiste" + }, + "upload": { + "zoneTitle": "Dateien hier ablegen oder vom Gerät auswählen", + "zoneHint": "PDF, Fotos und Office-Dateien bis je {maxSize}", + "browse": "Dateien auswählen", + "quotaAlmostFull": "Speicher fast voll", + "quotaUsed": "{used} von {quota} belegt", + "queuedAnnouncement": "{count} Datei(en) zur Upload-Warteschlange hinzugefügt" + }, + "selection": { + "count": "{count} ausgewählt", + "clear": "Auswahl aufheben" + }, + "list": { + "loadError": "Deine Dokumente konnten nicht geladen werden." + }, + "empty": { + "title": "Dein Dokumenten-Tresor ist leer", + "description": "Bewahre Arztbriefe, Laborbefunde, Bildgebung, Rezepte und Impfnachweise auf — verschlüsselt und durchsuchbar nach Typ, Erkrankung und Jahr.", + "action": "Erstes Dokument hochladen", + "noMatchesTitle": "Keine passenden Dokumente", + "noMatchesDescription": "Nichts entspricht der aktuellen Suche und den Filtern." }, "detail": { - "viewOriginal": "Original ansehen", - "edit": "Bearbeiten", - "extract": "Angaben auslesen", - "extracting": "Wird ausgelesen…", - "discard": "Verwerfen", - "notExtracted": "Noch keine Angaben ausgelesen. Nutze „Angaben auslesen“, um dieses Dokument zu lesen.", - "viewOriginalNewTab": "(öffnet in einem neuen Tab)", - "discardConfirmTitle": "Dieses Dokument verwerfen?", - "discardConfirmBody": "Das löscht das Dokument, die Originaldatei und alle vorbereiteten Angaben dauerhaft. Das lässt sich nicht rückgängig machen.", - "discardConfirmAction": "Verwerfen", - "discardCancel": "Abbrechen" - }, - "edit": { - "title": "Dokument bearbeiten", - "subtitle": "Umbenennen, neu kategorisieren oder das Dokumentdatum setzen.", + "title": "Dokumentdetails", + "loadError": "Dieses Dokument konnte nicht geladen werden.", + "saveError": "Die Änderung konnte nicht gespeichert werden.", + "deleteError": "Das Dokument konnte nicht gelöscht werden.", + "download": "Herunterladen", + "delete": "Löschen", "titleLabel": "Titel", - "kindLabel": "Kategorie", + "titlePlaceholder": "Titel hinzufügen", + "kindLabel": "Typ", "dateLabel": "Dokumentdatum", - "save": "Speichern", - "saving": "Wird gespeichert…", - "cancel": "Abbrechen" + "conditionsLabel": "Verknüpfte Erkrankungen", + "linkCondition": "Verknüpfen", + "noConditions": "Mit keiner Erkrankung verknüpft", + "previewUnavailable": "Keine Inline-Vorschau für diesen Dateityp", + "uploadedMeta": "Hinzugefügt am {date} · {size}" }, - "note": { - "providerUnsupported": "Richte in den KI-Einstellungen einen Anbieter für Dokumentenscans ein, um Angaben automatisch auszulesen.", - "alreadyConfirmed": "Dieses Dokument wurde bereits geprüft und kann nicht erneut ausgelesen werden." + "dropOverlay": { + "title": "Zum Hochladen ablegen", + "hint": "Deine Dateien werden verschlüsselt in deinen Dokumenten gespeichert" }, - "committed": { - "title": "Aus diesem Dokument übernommen" + "bulk": { + "barLabel": "Aktionen für ausgewählte Dokumente", + "setKind": "Typ ändern", + "linkCondition": "Erkrankung verknüpfen", + "delete": "Löschen", + "updated": "{count} Dokument(e) aktualisiert", + "deleted": "{count} Dokument(e) gelöscht", + "restored": "{count} Dokument(e) wiederhergestellt", + "partialFailure": "{failed} von {total} konnten nicht aktualisiert werden", + "failed": "Die Aktion konnte nicht ausgeführt werden" + }, + "episodeCard": { + "title": "Dokumente", + "link": "Verknüpfen", + "upload": "Hochladen", + "viewAll": "Alle anzeigen", + "emptyLine": "Noch keine Dokumente mit dieser Erkrankung verknüpft.", + "loadError": "Verknüpfte Dokumente konnten nicht geladen werden" + }, + "linkPicker": { + "title": "Dokumente verknüpfen", + "description": "Wähle Dokumente aus deinem Bestand, die zu dieser Erkrankung gehören.", + "searchPlaceholder": "Nach Titel oder Dateiname suchen", + "empty": "Keine Dokumente gefunden." } }, "selfDescription": { diff --git a/messages/en.json b/messages/en.json index 946fff862..9f3a0a5a2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5077,7 +5077,8 @@ "error": "Export failed ({code}).", "labs": "Lab results", "allergies": "Allergies & intolerances", - "familyHistory": "Family history" + "familyHistory": "Family history", + "documentsLink": "Documents for this period" }, "globalExcludedInjectionSitesLabel": "Globally excluded injection sites", "globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.", @@ -5935,7 +5936,14 @@ "mfaEnforcedOffHint": "Require a second factor for this user.", "copyIp": "Copy IP address", "ipCopied": "IP address copied", - "copyFailed": "Could not copy." + "copyFailed": "Could not copy.", + "documentMaxFile": "Document upload limit", + "documentMaxFileDescription": "Per-file size cap for the document vault (1–100 MB).", + "documentQuota": "Document storage quota", + "documentQuotaDescription": "Default per-account document storage (0.1–1024 GB).", + "userDocumentQuota": "Document quota override", + "userDocumentQuotaDefault": "Default", + "userDocumentQuotaHint": "Leave empty to use the instance default." }, "achievements": { "title": "Achievements", @@ -7338,7 +7346,8 @@ "extractFailed": "Couldn't read the report. Try a clearer photo.", "pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead." }, - "loadMoreReadings": "Load more readings" + "loadMoreReadings": "Load more readings", + "documentsLink": "Lab report documents" }, "modules": { "cycle": { @@ -7403,7 +7412,7 @@ }, "inboundDocuments": { "label": "Documents", - "description": "Store your clinical documents; optionally extract and review their facts before they're saved. Off by default." + "description": "Keep every medical letter, report, and scan in one encrypted, searchable place — linked to your conditions. Off by default." }, "mentalHealth": { "label": "Mental wellbeing", @@ -8015,19 +8024,8 @@ }, "documents": { "title": "Documents", - "subtitle": "Store your clinical documents and review what they say.", - "upload": { - "label": "Add a document", - "kindLabel": "Category", - "button": "Upload", - "uploading": "Uploading…", - "fileLabel": "File (required)", - "titleLabel": "Title (optional)", - "titlePlaceholder": "e.g. Cardiology report", - "dateLabel": "Document date (optional)", - "fileRequired": "Choose a file to upload.", - "accepts": "JPEG, PNG, WebP or PDF · up to 12 MB" - }, + "subtitle": "Every letter, report, and scan in one searchable place — encrypted at rest.", + "pageUpload": "Upload", "kind": { "DOCTOR_REPORT": "Doctor report", "DISCHARGE_LETTER": "Discharge letter", @@ -8035,124 +8033,112 @@ "LAB_RESULT": "Lab result", "IMAGING": "Imaging", "PRESCRIPTION": "Prescription", + "REFERRAL": "Referral", "INSURANCE": "Insurance", "VACCINATION": "Vaccination" }, - "list": { - "empty": "No documents yet.", - "noResults": "No documents match your filters.", - "loadMore": "Load more", - "loadingMore": "Loading…", - "pending": "{count} to review", - "facts": "{count} facts", - "clearFilters": "Clear filters", - "loadError": "Couldn't load your documents." + "filter": { + "searchLabel": "Search documents", + "searchPlaceholder": "Search title or filename…", + "groupLabel": "Filter documents", + "typeGroup": "Document type", + "conditionGroup": "Condition", + "yearGroup": "Year", + "clear": "Clear filters" }, - "status": { - "EXTRACTING": "Reading", - "EXTRACTED": "Ready to review", - "FAILED": "Couldn't read", - "CONFIRMED": "Reviewed", - "DISCARDED": "Discarded", - "STORED": "Stored" + "card": { + "untitled": "Untitled document", + "selectLabel": "Select {title}", + "openLabel": "Open {title}", + "attachmentBadge": "Download only", + "uploading": "Uploading…", + "dismissFailed": "Dismiss failed upload" }, - "review": { - "title": "Review extracted facts", - "confirm": "Save approved facts", - "confirming": "Saving…", - "needsReview": "Needs review", - "approve": "Approve", - "reject": "Reject", - "edit": "Edit", - "cancel": "Cancel", - "save": "Save", - "needsReviewHint": "Edit this fact to approve it.", - "confidenceLabel": "Extraction confidence", - "commitError": { - "unitMismatch": "the unit doesn't match the saved one", - "unitRequired": "a numeric value needs a unit", - "generic": "it couldn't be saved" - } - }, - "empty": { - "noFacts": "Nothing left to review." - }, - "factType": { - "CONDITION": "Diagnosis", - "OBSERVATION": "Value", - "MEDICATION_STATEMENT": "Medication" - }, - "fields": { - "label": "Name", - "value": "Value", - "unit": "Unit", - "date": "Date", - "dose": "Dose" + "error": { + "fileTooLarge": "This file is larger than the {maxSize} limit.", + "quotaExceeded": "Storage is full — {used} of {quota} used.", + "unsupportedType": "This file type is not supported.", + "purged": "This document was permanently removed and cannot be restored.", + "duplicateExists": "An identical document is already stored.", + "rateLimited": "Too many uploads right now — try again in a few minutes.", + "generic": "Upload failed. Please try again." }, "toast": { - "uploaded": "Document stored.", - "uploadFailed": "Couldn't store the document.", - "confirmed": "Approved facts saved.", - "confirmedPartial": "Saved, but {count} couldn't be saved — {reason}.", - "confirmFailed": "Couldn't save your decisions.", - "saved": "Fact updated.", - "discarded": "Document discarded.", - "extracted": "Facts extracted. Review them below.", - "extractFailed": "Couldn't extract facts.", - "localOcrDisabled": "Enable local OCR in settings to read this document on-device.", - "updated": "Document updated.", - "updateFailed": "Couldn't update the document.", - "discardFailed": "Couldn't discard the document.", - "uploadTooLarge": "File is too large (max 12 MB).", - "uploadFileType": "Upload a JPEG, PNG, WebP, or PDF.", - "uploadRateLimited": "Too many uploads. Try again later.", - "uploadInvalidMetadata": "Check the title, category, or date and try again." + "duplicate": "Already stored — highlighting the existing document.", + "deleted": "Document deleted.", + "restoreFailed": "Couldn't restore the document." }, - "toolbar": { - "searchLabel": "Search", - "searchPlaceholder": "Title or filename", - "kindFilterLabel": "Category", - "allKinds": "All categories", - "sortLabel": "Sort by", - "sort": { - "documentDate": "Document date", - "createdAt": "Date added", - "title": "Title" - }, - "orderAsc": "Ascending", - "orderDesc": "Descending", - "fromLabel": "From", - "toLabel": "To" + "timeline": { + "loadingMore": "Loading more…", + "listLabel": "Documents timeline" + }, + "upload": { + "zoneTitle": "Drop files here, or choose from your device", + "zoneHint": "PDF, photos, and office files up to {maxSize} each", + "browse": "Choose files", + "quotaAlmostFull": "Storage almost full", + "quotaUsed": "{used} of {quota} used", + "queuedAnnouncement": "{count} file(s) added to the upload queue" + }, + "selection": { + "count": "{count} selected", + "clear": "Clear selection" + }, + "list": { + "loadError": "Couldn't load your documents." + }, + "empty": { + "title": "Your document vault is empty", + "description": "Store doctor letters, lab reports, imaging, prescriptions, and vaccination records — encrypted, and searchable by type, condition, and year.", + "action": "Upload a first document", + "noMatchesTitle": "No matching documents", + "noMatchesDescription": "Nothing matches the current search and filters." }, "detail": { - "viewOriginal": "View original", - "edit": "Edit", - "extract": "Extract facts", - "extracting": "Extracting…", - "discard": "Discard", - "notExtracted": "No facts extracted yet. Use Extract facts to read this document.", - "viewOriginalNewTab": "(opens in a new tab)", - "discardConfirmTitle": "Discard this document?", - "discardConfirmBody": "This permanently deletes the document, its original file, and any staged facts. This can't be undone.", - "discardConfirmAction": "Discard", - "discardCancel": "Cancel" - }, - "edit": { - "title": "Edit document", - "subtitle": "Rename, recategorise, or set the document date.", + "title": "Document details", + "loadError": "Couldn't load this document.", + "saveError": "Couldn't save the change.", + "deleteError": "Couldn't delete the document.", + "download": "Download", + "delete": "Delete", "titleLabel": "Title", - "kindLabel": "Category", + "titlePlaceholder": "Add a title", + "kindLabel": "Type", "dateLabel": "Document date", - "save": "Save", - "saving": "Saving…", - "cancel": "Cancel" + "conditionsLabel": "Linked conditions", + "linkCondition": "Link", + "noConditions": "Not linked to a condition", + "previewUnavailable": "No inline preview for this file type", + "uploadedMeta": "Added {date} · {size}" }, - "note": { - "providerUnsupported": "Configure a document-scan provider in AI settings to extract facts automatically.", - "alreadyConfirmed": "This document has already been reviewed, so it can't be extracted again." + "dropOverlay": { + "title": "Drop files to upload", + "hint": "They are stored encrypted in your document vault" }, - "committed": { - "title": "Approved from this document" + "bulk": { + "barLabel": "Actions for selected documents", + "setKind": "Change type", + "linkCondition": "Link condition", + "delete": "Delete", + "updated": "{count} document(s) updated", + "deleted": "{count} document(s) deleted", + "restored": "{count} document(s) restored", + "partialFailure": "{failed} of {total} could not be updated", + "failed": "The action could not be completed" + }, + "episodeCard": { + "title": "Documents", + "link": "Link", + "upload": "Upload", + "viewAll": "Show all", + "emptyLine": "No documents linked to this condition yet.", + "loadError": "Could not load the linked documents" + }, + "linkPicker": { + "title": "Link documents", + "description": "Pick documents from your vault that belong to this condition.", + "searchPlaceholder": "Search by title or filename", + "empty": "No documents found." } }, "selfDescription": { diff --git a/messages/es.json b/messages/es.json index 54bc72f7b..68948531b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5077,7 +5077,8 @@ "error": "Error en la exportación ({code}).", "labs": "Resultados de análisis", "allergies": "Alergias e intolerancias", - "familyHistory": "Antecedentes familiares" + "familyHistory": "Antecedentes familiares", + "documentsLink": "Documentos del período" }, "globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente", "globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.", @@ -5935,7 +5936,14 @@ "mfaEnforcedOffHint": "Exigir un segundo factor a este usuario.", "copyIp": "Copiar dirección IP", "ipCopied": "Dirección IP copiada", - "copyFailed": "No se pudo copiar." + "copyFailed": "No se pudo copiar.", + "documentMaxFile": "Límite de subida de documentos", + "documentMaxFileDescription": "Tamaño máximo por archivo en el archivo de documentos (1–100 MB).", + "documentQuota": "Cuota de almacenamiento de documentos", + "documentQuotaDescription": "Almacenamiento de documentos por cuenta por defecto (0,1–1024 GB).", + "userDocumentQuota": "Cuota de documentos personalizada", + "userDocumentQuotaDefault": "Predeterminado", + "userDocumentQuotaHint": "Déjalo vacío para usar el valor predeterminado de la instancia." }, "achievements": { "title": "Logros", @@ -7338,7 +7346,8 @@ "extractFailed": "No se pudo leer el informe. Prueba con una foto más nítida.", "pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar." }, - "loadMoreReadings": "Cargar más mediciones" + "loadMoreReadings": "Cargar más mediciones", + "documentsLink": "Documentos de resultados" }, "modules": { "cycle": { @@ -7403,7 +7412,7 @@ }, "inboundDocuments": { "label": "Documentos", - "description": "Guarda tus documentos clínicos; opcionalmente extrae y revisa sus datos antes de guardarlos. Desactivado por defecto." + "description": "Guarda cada carta médica, informe y escaneo en un solo lugar cifrado y con búsqueda — vinculado a tus afecciones. Desactivado por defecto." }, "mentalHealth": { "label": "Bienestar mental", @@ -8015,19 +8024,8 @@ }, "documents": { "title": "Documentos", - "subtitle": "Guarda tus documentos clínicos y revisa lo que dicen.", - "upload": { - "label": "Añadir un documento", - "kindLabel": "Categoría", - "button": "Subir", - "uploading": "Subiendo…", - "fileLabel": "Archivo (obligatorio)", - "titleLabel": "Título (opcional)", - "titlePlaceholder": "p. ej. Informe de cardiología", - "dateLabel": "Fecha del documento (opcional)", - "fileRequired": "Elige un archivo para subir.", - "accepts": "JPEG, PNG, WebP o PDF · hasta 12 MB" - }, + "subtitle": "Cada carta, informe y escaneo en un solo lugar con búsqueda — cifrado en reposo.", + "pageUpload": "Subir", "kind": { "DOCTOR_REPORT": "Informe médico", "DISCHARGE_LETTER": "Informe de alta", @@ -8035,124 +8033,112 @@ "LAB_RESULT": "Resultado de laboratorio", "IMAGING": "Imagen", "PRESCRIPTION": "Receta", + "REFERRAL": "Derivación", "INSURANCE": "Seguro", "VACCINATION": "Vacunación" }, - "list": { - "empty": "Aún no hay documentos.", - "noResults": "Ningún documento coincide con tus filtros.", - "loadMore": "Cargar más", - "loadingMore": "Cargando…", - "pending": "{count} por revisar", - "facts": "{count} datos", - "clearFilters": "Borrar filtros", - "loadError": "No se pudieron cargar tus documentos." + "filter": { + "searchLabel": "Buscar documentos", + "searchPlaceholder": "Buscar por título o nombre de archivo…", + "groupLabel": "Filtrar documentos", + "typeGroup": "Tipo de documento", + "conditionGroup": "Afección", + "yearGroup": "Año", + "clear": "Borrar filtros" }, - "status": { - "EXTRACTING": "Leyendo", - "EXTRACTED": "Listo para revisar", - "FAILED": "No se pudo leer", - "CONFIRMED": "Revisado", - "DISCARDED": "Descartado", - "STORED": "Guardado" + "card": { + "untitled": "Documento sin título", + "selectLabel": "Seleccionar {title}", + "openLabel": "Abrir {title}", + "attachmentBadge": "Solo descarga", + "uploading": "Subiendo…", + "dismissFailed": "Descartar subida fallida" }, - "review": { - "title": "Revisar datos extraídos", - "confirm": "Guardar datos aprobados", - "confirming": "Guardando…", - "needsReview": "Requiere revisión", - "approve": "Aprobar", - "reject": "Rechazar", - "edit": "Editar", - "cancel": "Cancelar", - "save": "Guardar", - "needsReviewHint": "Edita este dato para poder aprobarlo.", - "confidenceLabel": "Confianza de la extracción", - "commitError": { - "unitMismatch": "la unidad no coincide con la guardada", - "unitRequired": "un valor numérico necesita una unidad", - "generic": "no se pudo guardar" - } - }, - "empty": { - "noFacts": "Nada más que revisar." - }, - "factType": { - "CONDITION": "Diagnóstico", - "OBSERVATION": "Valor", - "MEDICATION_STATEMENT": "Medicación" - }, - "fields": { - "label": "Nombre", - "value": "Valor", - "unit": "Unidad", - "date": "Fecha", - "dose": "Dosis" + "error": { + "fileTooLarge": "Este archivo supera el límite de {maxSize}.", + "quotaExceeded": "El almacenamiento está lleno — {used} de {quota} usados.", + "unsupportedType": "Este tipo de archivo no es compatible.", + "purged": "Este documento se eliminó definitivamente y no se puede restaurar.", + "duplicateExists": "Ya hay guardado un documento idéntico.", + "rateLimited": "Demasiadas subidas — inténtalo de nuevo en unos minutos.", + "generic": "La subida falló. Inténtalo de nuevo." }, "toast": { - "uploaded": "Documento guardado.", - "uploadFailed": "No se pudo guardar el documento.", - "confirmed": "Datos aprobados guardados.", - "confirmedPartial": "Guardado, pero {count} no se pudieron guardar: {reason}.", - "confirmFailed": "No se pudieron guardar tus decisiones.", - "saved": "Dato actualizado.", - "discarded": "Documento descartado.", - "extracted": "Datos extraídos. Revísalos abajo.", - "extractFailed": "No se pudieron extraer los datos.", - "localOcrDisabled": "Activa el OCR local en los ajustes para leer este documento en el dispositivo.", - "updated": "Documento actualizado.", - "updateFailed": "No se pudo actualizar el documento.", - "discardFailed": "No se pudo descartar el documento.", - "uploadTooLarge": "El archivo es demasiado grande (máx. 12 MB).", - "uploadFileType": "Sube un archivo JPEG, PNG, WebP o PDF.", - "uploadRateLimited": "Demasiadas subidas. Inténtalo de nuevo más tarde.", - "uploadInvalidMetadata": "Revisa el título, la categoría o la fecha e inténtalo de nuevo." + "duplicate": "Ya estaba guardado — se resalta el documento existente.", + "deleted": "Documento eliminado.", + "restoreFailed": "No se pudo restaurar el documento." }, - "toolbar": { - "searchLabel": "Buscar", - "searchPlaceholder": "Título o nombre de archivo", - "kindFilterLabel": "Categoría", - "allKinds": "Todas las categorías", - "sortLabel": "Ordenar por", - "sort": { - "documentDate": "Fecha del documento", - "createdAt": "Fecha de adición", - "title": "Título" - }, - "orderAsc": "Ascendente", - "orderDesc": "Descendente", - "fromLabel": "Desde", - "toLabel": "Hasta" + "timeline": { + "loadingMore": "Cargando más…", + "listLabel": "Cronología de documentos" + }, + "upload": { + "zoneTitle": "Suelta archivos aquí o elige desde tu dispositivo", + "zoneHint": "PDF, fotos y archivos de Office de hasta {maxSize} cada uno", + "browse": "Elegir archivos", + "quotaAlmostFull": "Almacenamiento casi lleno", + "quotaUsed": "{used} de {quota} usados", + "queuedAnnouncement": "{count} archivo(s) añadidos a la cola de subida" + }, + "selection": { + "count": "{count} seleccionados", + "clear": "Borrar selección" + }, + "list": { + "loadError": "No se pudieron cargar tus documentos." + }, + "empty": { + "title": "Tu archivo de documentos está vacío", + "description": "Guarda cartas médicas, resultados de laboratorio, imágenes, recetas y certificados de vacunación — cifrados y con búsqueda por tipo, afección y año.", + "action": "Subir un primer documento", + "noMatchesTitle": "No hay documentos coincidentes", + "noMatchesDescription": "Nada coincide con la búsqueda y los filtros actuales." }, "detail": { - "viewOriginal": "Ver original", - "edit": "Editar", - "extract": "Extraer datos", - "extracting": "Extrayendo…", - "discard": "Descartar", - "notExtracted": "Aún no se han extraído datos. Usa Extraer datos para leer este documento.", - "viewOriginalNewTab": "(se abre en una pestaña nueva)", - "discardConfirmTitle": "¿Descartar este documento?", - "discardConfirmBody": "Esto elimina de forma permanente el documento, su archivo original y los datos preparados. No se puede deshacer.", - "discardConfirmAction": "Descartar", - "discardCancel": "Cancelar" - }, - "edit": { - "title": "Editar documento", - "subtitle": "Renombra, recategoriza o fija la fecha del documento.", + "title": "Detalles del documento", + "loadError": "No se pudo cargar este documento.", + "saveError": "No se pudo guardar el cambio.", + "deleteError": "No se pudo eliminar el documento.", + "download": "Descargar", + "delete": "Eliminar", "titleLabel": "Título", - "kindLabel": "Categoría", + "titlePlaceholder": "Añadir un título", + "kindLabel": "Tipo", "dateLabel": "Fecha del documento", - "save": "Guardar", - "saving": "Guardando…", - "cancel": "Cancelar" + "conditionsLabel": "Afecciones vinculadas", + "linkCondition": "Vincular", + "noConditions": "Sin vincular a una afección", + "previewUnavailable": "Sin vista previa integrada para este tipo de archivo", + "uploadedMeta": "Añadido el {date} · {size}" }, - "note": { - "providerUnsupported": "Configura un proveedor de escaneo de documentos en los ajustes de IA para extraer datos automáticamente.", - "alreadyConfirmed": "Este documento ya se ha revisado, por lo que no se puede volver a extraer." + "dropOverlay": { + "title": "Suelta los archivos para subirlos", + "hint": "Se guardan cifrados en tu archivo de documentos" }, - "committed": { - "title": "Aprobado de este documento" + "bulk": { + "barLabel": "Acciones para los documentos seleccionados", + "setKind": "Cambiar tipo", + "linkCondition": "Vincular afección", + "delete": "Eliminar", + "updated": "{count} documento(s) actualizados", + "deleted": "{count} documento(s) eliminados", + "restored": "{count} documento(s) restaurados", + "partialFailure": "No se pudieron actualizar {failed} de {total}", + "failed": "No se pudo completar la acción" + }, + "episodeCard": { + "title": "Documentos", + "link": "Vincular", + "upload": "Subir", + "viewAll": "Ver todos", + "emptyLine": "Aún no hay documentos vinculados a esta afección.", + "loadError": "No se pudieron cargar los documentos vinculados" + }, + "linkPicker": { + "title": "Vincular documentos", + "description": "Elige documentos de tu archivo que pertenezcan a esta afección.", + "searchPlaceholder": "Buscar por título o nombre de archivo", + "empty": "No se encontraron documentos." } }, "selfDescription": { diff --git a/messages/fr.json b/messages/fr.json index ffad926a0..46914f47c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5077,7 +5077,8 @@ "error": "Échec de l’export ({code}).", "labs": "Résultats d'analyses", "allergies": "Allergies et intolérances", - "familyHistory": "Antécédents familiaux" + "familyHistory": "Antécédents familiaux", + "documentsLink": "Documents de la période" }, "globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement", "globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.", @@ -5935,7 +5936,14 @@ "mfaEnforcedOffHint": "Exiger un second facteur pour cet utilisateur.", "copyIp": "Copier l’adresse IP", "ipCopied": "Adresse IP copiée", - "copyFailed": "Impossible de copier." + "copyFailed": "Impossible de copier.", + "documentMaxFile": "Limite de téléversement de documents", + "documentMaxFileDescription": "Taille maximale par fichier dans les documents (1–100 Mo).", + "documentQuota": "Quota de stockage des documents", + "documentQuotaDescription": "Stockage de documents par compte par défaut (0,1–1024 Go).", + "userDocumentQuota": "Quota de documents personnalisé", + "userDocumentQuotaDefault": "Par défaut", + "userDocumentQuotaHint": "Laisser vide pour utiliser la valeur par défaut de l'instance." }, "achievements": { "title": "Réussites", @@ -7338,7 +7346,8 @@ "extractFailed": "Impossible de lire le compte rendu. Essayez une photo plus nette.", "pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo." }, - "loadMoreReadings": "Charger plus de mesures" + "loadMoreReadings": "Charger plus de mesures", + "documentsLink": "Documents de résultats" }, "modules": { "cycle": { @@ -7403,7 +7412,7 @@ }, "inboundDocuments": { "label": "Documents", - "description": "Conservez vos documents médicaux ; extrayez et vérifiez éventuellement leurs éléments avant l'enregistrement. Désactivé par défaut." + "description": "Conservez chaque courrier médical, compte rendu et scan dans un espace chiffré et consultable — lié à vos affections. Désactivé par défaut." }, "mentalHealth": { "label": "Bien-être mental", @@ -8015,19 +8024,8 @@ }, "documents": { "title": "Documents", - "subtitle": "Conservez vos documents médicaux et vérifiez ce qu'ils contiennent.", - "upload": { - "label": "Ajouter un document", - "kindLabel": "Catégorie", - "button": "Téléverser", - "uploading": "Téléversement…", - "fileLabel": "Fichier (requis)", - "titleLabel": "Titre (facultatif)", - "titlePlaceholder": "p. ex. Compte rendu de cardiologie", - "dateLabel": "Date du document (facultatif)", - "fileRequired": "Choisissez un fichier à téléverser.", - "accepts": "JPEG, PNG, WebP ou PDF · jusqu'à 12 Mo" - }, + "subtitle": "Chaque courrier, compte rendu et scan au même endroit, consultable — chiffré au repos.", + "pageUpload": "Téléverser", "kind": { "DOCTOR_REPORT": "Compte rendu médical", "DISCHARGE_LETTER": "Lettre de sortie", @@ -8035,124 +8033,112 @@ "LAB_RESULT": "Résultat de laboratoire", "IMAGING": "Imagerie", "PRESCRIPTION": "Ordonnance", + "REFERRAL": "Lettre d'adressage", "INSURANCE": "Assurance", "VACCINATION": "Vaccination" }, - "list": { - "empty": "Aucun document pour l'instant.", - "noResults": "Aucun document ne correspond à vos filtres.", - "loadMore": "Charger plus", - "loadingMore": "Chargement…", - "pending": "{count} à vérifier", - "facts": "{count} éléments", - "clearFilters": "Effacer les filtres", - "loadError": "Impossible de charger vos documents." + "filter": { + "searchLabel": "Rechercher des documents", + "searchPlaceholder": "Rechercher un titre ou un nom de fichier…", + "groupLabel": "Filtrer les documents", + "typeGroup": "Type de document", + "conditionGroup": "Affection", + "yearGroup": "Année", + "clear": "Effacer les filtres" }, - "status": { - "EXTRACTING": "Lecture", - "EXTRACTED": "Prêt à vérifier", - "FAILED": "Lecture impossible", - "CONFIRMED": "Vérifié", - "DISCARDED": "Écarté", - "STORED": "Conservé" + "card": { + "untitled": "Document sans titre", + "selectLabel": "Sélectionner {title}", + "openLabel": "Ouvrir {title}", + "attachmentBadge": "Téléchargement uniquement", + "uploading": "Téléversement…", + "dismissFailed": "Ignorer le téléversement échoué" }, - "review": { - "title": "Vérifier les données extraites", - "confirm": "Enregistrer les données approuvées", - "confirming": "Enregistrement…", - "needsReview": "À vérifier", - "approve": "Approuver", - "reject": "Rejeter", - "edit": "Modifier", - "cancel": "Annuler", - "save": "Enregistrer", - "needsReviewHint": "Modifie cette donnée pour pouvoir la valider.", - "confidenceLabel": "Confiance d'extraction", - "commitError": { - "unitMismatch": "l'unité ne correspond pas à celle enregistrée", - "unitRequired": "une valeur numérique nécessite une unité", - "generic": "il n'a pas pu être enregistré" - } - }, - "empty": { - "noFacts": "Plus rien à vérifier." - }, - "factType": { - "CONDITION": "Diagnostic", - "OBSERVATION": "Valeur", - "MEDICATION_STATEMENT": "Médicament" - }, - "fields": { - "label": "Nom", - "value": "Valeur", - "unit": "Unité", - "date": "Date", - "dose": "Dose" + "error": { + "fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.", + "quotaExceeded": "Le stockage est plein — {used} sur {quota} utilisés.", + "unsupportedType": "Ce type de fichier n'est pas pris en charge.", + "purged": "Ce document a été définitivement supprimé et ne peut pas être restauré.", + "duplicateExists": "Un document identique est déjà enregistré.", + "rateLimited": "Trop de téléversements — réessayez dans quelques minutes.", + "generic": "Le téléversement a échoué. Veuillez réessayer." }, "toast": { - "uploaded": "Document enregistré.", - "uploadFailed": "Impossible d'enregistrer le document.", - "confirmed": "Données approuvées enregistrées.", - "confirmedPartial": "Enregistré, mais {count} n'ont pas pu être enregistrés — {reason}.", - "confirmFailed": "Impossible d'enregistrer vos décisions.", - "saved": "Donnée mise à jour.", - "discarded": "Document écarté.", - "extracted": "Éléments extraits. Vérifiez-les ci-dessous.", - "extractFailed": "Impossible d'extraire les éléments.", - "localOcrDisabled": "Activez l'OCR local dans les paramètres pour lire ce document sur l'appareil.", - "updated": "Document mis à jour.", - "updateFailed": "Impossible de mettre à jour le document.", - "discardFailed": "Impossible de supprimer le document.", - "uploadTooLarge": "Le fichier est trop volumineux (max. 12 Mo).", - "uploadFileType": "Importe un fichier JPEG, PNG, WebP ou PDF.", - "uploadRateLimited": "Trop d'envois. Réessaie plus tard.", - "uploadInvalidMetadata": "Vérifie le titre, la catégorie ou la date, puis réessaie." + "duplicate": "Déjà enregistré — le document existant est mis en évidence.", + "deleted": "Document supprimé.", + "restoreFailed": "Impossible de restaurer le document." }, - "toolbar": { - "searchLabel": "Rechercher", - "searchPlaceholder": "Titre ou nom de fichier", - "kindFilterLabel": "Catégorie", - "allKinds": "Toutes les catégories", - "sortLabel": "Trier par", - "sort": { - "documentDate": "Date du document", - "createdAt": "Date d'ajout", - "title": "Titre" - }, - "orderAsc": "Croissant", - "orderDesc": "Décroissant", - "fromLabel": "Du", - "toLabel": "Au" + "timeline": { + "loadingMore": "Chargement…", + "listLabel": "Chronologie des documents" + }, + "upload": { + "zoneTitle": "Déposez des fichiers ici ou choisissez depuis votre appareil", + "zoneHint": "PDF, photos et fichiers Office jusqu'à {maxSize} chacun", + "browse": "Choisir des fichiers", + "quotaAlmostFull": "Stockage presque plein", + "quotaUsed": "{used} sur {quota} utilisés", + "queuedAnnouncement": "{count} fichier(s) ajoutés à la file de téléversement" + }, + "selection": { + "count": "{count} sélectionnés", + "clear": "Effacer la sélection" + }, + "list": { + "loadError": "Impossible de charger vos documents." + }, + "empty": { + "title": "Votre coffre à documents est vide", + "description": "Conservez courriers médicaux, résultats de laboratoire, imagerie, ordonnances et carnets de vaccination — chiffrés et consultables par type, affection et année.", + "action": "Téléverser un premier document", + "noMatchesTitle": "Aucun document correspondant", + "noMatchesDescription": "Rien ne correspond à la recherche et aux filtres actuels." }, "detail": { - "viewOriginal": "Voir l'original", - "edit": "Modifier", - "extract": "Extraire les éléments", - "extracting": "Extraction…", - "discard": "Écarter", - "notExtracted": "Aucun élément extrait pour l'instant. Utilisez Extraire les éléments pour lire ce document.", - "viewOriginalNewTab": "(s'ouvre dans un nouvel onglet)", - "discardConfirmTitle": "Supprimer ce document ?", - "discardConfirmBody": "Cela supprime définitivement le document, son fichier d'origine et les données préparées. Cette action est irréversible.", - "discardConfirmAction": "Supprimer", - "discardCancel": "Annuler" - }, - "edit": { - "title": "Modifier le document", - "subtitle": "Renommez, recatégorisez ou définissez la date du document.", + "title": "Détails du document", + "loadError": "Impossible de charger ce document.", + "saveError": "Impossible d'enregistrer la modification.", + "deleteError": "Impossible de supprimer le document.", + "download": "Télécharger", + "delete": "Supprimer", "titleLabel": "Titre", - "kindLabel": "Catégorie", + "titlePlaceholder": "Ajouter un titre", + "kindLabel": "Type", "dateLabel": "Date du document", - "save": "Enregistrer", - "saving": "Enregistrement…", - "cancel": "Annuler" + "conditionsLabel": "Affections liées", + "linkCondition": "Lier", + "noConditions": "Lié à aucune affection", + "previewUnavailable": "Pas d'aperçu intégré pour ce type de fichier", + "uploadedMeta": "Ajouté le {date} · {size}" }, - "note": { - "providerUnsupported": "Configurez un fournisseur d'analyse de documents dans les paramètres d'IA pour extraire les éléments automatiquement.", - "alreadyConfirmed": "Ce document a déjà été vérifié ; il ne peut pas être extrait à nouveau." + "dropOverlay": { + "title": "Déposez les fichiers pour les téléverser", + "hint": "Ils sont enregistrés chiffrés dans vos documents" }, - "committed": { - "title": "Validé depuis ce document" + "bulk": { + "barLabel": "Actions pour les documents sélectionnés", + "setKind": "Changer le type", + "linkCondition": "Lier une affection", + "delete": "Supprimer", + "updated": "{count} document(s) mis à jour", + "deleted": "{count} document(s) supprimés", + "restored": "{count} document(s) restaurés", + "partialFailure": "Impossible de mettre à jour {failed} sur {total}", + "failed": "L'action n'a pas pu être effectuée" + }, + "episodeCard": { + "title": "Documents", + "link": "Lier", + "upload": "Téléverser", + "viewAll": "Tout afficher", + "emptyLine": "Aucun document n'est encore lié à cette affection.", + "loadError": "Impossible de charger les documents liés" + }, + "linkPicker": { + "title": "Lier des documents", + "description": "Choisissez dans vos documents ceux qui concernent cette affection.", + "searchPlaceholder": "Rechercher par titre ou nom de fichier", + "empty": "Aucun document trouvé." } }, "selfDescription": { diff --git a/messages/it.json b/messages/it.json index aa5902b27..7d13b2fa0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5077,7 +5077,8 @@ "error": "Esportazione non riuscita ({code}).", "labs": "Risultati degli esami", "allergies": "Allergie e intolleranze", - "familyHistory": "Anamnesi familiare" + "familyHistory": "Anamnesi familiare", + "documentsLink": "Documenti del periodo" }, "globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente", "globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.", @@ -5935,7 +5936,14 @@ "mfaEnforcedOffHint": "Richiedi un secondo fattore per questo utente.", "copyIp": "Copia indirizzo IP", "ipCopied": "Indirizzo IP copiato", - "copyFailed": "Impossibile copiare." + "copyFailed": "Impossibile copiare.", + "documentMaxFile": "Limite di caricamento documenti", + "documentMaxFileDescription": "Dimensione massima per file nell'archivio documenti (1–100 MB).", + "documentQuota": "Quota di archiviazione documenti", + "documentQuotaDescription": "Spazio documenti predefinito per account (0,1–1024 GB).", + "userDocumentQuota": "Quota documenti personalizzata", + "userDocumentQuotaDefault": "Predefinito", + "userDocumentQuotaHint": "Lascia vuoto per usare il valore predefinito dell'istanza." }, "achievements": { "title": "Obiettivi", @@ -7338,7 +7346,8 @@ "extractFailed": "Impossibile leggere il referto. Prova con una foto più nitida.", "pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto." }, - "loadMoreReadings": "Carica altre misurazioni" + "loadMoreReadings": "Carica altre misurazioni", + "documentsLink": "Documenti dei referti" }, "modules": { "cycle": { @@ -7403,7 +7412,7 @@ }, "inboundDocuments": { "label": "Documenti", - "description": "Conserva i tuoi documenti clinici; facoltativamente estrai e rivedi i loro dati prima del salvataggio. Disattivato di default." + "description": "Conserva ogni lettera medica, referto e scansione in un unico archivio cifrato e ricercabile — collegato alle tue patologie. Disattivato di default." }, "mentalHealth": { "label": "Benessere mentale", @@ -8015,19 +8024,8 @@ }, "documents": { "title": "Documenti", - "subtitle": "Conserva i tuoi documenti clinici e controlla cosa contengono.", - "upload": { - "label": "Aggiungi un documento", - "kindLabel": "Categoria", - "button": "Carica", - "uploading": "Caricamento…", - "fileLabel": "File (obbligatorio)", - "titleLabel": "Titolo (facoltativo)", - "titlePlaceholder": "es. Referto di cardiologia", - "dateLabel": "Data del documento (facoltativa)", - "fileRequired": "Scegli un file da caricare.", - "accepts": "JPEG, PNG, WebP o PDF · fino a 12 MB" - }, + "subtitle": "Ogni lettera, referto e scansione in un unico posto ricercabile — cifrato a riposo.", + "pageUpload": "Carica", "kind": { "DOCTOR_REPORT": "Referto medico", "DISCHARGE_LETTER": "Lettera di dimissione", @@ -8035,124 +8033,112 @@ "LAB_RESULT": "Risultato di laboratorio", "IMAGING": "Diagnostica per immagini", "PRESCRIPTION": "Prescrizione", + "REFERRAL": "Impegnativa", "INSURANCE": "Assicurazione", "VACCINATION": "Vaccinazione" }, - "list": { - "empty": "Nessun documento ancora.", - "noResults": "Nessun documento corrisponde ai filtri.", - "loadMore": "Carica altri", - "loadingMore": "Caricamento…", - "pending": "{count} da rivedere", - "facts": "{count} dati", - "clearFilters": "Cancella i filtri", - "loadError": "Impossibile caricare i tuoi documenti." + "filter": { + "searchLabel": "Cerca documenti", + "searchPlaceholder": "Cerca titolo o nome file…", + "groupLabel": "Filtra documenti", + "typeGroup": "Tipo di documento", + "conditionGroup": "Patologia", + "yearGroup": "Anno", + "clear": "Cancella filtri" }, - "status": { - "EXTRACTING": "Lettura", - "EXTRACTED": "Pronto per la revisione", - "FAILED": "Lettura non riuscita", - "CONFIRMED": "Rivisto", - "DISCARDED": "Scartato", - "STORED": "Salvato" + "card": { + "untitled": "Documento senza titolo", + "selectLabel": "Seleziona {title}", + "openLabel": "Apri {title}", + "attachmentBadge": "Solo download", + "uploading": "Caricamento…", + "dismissFailed": "Ignora caricamento non riuscito" }, - "review": { - "title": "Rivedi i dati estratti", - "confirm": "Salva i dati approvati", - "confirming": "Salvataggio…", - "needsReview": "Da rivedere", - "approve": "Approva", - "reject": "Rifiuta", - "edit": "Modifica", - "cancel": "Annulla", - "save": "Salva", - "needsReviewHint": "Modifica questo dato per poterlo approvare.", - "confidenceLabel": "Affidabilità dell'estrazione", - "commitError": { - "unitMismatch": "l'unità non corrisponde a quella salvata", - "unitRequired": "un valore numerico richiede un'unità", - "generic": "non è stato possibile salvarlo" - } - }, - "empty": { - "noFacts": "Niente altro da rivedere." - }, - "factType": { - "CONDITION": "Diagnosi", - "OBSERVATION": "Valore", - "MEDICATION_STATEMENT": "Farmaco" - }, - "fields": { - "label": "Nome", - "value": "Valore", - "unit": "Unità", - "date": "Data", - "dose": "Dose" + "error": { + "fileTooLarge": "Questo file supera il limite di {maxSize}.", + "quotaExceeded": "Lo spazio è pieno — {used} di {quota} usati.", + "unsupportedType": "Questo tipo di file non è supportato.", + "purged": "Questo documento è stato rimosso definitivamente e non può essere ripristinato.", + "duplicateExists": "Un documento identico è già archiviato.", + "rateLimited": "Troppi caricamenti — riprova tra qualche minuto.", + "generic": "Caricamento non riuscito. Riprova." }, "toast": { - "uploaded": "Documento salvato.", - "uploadFailed": "Impossibile salvare il documento.", - "confirmed": "Dati approvati salvati.", - "confirmedPartial": "Salvato, ma {count} non è stato possibile salvare: {reason}.", - "confirmFailed": "Impossibile salvare le tue scelte.", - "saved": "Dato aggiornato.", - "discarded": "Documento scartato.", - "extracted": "Dati estratti. Rivedili qui sotto.", - "extractFailed": "Impossibile estrarre i dati.", - "localOcrDisabled": "Attiva l'OCR locale nelle impostazioni per leggere questo documento sul dispositivo.", - "updated": "Documento aggiornato.", - "updateFailed": "Impossibile aggiornare il documento.", - "discardFailed": "Impossibile eliminare il documento.", - "uploadTooLarge": "Il file è troppo grande (max 12 MB).", - "uploadFileType": "Carica un file JPEG, PNG, WebP o PDF.", - "uploadRateLimited": "Troppi caricamenti. Riprova più tardi.", - "uploadInvalidMetadata": "Controlla titolo, categoria o data e riprova." + "duplicate": "Già archiviato — il documento esistente viene evidenziato.", + "deleted": "Documento eliminato.", + "restoreFailed": "Impossibile ripristinare il documento." }, - "toolbar": { - "searchLabel": "Cerca", - "searchPlaceholder": "Titolo o nome file", - "kindFilterLabel": "Categoria", - "allKinds": "Tutte le categorie", - "sortLabel": "Ordina per", - "sort": { - "documentDate": "Data del documento", - "createdAt": "Data di aggiunta", - "title": "Titolo" - }, - "orderAsc": "Crescente", - "orderDesc": "Decrescente", - "fromLabel": "Dal", - "toLabel": "Al" + "timeline": { + "loadingMore": "Caricamento…", + "listLabel": "Cronologia dei documenti" + }, + "upload": { + "zoneTitle": "Trascina i file qui o scegli dal dispositivo", + "zoneHint": "PDF, foto e file Office fino a {maxSize} ciascuno", + "browse": "Scegli file", + "quotaAlmostFull": "Spazio quasi pieno", + "quotaUsed": "{used} di {quota} usati", + "queuedAnnouncement": "{count} file aggiunti alla coda di caricamento" + }, + "selection": { + "count": "{count} selezionati", + "clear": "Annulla selezione" + }, + "list": { + "loadError": "Impossibile caricare i tuoi documenti." + }, + "empty": { + "title": "Il tuo archivio documenti è vuoto", + "description": "Conserva lettere mediche, referti di laboratorio, imaging, prescrizioni e certificati vaccinali — cifrati e ricercabili per tipo, patologia e anno.", + "action": "Carica un primo documento", + "noMatchesTitle": "Nessun documento corrispondente", + "noMatchesDescription": "Nulla corrisponde alla ricerca e ai filtri attuali." }, "detail": { - "viewOriginal": "Vedi originale", - "edit": "Modifica", - "extract": "Estrai dati", - "extracting": "Estrazione…", - "discard": "Scarta", - "notExtracted": "Nessun dato estratto. Usa Estrai dati per leggere questo documento.", - "viewOriginalNewTab": "(si apre in una nuova scheda)", - "discardConfirmTitle": "Eliminare questo documento?", - "discardConfirmBody": "Questo elimina definitivamente il documento, il file originale e i dati preparati. L'operazione non è reversibile.", - "discardConfirmAction": "Elimina", - "discardCancel": "Annulla" - }, - "edit": { - "title": "Modifica documento", - "subtitle": "Rinomina, ricategorizza o imposta la data del documento.", + "title": "Dettagli del documento", + "loadError": "Impossibile caricare questo documento.", + "saveError": "Impossibile salvare la modifica.", + "deleteError": "Impossibile eliminare il documento.", + "download": "Scarica", + "delete": "Elimina", "titleLabel": "Titolo", - "kindLabel": "Categoria", + "titlePlaceholder": "Aggiungi un titolo", + "kindLabel": "Tipo", "dateLabel": "Data del documento", - "save": "Salva", - "saving": "Salvataggio…", - "cancel": "Annulla" + "conditionsLabel": "Patologie collegate", + "linkCondition": "Collega", + "noConditions": "Non collegato a una patologia", + "previewUnavailable": "Nessuna anteprima integrata per questo tipo di file", + "uploadedMeta": "Aggiunto il {date} · {size}" }, - "note": { - "providerUnsupported": "Configura un provider di scansione documenti nelle impostazioni IA per estrarre i dati automaticamente.", - "alreadyConfirmed": "Questo documento è già stato esaminato, quindi non può essere estratto di nuovo." + "dropOverlay": { + "title": "Rilascia i file per caricarli", + "hint": "Vengono salvati cifrati nei tuoi documenti" }, - "committed": { - "title": "Approvato da questo documento" + "bulk": { + "barLabel": "Azioni per i documenti selezionati", + "setKind": "Cambia tipo", + "linkCondition": "Collega patologia", + "delete": "Elimina", + "updated": "{count} documenti aggiornati", + "deleted": "{count} documenti eliminati", + "restored": "{count} documenti ripristinati", + "partialFailure": "Impossibile aggiornare {failed} su {total}", + "failed": "Impossibile completare l'azione" + }, + "episodeCard": { + "title": "Documenti", + "link": "Collega", + "upload": "Carica", + "viewAll": "Mostra tutti", + "emptyLine": "Nessun documento ancora collegato a questa patologia.", + "loadError": "Impossibile caricare i documenti collegati" + }, + "linkPicker": { + "title": "Collega documenti", + "description": "Scegli i documenti del tuo archivio che riguardano questa patologia.", + "searchPlaceholder": "Cerca per titolo o nome del file", + "empty": "Nessun documento trovato." } }, "selfDescription": { diff --git a/messages/pl.json b/messages/pl.json index fe0d8e170..e51e0c00d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5077,7 +5077,8 @@ "error": "Eksport nie powiódł się ({code}).", "labs": "Wyniki badań", "allergies": "Alergie i nietolerancje", - "familyHistory": "Wywiad rodzinny" + "familyHistory": "Wywiad rodzinny", + "documentsLink": "Dokumenty z tego okresu" }, "globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji", "globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.", @@ -5935,7 +5936,14 @@ "mfaEnforcedOffHint": "Wymagaj drugiego składnika dla tego użytkownika.", "copyIp": "Kopiuj adres IP", "ipCopied": "Skopiowano adres IP", - "copyFailed": "Nie udało się skopiować." + "copyFailed": "Nie udało się skopiować.", + "documentMaxFile": "Limit przesyłania dokumentów", + "documentMaxFileDescription": "Maksymalny rozmiar pliku w archiwum dokumentów (1–100 MB).", + "documentQuota": "Limit miejsca na dokumenty", + "documentQuotaDescription": "Domyślne miejsce na dokumenty na konto (0,1–1024 GB).", + "userDocumentQuota": "Indywidualny limit dokumentów", + "userDocumentQuotaDefault": "Domyślny", + "userDocumentQuotaHint": "Pozostaw puste, aby użyć domyślnego limitu instancji." }, "achievements": { "title": "Osiągnięcia", @@ -7338,7 +7346,8 @@ "extractFailed": "Nie udało się odczytać wyniku. Spróbuj wyraźniejszego zdjęcia.", "pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie." }, - "loadMoreReadings": "Wczytaj więcej pomiarów" + "loadMoreReadings": "Wczytaj więcej pomiarów", + "documentsLink": "Dokumenty wyników badań" }, "modules": { "cycle": { @@ -7403,7 +7412,7 @@ }, "inboundDocuments": { "label": "Dokumenty", - "description": "Przechowuj swoje dokumenty medyczne; opcjonalnie wyodrębniaj i przeglądaj ich fakty przed zapisaniem. Domyślnie wyłączone." + "description": "Przechowuj każde pismo lekarskie, wynik i skan w jednym zaszyfrowanym, przeszukiwalnym miejscu — powiązane z Twoimi schorzeniami. Domyślnie wyłączone." }, "mentalHealth": { "label": "Dobrostan psychiczny", @@ -8015,19 +8024,8 @@ }, "documents": { "title": "Dokumenty", - "subtitle": "Przechowuj swoje dokumenty medyczne i sprawdzaj, co zawierają.", - "upload": { - "label": "Dodaj dokument", - "kindLabel": "Kategoria", - "button": "Prześlij", - "uploading": "Przesyłanie…", - "fileLabel": "Plik (wymagany)", - "titleLabel": "Tytuł (opcjonalnie)", - "titlePlaceholder": "np. Raport kardiologiczny", - "dateLabel": "Data dokumentu (opcjonalnie)", - "fileRequired": "Wybierz plik do przesłania.", - "accepts": "JPEG, PNG, WebP lub PDF · do 12 MB" - }, + "subtitle": "Każde pismo, wynik i skan w jednym przeszukiwalnym miejscu — zaszyfrowane w spoczynku.", + "pageUpload": "Prześlij", "kind": { "DOCTOR_REPORT": "Raport lekarski", "DISCHARGE_LETTER": "Wypis ze szpitala", @@ -8035,124 +8033,112 @@ "LAB_RESULT": "Wynik laboratoryjny", "IMAGING": "Obrazowanie", "PRESCRIPTION": "Recepta", + "REFERRAL": "Skierowanie", "INSURANCE": "Ubezpieczenie", "VACCINATION": "Szczepienie" }, - "list": { - "empty": "Brak dokumentów.", - "noResults": "Żaden dokument nie pasuje do filtrów.", - "loadMore": "Załaduj więcej", - "loadingMore": "Ładowanie…", - "pending": "{count} do przeglądu", - "facts": "{count} faktów", - "clearFilters": "Wyczyść filtry", - "loadError": "Nie udało się wczytać Twoich dokumentów." + "filter": { + "searchLabel": "Szukaj dokumentów", + "searchPlaceholder": "Szukaj tytułu lub nazwy pliku…", + "groupLabel": "Filtruj dokumenty", + "typeGroup": "Typ dokumentu", + "conditionGroup": "Schorzenie", + "yearGroup": "Rok", + "clear": "Wyczyść filtry" }, - "status": { - "EXTRACTING": "Odczyt", - "EXTRACTED": "Gotowy do przeglądu", - "FAILED": "Nie udało się odczytać", - "CONFIRMED": "Przejrzany", - "DISCARDED": "Odrzucony", - "STORED": "Zapisany" + "card": { + "untitled": "Dokument bez tytułu", + "selectLabel": "Zaznacz {title}", + "openLabel": "Otwórz {title}", + "attachmentBadge": "Tylko do pobrania", + "uploading": "Przesyłanie…", + "dismissFailed": "Odrzuć nieudane przesyłanie" }, - "review": { - "title": "Przejrzyj wyodrębnione dane", - "confirm": "Zapisz zatwierdzone dane", - "confirming": "Zapisywanie…", - "needsReview": "Wymaga przeglądu", - "approve": "Zatwierdź", - "reject": "Odrzuć", - "edit": "Edytuj", - "cancel": "Anuluj", - "save": "Zapisz", - "needsReviewHint": "Edytuj tę pozycję, aby ją zatwierdzić.", - "confidenceLabel": "Pewność rozpoznania", - "commitError": { - "unitMismatch": "jednostka nie zgadza się z zapisaną", - "unitRequired": "wartość liczbowa wymaga jednostki", - "generic": "nie udało się zapisać" - } - }, - "empty": { - "noFacts": "Nic więcej do przejrzenia." - }, - "factType": { - "CONDITION": "Diagnoza", - "OBSERVATION": "Wartość", - "MEDICATION_STATEMENT": "Lek" - }, - "fields": { - "label": "Nazwa", - "value": "Wartość", - "unit": "Jednostka", - "date": "Data", - "dose": "Dawka" + "error": { + "fileTooLarge": "Ten plik przekracza limit {maxSize}.", + "quotaExceeded": "Miejsce jest pełne — użyto {used} z {quota}.", + "unsupportedType": "Ten typ pliku nie jest obsługiwany.", + "purged": "Ten dokument został trwale usunięty i nie można go przywrócić.", + "duplicateExists": "Identyczny dokument jest już zapisany.", + "rateLimited": "Zbyt wiele przesyłań — spróbuj ponownie za kilka minut.", + "generic": "Przesyłanie nie powiodło się. Spróbuj ponownie." }, "toast": { - "uploaded": "Dokument zapisany.", - "uploadFailed": "Nie udało się zapisać dokumentu.", - "confirmed": "Zatwierdzone dane zapisane.", - "confirmedPartial": "Zapisano, ale {count} nie udało się zapisać — {reason}.", - "confirmFailed": "Nie udało się zapisać Twoich decyzji.", - "saved": "Dane zaktualizowane.", - "discarded": "Dokument odrzucony.", - "extracted": "Fakty wyodrębnione. Przejrzyj je poniżej.", - "extractFailed": "Nie udało się wyodrębnić faktów.", - "localOcrDisabled": "Włącz lokalne OCR w ustawieniach, aby odczytać ten dokument na urządzeniu.", - "updated": "Dokument zaktualizowany.", - "updateFailed": "Nie udało się zaktualizować dokumentu.", - "discardFailed": "Nie udało się odrzucić dokumentu.", - "uploadTooLarge": "Plik jest za duży (maks. 12 MB).", - "uploadFileType": "Prześlij plik JPEG, PNG, WebP lub PDF.", - "uploadRateLimited": "Zbyt wiele przesłań. Spróbuj ponownie później.", - "uploadInvalidMetadata": "Sprawdź tytuł, kategorię lub datę i spróbuj ponownie." + "duplicate": "Już zapisano — istniejący dokument został wyróżniony.", + "deleted": "Dokument usunięty.", + "restoreFailed": "Nie udało się przywrócić dokumentu." }, - "toolbar": { - "searchLabel": "Szukaj", - "searchPlaceholder": "Tytuł lub nazwa pliku", - "kindFilterLabel": "Kategoria", - "allKinds": "Wszystkie kategorie", - "sortLabel": "Sortuj według", - "sort": { - "documentDate": "Data dokumentu", - "createdAt": "Data dodania", - "title": "Tytuł" - }, - "orderAsc": "Rosnąco", - "orderDesc": "Malejąco", - "fromLabel": "Od", - "toLabel": "Do" + "timeline": { + "loadingMore": "Wczytywanie…", + "listLabel": "Oś czasu dokumentów" + }, + "upload": { + "zoneTitle": "Upuść pliki tutaj lub wybierz z urządzenia", + "zoneHint": "PDF, zdjęcia i pliki Office do {maxSize} każdy", + "browse": "Wybierz pliki", + "quotaAlmostFull": "Miejsce prawie pełne", + "quotaUsed": "Użyto {used} z {quota}", + "queuedAnnouncement": "Dodano pliki do kolejki przesyłania: {count}" + }, + "selection": { + "count": "Zaznaczono: {count}", + "clear": "Wyczyść zaznaczenie" + }, + "list": { + "loadError": "Nie udało się wczytać Twoich dokumentów." + }, + "empty": { + "title": "Twój sejf dokumentów jest pusty", + "description": "Przechowuj pisma lekarskie, wyniki laboratoryjne, obrazowanie, recepty i zaświadczenia o szczepieniach — zaszyfrowane i przeszukiwalne według typu, schorzenia i roku.", + "action": "Prześlij pierwszy dokument", + "noMatchesTitle": "Brak pasujących dokumentów", + "noMatchesDescription": "Nic nie pasuje do bieżącego wyszukiwania i filtrów." }, "detail": { - "viewOriginal": "Zobacz oryginał", - "edit": "Edytuj", - "extract": "Wyodrębnij fakty", - "extracting": "Wyodrębnianie…", - "discard": "Odrzuć", - "notExtracted": "Nie wyodrębniono jeszcze faktów. Użyj Wyodrębnij fakty, aby odczytać ten dokument.", - "viewOriginalNewTab": "(otwiera się w nowej karcie)", - "discardConfirmTitle": "Odrzucić ten dokument?", - "discardConfirmBody": "Spowoduje to trwałe usunięcie dokumentu, oryginalnego pliku i przygotowanych danych. Tej operacji nie można cofnąć.", - "discardConfirmAction": "Odrzuć", - "discardCancel": "Anuluj" - }, - "edit": { - "title": "Edytuj dokument", - "subtitle": "Zmień nazwę, kategorię lub ustaw datę dokumentu.", + "title": "Szczegóły dokumentu", + "loadError": "Nie udało się wczytać tego dokumentu.", + "saveError": "Nie udało się zapisać zmiany.", + "deleteError": "Nie udało się usunąć dokumentu.", + "download": "Pobierz", + "delete": "Usuń", "titleLabel": "Tytuł", - "kindLabel": "Kategoria", + "titlePlaceholder": "Dodaj tytuł", + "kindLabel": "Typ", "dateLabel": "Data dokumentu", - "save": "Zapisz", - "saving": "Zapisywanie…", - "cancel": "Anuluj" + "conditionsLabel": "Powiązane schorzenia", + "linkCondition": "Powiąż", + "noConditions": "Niepowiązany ze schorzeniem", + "previewUnavailable": "Brak podglądu dla tego typu pliku", + "uploadedMeta": "Dodano {date} · {size}" }, - "note": { - "providerUnsupported": "Skonfiguruj dostawcę skanowania dokumentów w ustawieniach AI, aby automatycznie wyodrębniać fakty.", - "alreadyConfirmed": "Ten dokument został już sprawdzony, więc nie można go ponownie wyodrębnić." + "dropOverlay": { + "title": "Upuść pliki, aby je przesłać", + "hint": "Są zapisywane w Twoich dokumentach w postaci zaszyfrowanej" }, - "committed": { - "title": "Zatwierdzone z tego dokumentu" + "bulk": { + "barLabel": "Akcje dla zaznaczonych dokumentów", + "setKind": "Zmień typ", + "linkCondition": "Powiąż ze schorzeniem", + "delete": "Usuń", + "updated": "Zaktualizowano dokumenty: {count}", + "deleted": "Usunięto dokumenty: {count}", + "restored": "Przywrócono dokumenty: {count}", + "partialFailure": "Nie udało się zaktualizować {failed} z {total}", + "failed": "Nie udało się wykonać akcji" + }, + "episodeCard": { + "title": "Dokumenty", + "link": "Powiąż", + "upload": "Prześlij", + "viewAll": "Pokaż wszystkie", + "emptyLine": "Brak dokumentów powiązanych z tym schorzeniem.", + "loadError": "Nie udało się wczytać połączonych dokumentów" + }, + "linkPicker": { + "title": "Powiąż dokumenty", + "description": "Wybierz dokumenty z archiwum, które dotyczą tego schorzenia.", + "searchPlaceholder": "Szukaj po tytule lub nazwie pliku", + "empty": "Nie znaleziono dokumentów." } }, "selfDescription": { diff --git a/package.json b/package.json index 7043ed477..3c380a1f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.27.16", + "version": "1.27.17", "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", @@ -66,6 +66,7 @@ "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.1", "@tanstack/react-query": "5.101.2", + "@tanstack/react-virtual": "^3.14.5", "@types/sax": "^1.2.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a23c7c62a..4772a0b7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@tanstack/react-query': specifier: 5.101.2 version: 5.101.2(react@19.2.7) + '@tanstack/react-virtual': + specifier: ^3.14.5 + version: 3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/sax': specifier: ^1.2.7 version: 1.2.7 @@ -2868,6 +2871,15 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-virtual@3.14.5': + resolution: {integrity: sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.17.3': + resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==} + '@testcontainers/postgresql@12.0.3': resolution: {integrity: sha512-y0l/ItWxNfdMU783EOZ66lMq90uuuXPqE+fo1jNSRfMbtfUq/9Ez/wluDxxaS+D/i3I0juep+jmz/WVuip8i9Q==} @@ -9450,6 +9462,14 @@ snapshots: '@tanstack/query-core': 5.101.2 react: 19.2.7 + '@tanstack/react-virtual@3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/virtual-core': 3.17.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/virtual-core@3.17.3': {} + '@testcontainers/postgresql@12.0.3': dependencies: testcontainers: 12.0.3 diff --git a/prisma/migrations/0227_document_vault/migration.sql b/prisma/migrations/0227_document_vault/migration.sql new file mode 100644 index 000000000..97c48a5f5 --- /dev/null +++ b/prisma/migrations/0227_document_vault/migration.sql @@ -0,0 +1,78 @@ +-- Document vault: re-enable the parked inbound-documents module with the +-- hardened storage contract. +-- +-- 1. `inbound_document_kind` gains REFERRAL (Überweisung — clean LOINC +-- 57133-1 / ePA ANF mapping). +-- 2. `inbound_documents` gains `content_sha256` (lowercase hex of the +-- plaintext, for same-user duplicate detection; pre-existing rows stay +-- NULL and never dedupe) and `content_codec` ("base64v1" legacy string +-- codec | "binary2" binary codec; the default keeps existing rows +-- correct) plus a partial unique index enforcing at most one LIVE row +-- per (user_id, content_sha256). +-- 3. `document_condition_links` — m:n join between a document and an +-- illness/condition episode. Both sides cascade. +-- 4. `app_settings` gains the two admin-tunable limits (per-file cap, +-- default 25 MiB; per-user quota, default 1 GiB); `users` gains the +-- nullable per-user quota override. +-- +-- Additive and idempotent-safe on an existing database; applies clean on a +-- fresh database as part of the ordered migration chain. + +-- 1. Kind enum +ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'REFERRAL'; + +-- 2. Document columns + live-dedupe partial unique index +ALTER TABLE "inbound_documents" + ADD COLUMN "content_sha256" VARCHAR(64), + ADD COLUMN "content_codec" TEXT NOT NULL DEFAULT 'base64v1'; + +-- Partial unique index (documented in schema.prisma — Prisma cannot express +-- a partial index, so it lives here as raw SQL). Tombstoned rows are +-- excluded: a soft-deleted document must not block re-uploading the same +-- file, and restore re-enters the constraint (a conflicting restore 409s). +CREATE UNIQUE INDEX "inbound_documents_user_sha_live" + ON "inbound_documents" ("user_id", "content_sha256") + WHERE "deleted_at" IS NULL AND "content_sha256" IS NOT NULL; + +-- 3. Document ⇄ condition links +CREATE TABLE "document_condition_links" ( + "id" TEXT NOT NULL, + "document_id" TEXT NOT NULL, + "episode_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "document_condition_links_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "document_condition_links_document_id_episode_id_key" + ON "document_condition_links" ("document_id", "episode_id"); + +CREATE INDEX "document_condition_links_episode_id_idx" + ON "document_condition_links" ("episode_id"); + +CREATE INDEX "document_condition_links_user_id_idx" + ON "document_condition_links" ("user_id"); + +ALTER TABLE "document_condition_links" + ADD CONSTRAINT "document_condition_links_document_id_fkey" + FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "document_condition_links" + ADD CONSTRAINT "document_condition_links_episode_id_fkey" + FOREIGN KEY ("episode_id") REFERENCES "illness_episodes" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "document_condition_links" + ADD CONSTRAINT "document_condition_links_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- 4. Admin limits +ALTER TABLE "app_settings" + ADD COLUMN "document_max_file_bytes" INTEGER NOT NULL DEFAULT 26214400, + ADD COLUMN "document_quota_bytes" BIGINT NOT NULL DEFAULT 1073741824; + +ALTER TABLE "users" + ADD COLUMN "document_quota_bytes" BIGINT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a4f69bf9c..62555c6c5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -537,6 +537,12 @@ model User { // `GET /api/auth/me` as a `modules: { : boolean }` map. modulePreferencesJson Json? @map("module_preferences_json") + // Document vault — per-user storage-quota override in bytes. NULL = the + // instance default (`AppSettings.documentQuotaBytes`) applies. Admin-set + // only (the admin user PUT); resolved by + // `resolveDocumentLimits()` in `src/lib/documents/upload-policy.ts`. + documentQuotaBytes BigInt? @map("document_quota_bytes") + // v1.18.6 (CCH-03) — "Coach last seen" stamp powering the unread dot // on the Coach FAB. The proactive nudge now lands as an ASSISTANT // message in the conversation rail (CCH-02); the FAB shows a discreet @@ -777,6 +783,7 @@ model User { // approval. Opt-in module, off by default. inboundDocuments InboundDocument[] extractedFacts ExtractedFact[] + documentConditionLinks DocumentConditionLink[] // v1.19.0 — full Withings ECG waveform recordings (encrypted at rest). // The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION // Measurement EVENT row (v1.18.11); this holds the raw signal samples. @@ -3767,6 +3774,16 @@ model AppSettings { // never be disabled. Resolved in `src/lib/modules/gate.ts`. moduleAvailabilityJson Json? @map("module_availability_json") + // Document vault — the two admin-tunable limits (the only knobs beside the + // module key). `documentMaxFileBytes` is the per-file upload cap, default + // 25 MiB, clamped at runtime to the hard 100 MiB ceiling (single-shot GCM + + // bounded in-memory reads are load-bearing past that). `documentQuotaBytes` + // is the instance-wide per-user storage quota, default 1 GiB; a per-user + // override lives on `User.documentQuotaBytes`. Usage counts every + // non-purged row (tombstones still hold TOAST bytes). + documentMaxFileBytes Int @default(26214400) @map("document_max_file_bytes") + documentQuotaBytes BigInt @default(1073741824) @map("document_quota_bytes") + @@map("app_settings") } @@ -5400,11 +5417,12 @@ model IllnessEpisode { /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Parent condition for a flare/recurrence (self-relation). - parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull) - flares IllnessEpisode[] @relation("IllnessFlares") - dayLogs IllnessDayLog[] + parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull) + flares IllnessEpisode[] @relation("IllnessFlares") + dayLogs IllnessDayLog[] + documentLinks DocumentConditionLink[] @@index([userId, deletedAt]) @@index([userId, onsetAt]) @@ -5693,6 +5711,9 @@ enum InboundDocumentKind { LAB_RESULT IMAGING PRESCRIPTION + /// Überweisung / referral letter — a common German artefact with a clean + /// LOINC (57133-1) / ePA (ANF) mapping. Added with the document vault. + REFERRAL INSURANCE VACCINATION OTHER @@ -5760,6 +5781,21 @@ model InboundDocument { /// shared with the note columns. The original is sensitive and never /// stored in the clear or logged. contentEncrypted Bytes @map("content_encrypted") + /// Lowercase-hex SHA-256 of the PLAINTEXT document bytes, computed at + /// upload for same-user duplicate detection. Nullable: rows stored before + /// the vault release are not backfilled (only new uploads dedupe). A + /// partial unique index (raw SQL in migration 0227, not expressible in + /// Prisma) enforces at most one LIVE row per (user_id, content_sha256): + /// CREATE UNIQUE INDEX inbound_documents_user_sha_live + /// ON inbound_documents (user_id, content_sha256) + /// WHERE deleted_at IS NULL AND content_sha256 IS NOT NULL; + contentSha256 String? @map("content_sha256") @db.VarChar(64) + /// Storage codec of `contentEncrypted`. "base64v1" = legacy string path + /// (base64-of-binary → `encrypt()` string → UTF-8 bytes); "binary2" = the + /// binary AES-256-GCM layout written by `encryptBytes()` (no base64 + /// detour). Explicit column, never header-sniffed; the default keeps every + /// pre-existing row correct. + contentCodec String @default("base64v1") @map("content_codec") status InboundDocumentStatus @default(STORED) /// Provider type that ran the extraction (e.g. "anthropic", "openai", /// "local"). Diagnostic only. Null on a store-only document. @@ -5777,7 +5813,8 @@ model InboundDocument { /// Failure reason when status = FAILED (a short code, never the document). errorReason String? @map("error_reason") - facts ExtractedFact[] + facts ExtractedFact[] + conditionLinks DocumentConditionLink[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -5841,3 +5878,25 @@ model ExtractedFact { @@index([userId]) @@map("extracted_facts") } + +/// m:n join between a stored document and an illness/condition episode +/// (a discharge letter legitimately touches two conditions). Never +/// mandatory at upload; both sides cascade so a deleted document or +/// episode takes its links with it. `userId` is denormalised for the +/// owner-scoped queries the vault list runs. +model DocumentConditionLink { + id String @id @default(cuid()) + documentId String @map("document_id") + episodeId String @map("episode_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + episode IllnessEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([documentId, episodeId]) + @@index([episodeId]) + @@index([userId]) + @@map("document_condition_links") +} diff --git a/public/sw.js b/public/sw.js index b8d416146..6b169d116 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.27.16"; + /* @sw-version-fallback */ "v1.27.17"; 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/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 047dd0ec3..ebad6121a 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -24,6 +24,10 @@ import { Buffer } from "node:buffer"; import { PrismaClient } from "@/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto"; +import { + rotateColumn, + type CorpusClient, +} from "@/lib/crypto/encryption-corpus"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { @@ -484,17 +488,31 @@ async function main() { ), ); - // ───── v1.25 (W-DOCS-IN) inbound clinical document (Bytes column) ───── - // The raw uploaded doctor report / discharge letter (base64-of-binary → - // AES-256-GCM string → UTF-8 bytes). Always encrypted on write (NOT NULL), - // so every row rotates. - results.push( - await rotateBytesColumn( - "InboundDocument", - "contentEncrypted", - prisma.inboundDocument, - ), - ); + // ───── Inbound clinical document (Bytes column, codec-dispatched) ───── + // The raw uploaded document. Two layouts recorded per row in + // `contentCodec` ("base64v1" string codec | "binary2" binary codec), so + // rotation goes through the shared codec-aware corpus walk: bounded + // id-cursor batches (rows are up to cap-sized blobs — never an unbounded + // findMany) re-encrypted under each row's OWN codec. Idempotent, so an + // interrupted run resumes on re-invocation. + { + const docResult = await rotateColumn( + { inboundDocument: prisma.inboundDocument } as unknown as CorpusClient, + { + model: "InboundDocument", + field: "contentEncrypted", + kind: "bytes", + codecField: "contentCodec", + }, + ); + results.push({ + table: docResult.model, + field: docResult.field, + scanned: docResult.scanned, + rotated: docResult.rotated, + errors: docResult.errors, + }); + } // The staged extracted-fact payloads: the FHIR-staged clinical values and the // verbatim source-span provenance. Both NOT NULL, so every staged row rotates. results.push( diff --git a/src/__tests__/proxy-document-serve-framing.test.ts b/src/__tests__/proxy-document-serve-framing.test.ts new file mode 100644 index 000000000..97cdb1d9c --- /dev/null +++ b/src/__tests__/proxy-document-serve-framing.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * The document vault's decrypt-and-serve route is framed same-origin by the + * detail preview (`