Merge pull request #427 from MBombeck/release/v1.27.17

Release v1.27.17 — the document vault
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-07 15:31:13 +02:00
commit b6e6fa3bdc
83 files changed
+10557 -3249

No files matched your search

+10
View File
@@ -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
+2 -1
View File
@@ -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.
+351 -25
View File
@@ -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:
+583
View File
@@ -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<Page["locator"]>,
maxSteps = 40,
): Promise<void> {
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<void> {
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<</Type/Catalog>>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);
});
});
+301
View File
@@ -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<string, string>;
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<</Type/Catalog/Pages 2 0 R>>endobj\n` +
`2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n` +
`3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n` +
`xref\n0 4\ntrailer<</Size 4/Root 1 0 R>>\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<string> {
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<void> {
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<void> {
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<void> {
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} <count>` 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();
}
}
+107 -121
View File
@@ -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": {
+107 -121
View File
@@ -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": {
+107 -121
View File
@@ -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": {
+107 -121
View File
@@ -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": {
+107 -121
View File
@@ -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": {
+107 -121
View File
@@ -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": {
+2 -1
View File
@@ -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",
+20
View File
@@ -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
@@ -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;
+64 -5
View File
@@ -537,6 +537,12 @@ model User {
// `GET /api/auth/me` as a `modules: { <key>: 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")
}
+1 -1
View File
@@ -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/*`
+29 -11
View File
@@ -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(
@@ -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 (`<iframe>` for inline-class PDFs). The proxy's blanket
* `X-Frame-Options: DENY` + `frame-ancestors 'none'` would refuse that
* embed, and the blanket page CSP overrides whatever the route sets
* (middleware headers win) — so the proxy carves out EXACTLY the
* `/original` path with a same-origin framing posture and a document CSP
* (`default-src 'none'`; deliberately no `sandbox` — Chromium
* force-downloads sandboxed PDFs instead of rendering them).
*
* These tests pin both sides of that carve-out: the serve path gets the
* narrow posture, and every neighbouring documents API path keeps the full
* DENY posture.
*/
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { proxy } from "../proxy";
function setNodeEnv(value: "development" | "production") {
vi.stubEnv("NODE_ENV", value);
}
beforeEach(() => {
setNodeEnv("production");
});
afterEach(() => {
vi.unstubAllEnvs();
});
function makeRequest(pathname: string): NextRequest {
return new NextRequest(`http://localhost${pathname}`, {
headers: { cookie: "healthlog_session=sess-1" },
});
}
describe("proxy.ts document serve-route framing carve-out", () => {
it("allows same-origin framing on the /original serve path", () => {
const res = proxy(makeRequest("/api/documents/inbound/doc123/original"));
expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN");
const csp = res.headers.get("content-security-policy") ?? "";
expect(csp).toContain("frame-ancestors 'self'");
expect(csp).toContain("default-src 'none'");
// No `sandbox` directive: Chromium downloads sandboxed PDFs instead of
// rendering them, which would break the inline preview outright.
expect(csp).not.toMatch(/sandbox/);
// No script sources of any kind for a served document.
expect(csp).not.toMatch(/script-src/);
});
it("keeps the DENY posture on the documents list route", () => {
const res = proxy(makeRequest("/api/documents/inbound"));
expect(res.headers.get("x-frame-options")).toBe("DENY");
expect(res.headers.get("content-security-policy") ?? "").toContain(
"frame-ancestors 'none'",
);
});
it("keeps the DENY posture on the document detail route", () => {
const res = proxy(makeRequest("/api/documents/inbound/doc123"));
expect(res.headers.get("x-frame-options")).toBe("DENY");
});
it("does not match nested or suffixed lookalike paths", () => {
for (const path of [
"/api/documents/inbound/doc123/original/extra",
"/api/documents/inbound/doc123/originalx",
"/api/documents/inbound//original",
]) {
const res = proxy(makeRequest(path));
expect(res.headers.get("x-frame-options"), path).toBe("DENY");
}
});
it("keeps the DENY posture on app pages, including /documents itself", () => {
const res = proxy(makeRequest("/documents"));
expect(res.headers.get("x-frame-options")).toBe("DENY");
expect(res.headers.get("content-security-policy") ?? "").toContain(
"frame-ancestors 'none'",
);
});
});
+20
View File
@@ -51,6 +51,13 @@ export const GET = apiHandler(async () => {
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
moodLogGlobal: settings?.moodLogGlobal ?? true,
// Document vault limits (per-file cap + per-user quota default). BigInt
// column → Number for the JSON envelope (values are far below 2^53).
documentMaxFileBytes: settings?.documentMaxFileBytes ?? 26_214_400,
documentQuotaBytes:
settings?.documentQuotaBytes !== undefined
? Number(settings.documentQuotaBytes)
: 1_073_741_824,
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the
// resolver"; surfacing the raw value lets the admin UI render
// an empty picker placeholder until they opt in.
@@ -176,6 +183,17 @@ export const PUT = apiHandler(async (request: NextRequest) => {
auditDetails.reminderMissedMinutes = data.reminderMissedMinutes;
}
// Document vault limits (schema already bounds both; the upload-time
// resolver clamps the cap to the hard ceiling again as defence-in-depth).
if (data.documentMaxFileBytes !== undefined) {
updates.documentMaxFileBytes = data.documentMaxFileBytes;
auditDetails.documentMaxFileBytes = data.documentMaxFileBytes;
}
if (data.documentQuotaBytes !== undefined) {
updates.documentQuotaBytes = BigInt(data.documentQuotaBytes);
auditDetails.documentQuotaBytes = data.documentQuotaBytes;
}
// v1.4.25 W7 — server-default timezone for new signups.
// Empty string clears the override (resolver falls back to
// Europe/Berlin); a non-empty string must pass Intl validation
@@ -240,6 +258,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
reminderLateMinutes: settings.reminderLateMinutes,
reminderMissedMinutes: settings.reminderMissedMinutes,
moodLogGlobal: settings.moodLogGlobal,
documentMaxFileBytes: settings.documentMaxFileBytes,
documentQuotaBytes: Number(settings.documentQuotaBytes),
defaultUserTimezone: settings.defaultUserTimezone,
});
});
+25 -1
View File
@@ -19,6 +19,16 @@ const updateUserSchema = z.object({
// v1.23 — per-user "require a second factor" override. Effective requirement
// is the OR of this and the instance-wide AppSettings.mfaRequired policy.
mfaEnforced: z.boolean().optional(),
// Document vault — per-user storage-quota override in bytes. Null clears
// the override (the instance default applies again). Same bounds as the
// instance-wide setting.
documentQuotaBytes: z
.number()
.int()
.min(10_485_760)
.max(1_099_511_627_776)
.nullable()
.optional(),
});
export const PUT = apiHandler(
@@ -70,6 +80,12 @@ export const PUT = apiHandler(
...(parsed.data.mfaEnforced !== undefined && {
mfaEnforced: parsed.data.mfaEnforced,
}),
...(parsed.data.documentQuotaBytes !== undefined && {
documentQuotaBytes:
parsed.data.documentQuotaBytes === null
? null
: BigInt(parsed.data.documentQuotaBytes),
}),
},
select: {
id: true,
@@ -78,6 +94,7 @@ export const PUT = apiHandler(
role: true,
createdAt: true,
mfaEnforced: true,
documentQuotaBytes: true,
},
});
@@ -87,6 +104,13 @@ export const PUT = apiHandler(
details: { targetUserId: id, changes: parsed.data },
});
return apiSuccess(updatedUser);
return apiSuccess({
...updatedUser,
// BigInt → Number for the JSON envelope; null = no override.
documentQuotaBytes:
updatedUser.documentQuotaBytes === null
? null
: Number(updatedUser.documentQuotaBytes),
});
},
);
@@ -67,6 +67,8 @@ describe("GET /api/admin/users", () => {
email: "alice@example.com",
role: "ADMIN",
createdAt: created,
mfaEnforced: true,
documentQuotaBytes: BigInt(2_147_483_648),
_count: { passkeys: 2 },
},
{
@@ -75,6 +77,8 @@ describe("GET /api/admin/users", () => {
email: null,
role: "USER",
createdAt: created,
mfaEnforced: false,
documentQuotaBytes: null,
_count: { passkeys: 0 },
},
] as never);
@@ -91,10 +95,15 @@ describe("GET /api/admin/users", () => {
email: "alice@example.com",
role: "ADMIN",
createdAt: created.toISOString(),
mfaEnforced: true,
// BigInt in Prisma, plain number on the wire.
documentQuotaBytes: 2_147_483_648,
passkeyCount: 2,
});
expect(body.data[1].passkeyCount).toBe(0);
expect(body.data[1].email).toBeNull();
// No override → explicit null (the instance default applies).
expect(body.data[1].documentQuotaBytes).toBeNull();
});
it("queries Prisma with the safe select shape (no password / sensitive cols)", async () => {
@@ -112,6 +121,7 @@ describe("GET /api/admin/users", () => {
role: true,
createdAt: true,
mfaEnforced: true,
documentQuotaBytes: true,
_count: { select: { passkeys: true } },
});
expect(args.select).not.toHaveProperty("passwordHash");
+6
View File
@@ -17,6 +17,7 @@ export const GET = apiHandler(async () => {
role: true,
createdAt: true,
mfaEnforced: true,
documentQuotaBytes: true,
_count: { select: { passkeys: true } },
},
orderBy: { createdAt: "asc" },
@@ -32,6 +33,11 @@ export const GET = apiHandler(async () => {
// v1.23 — per-user "require a second factor" override. The effective
// requirement is the OR of this and the instance-wide policy.
mfaEnforced: u.mfaEnforced,
// Document vault — per-user storage-quota override. Null = the
// instance default applies. BigInt in Prisma, number on the wire
// (max override 1 TiB, comfortably inside Number's safe range).
documentQuotaBytes:
u.documentQuotaBytes === null ? null : Number(u.documentQuotaBytes),
passkeyCount: u._count.passkeys,
})),
);
@@ -17,12 +17,28 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
findFirstOrThrow: vi.fn(),
},
documentConditionLink: {
findMany: vi.fn(),
deleteMany: vi.fn(),
createMany: vi.fn(),
},
illnessEpisode: {
findMany: vi.fn(),
},
appSettings: {
findUnique: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((s: string) => `v1.${s}`),
decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")),
encryptBytes: vi.fn((b: Buffer) => b),
decryptBytes: vi.fn((b: Buffer) => b),
}));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
@@ -106,6 +122,15 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.documentConditionLink.deleteMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.documentConditionLink.createMany).mockResolvedValue({
count: 0,
} as never);
});
describe("PATCH /api/documents/inbound/[id]", () => {
@@ -158,4 +183,57 @@ describe("PATCH /api/documents/inbound/[id]", () => {
expect(res.status).toBe(422);
expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled();
});
it("replace-sets condition links from episodeIds", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-1",
} as never);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([
{ id: "ep-1" },
{ id: "ep-2" },
] as never);
vi.mocked(prisma.inboundDocument.findFirstOrThrow).mockResolvedValue(
docRow() as never,
);
const res = await PATCH(
mkReq("doc-1", { episodeIds: ["ep-1", "ep-2"] }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(200);
// Replace-set: everything outside the set is deleted, the set is upserted.
expect(prisma.documentConditionLink.deleteMany).toHaveBeenCalledWith({
where: {
userId: "user-1",
documentId: "doc-1",
episodeId: { notIn: ["ep-1", "ep-2"] },
},
});
expect(prisma.documentConditionLink.createMany).toHaveBeenCalledWith({
data: [
{ documentId: "doc-1", episodeId: "ep-1", userId: "user-1" },
{ documentId: "doc-1", episodeId: "ep-2", userId: "user-1" },
],
skipDuplicates: true,
});
// A links-only PATCH does not touch the metadata columns.
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
});
it("refuses a foreign episodeId with a 404-shaped response", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-1",
} as never);
// The owner-narrowed lookup misses the foreign id.
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
const res = await PATCH(
mkReq("doc-1", { episodeIds: ["ep-foreign"] }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(404);
expect(prisma.documentConditionLink.deleteMany).not.toHaveBeenCalled();
expect(prisma.documentConditionLink.createMany).not.toHaveBeenCalled();
});
});
@@ -46,7 +46,7 @@ import {
type InboundExtractionResult,
} from "@/lib/documents/extract";
import {
decryptDocumentFromBytes,
decryptDocumentContent,
encryptFactData,
encryptFactProvenance,
serialiseDocumentDetail,
@@ -75,6 +75,7 @@ type LoadedDocument = {
id: string;
kind: string;
contentEncrypted: Uint8Array;
contentCodec: string;
mimeType: string;
status: string;
};
@@ -135,6 +136,7 @@ export const POST = apiHandler(
id: true,
kind: true,
contentEncrypted: true,
contentCodec: true,
mimeType: true,
status: true,
},
@@ -324,7 +326,10 @@ async function handleVisionExtract(
// trust a stored label for the provider call).
let buffer: Buffer;
try {
buffer = decryptDocumentFromBytes(document.contentEncrypted);
buffer = decryptDocumentContent(
document.contentEncrypted,
document.contentCodec,
);
} catch {
return apiError("Couldn't read the stored document.", 422, {
errorCode: "documents.inbound.extractFailed",
@@ -104,6 +104,7 @@ describe("GET /api/documents/inbound/[id]/original", () => {
id: "doc-1",
filename: "report.pdf",
mimeType: "application/pdf",
contentCodec: "base64v1",
contentEncrypted: encryptDocumentToBytes(PDF_BYTES),
} as never);
@@ -130,6 +131,7 @@ describe("GET /api/documents/inbound/[id]/original", () => {
id: "doc-2",
filename: null,
mimeType: "image/png",
contentCodec: "base64v1",
contentEncrypted: encryptDocumentToBytes(Buffer.from("png bytes")),
} as never);
@@ -147,6 +149,7 @@ describe("GET /api/documents/inbound/[id]/original", () => {
id: "doc-4",
filename: "Müller-Bericht.pdf",
mimeType: "application/pdf",
contentCodec: "base64v1",
contentEncrypted: encryptDocumentToBytes(PDF_BYTES),
} as never);
@@ -176,6 +179,7 @@ describe("GET /api/documents/inbound/[id]/original", () => {
id: "doc-3",
filename: "report.pdf",
mimeType: "application/pdf",
contentCodec: "base64v1",
contentEncrypted: encryptDocumentToBytes(PDF_BYTES),
} as never);
decryptMock.mockImplementation(() => {
@@ -189,3 +193,68 @@ describe("GET /api/documents/inbound/[id]/original", () => {
expect(body.error).toBeTruthy();
});
});
describe("serving-posture header matrix (Class A inline vs Class B attachment)", () => {
function seed(mimeType: string, filename: string) {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-m",
filename,
mimeType,
contentCodec: "base64v1",
contentEncrypted: encryptDocumentToBytes(Buffer.from("payload")),
} as never);
}
it.each([
["application/pdf", "scan.pdf"],
["image/jpeg", "photo.jpg"],
["image/png", "scan.png"],
["image/webp", "scan.webp"],
["image/gif", "anim.gif"],
])("Class A %s → inline, true type, nosniff", async (mime, name) => {
seed(mime, name);
const res = await callGet(makeReq("doc-m"), ctx("doc-m"));
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe(mime);
expect(res.headers.get("Content-Disposition")).toContain("inline");
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
// The response CSP is owned by the proxy carve-out (`default-src
// 'none'; frame-ancestors 'self'`, deliberately no sandbox —
// Chromium force-downloads sandboxed PDFs). A route-level value
// would be overwritten there, so the route must not set one; see
// src/__tests__/proxy-document-serve-framing.test.ts for the pin.
expect(res.headers.get("Content-Security-Policy")).toBeNull();
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
});
it.each([
[
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"letter.docx",
],
["application/msword", "letter.doc"],
["text/plain", "befund.txt"],
["text/csv", "werte.csv"],
["application/rtf", "note.rtf"],
["image/tiff", "scan.tif"],
["image/heic", "photo.heic"],
["application/xml", "export.xml"],
["application/json", "export.json"],
])(
"Class B %s → attachment + octet-stream, never inline",
async (mime, name) => {
seed(mime, name);
const res = await callGet(makeReq("doc-m"), ctx("doc-m"));
expect(res.status).toBe(200);
// The true stored type is deliberately NOT surfaced on the wire.
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
const disposition = res.headers.get("Content-Disposition") ?? "";
expect(disposition.startsWith("attachment;")).toBe(true);
expect(disposition).toContain(name);
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
// CSP is proxy-owned on every serve response; the route sets none.
expect(res.headers.get("Content-Security-Policy")).toBeNull();
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
},
);
});
@@ -1,23 +1,38 @@
/**
* v1.25.1 (W-DOCS-IN) — view / download the original uploaded document.
* Document vault: view / download the original uploaded document.
*
* The raw doctor report / discharge letter is stored encrypted at rest
* (`InboundDocument.contentEncrypted`, AES-256-GCM). This route is the ONLY
* path that decrypts and serves it, so the user can re-read the source the
* extracted facts came from. Owner-scoped + module-gated; the bytes are PHI:
* never logged, never served cross-user, fail-closed on a decrypt error.
* The raw document is stored encrypted at rest
* (`InboundDocument.contentEncrypted`, AES-256-GCM, codec per row). This
* route is the ONLY path that decrypts and serves it. Owner-scoped +
* module-gated; the bytes are PHI: never logged, never served cross-user,
* fail-closed on a decrypt error.
*
* The response carries the stored `mimeType`. Uploads are constrained at the
* store path to the inline-safe set (JPEG / PNG / WebP / PDF, magic-byte
* sniffed), so the document is always served `Content-Disposition: inline` for
* in-tab rendering. The filename is sanitised before it reaches the header.
* Serving posture IS the security boundary (upload policy §N1): the serving
* class derives from the stored MIME type via `servingClassFor` —
*
* - inline (Class A: PDF/JPEG/PNG/WebP/GIF): true Content-Type,
* `Content-Disposition: inline`, `X-Content-Type-Options: nosniff`,
* `Cache-Control: private, no-store`.
* - attachment (Class B: Office/text/TIFF/HEIC/XML/JSON): ALWAYS
* `Content-Disposition: attachment` with `Content-Type:
* application/octet-stream` + `nosniff` — never inline, regardless of
* the stored type, so a misclassified file cannot execute in-origin.
*
* The response CSP is owned by the proxy, not this route: `src/proxy.ts`
* carves out exactly this path with `default-src 'none'; frame-ancestors
* 'self'` (middleware headers win over route headers, so anything set here
* would be dead weight). Deliberately no `sandbox` directive anywhere —
* Chromium force-downloads PDFs framed in sandboxed documents instead of
* rendering them, which would break the inline preview.
* `src/__tests__/proxy-document-serve-framing.test.ts` pins that posture.
*/
import { NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { decryptDocumentFromBytes } from "@/lib/documents/store";
import { decryptDocumentContent } from "@/lib/documents/store";
import { servingClassFor } from "@/lib/documents/upload-policy";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -40,6 +55,22 @@ const MIME_EXTENSION: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
"image/tiff": "tif",
"image/heic": "heic",
"application/msword": "doc",
"application/vnd.ms-excel": "xls",
"application/vnd.ms-powerpoint": "ppt",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
"docx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
"pptx",
"text/plain": "txt",
"text/csv": "csv",
"application/rtf": "rtf",
"application/xml": "xml",
"application/json": "json",
};
/**
@@ -109,6 +140,7 @@ export const GET = apiHandler(
filename: true,
mimeType: true,
contentEncrypted: true,
contentCodec: true,
},
});
if (!document) {
@@ -119,10 +151,14 @@ export const GET = apiHandler(
let bytes: Buffer;
try {
bytes = decryptDocumentFromBytes(document.contentEncrypted);
bytes = decryptDocumentContent(
document.contentEncrypted,
document.contentCodec,
);
} catch {
// Fail closed — never fall back to the raw ciphertext. The error reason
// (bad / missing key id) is logged by the annotation, never the bytes.
// (bad / missing key id, unknown codec) is logged by the annotation,
// never the bytes.
annotate({
action: { name: "documents.inbound.original.decryptFailed" },
meta: { documentId: document.id },
@@ -132,16 +168,18 @@ export const GET = apiHandler(
});
}
const servingClass = servingClassFor(document.mimeType);
const downloadName = safeDownloadName(
document.filename,
document.id,
document.mimeType,
);
// ASCII fallback for the bare `filename=` (replace any non-ASCII byte) plus
// an RFC 5987 `filename*` that carries the real UTF-8 name to compliant
// clients. The stored set is inline-safe by construction, so always inline.
// ASCII fallback for the bare `filename=` (replace any non-ASCII byte)
// plus an RFC 5987 `filename*` that carries the real UTF-8 name to
// compliant clients.
const asciiName = downloadName.replace(/[^ -~]/g, "_");
const disposition = `inline; filename="${asciiName}"; filename*=UTF-8''${encodeRfc5987(downloadName)}`;
const dispositionType = servingClass === "inline" ? "inline" : "attachment";
const disposition = `${dispositionType}; filename="${asciiName}"; filename*=UTF-8''${encodeRfc5987(downloadName)}`;
annotate({
action: { name: "documents.inbound.original.get" },
@@ -149,19 +187,32 @@ export const GET = apiHandler(
documentId: document.id,
mimeType: document.mimeType,
bytes: bytes.byteLength,
disposition: "inline",
disposition: dispositionType,
},
});
const headers: Record<string, string> = {
// Class B leaves the origin ONLY as an opaque download — the true
// stored type is deliberately not surfaced on the wire.
"Content-Type":
servingClass === "inline"
? document.mimeType
: "application/octet-stream",
"Content-Length": String(bytes.byteLength),
"Content-Disposition": disposition,
// The disposition/type pair is authoritative — never MIME-sniffed.
"X-Content-Type-Options": "nosniff",
// Private PHI — never cache in a shared / disk cache.
"Cache-Control": "private, no-store",
// No Content-Security-Policy here: the proxy's serve-route carve-out
// owns it (`default-src 'none'; frame-ancestors 'self'`, no sandbox —
// Chromium force-downloads sandboxed PDFs). A route-level CSP would be
// silently overwritten by the middleware header pass.
};
return new NextResponse(bytes as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": document.mimeType,
"Content-Length": String(bytes.byteLength),
"Content-Disposition": disposition,
// Private PHI — never cache in a shared / disk cache.
"Cache-Control": "private, no-store",
},
headers,
});
},
);
@@ -0,0 +1,155 @@
/**
* Document vault: restore a soft-deleted (tombstoned) document.
*
* Delete is undo-able: the DELETE route tombstones (`deletedAt`), the UI
* shows an "Undo" toast that calls this route, and the daily purge job
* physically removes tombstones older than the 30-day grace window. This
* route clears the tombstone; it answers 409 when the row is gone (purged —
* indistinguishable from never-existed by construction, and owner-scoped
* either way) or when restoring would collide with a live duplicate of the
* same bytes (the `inbound_documents_user_sha_live` partial unique index).
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { loadConditionLinks } from "@/lib/documents/links";
import { serialiseDocumentDetail } from "@/lib/documents/store";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { isP2002 } from "@/lib/prisma-errors";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import type { InboundDocumentStatus } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string }> };
export const dynamic = "force-dynamic";
/** Shares the write posture of the other owner-scoped document mutations. */
const WRITE_LIMIT_PER_HOUR = 240;
const WRITE_WINDOW_MS = 60 * 60 * 1000;
/**
* Post-restore lifecycle status, derived from the staged facts: no facts →
* STORED, any PENDING → EXTRACTED, otherwise every fact was acted on →
* CONFIRMED. (DELETE stamped DISCARDED; the prior status is not persisted.)
*/
function restoredStatus(
facts: Array<{ status: string }>,
): InboundDocumentStatus {
if (facts.length === 0) return "STORED";
return facts.some((f) => f.status === "PENDING") ? "EXTRACTED" : "CONFIRMED";
}
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`documents-write:${user.id}`,
WRITE_LIMIT_PER_HOUR,
WRITE_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests. Try again later.", 429, {
errorCode: "documents.inbound.rateLimited",
});
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const { id } = await params;
const row = await prisma.inboundDocument.findFirst({
where: { id, userId: user.id },
omit: { contentEncrypted: true },
include: { facts: { orderBy: { createdAt: "asc" } } },
});
if (!row) {
// Purged (past the 30-day grace) or never existed — the tombstone is
// gone either way, so the undo window is over: 409, not 404.
return apiError("This document can no longer be restored.", 409, {
errorCode: "documents.inbound.restoreGone",
reason: "purged",
});
}
if (row.deletedAt === null) {
// Already live — restoring twice is a no-op, not an error (the undo
// toast may fire after a concurrent restore).
const links = await loadConditionLinks(user.id, [row.id]);
return apiSuccess(
serialiseDocumentDetail(row, row.facts, links.get(row.id) ?? []),
);
}
// A live duplicate of the same bytes blocks the restore (the partial
// unique index would reject it; check first for a clean 409).
if (row.contentSha256) {
const conflict = await prisma.inboundDocument.findFirst({
where: {
userId: user.id,
contentSha256: row.contentSha256,
deletedAt: null,
id: { not: row.id },
},
select: { id: true },
});
if (conflict) {
return apiError("An identical document is already stored.", 409, {
errorCode: "documents.inbound.restoreDuplicate",
reason: "duplicateExists",
existingId: conflict.id,
});
}
}
try {
await prisma.inboundDocument.update({
where: { id: row.id },
data: { deletedAt: null, status: restoredStatus(row.facts) },
});
} catch (err) {
if (isP2002(err)) {
// Raced a concurrent identical upload into the partial unique index.
return apiError("An identical document is already stored.", 409, {
errorCode: "documents.inbound.restoreDuplicate",
reason: "duplicateExists",
});
}
throw err;
}
const restored = await prisma.inboundDocument.findFirstOrThrow({
where: { id: row.id },
omit: { contentEncrypted: true },
include: { facts: { orderBy: { createdAt: "asc" } } },
});
const links = await loadConditionLinks(user.id, [restored.id]);
await auditLog("documents.inbound.restore", {
userId: user.id,
ipAddress: getClientIp(request),
details: { documentId: restored.id },
});
annotate({
action: { name: "documents.vault.restore" },
meta: { documentId: restored.id },
});
return apiSuccess(
serialiseDocumentDetail(
restored,
restored.facts,
links.get(restored.id) ?? [],
),
);
},
);
+56 -8
View File
@@ -18,6 +18,11 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
loadConditionLinks,
narrowOwnedEpisodeIds,
replaceConditionLinks,
} from "@/lib/documents/links";
import { serialiseDocumentDetail } from "@/lib/documents/store";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -62,6 +67,7 @@ export const GET = apiHandler(
const { id } = await params;
const document = await prisma.inboundDocument.findFirst({
where: { id, userId: user.id, deletedAt: null },
omit: { contentEncrypted: true },
include: { facts: { orderBy: { createdAt: "asc" } } },
});
if (!document) {
@@ -70,12 +76,20 @@ export const GET = apiHandler(
});
}
const links = await loadConditionLinks(user.id, [document.id]);
annotate({
action: { name: "documents.inbound.get" },
meta: { documentId: id, facts: document.facts.length },
});
return apiSuccess(serialiseDocumentDetail(document, document.facts));
return apiSuccess(
serialiseDocumentDetail(
document,
document.facts,
links.get(document.id) ?? [],
),
);
},
);
@@ -110,6 +124,22 @@ export const PATCH = apiHandler(
});
}
// Replace-set condition links: every episode id must be a LIVE episode
// of the caller — a foreign/unknown id gets a 404-shaped refusal.
let nextEpisodeIds: string[] | undefined;
if (parsed.data.episodeIds !== undefined) {
const narrowed = await narrowOwnedEpisodeIds(
user.id,
parsed.data.episodeIds,
);
if (narrowed === null) {
return apiError("Episode not found", 404, {
errorCode: "documents.inbound.episodeNotFound",
});
}
nextEpisodeIds = narrowed;
}
// No mass assignment — each editable column is set explicitly only when
// the client sent it. `userId` is never a body field.
const data: Prisma.InboundDocumentUpdateInput = {};
@@ -121,31 +151,49 @@ export const PATCH = apiHandler(
: null;
}
await prisma.inboundDocument.update({
where: { id: existing.id },
data,
});
if (Object.keys(data).length > 0) {
await prisma.inboundDocument.update({
where: { id: existing.id },
data,
});
}
if (nextEpisodeIds !== undefined) {
await replaceConditionLinks(prisma, user.id, existing.id, nextEpisodeIds);
}
const document = await prisma.inboundDocument.findFirstOrThrow({
where: { id: existing.id },
omit: { contentEncrypted: true },
include: { facts: { orderBy: { createdAt: "asc" } } },
});
const links = await loadConditionLinks(user.id, [document.id]);
const touched = [
...Object.keys(data),
...(nextEpisodeIds !== undefined ? ["episodeIds"] : []),
];
await auditLog("documents.inbound.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
documentId: existing.id,
fields: Object.keys(data),
fields: touched,
},
});
annotate({
action: { name: "documents.inbound.update" },
meta: { documentId: existing.id, fields: Object.keys(data).join(",") },
meta: { documentId: existing.id, fields: touched.join(",") },
});
return apiSuccess(serialiseDocumentDetail(document, document.facts));
return apiSuccess(
serialiseDocumentDetail(
document,
document.facts,
links.get(document.id) ?? [],
),
);
},
);
@@ -1,30 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* v1.25 — documents library: store-only upload + browsable list.
* Document vault: store-only upload + browsable list.
*
* Pins the store-first inversion: a file uploads + persists as a STORED row
* with NO provider / consent / budget call on the path (a self-hoster with no
* document-scan provider can still file documents), and the list honours the
* `q` / `kind` filters with keyset pagination.
* Pins the vault upload contract: a file persists as a STORED row with the
* binary codec + plaintext sha256 and NO provider / consent / budget call on
* the path, the policy-layer error contract (413 fileTooLarge / quotaExceeded
* with limits in `meta`, 415 unsupportedType), the duplicate short-circuit
* (200 + `meta.duplicate`, no second row), and — hardening — that the list
* query NEVER selects the encrypted blob column.
*/
vi.mock("@/lib/db", () => ({
prisma: {
inboundDocument: {
create: vi.fn(),
findMany: vi.fn(),
},
extractedFact: {
groupBy: vi.fn(),
},
},
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const { txQueryRaw, txCreate, txCreateMany } = vi.hoisted(() => ({
txQueryRaw: vi.fn(),
txCreate: vi.fn(),
txCreateMany: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((s: string) => `v1.${s}`),
decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")),
}));
vi.mock("@/lib/db", () => {
const tx = {
$queryRaw: txQueryRaw,
inboundDocument: { create: txCreate },
documentConditionLink: { createMany: txCreateMany },
};
return {
prisma: {
inboundDocument: {
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
},
extractedFact: {
groupBy: vi.fn(),
},
documentConditionLink: {
findMany: vi.fn(),
},
illnessEpisode: {
findMany: vi.fn(),
},
appSettings: {
findUnique: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
$transaction: vi.fn(async (cb: (t: unknown) => Promise<unknown>) =>
cb(tx),
),
},
};
});
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
@@ -56,6 +85,8 @@ vi.mock("next/headers", () => ({
})),
}));
import { createHash } from "node:crypto";
import { POST, GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
@@ -71,18 +102,23 @@ const SESSION_OK = {
const ONE_BY_ONE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==";
const PNG_BYTES = Buffer.from(ONE_BY_ONE_PNG_BASE64, "base64");
const PNG_SHA256 = createHash("sha256").update(PNG_BYTES).digest("hex");
function mkUpload(fields: Record<string, string> = {}) {
const body = Buffer.from(ONE_BY_ONE_PNG_BASE64, "base64");
const blobBytes = new Uint8Array(body.byteLength);
blobBytes.set(body);
function mkUpload(
fields: Record<string, string | string[]> = {},
bytes: Buffer = PNG_BYTES,
filename = "x.png",
) {
const blobBytes = new Uint8Array(bytes.byteLength);
blobBytes.set(bytes);
const formData = new FormData();
formData.append(
"file",
new Blob([blobBytes], { type: "image/png" }),
"x.png",
);
for (const [k, v] of Object.entries(fields)) formData.append(k, v);
formData.append("file", new Blob([blobBytes]), filename);
for (const [k, v] of Object.entries(fields)) {
for (const item of Array.isArray(v) ? v : [v]) {
formData.append(k, item);
}
}
return new Request("http://localhost/api/documents/inbound", {
method: "POST",
body: formData,
@@ -103,6 +139,8 @@ function docRow(over: Record<string, unknown> = {}) {
reportDate: null,
documentDate: null,
errorReason: null,
contentSha256: PNG_SHA256,
contentCodec: "binary2",
createdAt: new Date("2026-06-01T00:00:00.000Z"),
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
...over,
@@ -114,11 +152,21 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(null as never);
// No settings row / no user override → policy defaults (25 MiB / 1 GiB).
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
txQueryRaw.mockResolvedValue([{ used: BigInt(0) }]);
txCreate.mockResolvedValue(docRow() as never);
txCreateMany.mockResolvedValue({ count: 0 } as never);
});
describe("POST /api/documents/inbound (store-only)", () => {
it("stores a file as STORED with no provider call and returns 201", async () => {
vi.mocked(prisma.inboundDocument.create).mockResolvedValue(
describe("POST /api/documents/inbound (vault upload)", () => {
it("stores a file binary-codec encrypted with its plaintext sha256", async () => {
txCreate.mockResolvedValue(
docRow({ kind: "LAB_RESULT", title: "Blood panel" }) as never,
);
@@ -132,47 +180,151 @@ describe("POST /api/documents/inbound (store-only)", () => {
expect(body.data.status).toBe("STORED");
expect(body.data.kind).toBe("LAB_RESULT");
expect(body.data.title).toBe("Blood panel");
expect(body.data.servingClass).toBe("inline");
expect(body.data.conditionLinks).toEqual([]);
// The row is created with status STORED, no provider, userId from session.
const arg = vi.mocked(prisma.inboundDocument.create).mock.calls[0]![0]!;
// The row is created with status STORED, no provider, userId from the
// session, the ACTIVE binary codec, and the plaintext sha256.
const arg = txCreate.mock.calls[0]![0]!;
expect(arg.data.status).toBe("STORED");
expect(arg.data.userId).toBe("user-1");
expect(arg.data.kind).toBe("LAB_RESULT");
expect(arg.data.contentCodec).toBe("binary2");
expect(arg.data.contentSha256).toBe(PNG_SHA256);
expect("providerType" in arg.data).toBe(false);
// No userId smuggled from the form is honoured — it comes from the session.
expect(arg.data.userId).toBe("user-1");
// The persisted blob is ciphertext, never the plaintext bytes.
const stored = Buffer.from(arg.data.contentEncrypted as Uint8Array);
expect(stored.equals(PNG_BYTES)).toBe(false);
// The create never round-trips the blob back out.
expect(arg.omit).toEqual({ contentEncrypted: true });
});
it("rejects a non-image/pdf payload with 415", async () => {
const formData = new FormData();
formData.append("file", new Blob([new Uint8Array([1, 2, 3, 4])]), "x.bin");
const req = new Request("http://localhost/api/documents/inbound", {
method: "POST",
body: formData,
});
const res = await post(req);
it("rejects an unidentifiable payload with 415 + reason unsupportedType", async () => {
const res = await post(
mkUpload({}, Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]), "x.bin"),
);
expect(res.status).toBe(415);
expect(prisma.inboundDocument.create).not.toHaveBeenCalled();
const body = await res.json();
expect(body.meta.reason).toBe("unsupportedType");
expect(txCreate).not.toHaveBeenCalled();
});
it("rejects an over-cap file with 413 + the configured limit", async () => {
// Admin capped uploads below the PNG's 70 bytes (clamped ≥ 1 in the
// resolver); the file itself trips the per-file check.
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
documentMaxFileBytes: 16,
documentQuotaBytes: BigInt(1_073_741_824),
} as never);
const res = await post(mkUpload());
expect(res.status).toBe(413);
const body = await res.json();
expect(body.meta.reason).toBe("fileTooLarge");
expect(body.meta.maxFileBytes).toBe(16);
expect(txCreate).not.toHaveBeenCalled();
});
it("rejects an upload past the quota with 413 + quota figures", async () => {
// Per-user override of 100 bytes; 60 already used; the 70-byte PNG tips it.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentQuotaBytes: BigInt(100),
} as never);
txQueryRaw.mockResolvedValue([{ used: BigInt(60) }]);
const res = await post(mkUpload());
expect(res.status).toBe(413);
const body = await res.json();
expect(body.meta.reason).toBe("quotaExceeded");
expect(body.meta.quotaBytes).toBe(100);
expect(body.meta.usedBytes).toBe(60);
expect(txCreate).not.toHaveBeenCalled();
});
it("returns the existing live row with meta.duplicate on a re-upload", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
docRow({ id: "doc-existing" }) as never,
);
const res = await post(mkUpload());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta.duplicate).toBe(true);
expect(body.data.id).toBe("doc-existing");
// The duplicate lookup is scoped to the caller's LIVE rows by sha256.
const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0]![0]!
.where!;
expect(where.userId).toBe("user-1");
expect(where.contentSha256).toBe(PNG_SHA256);
expect(where.deletedAt).toBeNull();
expect(txCreate).not.toHaveBeenCalled();
});
it("pre-links the caller's episodes and refuses a foreign episode id", async () => {
// Owned episode → link rows land in the same transaction.
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([
{ id: "ep-1" },
] as never);
let res = await post(mkUpload({ episodeIds: ["ep-1"] }));
expect(res.status).toBe(201);
expect(txCreateMany).toHaveBeenCalledWith({
data: [{ documentId: "doc-1", episodeId: "ep-1", userId: "user-1" }],
});
// Foreign/unknown episode id → 404-shaped refusal, nothing persisted.
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: true,
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
res = await post(mkUpload({ episodeIds: ["ep-foreign"] }));
expect(res.status).toBe(404);
expect(txCreate).not.toHaveBeenCalled();
});
});
describe("GET /api/documents/inbound (list)", () => {
it("applies q + kind filters and returns a keyset page with grouped counts", async () => {
it("never selects the encrypted blob column (omit hardening)", async () => {
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
const res = await get(
new Request("http://localhost/api/documents/inbound"),
);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!;
expect(arg.omit).toEqual({ contentEncrypted: true });
expect(arg.select).toBeUndefined();
expect(arg.include).toBeUndefined();
});
it("applies q + multi-kind + episode filters and returns grouped counts", async () => {
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
docRow({ id: "d1" }),
docRow({ id: "d2" }),
] as never);
// factCount excludes REJECTED; pendingCount is the PENDING subset. The
// counts come from a single grouped query, not from materialised fact rows.
vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([
{ documentId: "d2", status: "PENDING", _count: { _all: 1 } },
{ documentId: "d2", status: "APPROVED", _count: { _all: 1 } },
{ documentId: "d2", status: "REJECTED", _count: { _all: 3 } },
] as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue([
{
documentId: "d1",
episodeId: "ep-1",
episode: { label: "Knie" },
},
] as never);
const res = await get(
new Request(
"http://localhost/api/documents/inbound?q=panel&kind=LAB_RESULT&sort=createdAt&order=desc&limit=10",
"http://localhost/api/documents/inbound?q=panel&kind=LAB_RESULT&kind=IMAGING&episodeId=ep-1&sort=createdAt&order=desc&limit=10",
),
);
expect(res.status).toBe(200);
@@ -180,11 +332,11 @@ describe("GET /api/documents/inbound (list)", () => {
expect(body.data.documents).toHaveLength(2);
expect(body.data.nextCursor).toBeNull();
// d1 has no facts; d2 has 1 pending + 1 approved + 3 rejected → factCount 2
// (rejected excluded), pendingCount 1.
// d1 carries its condition link; d2 has 1 pending + 1 approved +
// 3 rejected → factCount 2 (rejected excluded), pendingCount 1.
const [d1, d2] = body.data.documents;
expect(d1.conditionLinks).toEqual([{ episodeId: "ep-1", name: "Knie" }]);
expect(d1.factCount).toBe(0);
expect(d1.pendingCount).toBe(0);
expect(d2.factCount).toBe(2);
expect(d2.pendingCount).toBe(1);
@@ -192,23 +344,32 @@ describe("GET /api/documents/inbound (list)", () => {
const where = arg.where!;
expect(where.userId).toBe("user-1");
expect(where.deletedAt).toBeNull();
expect(where.kind).toBe("LAB_RESULT");
expect(where.kind).toEqual({ in: ["LAB_RESULT", "IMAGING"] });
expect(where.conditionLinks).toEqual({ some: { episodeId: "ep-1" } });
expect(where.OR).toEqual([
{ title: { contains: "panel", mode: "insensitive" } },
{ filename: { contains: "panel", mode: "insensitive" } },
]);
// No fact rows are materialised in the page query — counts come from groupBy.
expect(arg.include).toBeUndefined();
// limit+1 fetched for the has-more probe.
expect(arg.take).toBe(11);
});
it("maps the year filter to a UTC calendar-year documentDate range", async () => {
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
const res = await get(
new Request("http://localhost/api/documents/inbound?year=2025"),
);
expect(res.status).toBe(200);
const where = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!
.where!;
expect(where.documentDate).toEqual({
gte: new Date(Date.UTC(2025, 0, 1)),
lt: new Date(Date.UTC(2026, 0, 1)),
});
});
it("emits nextCursor when a full page+1 is returned", async () => {
const rows = Array.from({ length: 3 }, (_, i) => ({
...docRow({ id: `d${i}` }),
_count: { facts: 0 },
facts: [],
}));
const rows = Array.from({ length: 3 }, (_, i) => docRow({ id: `d${i}` }));
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue(rows as never);
const res = await get(
+203
View File
@@ -0,0 +1,203 @@
/**
* Document vault: bulk actions over up to 100 owner-scoped documents.
*
* `{ ids[], action, kind?, episodeId? }` → a per-id result array. Actions:
* `setKind` (recategorise), `linkEpisode` / `unlinkEpisode` (condition
* links), `delete` (tombstone, undo-able), `restore` (clear tombstone). A
* partial failure never aborts the batch — each id reports `ok` or a short
* machine reason. Every id is narrowed to the caller; a foreign id reads as
* "notFound", exactly like the single-document routes.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { isP2002 } from "@/lib/prisma-errors";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
documentBulkSchema,
type DocumentBulkResultDto,
} from "@/lib/validations/inbound-documents";
export const dynamic = "force-dynamic";
/**
* One bulk call covers up to 100 rows, so the bucket is tighter than the
* single-row write bucket (240/h) while allowing the same overall throughput.
*/
const BULK_LIMIT_PER_HOUR = 60;
const BULK_WINDOW_MS = 60 * 60 * 1000;
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`documents-bulk:${user.id}`,
BULK_LIMIT_PER_HOUR,
BULK_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests. Try again later.", 429, {
errorCode: "documents.inbound.rateLimited",
});
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = documentBulkSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const { ids, action, kind, episodeId } = parsed.data;
const uniqueIds = [...new Set(ids)];
// Episode actions: the target episode must be a LIVE episode of the caller.
if (action === "linkEpisode" || action === "unlinkEpisode") {
const episode = await prisma.illnessEpisode.findFirst({
where: { id: episodeId!, userId: user.id, deletedAt: null },
select: { id: true },
});
if (!episode) {
return apiError("Episode not found", 404, {
errorCode: "documents.inbound.episodeNotFound",
});
}
}
// One owner-scoped sweep resolves every id's current state; per-id work
// then never touches a row the caller does not own.
const owned = await prisma.inboundDocument.findMany({
where: { id: { in: uniqueIds }, userId: user.id },
select: { id: true, deletedAt: true, contentSha256: true },
});
const byId = new Map(owned.map((d) => [d.id, d]));
const results: DocumentBulkResultDto[] = [];
for (const id of uniqueIds) {
const row = byId.get(id);
if (!row) {
results.push({ id, ok: false, error: "notFound" });
continue;
}
try {
switch (action) {
case "setKind": {
if (row.deletedAt !== null) {
results.push({ id, ok: false, error: "notFound" });
continue;
}
await prisma.inboundDocument.update({
where: { id },
data: { kind: kind! },
});
break;
}
case "linkEpisode": {
if (row.deletedAt !== null) {
results.push({ id, ok: false, error: "notFound" });
continue;
}
await prisma.documentConditionLink.createMany({
data: [{ documentId: id, episodeId: episodeId!, userId: user.id }],
skipDuplicates: true,
});
break;
}
case "unlinkEpisode": {
await prisma.documentConditionLink.deleteMany({
where: { documentId: id, episodeId: episodeId!, userId: user.id },
});
break;
}
case "delete": {
if (row.deletedAt !== null) {
// Already tombstoned — deleting twice is a no-op success.
break;
}
await prisma.inboundDocument.update({
where: { id },
data: { deletedAt: new Date(), status: "DISCARDED" },
});
break;
}
case "restore": {
if (row.deletedAt === null) {
// Already live — restoring twice is a no-op success.
break;
}
if (row.contentSha256) {
const conflict = await prisma.inboundDocument.findFirst({
where: {
userId: user.id,
contentSha256: row.contentSha256,
deletedAt: null,
id: { not: id },
},
select: { id: true },
});
if (conflict) {
results.push({ id, ok: false, error: "conflict" });
continue;
}
}
const facts = await prisma.extractedFact.findMany({
where: { documentId: id, userId: user.id },
select: { status: true },
});
const status =
facts.length === 0
? "STORED"
: facts.some((f) => f.status === "PENDING")
? "EXTRACTED"
: "CONFIRMED";
await prisma.inboundDocument.update({
where: { id },
data: { deletedAt: null, status },
});
break;
}
}
results.push({ id, ok: true, error: null });
} catch (err) {
if (isP2002(err)) {
results.push({ id, ok: false, error: "conflict" });
continue;
}
throw err;
}
}
const okCount = results.filter((r) => r.ok).length;
await auditLog("documents.inbound.bulk", {
userId: user.id,
ipAddress: getClientIp(request),
details: { action, requested: uniqueIds.length, succeeded: okCount },
});
annotate({
action: { name: "documents.vault.bulk" },
meta: { bulkAction: action, requested: uniqueIds.length, ok: okCount },
});
return apiSuccess({ results });
});
+299 -79
View File
@@ -1,39 +1,54 @@
/**
* v1.25 — documents library: store-first upload + browsable list.
* Document vault: store-first upload + browsable list.
*
* POST is STORE-ONLY and provider-free. A self-hoster uploads any clinical
* document; it is stored ENCRYPTED at rest with `status: STORED` and NO
* extraction run. There is no provider resolution, no AI consent guard, no
* budget, and NO egress on this path — a file can always be filed, even on an
* account with no document-scan provider configured. Optional AI extraction is
* a separate, user-triggered action (`POST /api/documents/inbound/[id]/extract`).
* POST is STORE-ONLY and provider-free. A self-hoster uploads any accepted
* document; it is stored ENCRYPTED at rest (binary codec) with
* `status: STORED` and NO extraction run. There is no provider resolution, no
* AI consent guard, no budget, and NO egress on this path — a file can always
* be filed, even on an account with no document-scan provider configured.
*
* GET lists the caller's documents with title/filename search, a category
* filter, a `documentDate` range, sort, and keyset pagination — all through the
* parameter-bound Prisma query builder (no raw SQL).
* The upload path enforces the vault policy layer
* (`src/lib/documents/upload-policy.ts`): magic-byte classification (the wire
* Content-Type is never trusted), the admin-tunable per-file cap (bounded
* read aborts at the cap), the per-user quota (checked in the same
* transaction as the insert, tombstone-inclusive), sha256 duplicate detection
* (a same-user re-upload returns the existing live row, never a second copy),
* an honoured `Idempotency-Key`, and optional `episodeIds[]` pre-linking.
*
* GET lists the caller's documents with title/filename search, kind /
* episode / year / date-range filters, sort, and keyset pagination — and
* NEVER selects the encrypted blob column (`omit: { contentEncrypted: true }`).
*
* The document is UNTRUSTED (prompt-injection): the server never acts on an
* instruction inside it. Storing it does nothing with its contents; the later
* review-then-confirm step is the safety boundary for any extracted facts.
* instruction inside it. Storing it does nothing with its contents.
*/
import { Buffer } from "node:buffer";
import { createHash } from "node:crypto";
import { NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
encryptDocumentToBytes,
loadConditionLinks,
narrowOwnedEpisodeIds,
} from "@/lib/documents/links";
import {
encryptDocumentContent,
serialiseDocument,
type SerialisableDocument,
} from "@/lib/documents/store";
import {
BodyTooLargeError,
detectOcrMimeType,
OCR_MAX_BYTES,
readBoundedBody,
} from "@/lib/labs/ocr-upload";
detectDocumentType,
resolveDocumentLimits,
} from "@/lib/documents/upload-policy";
import { withIdempotency } from "@/lib/idempotency";
import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { isP2002 } from "@/lib/prisma-errors";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
documentCreateSchema,
@@ -50,13 +65,78 @@ export const dynamic = "force-dynamic";
const UPLOAD_LIMIT_PER_HOUR = 60;
const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
/**
* Multipart envelope allowance on top of the per-file cap: boundaries plus
* the small metadata fields (title / kind / documentDate / episodeIds). The
* exact per-file cap is re-enforced on the extracted file bytes below.
*/
const MULTIPART_OVERHEAD_BYTES = 64 * 1024;
/** Parse a YYYY-MM-DD form field into a UTC midnight Date. */
function isoDateToUtc(value: string): Date {
return new Date(`${value}T00:00:00.000Z`);
}
/** §3.2 — 413 fileTooLarge with the configured limit in `meta`. */
function fileTooLarge(maxFileBytes: number): NextResponse {
return apiError("File is too large.", 413, {
errorCode: "documents.inbound.fileTooLarge",
reason: "fileTooLarge",
maxFileBytes,
});
}
/** Internal signal: the quota gate inside the insert transaction tripped. */
class QuotaExceededError extends Error {
constructor(public readonly usedBytes: number) {
super("Document quota exceeded");
this.name = "QuotaExceededError";
}
}
/**
* §3.2 — a duplicate upload is NOT an error: return the existing live row
* with `meta.duplicate: true` at the envelope level (the UI toasts "already
* stored" and highlights the row).
*/
async function duplicateResponse(
userId: string,
existing: SerialisableDocument,
): Promise<NextResponse> {
const [links, groups] = await Promise.all([
loadConditionLinks(userId, [existing.id]),
prisma.extractedFact.groupBy({
by: ["status"],
where: { userId, documentId: existing.id },
_count: { _all: true },
}),
]);
let factCount = 0;
let pendingCount = 0;
for (const g of groups) {
if (g.status !== "REJECTED") factCount += g._count._all;
if (g.status === "PENDING") pendingCount += g._count._all;
}
annotate({
action: { name: "documents.vault.upload" },
meta: { documentId: existing.id, duplicate: true },
});
return NextResponse.json(
{
data: serialiseDocument(
existing,
{ factCount, pendingCount },
links.get(existing.id) ?? [],
),
error: null,
meta: { duplicate: true },
},
{ status: 200 },
);
}
/** POST — store an uploaded document encrypted at rest. No extraction. */
export const POST = apiHandler(async (request: Request) => {
async function postUpload(request: Request): Promise<Response> {
const { user } = await requireAuth();
// Opt-in module gate — even a valid Bearer token is refused when the surface
@@ -79,24 +159,25 @@ export const POST = apiHandler(async (request: Request) => {
return response;
}
const limits = await resolveDocumentLimits(user.id);
const bodyCap = limits.maxFileBytes + MULTIPART_OVERHEAD_BYTES;
// Instant rejection on a declared oversize (a CD/ISO-sized upload never
// allocates); the bounded read below covers chunked/undeclared bodies.
const contentLength = Number(request.headers.get("content-length") ?? 0);
if (contentLength > OCR_MAX_BYTES) {
return apiError("File is too large (max 12 MB).", 413, {
errorCode: "documents.inbound.fileTooLarge",
});
if (contentLength > bodyCap) {
return fileTooLarge(limits.maxFileBytes);
}
let formData: FormData;
try {
const bytes = await readBoundedBody(request.body, OCR_MAX_BYTES);
const bytes = await readBoundedBody(request.body, bodyCap);
formData = await new Response(new Blob([bytes]), {
headers: { "content-type": request.headers.get("content-type") ?? "" },
}).formData();
} catch (err) {
if (err instanceof BodyTooLargeError) {
return apiError("File is too large (max 12 MB).", 413, {
errorCode: "documents.inbound.fileTooLarge",
});
return fileTooLarge(limits.maxFileBytes);
}
return apiError("Invalid multipart body", 400);
}
@@ -106,12 +187,17 @@ export const POST = apiHandler(async (request: Request) => {
return apiError("Field 'file' must be a file", 422);
}
// Optional metadata (title / kind / documentDate). The file is read
// separately; these are the form fields beside it.
// Optional metadata (title / kind / documentDate / episodeIds). The file is
// read separately; these are the form fields beside it. `episodeIds` may be
// repeated.
const rawEpisodeIds = formData
.getAll("episodeIds")
.filter((v): v is string => typeof v === "string");
const parsed = documentCreateSchema.safeParse({
title: formData.get("title") ?? undefined,
kind: formData.get("kind") ?? undefined,
documentDate: formData.get("documentDate") ?? undefined,
episodeIds: rawEpisodeIds.length > 0 ? rawEpisodeIds : undefined,
});
if (!parsed.success) {
return apiError("Invalid document metadata", 422, {
@@ -125,63 +211,167 @@ export const POST = apiHandler(async (request: Request) => {
} catch {
return apiError("Failed to read uploaded file", 400);
}
const mime = detectOcrMimeType(buffer);
if (!mime) {
return apiError("Upload a JPEG, PNG, WebP, or PDF.", 415, {
errorCode: "documents.inbound.fileType",
if (buffer.byteLength > limits.maxFileBytes) {
return fileTooLarge(limits.maxFileBytes);
}
if (buffer.byteLength === 0) {
return apiError("Uploaded file is empty", 422, {
errorCode: "documents.inbound.invalidMetadata",
});
}
// No mass assignment — every column is set field-by-field; `userId` comes
// from the session, never the body. `status` is STORED (the library default);
// no provider / consent / budget call runs here.
//
// `documentDate` is the effective filing date the library sorts, groups, and
// range-filters on. When the user does not supply one at upload, default it to
// the upload day (now) — the same effective date the UI shows — so the column
// is never NULL on a stored row. This keeps display == sort == filter: an
// undated upload no longer sinks below older dated rows under `nulls: "last"`,
// and a "this month" range filter sees it. (At store time `reportDate` is
// still null; it only exists after an extraction, so the default resolves to
// the upload day.) The date stays user-editable via PATCH, which still allows
// clearing it back to NULL.
const document = await prisma.inboundDocument.create({
data: {
userId: user.id,
kind: parsed.data.kind ?? "OTHER",
// Stored plaintext on purpose (mirrors `filename`) so the list can
// ILIKE-search + ORDER BY it. It MAY hold PHI the user types; that is the
// accepted tradeoff for server-side search/sort — the document body stays
// encrypted.
title: parsed.data.title ?? null,
filename: typeof file.name === "string" ? file.name.slice(0, 255) : null,
mimeType: mime,
byteSize: buffer.byteLength,
contentEncrypted: encryptDocumentToBytes(buffer),
status: "STORED",
documentDate: parsed.data.documentDate
? isoDateToUtc(parsed.data.documentDate)
: new Date(),
},
// §3.1 — magic-byte classification; the wire Content-Type is never trusted.
const detected = detectDocumentType(
buffer,
typeof file.name === "string" ? file.name : null,
);
if (!detected) {
return apiError("This file type is not supported.", 415, {
errorCode: "documents.inbound.fileType",
reason: "unsupportedType",
});
}
// Pre-linking: every episode id must be a LIVE episode of the caller.
const episodeIds = await narrowOwnedEpisodeIds(
user.id,
parsed.data.episodeIds ?? [],
);
if (episodeIds === null) {
return apiError("Episode not found", 404, {
errorCode: "documents.inbound.episodeNotFound",
});
}
// sha256 of the PLAINTEXT for same-user duplicate detection.
const contentSha256 = createHash("sha256").update(buffer).digest("hex");
// Fast-path dedupe check; the partial unique index closes the race below.
const existing = await prisma.inboundDocument.findFirst({
where: { userId: user.id, contentSha256, deletedAt: null },
omit: { contentEncrypted: true },
});
if (existing) {
return duplicateResponse(user.id, existing);
}
// Quota gate + insert + pre-links in ONE transaction. Usage counts every
// non-purged row — tombstones still hold TOAST bytes, so "deleted" bytes
// are never invisible weight (undo-delete never changes usage).
let document: SerialisableDocument;
try {
document = await prisma.$transaction(async (tx) => {
// Serialise the quota gate per user: without this, N concurrent
// uploads all read the same SUM before any of them commits and the
// quota can be overshot by up to N × cap in one burst. The advisory
// lock is transaction-scoped (released on commit/rollback) and keyed
// on the user id, so uploads by different users never queue on each
// other.
// (`pg_advisory_xact_lock` returns void, which the client cannot
// deserialize as a column — selecting FROM it yields a plain int row.)
await tx.$queryRaw`
SELECT 1 AS locked
FROM pg_advisory_xact_lock(hashtextextended('documents-quota:' || ${user.id}, 0))
`;
const rows = await tx.$queryRaw<Array<{ used: bigint }>>`
SELECT COALESCE(SUM(byte_size), 0)::bigint AS used
FROM inbound_documents
WHERE user_id = ${user.id}
`;
const usedBytes = Number(rows[0]?.used ?? 0);
if (usedBytes + buffer.byteLength > limits.quotaBytes) {
throw new QuotaExceededError(usedBytes);
}
const { content, codec } = encryptDocumentContent(buffer);
// No mass assignment — every column is set field-by-field; `userId`
// comes from the session, never the body. `documentDate` defaults to
// the upload day so display == sort == filter (user-editable later).
const created = await tx.inboundDocument.create({
data: {
userId: user.id,
kind: parsed.data.kind ?? "OTHER",
// Stored plaintext on purpose (mirrors `filename`) so the list can
// ILIKE-search + ORDER BY it. It MAY hold PHI the user types; that
// is the accepted tradeoff for server-side search/sort — the
// document body stays encrypted.
title: parsed.data.title ?? null,
filename:
typeof file.name === "string" ? file.name.slice(0, 255) : null,
mimeType: detected.mimeType,
byteSize: buffer.byteLength,
contentEncrypted: content,
contentCodec: codec,
contentSha256,
status: "STORED",
documentDate: parsed.data.documentDate
? isoDateToUtc(parsed.data.documentDate)
: new Date(),
},
omit: { contentEncrypted: true },
});
if (episodeIds.length > 0) {
await tx.documentConditionLink.createMany({
data: episodeIds.map((episodeId) => ({
documentId: created.id,
episodeId,
userId: user.id,
})),
});
}
return created;
});
} catch (err) {
if (err instanceof QuotaExceededError) {
return apiError("Storage quota exceeded.", 413, {
errorCode: "documents.inbound.quotaExceeded",
reason: "quotaExceeded",
quotaBytes: limits.quotaBytes,
usedBytes: err.usedBytes,
});
}
if (isP2002(err)) {
// A racing upload of the same bytes won the partial unique index.
// Surface the winner as the duplicate — same outcome as the fast path.
const winner = await prisma.inboundDocument.findFirst({
where: { userId: user.id, contentSha256, deletedAt: null },
omit: { contentEncrypted: true },
});
if (winner) return duplicateResponse(user.id, winner);
}
throw err;
}
await auditLog("documents.inbound.store", {
userId: user.id,
ipAddress: getClientIp(request),
details: { documentId: document.id, mime },
details: { documentId: document.id, mime: detected.mimeType },
});
annotate({
action: { name: "documents.inbound.store" },
meta: { documentId: document.id, byteSize: document.byteSize },
action: { name: "documents.vault.upload" },
meta: {
documentId: document.id,
byteSize: document.byteSize,
servingClass: detected.servingClass,
linked: episodeIds.length,
},
});
const links = await loadConditionLinks(user.id, [document.id]);
return apiSuccess(
serialiseDocument(document, { factCount: 0, pendingCount: 0 }),
serialiseDocument(
document,
{ factCount: 0, pendingCount: 0 },
links.get(document.id) ?? [],
),
201,
);
});
}
export const POST = apiHandler(withIdempotency<[Request]>(postUpload));
/** GET — list the caller's documents (search / filter / sort / paginate). */
export const GET = apiHandler(async (request: Request) => {
@@ -191,22 +381,37 @@ export const GET = apiHandler(async (request: Request) => {
if (!gate.enabled) return gate.response;
const url = new URL(request.url);
const parsed = documentListQuerySchema.safeParse(
Object.fromEntries(url.searchParams),
);
// `kind` is a multi-value facet: repeated params and/or comma-separated.
const kinds = url.searchParams
.getAll("kind")
.flatMap((v) => v.split(","))
.map((v) => v.trim())
.filter((v) => v.length > 0);
const single = Object.fromEntries(url.searchParams);
const parsed = documentListQuerySchema.safeParse({
...single,
kind: kinds.length > 0 ? kinds : undefined,
});
if (!parsed.success) {
return apiError("Invalid list query", 422, {
errorCode: "documents.inbound.invalidQuery",
});
}
const { q, kind, from, to, sort, order, cursor, limit } = parsed.data;
const { q, kind, episodeId, year, from, to, sort, order, cursor, limit } =
parsed.data;
const where: Prisma.InboundDocumentWhereInput = {
userId: user.id,
deletedAt: null,
};
if (kind) where.kind = kind;
if (from || to) {
if (kind && kind.length > 0) where.kind = { in: kind };
if (episodeId) where.conditionLinks = { some: { episodeId } };
if (year !== undefined) {
where.documentDate = {
gte: new Date(Date.UTC(year, 0, 1)),
lt: new Date(Date.UTC(year + 1, 0, 1)),
};
} else if (from || to) {
where.documentDate = {
...(from ? { gte: isoDateToUtc(from) } : {}),
// inclusive end-of-day for the `to` bound
@@ -234,8 +439,11 @@ export const GET = apiHandler(async (request: Request) => {
{ id: order },
];
// Hardening: the list NEVER fetches the encrypted blob column — a page of
// 50 rows would otherwise drag up to 50 × cap ciphertext bytes per request.
const rows = await prisma.inboundDocument.findMany({
where,
omit: { contentEncrypted: true },
orderBy,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
@@ -247,8 +455,8 @@ export const GET = apiHandler(async (request: Request) => {
// Fact tallies for the whole page in ONE grouped count — no per-document
// fan-out and no materialising fact-id rows. `factCount` counts every
// non-REJECTED fact (a rejected fact is discarded, not part of the document's
// tally), `pendingCount` the PENDING subset still awaiting review.
// non-REJECTED fact (a rejected fact is discarded, not part of the
// document's tally), `pendingCount` the PENDING subset awaiting review.
const factCounts = new Map<
string,
{ factCount: number; pendingCount: number }
@@ -271,9 +479,20 @@ export const GET = apiHandler(async (request: Request) => {
}
}
// Condition links for the page in ONE grouped query (no N+1).
const linkMap = await loadConditionLinks(
user.id,
page.map((d) => d.id),
);
annotate({
action: { name: "documents.inbound.list" },
meta: { count: page.length, sort, order, filtered: Boolean(q || kind) },
meta: {
count: page.length,
sort,
order,
filtered: Boolean(q || (kind && kind.length > 0) || episodeId || year),
},
});
return apiSuccess({
@@ -281,6 +500,7 @@ export const GET = apiHandler(async (request: Request) => {
serialiseDocument(
doc,
factCounts.get(doc.id) ?? { factCount: 0, pendingCount: 0 },
linkMap.get(doc.id) ?? [],
),
),
nextCursor,
@@ -0,0 +1,64 @@
/**
* Document vault: storage usage + effective limits for the calling user.
*
* The UI reads this before offering an upload (quota bar above 80 % usage,
* client-side pre-flight against `maxFileBytes`, picker `accept` list from
* `acceptedExtensions`). `usedBytes` counts every non-purged row — tombstones
* still hold TOAST bytes until the purge job reclaims them, so "deleted"
* bytes are never invisible weight and an undo never changes usage.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import {
DOCUMENT_ACCEPTED_EXTENSIONS,
resolveDocumentLimits,
} from "@/lib/documents/upload-policy";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
const [limits, rows, linkRows] = await Promise.all([
resolveDocumentLimits(user.id),
prisma.$queryRaw<Array<{ used: bigint }>>`
SELECT COALESCE(SUM(byte_size), 0)::bigint AS used
FROM inbound_documents
WHERE user_id = ${user.id}
`,
// Episodes that carry at least one LIVE document link — the filter
// bar's condition chips. Sourced here (not from the loaded corpus) so
// a chip exists even when its documents sit pages deep in the
// timeline; one indexed grouped query, no blobs.
prisma.documentConditionLink.findMany({
where: { userId: user.id, document: { deletedAt: null } },
select: { episodeId: true, episode: { select: { label: true } } },
distinct: ["episodeId"],
orderBy: { episodeId: "asc" },
}),
]);
const usedBytes = Number(rows[0]?.used ?? 0);
annotate({
action: { name: "documents.vault.usage" },
meta: { usedBytes, quotaBytes: limits.quotaBytes },
});
const payload: DocumentUsageDto = {
usedBytes,
quotaBytes: limits.quotaBytes,
maxFileBytes: limits.maxFileBytes,
acceptedExtensions: [...DOCUMENT_ACCEPTED_EXTENSIONS],
linkedEpisodes: linkRows.map((row) => ({
episodeId: row.episodeId,
name: row.episode.label,
})),
};
return apiSuccess(payload);
});
+12 -39
View File
@@ -1,45 +1,18 @@
"use client";
import { Suspense } from "react";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { InboundDocumentsView } from "@/components/documents/inbound-documents-view";
import { DocumentsView } from "@/components/documents/documents-view";
/**
* v1.25.0 (W-DOCS-IN) — inbound clinical documents entry.
*
* Born-gated on the resolved `modules.inboundDocuments` flag from
* `GET /api/auth/me` (opt-in / default-off — the per-user opt-in AND the
* operator server-wide kill-switch). An unauthenticated visitor is bounced to
* login; an authenticated account without the module opted in is bounced home
* (the nav entry is already hidden for them, so this only catches a direct URL
* hit). Every `/api/documents/inbound/*` route also enforces the gate
* server-side, so this is a UX redirect, not the security boundary.
* The Dokumente vault — server shell only. The client surface owns the
* module gate, the URL-backed filter state (which needs `useSearchParams`,
* hence the Suspense boundary), the virtualized timeline, and the upload
* queue. The route rides the AuthShell's wide-container flag: the shell
* owns the page frame, this file never re-implements paddings.
*/
export default function DocumentsPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
const enabled = user?.modules?.inboundDocuments === true;
useEffect(() => {
if (isLoading) return;
if (!isAuthenticated) {
router.push("/auth/login");
} else if (!enabled) {
router.push("/");
}
}, [isLoading, isAuthenticated, enabled, router]);
if (isLoading || !isAuthenticated || !enabled) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return <InboundDocumentsView />;
return (
<Suspense fallback={null}>
<DocumentsView />
</Suspense>
);
}
+24 -2
View File
@@ -4,7 +4,15 @@ import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Pencil, Plus, ScanLine, Wrench } from "lucide-react";
import {
ArrowRight,
FolderOpen,
Loader2,
Pencil,
Plus,
ScanLine,
Wrench,
} from "lucide-react";
import { LabForm } from "@/components/labs/lab-form";
import { LabList } from "@/components/labs/lab-list";
@@ -27,7 +35,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
export default function LabsPage() {
const { isAuthenticated, isLoading } = useAuth();
const { user, isAuthenticated, isLoading } = useAuth();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -128,6 +136,20 @@ export default function LabsPage() {
}
/>
{/* Entry point into the document vault, pre-filtered to lab-result
documents (the original report PDFs behind these values). Only
rendered when the documents module is enabled for this account. */}
{user?.modules?.inboundDocuments ? (
<Link
href="/documents?kind=LAB_RESULT"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<FolderOpen className="size-4" aria-hidden />
{t("labs.documentsLink")}
<ArrowRight className="size-3.5" aria-hidden />
</Link>
) : null}
<ResponsiveSheet
open={dialogOpen}
onOpenChange={setDialogOpen}
+8
View File
@@ -28,6 +28,9 @@ export interface AdminUser {
createdAt: string;
// v1.23 — per-user "require a second factor" override.
mfaEnforced: boolean;
// Document vault — per-user storage-quota override in bytes; null means
// the instance-wide default applies.
documentQuotaBytes: number | null;
passkeyCount: number;
}
@@ -88,6 +91,11 @@ export interface AdminSettings {
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the resolver".
defaultUserTimezone: string | null;
moodLogGlobal?: boolean;
// Document vault — the two admin-tunable limits (bytes on the wire; the
// UI edits them as MB / GB). The per-file cap clamps to the hard 100 MiB
// ceiling server-side.
documentMaxFileBytes: number;
documentQuotaBytes: number;
}
export interface AdminAuditEntry {
@@ -1,14 +1,29 @@
"use client";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Settings } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
import { listSupportedTimezones } from "@/lib/tz/format";
// Document-vault limits: bytes on the wire, MB / GB in the UI. The bounds
// mirror the server-side schema (per-file cap hard-clamped to 100 MiB —
// single-shot GCM + bounded in-memory reads are load-bearing past that).
const MIB = 1_048_576;
const GIB = 1_073_741_824;
const MAX_FILE_MB_MIN = 1;
const MAX_FILE_MB_MAX = 100;
const QUOTA_GB_MIN = 0.1;
const QUOTA_GB_MAX = 1024;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
// retired; the shared `<NativeSelect>` primitive owns the visual
// contract now. The admin copy previously diverged on the
@@ -26,6 +41,43 @@ export function GeneralSettingsSection() {
const currentDefaultTz = settings?.defaultUserTimezone ?? "";
// Draft-while-editing pattern for the two limit inputs: null = not
// editing (show the stored value), string = the in-progress draft.
// Commit clamps to the schema bounds and converts to bytes.
const [maxFileDraft, setMaxFileDraft] = useState<string | null>(null);
const [quotaDraft, setQuotaDraft] = useState<string | null>(null);
const storedMaxFileMb =
settings?.documentMaxFileBytes != null
? String(Math.round(settings.documentMaxFileBytes / MIB))
: "";
const storedQuotaGb =
settings?.documentQuotaBytes != null
? String(Math.round((settings.documentQuotaBytes / GIB) * 10) / 10)
: "";
const commitMaxFile = () => {
if (maxFileDraft === null) return;
const parsed = Number(maxFileDraft);
setMaxFileDraft(null);
if (!Number.isFinite(parsed) || maxFileDraft.trim() === "") return;
const mb = clamp(Math.round(parsed), MAX_FILE_MB_MIN, MAX_FILE_MB_MAX);
const bytes = mb * MIB;
if (bytes === settings?.documentMaxFileBytes) return;
updateSettings.mutate({ documentMaxFileBytes: bytes });
};
const commitQuota = () => {
if (quotaDraft === null) return;
const parsed = Number(quotaDraft);
setQuotaDraft(null);
if (!Number.isFinite(parsed) || quotaDraft.trim() === "") return;
const gb = clamp(parsed, QUOTA_GB_MIN, QUOTA_GB_MAX);
const bytes = Math.round(gb * GIB);
if (bytes === settings?.documentQuotaBytes) return;
updateSettings.mutate({ documentQuotaBytes: bytes });
};
return (
<SettingsCard>
<SettingsCardHeader
@@ -117,6 +169,65 @@ export function GeneralSettingsSection() {
))}
</NativeSelect>
</div>
{/* Document vault — the two admin-tunable limits (per-file cap +
per-user storage quota). Everything else about the vault is
committed behaviour; these are deliberately the only knobs. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-medium">{t("admin.documentMaxFile")}</p>
<p className="text-muted-foreground text-xs">
{t("admin.documentMaxFileDescription")}
</p>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<Input
type="number"
inputMode="numeric"
min={MAX_FILE_MB_MIN}
max={MAX_FILE_MB_MAX}
step={1}
className="w-24 text-right tabular-nums"
aria-label={t("admin.documentMaxFile")}
value={maxFileDraft ?? storedMaxFileMb}
onChange={(e) => setMaxFileDraft(e.target.value)}
onBlur={commitMaxFile}
onKeyDown={(e) => {
if (e.key === "Enter") commitMaxFile();
}}
disabled={updateSettings.isPending || settings == null}
/>
<span className="text-muted-foreground text-xs">MB</span>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-medium">{t("admin.documentQuota")}</p>
<p className="text-muted-foreground text-xs">
{t("admin.documentQuotaDescription")}
</p>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<Input
type="number"
inputMode="decimal"
min={QUOTA_GB_MIN}
max={QUOTA_GB_MAX}
step={0.1}
className="w-24 text-right tabular-nums"
aria-label={t("admin.documentQuota")}
value={quotaDraft ?? storedQuotaGb}
onChange={(e) => setQuotaDraft(e.target.value)}
onBlur={commitQuota}
onKeyDown={(e) => {
if (e.key === "Enter") commitQuota();
}}
disabled={updateSettings.isPending || settings == null}
/>
<span className="text-muted-foreground text-xs">GB</span>
</div>
</div>
</div>
</SettingsCard>
);
@@ -55,6 +55,9 @@ export function UserManagementSection() {
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [editUsername, setEditUsername] = useState("");
const [editEmail, setEditEmail] = useState("");
// Document vault — per-user storage-quota override, edited in GB.
// Empty string = no override (the instance default applies).
const [editQuotaGb, setEditQuotaGb] = useState("");
const [resetUser, setResetUser] = useState<AdminUser | null>(null);
const [resetPassword, setResetPassword] = useState("");
const [resetMsg, setResetMsg] = useState<string | null>(null);
@@ -142,6 +145,11 @@ export function UserManagementSection() {
setEditingUser(u);
setEditUsername(u.username);
setEditEmail(u.email ?? "");
setEditQuotaGb(
u.documentQuotaBytes === null
? ""
: String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 10) / 10),
);
}
function startReset(u: AdminUser) {
@@ -448,11 +456,22 @@ export function UserManagementSection() {
onSubmit={(e) => {
e.preventDefault();
if (updateUser.isPending) return;
const quotaTrimmed = editQuotaGb.trim();
const quotaParsed = Number(quotaTrimmed);
updateUser.mutate({
id: editingUser.id,
data: {
username: editUsername,
email: editEmail || null,
// Empty clears the override; a value clamps to the same
// bounds the server schema enforces (0.1–1024 GB).
documentQuotaBytes:
quotaTrimmed === "" || !Number.isFinite(quotaParsed)
? null
: Math.round(
Math.min(1024, Math.max(0.1, quotaParsed)) *
1_073_741_824,
),
},
});
}}
@@ -481,6 +500,32 @@ export function UserManagementSection() {
data-1p-ignore="true"
/>
</div>
<div className="space-y-1">
<Label htmlFor="edit-document-quota">
{t("admin.userDocumentQuota")}
</Label>
<div className="flex items-center gap-2">
<Input
id="edit-document-quota"
type="number"
inputMode="decimal"
min={0.1}
max={1024}
step={0.1}
className="w-28 text-right tabular-nums"
value={editQuotaGb}
onChange={(e) => setEditQuotaGb(e.target.value)}
placeholder={t("admin.userDocumentQuotaDefault")}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
/>
<span className="text-muted-foreground text-xs">GB</span>
</div>
<p className="text-muted-foreground text-xs">
{t("admin.userDocumentQuotaHint")}
</p>
</div>
<div className="flex gap-2">
<Button
type="submit"
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* The floating bulk bar, pinned via single-pass static renders: a labelled
* toolbar carrying the selected count and the four bulk verbs. The
* link-condition menu only renders when the account actually has episodes
* to link — no dead affordance on an episode-free account.
*/
import { I18nProvider } from "@/lib/i18n/context";
import { DocumentBulkBar } from "../document-bulk-bar";
function render(episodes: { id: string; label: string }[]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentBulkBar
selectedCount={3}
episodes={episodes}
busy={false}
onSetKind={() => {}}
onLinkEpisode={() => {}}
onDelete={() => {}}
onClear={() => {}}
/>
</I18nProvider>,
);
}
describe("<DocumentBulkBar>", () => {
it("renders a labelled toolbar with count and the bulk verbs", () => {
const html = render([{ id: "ep1", label: "Knee" }]);
expect(html).toContain('data-slot="document-bulk-bar"');
expect(html).toContain('role="toolbar"');
expect(html).toContain("3 selected");
expect(html).toContain("Change type");
expect(html).toContain("Link condition");
expect(html).toContain("Delete");
expect(html).toContain("Clear selection");
});
it("omits the link-condition verb when the account has no episodes", () => {
const html = render([]);
expect(html).not.toContain("Link condition");
expect(html).toContain("Change type");
});
});
@@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DocumentCard, UploadStateCard } from "../document-card";
import type { UploadQueueItem } from "../use-document-upload";
/**
* The vault card's render contract: title falls back filename → "untitled",
* the meta line stays muted, the attachment-class badge appears ONLY for
* download-only formats, condition links paint as pills, and the two
* transient upload cards (in-flight ring / translated failure) render from
* the same footprint.
*/
function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
return {
id: "doc-1",
kind: "IMAGING",
title: "MRT Knie",
filename: "mrt-knie.pdf",
mimeType: "application/pdf",
byteSize: 2_500_000,
status: "STORED",
providerType: null,
reportDate: null,
documentDate: "2025-10-04",
errorReason: null,
factCount: 0,
pendingCount: 0,
conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }],
servingClass: "inline",
createdAt: "2025-10-05T08:00:00.000Z",
updatedAt: "2025-10-05T08:00:00.000Z",
...overrides,
};
}
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const noop = () => {};
describe("<DocumentCard>", () => {
it("renders title, condition pill, and NO attachment badge for inline docs", () => {
const html = render(
<DocumentCard
document={doc()}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).toContain("MRT Knie");
expect(html).toContain("Knie");
expect(html).toContain('data-document-id="doc-1"');
expect(html).not.toContain("Download only");
});
it("shows the download-only badge for attachment-class documents", () => {
const html = render(
<DocumentCard
document={doc({
servingClass: "attachment",
mimeType: "application/octet-stream",
filename: "befund.docx",
title: null,
})}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).toContain("Download only");
// Title falls back to the filename.
expect(html).toContain("befund.docx");
});
it("falls back to the untitled label and rings when highlighted", () => {
const html = render(
<DocumentCard
document={doc({ title: null, filename: null })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted
/>,
);
expect(html).toContain("Untitled document");
expect(html).toContain("ring-2");
});
});
describe("<UploadStateCard>", () => {
const base: UploadQueueItem = {
localId: "u1",
fileName: "scan.jpg",
byteSize: 4_000_000,
status: "uploading",
progress: 0.4,
};
it("paints the progress ring while uploading", () => {
const html = render(<UploadStateCard item={base} onDismiss={noop} />);
expect(html).toContain('role="progressbar"');
expect(html).toContain('aria-valuenow="40"');
expect(html).toContain("scan.jpg");
});
it("renders the translated over-limit reason with the configured cap", () => {
const html = render(
<UploadStateCard
item={{
...base,
status: "error",
failure: {
ok: false,
reason: "fileTooLarge",
maxFileBytes: 26_214_400,
},
}}
onDismiss={noop}
/>,
);
expect(html).toContain('role="alert"');
expect(html).toContain("25 MB");
expect(html).not.toContain('role="progressbar"');
});
it("renders the quota reason with used/quota figures", () => {
const html = render(
<UploadStateCard
item={{
...base,
status: "error",
failure: {
ok: false,
reason: "quotaExceeded",
quotaBytes: 1_073_741_824,
usedBytes: 1_020_054_732,
},
}}
onDismiss={noop}
/>,
);
expect(html).toContain("Storage is full");
expect(html).toContain("1 GB");
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* Static-render pins for the timeline's accessibility contract: the
* virtualized container is a labelled list, and the transient upload queue
* is a polite live region. (The windowed rows themselves only mount
* client-side once the virtualizer measures the scroll container — the
* keyboard path is covered by the e2e battery.)
*/
import { I18nProvider } from "@/lib/i18n/context";
import { DocumentTimeline } from "../document-timeline";
import type { UploadQueueItem } from "../use-document-upload";
function render(uploadItems: UploadQueueItem[]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentTimeline
documents={[]}
uploadItems={uploadItems}
onDismissUpload={() => {}}
hasNextPage={false}
isFetchingNextPage={false}
onLoadMore={() => {}}
selectedIds={new Set()}
onToggleSelected={() => {}}
onOpen={() => {}}
highlightId={null}
/>
</I18nProvider>,
);
}
describe("<DocumentTimeline> semantics", () => {
it("renders the virtualized container as a labelled list", () => {
const html = render([]);
expect(html).toContain('role="list"');
expect(html).toContain('aria-label="Documents timeline"');
});
it("announces the upload queue through a polite live region", () => {
const html = render([
{
localId: "l1",
fileName: "scan.pdf",
byteSize: 1024,
status: "uploading",
progress: 0.4,
},
]);
expect(html).toContain('aria-live="polite"');
expect(html).toContain("scan.pdf");
});
});
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The vault page's designed states, pinned via single-pass static renders:
*
* - first load paints the skeleton grid (`documents-loading`) — a shape
* that matches the loaded card grid, never a bare spinner;
* - an empty unfiltered corpus paints the TEACHING empty state (what
* belongs here + an upload affordance), not the error copy;
* - an empty FILTERED view paints the no-matches state with a
* clear-filters affordance — a distinct state, per the standards a
* failed query may never fall through to either.
*/
let mockSearch = "";
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock, replace: vi.fn() }),
usePathname: () => "/documents",
useSearchParams: () => new URLSearchParams(mockSearch),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules: { inboundDocuments: true } },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return {
...actual,
// Never resolves within the synchronous SSR render → queries whose
// cache is not seeded stay pending.
apiGet: () => new Promise(() => {}),
apiPatch: vi.fn(),
apiPost: vi.fn(),
apiDelete: vi.fn(),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { DocumentsView } from "../documents-view";
function render(search: string, seedList?: { kinds?: never } | object) {
mockSearch = search;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
if (seedList !== undefined) {
queryClient.setQueryData(queryKeys.inboundDocumentList(seedList), {
pages: [{ documents: [], nextCursor: null }],
pageParams: [null],
});
}
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<DocumentsView />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<DocumentsView> states", () => {
it("first load: skeleton grid matching the card layout, no spinner", () => {
const html = render("");
expect(html).toContain('data-slot="documents-loading"');
expect(html).toContain('data-slot="skeleton"');
expect(html).toContain('data-slot="document-filter-bar"');
expect(html).toContain('data-slot="document-upload-zone"');
// A pending list must never read as an empty vault.
expect(html).not.toContain("Your document vault is empty");
});
it("empty unfiltered corpus: the teaching empty state with an upload CTA", () => {
const html = render("", {});
expect(html).toContain('data-slot="empty-state"');
expect(html).toContain("Your document vault is empty");
expect(html).toContain("Upload a first document");
expect(html).not.toContain("No matching documents");
});
it("empty filtered view: the distinct no-matches state with clear-filters", () => {
const html = render("kind=IMAGING", { kinds: ["IMAGING"] });
expect(html).toContain("No matching documents");
expect(html).toContain("Clear filters");
expect(html).not.toContain("Your document vault is empty");
});
});
@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The illness page's "Dokumente" section, pinned via static renders:
* module-gated (renders NOTHING when the account has not opted in), a
* quiet one-line affordance on an episode without documents (never a
* teaching empty state), and compact deep-linking rows when links exist.
*/
let moduleEnabled = true;
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules: { inboundDocuments: moduleEnabled } },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return {
...actual,
apiGet: () => new Promise(() => {}),
apiPost: vi.fn(),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { EpisodeDocumentsCard } from "../episode-documents-card";
function doc(id: string, title: string): InboundDocumentDto {
return {
id,
kind: "IMAGING",
title,
filename: `${id}.pdf`,
mimeType: "application/pdf",
byteSize: 2048,
status: "STORED",
providerType: null,
reportDate: null,
documentDate: "2025-10-04",
errorReason: null,
factCount: 0,
pendingCount: 0,
conditionLinks: [{ episodeId: "ep1", name: "Knee" }],
servingClass: "inline",
createdAt: "2025-10-05T08:00:00.000Z",
updatedAt: "2025-10-05T08:00:00.000Z",
};
}
function render(seed?: InboundDocumentDto[]) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
if (seed !== undefined) {
queryClient.setQueryData(queryKeys.inboundDocumentEpisodePreview("ep1"), {
documents: seed,
nextCursor: null,
});
}
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<EpisodeDocumentsCard episodeId="ep1" />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<EpisodeDocumentsCard>", () => {
it("renders nothing when the documents module is off", () => {
moduleEnabled = false;
expect(render([])).toBe("");
moduleEnabled = true;
});
it("shows a quiet one-line affordance on an episode without documents", () => {
const html = render([]);
expect(html).toContain('data-slot="episode-documents-card"');
expect(html).toContain("No documents linked to this condition yet.");
expect(html).toContain("Link");
expect(html).toContain("Upload");
// Not the vault's teaching empty state.
expect(html).not.toContain('data-slot="empty-state"');
});
it("lists linked documents as rows deep-linking into the vault", () => {
const html = render([doc("d1", "MRT Knie"), doc("d2", "Röntgen")]);
expect(html).toContain("MRT Knie");
expect(html).toContain("Röntgen");
expect(html).toContain("/documents?episode=ep1&amp;doc=d1");
// The pre-linked upload deep link carries the episode filter.
expect(html).toContain('href="/documents?episode=ep1"');
});
});
@@ -1,373 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type {
ExtractedFactDto,
InboundDocumentDetailDto,
InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
/**
* v1.25 — the Documents library.
*
* The harness is SSR-only (node env, no jsdom): we seed the TanStack cache and
* assert the static markup. Interaction-driven paths (the exact request params,
* the provider-unsupported classification) are covered by the pure-helper unit
* tests in `library-utils.test.ts`; here we assert the rendered library, the
* detail/review surface, the edit form, and the inline provider note.
*/
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return {
...actual,
apiFetch: () => new Promise(() => {}),
apiPost: vi.fn(),
apiPatch: vi.fn(),
apiDelete: vi.fn(),
};
});
import {
AlreadyConfirmedNote,
DocumentDetail,
DocumentMetaEditForm,
InboundDocumentsView,
ProviderUnsupportedNote,
} from "../inbound-documents-view";
function doc(
id: string,
over: Partial<InboundDocumentDto> = {},
): InboundDocumentDto {
return {
id,
kind: "LAB_RESULT",
title: null,
filename: `${id}.pdf`,
mimeType: "application/pdf",
byteSize: 1000,
status: "STORED",
providerType: null,
reportDate: null,
documentDate: "2026-06-20",
errorReason: null,
factCount: 0,
pendingCount: 0,
createdAt: "2026-06-20T08:00:00.000Z",
updatedAt: "2026-06-20T08:00:00.000Z",
...over,
};
}
function render(node: React.ReactNode, seed?: (qc: QueryClient) => void) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
seed?.(queryClient);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("<InboundDocumentsView> library", () => {
it("renders the grouped list with titles, badges, and load-more", () => {
const html = render(<InboundDocumentsView />, (qc) => {
qc.setQueryData(
queryKeys.inboundDocumentList({
q: undefined,
kind: undefined,
sort: "documentDate",
order: "desc",
}),
{
pages: [
{
documents: [
doc("d1", {
title: "Cardiology report",
documentDate: "2026-06-20",
pendingCount: 2,
factCount: 3,
}),
doc("d2", {
title: "Blood panel",
documentDate: "2026-06-20",
}),
],
nextCursor: "cursor-next",
},
],
pageParams: [null],
},
);
});
// Library + toolbar shells render.
expect(html).toContain('data-slot="documents-library"');
expect(html).toContain('data-slot="documents-toolbar"');
// Date group header + rows.
expect(html).toContain('data-slot="documents-date-group"');
expect(html).toContain('data-slot="documents-row"');
// Titles surface (title preferred over filename).
expect(html).toContain("Cardiology report");
expect(html).toContain("Blood panel");
// Kind + status badges from the EN bundle.
expect(html).toContain("Lab result");
expect(html).toContain("Stored");
// pendingCount badge.
expect(html).toContain("2 to review");
// nextCursor → load-more button.
expect(html).toContain("Load more");
// A11Y-M2: the row's aria-controls points at the detail region id.
expect(html).toContain('aria-controls="documents-detail-d1"');
// QoL: accepted formats + size ceiling are shown by the file field.
expect(html).toContain("JPEG, PNG, WebP or PDF · up to 12 MB");
});
it("disables the upload button and shows a hint until a file is chosen", () => {
const html = render(<InboundDocumentsView />, (qc) => {
qc.setQueryData(
queryKeys.inboundDocumentList({
q: undefined,
kind: undefined,
sort: "documentDate",
order: "desc",
}),
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
);
});
// A11Y-M4: with no file staged, an inline hint explains why upload is
// disabled (the hint only renders on the !hasFile / not-pending path).
expect(html).toContain("Choose a file to upload.");
});
it("renders the document-date range filter inputs", () => {
const html = render(<InboundDocumentsView />, (qc) => {
qc.setQueryData(
queryKeys.inboundDocumentList({
q: undefined,
kind: undefined,
sort: "documentDate",
order: "desc",
}),
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
);
});
// PERF-L: from/to date inputs are wired into the toolbar.
expect(html).toContain('id="doc-from"');
expect(html).toContain('id="doc-to"');
});
it("shows the empty state when there are no documents", () => {
const html = render(<InboundDocumentsView />, (qc) => {
qc.setQueryData(
queryKeys.inboundDocumentList({
q: undefined,
kind: undefined,
sort: "documentDate",
order: "desc",
}),
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
);
});
expect(html).toContain("No documents yet.");
expect(html).not.toContain('data-slot="documents-row"');
});
});
function fact(id: string): ExtractedFactDto {
return {
id,
factType: "OBSERVATION",
status: "PENDING",
confidence: 0.9,
needsReview: false,
data: {
label: "LDL Cholesterol",
code: null,
codeSystem: null,
value: 95,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: null,
effectiveDate: "2026-06-20",
},
provenance: { sourceText: "LDL 95 mg/dL", page: 0, confidence: 0.9 },
committedRecordId: null,
committedRecordType: null,
};
}
describe("<DocumentDetail>", () => {
function detail(over: Partial<InboundDocumentDetailDto> = {}) {
return {
...doc("d1", { title: "Cardiology report", status: "EXTRACTED" }),
facts: [fact("f1")],
...over,
} as InboundDocumentDetailDto;
}
it("renders the original link, edit + extract controls, and the review facts", () => {
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
},
);
expect(html).toContain('data-slot="documents-detail"');
// Original view link points at the raw-bytes route.
expect(html).toContain("/api/documents/inbound/d1/original");
expect(html).toContain('data-slot="documents-edit-trigger"');
// Extract button is present (status is not CONFIRMED).
expect(html).toContain('data-slot="documents-extract"');
expect(html).toContain("Extract facts");
// The staged fact's transcription summary renders for review.
expect(html).toContain("LDL Cholesterol");
expect(html).toContain("Save approved facts");
});
it("hides the extract button once the document is confirmed", () => {
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(
queryKeys.inboundDocument("d1"),
detail({ status: "CONFIRMED", facts: [] }),
);
},
);
expect(html).not.toContain('data-slot="documents-extract"');
});
it("hides the extract button once any fact is approved (PERF-H1 gate)", () => {
const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" };
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(
queryKeys.inboundDocument("d1"),
detail({ status: "EXTRACTED", factCount: 1, facts: [approved] }),
);
},
);
expect(html).not.toContain('data-slot="documents-extract"');
});
it("threads the region id onto the detail card for aria-controls", () => {
const html = render(
<DocumentDetail
documentId="d1"
regionId="documents-detail-d1"
onClosed={() => {}}
/>,
(qc) => {
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
},
);
expect(html).toContain('id="documents-detail-d1"');
});
it("falls back to the kind label when title and filename are absent", () => {
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(
queryKeys.inboundDocument("d1"),
detail({ title: null, filename: null, kind: "IMAGING" }),
);
},
);
expect(html).toContain("Imaging");
});
it("shows the committed facts summary once approved, not the empty note", () => {
const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" };
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(
queryKeys.inboundDocument("d1"),
detail({ status: "CONFIRMED", factCount: 1, facts: [approved] }),
);
},
);
// M6: provenance stays visible — the approved facts are listed read-only…
expect(html).toContain('data-slot="documents-committed"');
expect(html).toContain("Approved from this document");
expect(html).toContain("LDL Cholesterol");
// …instead of stranding the user on "Nothing left to review."
expect(html).not.toContain("Nothing left to review.");
});
it("explains why a low-confidence fact can't be approved yet", () => {
const lowConf: ExtractedFactDto = { ...fact("f1"), needsReview: true };
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(
queryKeys.inboundDocument("d1"),
detail({ facts: [lowConf] }),
);
},
);
// M7: the disabled Approve is tied to a plain-language reason.
expect(html).toContain("Edit this fact to approve it.");
expect(html).toContain("Needs review");
});
it("offers a confirm step before discarding", () => {
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
},
);
// A11Y-M3: discard is gated behind an AlertDialog trigger, not one click.
expect(html).toContain('data-slot="documents-discard-trigger"');
});
});
describe("<DocumentMetaEditForm>", () => {
it("renders the metadata fields seeded from the document", () => {
const html = render(
<DocumentMetaEditForm
doc={doc("d1", { title: "Old title", documentDate: "2026-06-20" })}
onSaved={() => {}}
onCancel={() => {}}
/>,
);
expect(html).toContain('data-slot="documents-edit-form"');
expect(html).toContain('value="Old title"');
expect(html).toContain('value="2026-06-20"');
expect(html).toContain("Save");
});
});
describe("<ProviderUnsupportedNote>", () => {
it("renders the calm inline note as a polite live region (A11Y-H1)", () => {
const html = render(<ProviderUnsupportedNote />);
expect(html).toContain('data-slot="documents-provider-note"');
expect(html).toContain("Configure a document-scan provider");
expect(html).toContain('role="status"');
expect(html).toContain('aria-live="polite"');
});
});
describe("<AlreadyConfirmedNote>", () => {
it("renders a calm polite live region when extraction is refused", () => {
const html = render(<AlreadyConfirmedNote />);
expect(html).toContain('data-slot="documents-already-confirmed-note"');
expect(html).toContain('role="status"');
expect(html).toContain('aria-live="polite"');
expect(html).toContain("already been reviewed");
});
});
@@ -1,283 +0,0 @@
import { describe, it, expect } from "vitest";
import { ApiError } from "@/lib/api/api-fetch";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import enMessages from "../../../../messages/en.json";
import {
buildDocumentListSearch,
classifyUploadError,
commitFailureReasonKey,
confirmFailureReasonKey,
documentDateKey,
formatDateGroupLabel,
groupDocumentsByDate,
isAlreadyConfirmedError,
isLocalOcrDisabledError,
isProviderUnsupportedError,
} from "../library-utils";
/** Resolve a dotted i18n key against a bundle (the dynamic reason keys aren't
* literal `t()` call sites, so the call-site coverage guard can't see them —
* assert here that each one actually resolves to a string). */
function resolveKey(bundle: unknown, key: string): unknown {
return key
.split(".")
.reduce<unknown>(
(acc, part) =>
acc && typeof acc === "object"
? (acc as Record<string, unknown>)[part]
: undefined,
bundle,
);
}
function doc(
id: string,
over: Partial<InboundDocumentDto> = {},
): InboundDocumentDto {
return {
id,
kind: "OTHER",
title: null,
filename: `${id}.pdf`,
mimeType: "application/pdf",
byteSize: 1000,
status: "STORED",
providerType: null,
reportDate: null,
documentDate: null,
errorReason: null,
factCount: 0,
pendingCount: 0,
createdAt: "2026-06-20T08:00:00.000Z",
updatedAt: "2026-06-20T08:00:00.000Z",
...over,
};
}
describe("buildDocumentListSearch", () => {
it("emits the active filters, sort, order, and limit", () => {
const qs = buildDocumentListSearch(
{
q: "cardio",
kind: "LAB_RESULT",
from: "2026-01-01",
to: "2026-06-01",
sort: "title",
order: "asc",
},
null,
25,
);
const sp = new URLSearchParams(qs);
expect(sp.get("q")).toBe("cardio");
expect(sp.get("kind")).toBe("LAB_RESULT");
expect(sp.get("from")).toBe("2026-01-01");
expect(sp.get("to")).toBe("2026-06-01");
expect(sp.get("sort")).toBe("title");
expect(sp.get("order")).toBe("asc");
expect(sp.get("limit")).toBe("25");
expect(sp.has("cursor")).toBe(false);
});
it("omits empty filters and appends the keyset cursor when present", () => {
const qs = buildDocumentListSearch(
{ sort: "documentDate", order: "desc" },
"cursor-123",
);
const sp = new URLSearchParams(qs);
expect(sp.has("q")).toBe(false);
expect(sp.has("kind")).toBe(false);
expect(sp.get("sort")).toBe("documentDate");
expect(sp.get("order")).toBe("desc");
expect(sp.get("cursor")).toBe("cursor-123");
});
});
describe("documentDateKey", () => {
it("prefers the user filing date", () => {
expect(documentDateKey(doc("a", { documentDate: "2026-03-15" }))).toBe(
"2026-03-15",
);
});
it("falls back to the upload day from createdAt", () => {
expect(
documentDateKey(doc("a", { createdAt: "2026-06-20T08:00:00.000Z" })),
).toBe("2026-06-20");
});
});
describe("groupDocumentsByDate", () => {
it("buckets consecutive same-date documents, preserving server order", () => {
const groups = groupDocumentsByDate([
doc("a", { documentDate: "2026-06-20" }),
doc("b", { documentDate: "2026-06-20" }),
doc("c", { documentDate: "2026-06-19" }),
]);
expect(groups.map((g) => g.key)).toEqual(["2026-06-20", "2026-06-19"]);
expect(groups[0].documents.map((d) => d.id)).toEqual(["a", "b"]);
expect(groups[1].documents.map((d) => d.id)).toEqual(["c"]);
});
it("returns an empty array for no documents", () => {
expect(groupDocumentsByDate([])).toEqual([]);
});
});
describe("isProviderUnsupportedError", () => {
it("matches the extract 422 provider-unsupported signal", () => {
const err = new ApiError("no provider", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
expect(isProviderUnsupportedError(err)).toBe(true);
});
it("rejects other API errors and non-errors", () => {
expect(
isProviderUnsupportedError(
new ApiError("rate", 429, {
errorCode: "documents.inbound.rateLimited",
}),
),
).toBe(false);
expect(isProviderUnsupportedError(new Error("boom"))).toBe(false);
expect(isProviderUnsupportedError(null)).toBe(false);
});
});
describe("isAlreadyConfirmedError", () => {
it("matches the extract 409 partly-confirmed signal", () => {
const err = new ApiError("already confirmed", 409, {
errorCode: "documents.inbound.alreadyPartlyConfirmed",
});
expect(isAlreadyConfirmedError(err)).toBe(true);
});
it("matches the extract 422 fully-confirmed signal", () => {
const err = new ApiError("already confirmed", 422, {
errorCode: "documents.inbound.alreadyConfirmed",
});
expect(isAlreadyConfirmedError(err)).toBe(true);
});
it("rejects other API errors and non-errors", () => {
expect(
isAlreadyConfirmedError(
new ApiError("no provider", 422, {
errorCode: "documents.inbound.providerUnsupported",
}),
),
).toBe(false);
expect(isAlreadyConfirmedError(new Error("boom"))).toBe(false);
expect(isAlreadyConfirmedError(null)).toBe(false);
});
});
describe("classifyUploadError", () => {
it("maps each upload error code to its kind", () => {
const code = (c: string) =>
classifyUploadError(new ApiError("x", 400, { errorCode: c }));
expect(code("documents.inbound.fileTooLarge")).toBe("tooLarge");
expect(code("documents.inbound.fileType")).toBe("fileType");
expect(code("documents.inbound.rateLimited")).toBe("rateLimited");
expect(code("documents.inbound.invalidMetadata")).toBe("invalidMetadata");
});
it("falls back to the HTTP status when the body carries no error code", () => {
expect(classifyUploadError(new ApiError("too big", 413))).toBe("tooLarge");
expect(classifyUploadError(new ApiError("wrong type", 415))).toBe(
"fileType",
);
expect(classifyUploadError(new ApiError("slow down", 429))).toBe(
"rateLimited",
);
});
it("returns generic for unknown errors and non-API errors", () => {
expect(classifyUploadError(new ApiError("boom", 500))).toBe("generic");
expect(classifyUploadError(new Error("network"))).toBe("generic");
expect(classifyUploadError(null)).toBe("generic");
});
});
describe("isLocalOcrDisabledError", () => {
it("matches the extract 422 local-OCR-disabled signal", () => {
const err = new ApiError("local ocr off", 422, {
errorCode: "documents.inbound.localOcrDisabled",
});
expect(isLocalOcrDisabledError(err)).toBe(true);
});
it("rejects other API errors and non-errors", () => {
expect(
isLocalOcrDisabledError(
new ApiError("no provider", 422, {
errorCode: "documents.inbound.providerUnsupported",
}),
),
).toBe(false);
expect(isLocalOcrDisabledError(new Error("boom"))).toBe(false);
expect(isLocalOcrDisabledError(null)).toBe(false);
});
});
describe("commitFailureReasonKey", () => {
it("maps each commit-failure code to a reason key that resolves", () => {
const cases: Record<string, string> = {
"observation.unitMismatch": "documents.review.commitError.unitMismatch",
"observation.unitRequired": "documents.review.commitError.unitRequired",
"something.unknown": "documents.review.commitError.generic",
};
for (const [code, key] of Object.entries(cases)) {
expect(commitFailureReasonKey(code)).toBe(key);
// Every reason the toast can surface resolves to a real string.
expect(typeof resolveKey(enMessages, key)).toBe("string");
}
});
});
describe("confirmFailureReasonKey", () => {
it("surfaces the shared reason when every failed fact agrees", () => {
const key = confirmFailureReasonKey([
{ reason: "observation.unitMismatch" },
{ reason: "observation.unitMismatch" },
]);
expect(key).toBe("documents.review.commitError.unitMismatch");
expect(typeof resolveKey(enMessages, key)).toBe("string");
});
it("falls back to the generic reason when failures disagree", () => {
const key = confirmFailureReasonKey([
{ reason: "observation.unitMismatch" },
{ reason: "observation.unitRequired" },
]);
expect(key).toBe("documents.review.commitError.generic");
});
it("uses the single failure's reason for a one-item batch", () => {
expect(
confirmFailureReasonKey([{ reason: "observation.unitRequired" }]),
).toBe("documents.review.commitError.unitRequired");
});
});
describe("formatDateGroupLabel", () => {
it("delegates the calendar day to the supplied app formatter", () => {
const seen: string[] = [];
const formatDate = (value: string) => {
seen.push(value);
return "FORMATTED";
};
expect(formatDateGroupLabel("2026-06-20", formatDate)).toBe("FORMATTED");
// Handed over as a noon-UTC instant so the day never shifts under the tz.
expect(seen).toEqual(["2026-06-20T12:00:00.000Z"]);
});
it("returns a malformed key unchanged without calling the formatter", () => {
const formatDate = () => {
throw new Error("should not be called");
};
expect(formatDateGroupLabel("not-a-date", formatDate)).toBe("not-a-date");
});
});
@@ -0,0 +1,283 @@
import { describe, expect, it } from "vitest";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
buildTimelineItems,
buildVaultListApiSearch,
classifyUploadFailure,
countActiveFilters,
documentDateKey,
expandRangeSelection,
formatBytes,
formatMonthLabel,
parseUploadResponse,
parseVaultSearchParams,
vaultFiltersToSearch,
} from "../vault-utils";
function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
return {
id: "doc-1",
kind: "DOCTOR_REPORT",
title: null,
filename: "letter.pdf",
mimeType: "application/pdf",
byteSize: 1024,
status: "STORED",
providerType: null,
reportDate: null,
documentDate: "2026-03-10",
errorReason: null,
factCount: 0,
pendingCount: 0,
conditionLinks: [],
servingClass: "inline",
createdAt: "2026-03-11T08:00:00.000Z",
updatedAt: "2026-03-11T08:00:00.000Z",
...overrides,
};
}
describe("URL facet round-trip", () => {
it("parses q / kind / episode / year and serialises back identically", () => {
const search = "q=MRT&kind=IMAGING%2CLAB_RESULT&episode=ep1&year=2025";
const filters = parseVaultSearchParams(new URLSearchParams(search));
expect(filters).toEqual({
q: "MRT",
kinds: ["IMAGING", "LAB_RESULT"],
episodeId: "ep1",
year: 2025,
});
expect(vaultFiltersToSearch(filters)).toBe(
"q=MRT&kind=IMAGING%2CLAB_RESULT&episode=ep1&year=2025",
);
});
it("accepts repeated kind params and drops unknown kinds", () => {
const sp = new URLSearchParams();
sp.append("kind", "IMAGING");
sp.append("kind", "NOT_A_KIND");
sp.append("kind", "IMAGING"); // duplicate collapses
const filters = parseVaultSearchParams(sp);
expect(filters.kinds).toEqual(["IMAGING"]);
});
it("normalises kind order so the cache key is stable", () => {
const a = parseVaultSearchParams(
new URLSearchParams("kind=LAB_RESULT,IMAGING"),
);
const b = parseVaultSearchParams(
new URLSearchParams("kind=IMAGING,LAB_RESULT"),
);
expect(a).toEqual(b);
});
it("ignores a malformed year and returns the empty default view", () => {
expect(parseVaultSearchParams(new URLSearchParams("year=20x5"))).toEqual(
{},
);
expect(vaultFiltersToSearch({})).toBe("");
});
it("counts active facets per chip (search counts once)", () => {
expect(countActiveFilters({})).toBe(0);
expect(
countActiveFilters({
q: "a",
kinds: ["IMAGING", "LAB_RESULT"],
episodeId: "e",
year: 2026,
}),
).toBe(5);
});
});
describe("API list search", () => {
it("pins timeline sort, maps episode → episodeId, threads the cursor", () => {
const search = buildVaultListApiSearch(
{ kinds: ["IMAGING"], episodeId: "ep1", year: 2025 },
"cursor-9",
50,
);
const sp = new URLSearchParams(search);
expect(sp.get("kind")).toBe("IMAGING");
expect(sp.get("episodeId")).toBe("ep1");
expect(sp.get("year")).toBe("2025");
expect(sp.get("sort")).toBe("documentDate");
expect(sp.get("order")).toBe("desc");
expect(sp.get("cursor")).toBe("cursor-9");
expect(sp.get("limit")).toBe("50");
});
});
describe("timeline items", () => {
it("buckets by month and chunks rows to the column count", () => {
const docs = [
doc({ id: "a", documentDate: "2026-03-20" }),
doc({ id: "b", documentDate: "2026-03-15" }),
doc({ id: "c", documentDate: "2026-03-10" }),
doc({ id: "d", documentDate: "2026-02-28" }),
];
const items = buildTimelineItems(docs, 2);
expect(items.map((i) => i.type)).toEqual([
"month",
"row",
"row",
"month",
"row",
]);
expect(items[0]).toEqual({ type: "month", key: "2026-03" });
expect(items[1]).toMatchObject({ documents: [docs[0], docs[1]] });
expect(items[2]).toMatchObject({ documents: [docs[2]] });
expect(items[4]).toMatchObject({ documents: [docs[3]] });
});
it("files an undated document under its upload day", () => {
const d = doc({ documentDate: null, createdAt: "2026-01-05T10:00:00Z" });
expect(documentDateKey(d)).toBe("2026-01-05");
const items = buildTimelineItems([d], 3);
expect(items[0]).toEqual({ type: "month", key: "2026-01" });
});
it("keeps the DOM bounded: item count scales with rows, not documents", () => {
const docs = Array.from({ length: 1000 }, (_, i) =>
doc({ id: `d${i}`, documentDate: "2026-03-01" }),
);
const items = buildTimelineItems(docs, 4);
// 1 month header + 250 rows — the virtualizer then windows over these.
expect(items).toHaveLength(251);
});
it("formats a month key in the viewer's locale", () => {
expect(formatMonthLabel("2026-03", "de")).toBe("März 2026");
expect(formatMonthLabel("not-a-month", "de")).toBe("not-a-month");
});
});
describe("byte formatting", () => {
it("scales units and keeps one decimal below 10", () => {
expect(formatBytes(0, "en")).toBe("0 B");
expect(formatBytes(512, "en")).toBe("512 B");
expect(formatBytes(26_214_400, "en")).toBe("25 MB");
expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB");
});
});
describe("upload response contract (§3.2)", () => {
it("HTTP 200 + meta.duplicate returns the EXISTING row as a duplicate", () => {
const body = JSON.stringify({
data: doc({ id: "existing-row" }),
error: null,
meta: { duplicate: true },
});
const result = parseUploadResponse(200, body);
expect(result).toMatchObject({
ok: true,
duplicate: true,
document: { id: "existing-row" },
});
});
it("a plain 201 success is not a duplicate", () => {
const body = JSON.stringify({ data: doc({ id: "fresh" }), error: null });
expect(parseUploadResponse(201, body)).toMatchObject({
ok: true,
duplicate: false,
document: { id: "fresh" },
});
});
it("maps every meta.reason to its translated-copy class", () => {
expect(
parseUploadResponse(
413,
JSON.stringify({
data: null,
error: "too large",
meta: { reason: "fileTooLarge", maxFileBytes: 26_214_400 },
}),
),
).toEqual({
ok: false,
reason: "fileTooLarge",
maxFileBytes: 26_214_400,
quotaBytes: undefined,
usedBytes: undefined,
existingId: undefined,
});
expect(
parseUploadResponse(
413,
JSON.stringify({
data: null,
error: "quota",
meta: {
reason: "quotaExceeded",
quotaBytes: 1_073_741_824,
usedBytes: 1_000_000_000,
},
}),
),
).toMatchObject({
ok: false,
reason: "quotaExceeded",
quotaBytes: 1_073_741_824,
usedBytes: 1_000_000_000,
});
expect(
classifyUploadFailure(415, { reason: "unsupportedType" }),
).toMatchObject({ reason: "unsupportedType" });
expect(classifyUploadFailure(409, { reason: "purged" })).toMatchObject({
reason: "purged",
});
expect(
classifyUploadFailure(409, {
reason: "duplicateExists",
existingId: "row-1",
}),
).toMatchObject({ reason: "duplicateExists", existingId: "row-1" });
});
it("falls back to the HTTP status when a proxy strips the body", () => {
expect(parseUploadResponse(413, "")).toMatchObject({
reason: "fileTooLarge",
});
expect(parseUploadResponse(415, "<html>")).toMatchObject({
reason: "unsupportedType",
});
expect(parseUploadResponse(429, "")).toMatchObject({
reason: "rateLimited",
});
expect(parseUploadResponse(500, "")).toMatchObject({ reason: "generic" });
});
});
describe("shift-click range selection", () => {
const order = ["a", "b", "c", "d", "e"];
it("selects the inclusive range between anchor and target, both ways", () => {
expect(
[...expandRangeSelection(order, new Set(["b"]), "b", "d")].sort(),
).toEqual(["b", "c", "d"]);
expect(
[...expandRangeSelection(order, new Set(["d"]), "d", "b")].sort(),
).toEqual(["b", "c", "d"]);
});
it("is additive — a range gesture never deselects", () => {
const next = expandRangeSelection(order, new Set(["a", "c"]), "c", "e");
expect([...next].sort()).toEqual(["a", "c", "d", "e"]);
});
it("falls back to a plain toggle without a live anchor", () => {
expect([...expandRangeSelection(order, new Set(), null, "c")]).toEqual([
"c",
]);
// Anchor filtered out of the current order → plain toggle (deselect).
expect([
...expandRangeSelection(order, new Set(["c"]), "gone", "c"),
]).toEqual([]);
});
});
@@ -0,0 +1,136 @@
"use client";
/**
* Floating bulk-action bar for the vault's multi-select mode. Appears once
* at least one document is selected, pinned above the bottom nav on phones
* and near the bottom edge on desktop. Carries the selected count and the
* four bulk verbs — set type, link condition, delete (undo-able), clear —
* all driving `POST /api/documents/inbound/bulk` from the page.
*
* A `role="toolbar"` with proper labels; the destructive verb sits last
* before Clear so a stray tap sequence never ends on Delete. The bar is a
* deliberate hand-rolled shell (a floating toolbar is not a Card surface):
* dense-tile padding `p-3` per the standards.
*/
import { FolderPlus, Tag, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta";
export interface BulkEpisodeOption {
id: string;
label: string;
}
export function DocumentBulkBar({
selectedCount,
episodes,
busy,
onSetKind,
onLinkEpisode,
onDelete,
onClear,
}: {
selectedCount: number;
/** The caller's live illness episodes — the link-condition targets. */
episodes: BulkEpisodeOption[];
/** A bulk call is in flight — the verbs disable, Clear stays live. */
busy: boolean;
onSetKind: (kind: InboundDocumentKindValue) => void;
onLinkEpisode: (episodeId: string) => void;
onDelete: () => void;
onClear: () => void;
}) {
const { t } = useTranslations();
return (
<div
data-slot="document-bulk-bar"
role="toolbar"
aria-label={t("documents.bulk.barLabel")}
className={cn(
"bg-card border-border fixed bottom-20 left-1/2 z-40 -translate-x-1/2 md:bottom-6",
"flex w-[calc(100%-2rem)] max-w-2xl flex-wrap items-center gap-2 rounded-xl border p-3 shadow-lg",
)}
>
<p className="px-1 text-sm font-medium whitespace-nowrap" role="status">
{t("documents.selection.count", { count: selectedCount })}
</p>
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={busy}>
<Tag className="size-4" aria-hidden />
{t("documents.bulk.setKind")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{DOCUMENT_KIND_ORDER.map((kind) => {
const Icon = DOCUMENT_KIND_ICONS[kind];
return (
<DropdownMenuItem key={kind} onSelect={() => onSetKind(kind)}>
<Icon className="size-4" aria-hidden />
{t(`documents.kind.${kind}`)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{episodes.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={busy}>
<FolderPlus className="size-4" aria-hidden />
{t("documents.bulk.linkCondition")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{episodes.map((episode) => (
<DropdownMenuItem
key={episode.id}
onSelect={() => onLinkEpisode(episode.id)}
>
<span className="max-w-56 truncate">{episode.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{/* Solid destructive (matching the detail sheet's Delete) — the
outline variant's destructive text on the card surface fails the
WCAG contrast gate. */}
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={onDelete}
>
<Trash2 className="size-4" aria-hidden />
{t("documents.bulk.delete")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={onClear}
>
<X className="size-3.5" aria-hidden />
{t("documents.selection.clear")}
</Button>
</div>
</div>
);
}
+315
View File
@@ -0,0 +1,315 @@
"use client";
/**
* One document in the vault timeline (compact-density Card) plus the two
* transient upload-state cards (in-flight with a live progress ring, failed
* with the translated §3.2 reason). All three share the card footprint so an
* optimistic entry morphs into the stored row without the grid jumping.
*
* Anatomy per the design standards: kind icon `text-foreground size-5`,
* title in foreground (`text-sm font-medium`, truncated), one muted meta
* line (date · size · filename), condition-tag pills, an attachment-class
* badge for download-only formats, and a selection checkbox that appears on
* hover / focus / while selected. The whole card is clickable through an
* invisible overlay button; the checkbox floats above it.
*/
import { Download, X } from "lucide-react";
import { useRef } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import type { UploadQueueItem } from "./use-document-upload";
import { documentDateKey, formatBytes } from "./vault-utils";
/** Press-and-hold duration that flips a touch press into "select" mode. */
const LONG_PRESS_MS = 500;
export function DocumentCard({
document,
selected,
onToggleSelected,
onOpen,
onDelete,
highlighted,
tabIndex = 0,
onCardFocus,
onPrefetch,
}: {
document: InboundDocumentDto;
selected: boolean;
/** `range` = extend the selection from the last anchor (shift-click). */
onToggleSelected: (id: string, range?: boolean) => void;
onOpen: (id: string) => void;
/** Delete key on the focused card — undo-able delete owned by the page. */
onDelete?: (id: string) => void;
/** Brief ring after a duplicate upload resolved to this existing row. */
highlighted: boolean;
/** Roving-tabindex slot from the timeline (0 = the one tabbable card). */
tabIndex?: number;
/** The open-button gained focus (keyboard/roving bookkeeping). */
onCardFocus?: (id: string) => void;
/** Hover/focus intent — prefetches the detail metadata (never the blob). */
onPrefetch?: (id: string) => void;
}) {
const { t, locale } = useTranslations();
const format = useFormatters();
// Touch long-press selects instead of opening; the click that the
// browser fires after the release is swallowed once.
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressFired = useRef(false);
const cancelLongPress = () => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
};
const title =
document.title ?? document.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[document.kind];
const date = format.date(`${documentDateKey(document)}T12:00:00.000Z`);
const size = formatBytes(document.byteSize, locale);
const showFilename =
document.filename !== null && document.filename !== title;
return (
<Card
data-slot="document-card"
data-document-id={document.id}
className={cn(
"group relative h-full gap-2 py-3 transition-shadow md:py-4",
"hover:shadow-sm",
highlighted && "ring-primary ring-2",
)}
>
<CardContent className="flex h-full flex-col gap-2 px-4">
<div className="flex items-start gap-2">
<Icon
className="text-foreground mt-0.5 size-5 shrink-0"
aria-hidden
/>
<p className="min-w-0 flex-1 truncate text-sm font-medium">{title}</p>
<Checkbox
checked={selected}
// Explicit click handling instead of onCheckedChange: the mouse
// event carries `shiftKey` for file-manager range selection.
// preventDefault stops Radix's internal toggle (state is fully
// controlled by the page's selection set anyway).
onClick={(e) => {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
}}
aria-label={t("documents.card.selectLabel", { title })}
className={cn(
"relative z-10 shrink-0 transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
selected && "opacity-100",
)}
/>
</div>
<p className="text-muted-foreground truncate text-xs">
{date} · {size}
{showFilename ? ` · ${document.filename}` : ""}
</p>
{document.conditionLinks.length > 0 ||
document.servingClass === "attachment" ? (
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
{document.conditionLinks.map((link) => (
<span
key={link.episodeId}
className="bg-muted text-foreground inline-flex max-w-40 items-center rounded-full px-2 py-0.5 text-xs"
>
<span className="truncate">{link.name}</span>
</span>
))}
{document.servingClass === "attachment" ? (
<Badge
variant="outline"
className="text-muted-foreground gap-1 text-xs font-normal"
>
<Download className="size-3" aria-hidden />
{t("documents.card.attachmentBadge")}
</Badge>
) : null}
</div>
) : null}
</CardContent>
{/* Whole-card click target. Painted last so it sits above the content
(the checkbox stays reachable via its own z-10). Keyboard contract
(roving tabindex owned by the timeline): Enter opens (native
click), Space toggles selection, Delete removes with undo. Touch:
press-and-hold selects instead of opening. */}
<button
type="button"
data-slot="document-open"
tabIndex={tabIndex}
onClick={() => {
if (longPressFired.current) {
longPressFired.current = false;
return;
}
onOpen(document.id);
}}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
onDelete?.(document.id);
}
}}
onTouchStart={() => {
longPressFired.current = false;
cancelLongPress();
longPressTimer.current = setTimeout(() => {
longPressFired.current = true;
onToggleSelected(document.id);
}, LONG_PRESS_MS);
}}
onTouchMove={cancelLongPress}
onTouchEnd={cancelLongPress}
onTouchCancel={cancelLongPress}
onMouseEnter={() => onPrefetch?.(document.id)}
onFocus={() => {
onCardFocus?.(document.id);
onPrefetch?.(document.id);
}}
aria-label={t("documents.card.openLabel", { title })}
className="focus-visible:ring-ring/50 absolute inset-0 rounded-xl focus-visible:ring-[3px] focus-visible:outline-none"
/>
</Card>
);
}
/** SVG progress ring for the in-flight upload card (0..1). */
function ProgressRing({ fraction }: { fraction: number }) {
const clamped = Math.min(1, Math.max(0, fraction));
const r = 13;
const circumference = 2 * Math.PI * r;
return (
<svg
viewBox="0 0 32 32"
className="size-8 shrink-0 -rotate-90"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(clamped * 100)}
>
<circle
cx="16"
cy="16"
r={r}
fill="none"
strokeWidth="3"
className="stroke-muted"
/>
<circle
cx="16"
cy="16"
r={r}
fill="none"
strokeWidth="3"
strokeLinecap="round"
className="stroke-primary transition-[stroke-dashoffset] duration-200"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - clamped)}
/>
</svg>
);
}
/**
* Transient card for a queue entry: uploading (progress ring) or failed
* (translated reason + dismiss). Shares the DocumentCard footprint.
*/
export function UploadStateCard({
item,
onDismiss,
}: {
item: UploadQueueItem;
onDismiss: (localId: string) => void;
}) {
const { t, locale } = useTranslations();
const failure = item.failure;
let failureCopy: string | null = null;
if (failure) {
switch (failure.reason) {
case "fileTooLarge":
failureCopy = t("documents.error.fileTooLarge", {
maxSize: formatBytes(failure.maxFileBytes ?? 0, locale),
});
break;
case "quotaExceeded":
failureCopy = t("documents.error.quotaExceeded", {
used: formatBytes(failure.usedBytes ?? 0, locale),
quota: formatBytes(failure.quotaBytes ?? 0, locale),
});
break;
case "unsupportedType":
failureCopy = t("documents.error.unsupportedType");
break;
case "purged":
failureCopy = t("documents.error.purged");
break;
case "duplicateExists":
failureCopy = t("documents.error.duplicateExists");
break;
case "rateLimited":
failureCopy = t("documents.error.rateLimited");
break;
default:
failureCopy = t("documents.error.generic");
}
}
return (
<Card
data-slot="document-upload-card"
className={cn(
"h-full gap-2 border-dashed py-3 md:py-4",
item.status === "error" && "border-destructive/50",
)}
>
<CardContent className="flex h-full flex-col gap-2 px-4">
<div className="flex items-center gap-3">
{item.status === "uploading" ? (
<ProgressRing fraction={item.progress} />
) : null}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.fileName}</p>
<p className="text-muted-foreground text-xs">
{item.status === "uploading"
? t("documents.card.uploading")
: formatBytes(item.byteSize, locale)}
</p>
</div>
{item.status === "error" ? (
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
onClick={() => onDismiss(item.localId)}
aria-label={t("documents.card.dismissFailed")}
>
<X className="size-4" aria-hidden />
</Button>
) : null}
</div>
{failureCopy ? (
<p role="alert" className="text-destructive text-xs">
{failureCopy}
</p>
) : null}
</CardContent>
</Card>
);
}
@@ -0,0 +1,517 @@
"use client";
/**
* Document detail on `ResponsiveSheet` (bottom sheet on phones, dialog on
* desktop). Inline-class documents (PDF / browser-native images) preview
* inline against the owner-scoped decrypt-and-serve route — skeleton first,
* content on load, internal scroll only (`overscroll-contain`). Attachment-
* class documents get NO fake preview: type glyph, filename, size, and a
* prominent Download button.
*
* Metadata edits save on commit (title on Enter/blur, kind and filing date
* on change, condition links on toggle — replace-set PATCH). Mutation
* errors surface inline (`role="alert"`), never as a QueryErrorCard.
* Delete soft-deletes with an Undo toast (restore endpoint); a restore
* refusal maps `meta.reason` (`purged`, `duplicateExists`) to translated
* copy. No share affordance anywhere — deliberately.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Download, Pencil, Plus, Trash2 } from "lucide-react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
apiDelete,
ApiError,
apiGet,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import {
INBOUND_DOCUMENT_KINDS,
type InboundDocumentDetailDto,
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import { formatBytes } from "./vault-utils";
type PatchInput = {
title?: string | null;
kind?: InboundDocumentKindValue;
documentDate?: string | null;
episodeIds?: string[];
};
/** Inline preview for Class A documents; skeleton until the blob paints. */
function InlinePreview({
documentId,
mimeType,
title,
}: {
documentId: string;
mimeType: string;
title: string;
}) {
const [loaded, setLoaded] = useState(false);
const src = `/api/documents/inbound/${documentId}/original`;
const isPdf = mimeType === "application/pdf";
// Chromium's PDF viewer grabs focus while it initialises — BEFORE the
// frame's `load` event — stranding keyboard events inside the embedded
// document, so Escape stops closing the sheet the moment a PDF preview
// opens. While the guard window is open (armed at mount, extended at
// load, ended early by a deliberate pointer interaction with the
// preview), any focus landing on the frame bounces straight back to the
// dialog container; a user who clicks into the PDF is left alone.
const frameRef = useRef<HTMLIFrameElement | null>(null);
const stealGuardUntil = useRef(0);
const reclaimFocus = (frame: HTMLIFrameElement) => {
if (
Date.now() < stealGuardUntil.current &&
document.activeElement === frame
) {
frame.closest<HTMLElement>('[role="dialog"]')?.focus();
}
};
useEffect(() => {
// Arm at mount, then run a frame-by-frame sweep for the steal variants
// that never dispatch a focus event on the frame element.
// Self-terminates with the guard.
stealGuardUntil.current = Date.now() + 3_000;
let raf = 0;
const sweep = () => {
const frame = frameRef.current;
if (!frame || Date.now() >= stealGuardUntil.current) return;
if (document.activeElement === frame) {
frame.closest<HTMLElement>('[role="dialog"]')?.focus();
}
raf = requestAnimationFrame(sweep);
};
raf = requestAnimationFrame(sweep);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className="relative max-h-[55vh] overflow-auto overscroll-contain rounded-lg">
{!loaded ? (
<Skeleton
className={cn("w-full rounded-lg", isPdf ? "h-[55vh]" : "h-64")}
/>
) : null}
{isPdf ? (
// No `sandbox` attribute by design: Chromium force-downloads PDFs
// in sandboxed frames instead of rendering them. The serve
// response itself carries `default-src 'none'` +
// `frame-ancestors 'self'` + true Content-Type + nosniff, and the
// upload policy denies every format whose inline render could
// execute script.
<iframe
ref={frameRef}
src={src}
title={title}
onLoad={(event) => {
setLoaded(true);
// The viewer may still initialise (and steal focus) after
// `load` — keep the guard open a little longer.
stealGuardUntil.current = Math.max(
stealGuardUntil.current,
Date.now() + 2_000,
);
reclaimFocus(event.currentTarget);
}}
onFocus={(event) => reclaimFocus(event.currentTarget)}
onPointerDown={() => {
// Deliberate interaction with the preview ends the guard.
stealGuardUntil.current = 0;
}}
className={cn(
"border-border h-[55vh] w-full rounded-lg border",
!loaded && "absolute inset-0 opacity-0",
)}
/>
) : (
// Decrypted same-origin blob stream: next/image cannot optimise it
// and would only proxy the bytes through a second request.
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={title}
onLoad={() => setLoaded(true)}
className={cn(
"mx-auto max-h-[55vh] w-auto max-w-full rounded-lg object-contain",
!loaded && "absolute inset-0 opacity-0",
)}
/>
)}
</div>
);
}
export function DocumentDetailSheet({
documentId,
open,
onOpenChange,
}: {
documentId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t, locale } = useTranslations();
const format = useFormatters();
const queryClient = useQueryClient();
const detail = useQuery({
queryKey: queryKeys.inboundDocument(documentId ?? "none"),
enabled: open && documentId !== null,
queryFn: () =>
apiGet<InboundDocumentDetailDto>(`/api/documents/inbound/${documentId}`),
});
const doc = detail.data;
const episodes = useIllnessEpisodes(true);
const [mutationError, setMutationError] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
// Reset transient edit state whenever another document opens —
// render-phase derived-state adjustment, not an effect.
const [lastDocumentId, setLastDocumentId] = useState(documentId);
if (lastDocumentId !== documentId) {
setLastDocumentId(documentId);
setEditingTitle(false);
setMutationError(null);
}
const patch = useMutation({
mutationFn: (input: PatchInput) =>
apiPatch<InboundDocumentDetailDto>(
`/api/documents/inbound/${documentId}`,
input,
),
onSuccess: () => {
setMutationError(null);
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
onError: () => setMutationError(t("documents.detail.saveError")),
});
const restore = useMutation({
mutationFn: (id: string) =>
apiPost(`/api/documents/inbound/${id}/restore`, {}),
onSuccess: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
onError: (error) => {
const reason =
error instanceof ApiError && typeof error.meta?.reason === "string"
? error.meta.reason
: "";
if (reason === "purged") {
toast.error(t("documents.error.purged"));
} else if (reason === "duplicateExists") {
toast.error(t("documents.error.duplicateExists"));
} else {
toast.error(t("documents.toast.restoreFailed"));
}
},
});
const remove = useMutation({
mutationFn: (id: string) => apiDelete(`/api/documents/inbound/${id}`),
onSuccess: (_data, id) => {
onOpenChange(false);
void invalidateKeys(queryClient, [queryKeys.documents()]);
toast.success(t("documents.toast.deleted"), {
action: {
label: t("common.undo"),
onClick: () => restore.mutate(id),
},
});
},
onError: () => setMutationError(t("documents.detail.deleteError")),
});
const commitTitle = () => {
if (!doc) return;
setEditingTitle(false);
const next = titleDraft.trim();
const current = doc.title ?? "";
if (next === current) return;
patch.mutate({ title: next === "" ? null : next });
};
const toggleEpisode = (episodeId: string) => {
if (!doc) return;
const current = doc.conditionLinks.map((l) => l.episodeId);
const next = current.includes(episodeId)
? current.filter((id) => id !== episodeId)
: [...current, episodeId];
patch.mutate({ episodeIds: next });
};
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={title}
description={t("documents.detail.title")}
contentWidth="3xl"
footer={
doc ? (
<>
<Button variant="outline" asChild data-slot="document-download">
<a href={originalHref} download={doc.filename ?? undefined}>
<Download className="size-4" aria-hidden />
{t("documents.detail.download")}
</a>
</Button>
<Button
variant="destructive"
onClick={() => remove.mutate(doc.id)}
disabled={remove.isPending}
>
<Trash2 className="size-4" aria-hidden />
{t("documents.detail.delete")}
</Button>
</>
) : undefined
}
>
{detail.isPending && open ? (
<div className="space-y-3" data-slot="document-detail-loading">
<Skeleton className="h-48 w-full rounded-lg" />
<Skeleton className="h-9 w-2/3" />
<Skeleton className="h-9 w-1/2" />
</div>
) : null}
{detail.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.detail.loadError")}
</p>
) : null}
{doc ? (
<div className="space-y-6">
{doc.servingClass === "inline" ? (
<InlinePreview
documentId={doc.id}
mimeType={doc.mimeType}
title={title}
/>
) : (
<div className="border-border flex flex-col items-center gap-2 rounded-lg border px-4 py-8 text-center">
{Icon ? (
<Icon className="text-muted-foreground size-8" aria-hidden />
) : null}
<p className="max-w-full truncate text-sm font-medium">
{doc.filename ?? title}
</p>
<p className="text-muted-foreground text-xs">
{t("documents.detail.previewUnavailable")} ·{" "}
{formatBytes(doc.byteSize, locale)}
</p>
<Button asChild size="sm" className="mt-1">
<a href={originalHref} download={doc.filename ?? undefined}>
<Download className="size-4" aria-hidden />
{t("documents.detail.download")}
</a>
</Button>
</div>
)}
{mutationError ? (
<p role="alert" className="text-destructive text-sm">
{mutationError}
</p>
) : null}
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="document-title-input">
{t("documents.detail.titleLabel")}
</Label>
{editingTitle ? (
<Input
id="document-title-input"
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === "Enter") commitTitle();
if (e.key === "Escape") setEditingTitle(false);
}}
maxLength={200}
placeholder={t("documents.detail.titlePlaceholder")}
/>
) : (
<button
type="button"
id="document-title-input"
onClick={() => {
setTitleDraft(doc.title ?? "");
setEditingTitle(true);
}}
className="border-input hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
>
<span
className={cn(
"truncate",
doc.title === null && "text-muted-foreground",
)}
>
{doc.title ?? t("documents.detail.titlePlaceholder")}
</span>
<Pencil
className="text-muted-foreground size-3.5 shrink-0"
aria-hidden
/>
</button>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="document-kind-select">
{t("documents.detail.kindLabel")}
</Label>
<Select
value={doc.kind}
onValueChange={(value) =>
patch.mutate({ kind: value as InboundDocumentKindValue })
}
>
<SelectTrigger id="document-kind-select" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INBOUND_DOCUMENT_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{t(`documents.kind.${kind}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="document-date-field">
{t("documents.detail.dateLabel")}
</Label>
<DateField
id="document-date-field"
value={doc.documentDate ?? ""}
onChange={(value) =>
patch.mutate({ documentDate: value === "" ? null : value })
}
aria-label={t("documents.detail.dateLabel")}
/>
</div>
</div>
<div className="space-y-1.5">
<p className="text-sm leading-none font-medium">
{t("documents.detail.conditionsLabel")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{doc.conditionLinks.map((link) => (
<Link
key={link.episodeId}
href={`/illness/${link.episodeId}`}
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-48 items-center rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="truncate">{link.name}</span>
</Link>
))}
{doc.conditionLinks.length === 0 ? (
<span className="text-muted-foreground text-xs">
{t("documents.detail.noConditions")}
</span>
) : null}
{(episodes.data?.length ?? 0) > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 rounded-full px-2.5 text-xs"
>
<Plus className="size-3" aria-hidden />
{t("documents.detail.linkCondition")}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-2">
<ul className="max-h-56 space-y-0.5 overflow-y-auto overscroll-contain">
{episodes.data?.map((episode) => {
const linked = doc.conditionLinks.some(
(l) => l.episodeId === episode.id,
);
return (
<li key={episode.id}>
<button
type="button"
onClick={() => toggleEpisode(episode.id)}
className="hover:bg-muted focus-visible:ring-ring/50 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
aria-pressed={linked}
>
<Check
className={cn(
"size-4 shrink-0",
linked ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="truncate">
{episode.label}
</span>
</button>
</li>
);
})}
</ul>
</PopoverContent>
</Popover>
) : null}
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("documents.detail.uploadedMeta", {
date: format.date(doc.createdAt),
size: formatBytes(doc.byteSize, locale),
})}
{doc.filename ? ` · ${doc.filename}` : ""}
</p>
</div>
</div>
) : null}
</ResponsiveSheet>
);
}
@@ -0,0 +1,202 @@
"use client";
/**
* The vault's sticky filter rail: debounced title/filename search, the
* nine-kind type-chip row (multi-select, OR inside the facet), condition
* chips (episodes that actually carry links), a year segmenter (years
* present in the corpus), and a one-tap clear with the active-facet count.
*
* Purely presentational — the filter state lives in the page URL and is
* owned by `documents-view.tsx`; this bar only renders the controls. It
* sticks below the top edge inside the shell's scroll container (sticky,
* translucent background, border-b — no viewport-height tricks).
*/
import { Search, X } from "lucide-react";
import type { RefObject } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta";
/** Shared chip chrome — mirrors the app-wide FilterBar pill vocabulary. */
const CHIP_CLASSES =
"border-border bg-card text-foreground inline-flex min-h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-sm whitespace-nowrap shadow-xs transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none";
const CHIP_ACTIVE_CLASSES = "border-primary/40 bg-primary/10";
export interface ConditionChip {
episodeId: string;
name: string;
}
export function DocumentFilterBar({
searchValue,
onSearchChange,
searchInputRef,
activeKinds,
onToggleKind,
conditionChips,
activeEpisodeId,
onToggleEpisode,
years,
activeYear,
onToggleYear,
activeCount,
onClearAll,
}: {
searchValue: string;
onSearchChange: (value: string) => void;
/** Focused by the page-level `/` shortcut. */
searchInputRef: RefObject<HTMLInputElement | null>;
activeKinds: ReadonlySet<InboundDocumentKindValue>;
onToggleKind: (kind: InboundDocumentKindValue) => void;
conditionChips: ConditionChip[];
activeEpisodeId: string | undefined;
onToggleEpisode: (episodeId: string) => void;
years: number[];
activeYear: number | undefined;
onToggleYear: (year: number) => void;
activeCount: number;
onClearAll: () => void;
}) {
const { t } = useTranslations();
return (
<div
data-slot="document-filter-bar"
className="bg-background/95 border-border sticky top-0 z-10 -mx-4 border-b px-4 pt-1 pb-3 backdrop-blur md:-mx-6 md:px-6"
>
<div className="flex items-center gap-3">
<div className="relative w-full max-w-md">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
ref={searchInputRef}
type="search"
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("documents.filter.searchPlaceholder")}
aria-label={t("documents.filter.searchLabel")}
className="pr-8 pl-9"
aria-keyshortcuts="/"
/>
<kbd
aria-hidden
className="border-border text-muted-foreground pointer-events-none absolute top-1/2 right-2.5 hidden -translate-y-1/2 rounded border px-1.5 font-mono text-xs sm:inline-block"
>
/
</kbd>
</div>
{activeCount > 0 ? (
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="text-muted-foreground shrink-0"
>
<X className="size-3.5" aria-hidden />
{t("documents.filter.clear")}
{activeCount > 1 ? (
<span className="tabular-nums">({activeCount})</span>
) : null}
</Button>
) : null}
</div>
<div
className="-mx-4 mt-3 flex [scrollbar-width:none] items-center gap-2 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:overflow-visible md:px-0 [&::-webkit-scrollbar]:hidden"
role="group"
aria-label={t("documents.filter.groupLabel")}
>
<span
role="group"
aria-label={t("documents.filter.typeGroup")}
className="contents"
>
{DOCUMENT_KIND_ORDER.map((kind) => {
const Icon = DOCUMENT_KIND_ICONS[kind];
const active = activeKinds.has(kind);
return (
<button
key={kind}
type="button"
aria-pressed={active}
onClick={() => onToggleKind(kind)}
className={cn(CHIP_CLASSES, active && CHIP_ACTIVE_CLASSES)}
>
<Icon className="size-3.5 shrink-0" aria-hidden />
{t(`documents.kind.${kind}`)}
</button>
);
})}
</span>
{conditionChips.length > 0 ? (
<>
<span
aria-hidden
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
/>
<span
role="group"
aria-label={t("documents.filter.conditionGroup")}
className="contents"
>
{conditionChips.map((chip) => {
const active = chip.episodeId === activeEpisodeId;
return (
<button
key={chip.episodeId}
type="button"
aria-pressed={active}
onClick={() => onToggleEpisode(chip.episodeId)}
className={cn(CHIP_CLASSES, active && CHIP_ACTIVE_CLASSES)}
>
<span className="max-w-40 truncate">{chip.name}</span>
</button>
);
})}
</span>
</>
) : null}
{years.length > 0 ? (
<>
<span
aria-hidden
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
/>
<span
role="group"
aria-label={t("documents.filter.yearGroup")}
className="contents"
>
{years.map((year) => {
const active = year === activeYear;
return (
<button
key={year}
type="button"
aria-pressed={active}
onClick={() => onToggleYear(year)}
className={cn(
CHIP_CLASSES,
"tabular-nums",
active && CHIP_ACTIVE_CLASSES,
)}
>
{year}
</button>
);
})}
</span>
</>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,48 @@
/**
* The vault's kind vocabulary: one Lucide glyph per document kind, in the
* fixed display order the type-chip rail and the cards share. Icons are
* passed as components — the consuming primitive owns size and colour per
* the design standards.
*/
import type { ComponentType } from "react";
import {
ArrowRightLeft,
File,
FileText,
FlaskConical,
Pill,
ScanLine,
ShieldCheck,
Stethoscope,
Syringe,
} from "lucide-react";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
/** Display order for the type-chip rail (everyday kinds first, OTHER last). */
export const DOCUMENT_KIND_ORDER: readonly InboundDocumentKindValue[] = [
"DOCTOR_REPORT",
"LAB_RESULT",
"IMAGING",
"DISCHARGE_LETTER",
"PRESCRIPTION",
"REFERRAL",
"VACCINATION",
"INSURANCE",
"OTHER",
];
export const DOCUMENT_KIND_ICONS: Record<
InboundDocumentKindValue,
ComponentType<{ className?: string }>
> = {
DOCTOR_REPORT: Stethoscope,
DISCHARGE_LETTER: FileText,
LAB_RESULT: FlaskConical,
IMAGING: ScanLine,
PRESCRIPTION: Pill,
REFERRAL: ArrowRightLeft,
INSURANCE: ShieldCheck,
VACCINATION: Syringe,
OTHER: File,
};
@@ -0,0 +1,194 @@
"use client";
/**
* "Verknüpfen" — link existing vault documents to an illness episode from
* the episode's detail page. A `ResponsiveSheet` with a debounced search
* over the vault (title/filename, same recall path as the vault page) and
* one toggle row per document; toggling fires the bulk link/unlink action
* for that single id (idempotent by contract, no-op-success on
* already-in-state rows) and refreshes every document read through the
* `["documents"]` prefix.
*
* Shows one page (50) of matches — the search input is the way to reach an
* older document, mirroring the vault's own recall model.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Search } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type {
DocumentBulkResultDto,
InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import { documentDateKey } from "./vault-utils";
interface ListPage {
documents: InboundDocumentDto[];
nextCursor: string | null;
}
export function DocumentLinkPicker({
episodeId,
open,
onOpenChange,
}: {
episodeId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const queryClient = useQueryClient();
const [searchDraft, setSearchDraft] = useState("");
const [query, setQuery] = useState("");
// 200 ms debounce, matching the vault's search feel.
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
},
[],
);
const onSearchChange = (value: string) => {
setSearchDraft(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setQuery(value.trim()), 200);
};
const list = useQuery({
queryKey: queryKeys.inboundDocumentPicker(query),
enabled: open,
queryFn: () => {
const sp = new URLSearchParams();
if (query) sp.set("q", query);
sp.set("sort", "documentDate");
sp.set("order", "desc");
sp.set("limit", "50");
return apiGet<ListPage>(`/api/documents/inbound?${sp.toString()}`);
},
});
const toggle = useMutation({
mutationFn: (input: { documentId: string; linked: boolean }) =>
apiPost<{ results: DocumentBulkResultDto[] }>(
"/api/documents/inbound/bulk",
{
ids: [input.documentId],
action: input.linked ? "unlinkEpisode" : "linkEpisode",
episodeId,
},
),
onSuccess: (data) => {
if (data.results.some((r) => !r.ok)) {
toast.error(t("documents.bulk.failed"));
}
},
onError: () => toast.error(t("documents.bulk.failed")),
onSettled: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
});
const documents = list.data?.documents ?? [];
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("documents.linkPicker.title")}
description={t("documents.linkPicker.description")}
>
<div className="space-y-3">
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
type="search"
value={searchDraft}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("documents.linkPicker.searchPlaceholder")}
aria-label={t("documents.linkPicker.searchPlaceholder")}
className="pl-9"
/>
</div>
{list.isPending && open ? (
<div className="space-y-2">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : list.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.list.loadError")}
</p>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("documents.linkPicker.empty")}
</p>
) : (
<ul className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain">
{documents.map((doc) => {
const linked = doc.conditionLinks.some(
(l) => l.episodeId === episodeId,
);
const title =
doc.title ?? doc.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
return (
<li key={doc.id}>
<button
type="button"
aria-pressed={linked}
disabled={toggle.isPending}
onClick={() =>
toggle.mutate({ documentId: doc.id, linked })
}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
linked && "border-primary/40 bg-primary/5",
)}
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{title}
</span>
<span className="text-muted-foreground block text-xs">
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
</span>
</span>
<Check
className={cn(
"text-primary size-4 shrink-0",
linked ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
</button>
</li>
);
})}
</ul>
)}
</div>
</ResponsiveSheet>
);
}
@@ -0,0 +1,327 @@
"use client";
/**
* The vault timeline: a virtualized, month-sectioned card grid windowed
* with `@tanstack/react-virtual` over the shell's scroll container
* (`#main-content` — single scroll owner per the design standards; the
* timeline never brings its own scrollport). The flat item list (month
* labels + chunked card rows) comes from `buildTimelineItems`, so the
* mounted DOM stays bounded (< ~400 nodes) regardless of corpus size.
*
* Columns are measured, not breakpoint-classed: a ResizeObserver on the
* grid container drives the per-row chunking (4 / 3 / 2 / 1), which keeps
* the virtualizer's row model and the painted grid in lockstep.
*
* In-flight / failed uploads render as a small non-virtualized grid above
* the timeline — they are few (client concurrency 3) and must appear
* < 100 ms after file selection, before any query settles.
*/
import { Loader2 } from "lucide-react";
import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useTranslations } from "@/lib/i18n/context";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DocumentCard, UploadStateCard } from "./document-card";
import type { UploadQueueItem } from "./use-document-upload";
import { buildTimelineItems, formatMonthLabel } from "./vault-utils";
const SCROLL_CONTAINER_ID = "main-content";
/** Measured-width → column count (desktop 4/3, tablet 2, phone 1). */
function columnsForWidth(width: number): number {
if (width >= 1200) return 4;
if (width >= 900) return 3;
if (width >= 600) return 2;
return 1;
}
export function DocumentTimeline({
documents,
uploadItems,
onDismissUpload,
hasNextPage,
isFetchingNextPage,
onLoadMore,
selectedIds,
onToggleSelected,
onOpen,
onDelete,
highlightId,
onPrefetch,
}: {
documents: InboundDocumentDto[];
uploadItems: UploadQueueItem[];
onDismissUpload: (localId: string) => void;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onLoadMore: () => void;
selectedIds: ReadonlySet<string>;
onToggleSelected: (id: string, range?: boolean) => void;
onOpen: (id: string) => void;
/** Delete key on the focused card — the page owns the undo-able delete. */
onDelete?: (id: string) => void;
highlightId: string | null;
onPrefetch?: (id: string) => void;
}) {
const { t, locale } = useTranslations();
const listRef = useRef<HTMLDivElement | null>(null);
const [columns, setColumns] = useState(1);
const [scrollMargin, setScrollMargin] = useState(0);
// Roving tabindex over the card grid: exactly one card is tabbable; the
// arrow keys move the active slot. Falls back to the first document when
// the remembered card left the corpus (filter change, deletion).
const [activeId, setActiveId] = useState<string | null>(null);
// Measured columns — the ResizeObserver drives the row chunking.
useEffect(() => {
const el = listRef.current;
if (!el) return;
const update = () => setColumns(columnsForWidth(el.clientWidth));
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const items = useMemo(
() => buildTimelineItems(documents, columns),
[documents, columns],
);
// The timeline does not start at the scrollport's top edge (page header,
// filter bar, upload row sit above it) — feed the offset to the
// virtualizer so window positions line up. Re-measured when the content
// above changes height (upload cards appearing/leaving) and on resize.
useEffect(() => {
const scrollEl = document.getElementById(SCROLL_CONTAINER_ID);
const listEl = listRef.current;
if (!scrollEl || !listEl) return;
const measure = () => {
const margin =
listEl.getBoundingClientRect().top -
scrollEl.getBoundingClientRect().top +
scrollEl.scrollTop;
setScrollMargin(margin);
};
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [uploadItems.length, columns]);
// The React Compiler cannot memoize across TanStack Virtual's instance
// API (library-level opt-out, not a fixable call-site problem) — the
// windowing still works, the compiler just skips this component.
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => document.getElementById(SCROLL_CONTAINER_ID),
estimateSize: (index) => (items[index].type === "month" ? 40 : 140),
getItemKey: (index) => items[index].key,
overscan: 6,
scrollMargin,
});
const virtualItems = virtualizer.getVirtualItems();
// ── Keyboard navigation (roving tabindex) ─────────────────────────────
// The tabbable slot: the remembered active card, else the first document.
const rovingId =
activeId !== null && documents.some((d) => d.id === activeId)
? activeId
: (documents[0]?.id ?? null);
// Which virtual item (grid row) a document renders in — the arrow-key
// handler scrolls that row into the window before focusing the card.
const rowIndexById = useMemo(() => {
const map = new Map<string, number>();
items.forEach((item, index) => {
if (item.type !== "row") return;
for (const doc of item.documents) map.set(doc.id, index);
});
return map;
}, [items]);
const focusDocument = (id: string) => {
setActiveId(id);
const rowIndex = rowIndexById.get(id);
if (rowIndex !== undefined) {
virtualizer.scrollToIndex(rowIndex, { align: "auto" });
}
// The row may only mount on the next virtualizer paint — retry across
// a few frames, bounded.
let attempts = 0;
const tryFocus = () => {
const button = listRef.current?.querySelector<HTMLButtonElement>(
`[data-document-id="${CSS.escape(id)}"] [data-slot="document-open"]`,
);
if (button) {
button.focus();
return;
}
attempts += 1;
if (attempts < 20) requestAnimationFrame(tryFocus);
};
requestAnimationFrame(tryFocus);
};
const onGridKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (documents.length === 0) return;
const currentIndex = rovingId
? documents.findIndex((d) => d.id === rovingId)
: 0;
let nextIndex: number | null = null;
switch (event.key) {
case "ArrowRight":
nextIndex = Math.min(documents.length - 1, currentIndex + 1);
break;
case "ArrowLeft":
nextIndex = Math.max(0, currentIndex - 1);
break;
case "ArrowDown":
nextIndex = Math.min(documents.length - 1, currentIndex + columns);
break;
case "ArrowUp":
nextIndex = Math.max(0, currentIndex - columns);
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = documents.length - 1;
break;
default:
return;
}
event.preventDefault();
if (nextIndex !== currentIndex) {
focusDocument(documents[nextIndex].id);
}
};
// Keyset infinite feed: pull the next page when the window nears the end.
const lastIndex = virtualItems[virtualItems.length - 1]?.index ?? -1;
useEffect(() => {
if (
hasNextPage &&
!isFetchingNextPage &&
lastIndex >= items.length - columns * 3 - 1
) {
onLoadMore();
}
}, [
lastIndex,
items.length,
columns,
hasNextPage,
isFetchingNextPage,
onLoadMore,
]);
return (
<div data-slot="document-timeline" className="space-y-4">
{uploadItems.length > 0 ? (
<div
data-slot="document-upload-queue"
aria-live="polite"
className="grid gap-4"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
}}
>
{uploadItems.map((item) => (
<UploadStateCard
key={item.localId}
item={item}
onDismiss={onDismissUpload}
/>
))}
</div>
) : null}
{/* List semantics over the virtualized window: the container is one
list, each windowed item (month label or card row) one list item —
honest for a windowed structure where per-document posinset would
lie whenever pages are still loading. Keyboard contract on the
grid: arrows move the roving slot, Enter opens, Space selects,
Delete removes (undo-able), documented on the cards. */}
<div
ref={listRef}
role="list"
aria-label={t("documents.timeline.listLabel")}
onKeyDown={onGridKeyDown}
>
<div
className="relative"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualItems.map((virtualItem) => {
const item = items[virtualItem.index];
return (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
role="listitem"
className="absolute inset-x-0 top-0"
style={{
transform: `translateY(${
virtualItem.start - scrollMargin
}px)`,
}}
>
{item.type === "month" ? (
<h2 className="text-muted-foreground pt-2 pb-3 text-xs font-medium tracking-wide uppercase">
{formatMonthLabel(item.key, locale)}
</h2>
) : (
<div
className="grid gap-4 pb-4"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
}}
>
{item.documents.map((doc) => (
<DocumentCard
key={doc.id}
document={doc}
selected={selectedIds.has(doc.id)}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
onDelete={onDelete}
highlighted={highlightId === doc.id}
tabIndex={rovingId === doc.id ? 0 : -1}
onCardFocus={setActiveId}
onPrefetch={onPrefetch}
/>
))}
</div>
)}
</div>
);
})}
</div>
</div>
{isFetchingNextPage ? (
<div
className="text-muted-foreground flex items-center justify-center gap-2 py-4 text-sm"
role="status"
>
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
{t("documents.timeline.loadingMore")}
</div>
) : null}
</div>
);
}
+664
View File
@@ -0,0 +1,664 @@
"use client";
/**
* The Dokumente vault — one wide, calm, filing-cabinet-fast surface.
*
* Filter state lives in the URL (`?q&kind&episode&year`) so every view is
* shareable, back-button-safe, and deep-linkable from the illness page and
* labs. Search debounces 200 ms and `/` focuses it. The timeline below is
* virtualized; uploads appear optimistically above it in < 100 ms.
*
* Born-gated on the resolved `modules.inboundDocuments` flag from
* `GET /api/auth/me` (per-user opt-in AND the operator availability layer).
* An unauthenticated visitor bounces to login; an account without the
* module bounces home. Every `/api/documents/inbound/*` route re-enforces
* the gate server-side — this is a UX redirect, not the security boundary.
*/
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
FolderOpen,
Loader2,
SearchX,
Upload,
UploadCloud,
} from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
import {
DOCUMENT_BULK_MAX_IDS,
type DocumentBulkAction,
type DocumentBulkResultDto,
type DocumentUsageDto,
type InboundDocumentDetailDto,
type InboundDocumentDto,
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DocumentBulkBar } from "./document-bulk-bar";
import { DocumentDetailSheet } from "./document-detail-sheet";
import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar";
import { DocumentTimeline } from "./document-timeline";
import { UploadZone } from "./upload-zone";
import { useDocumentUpload } from "./use-document-upload";
import { usePageFileDrop } from "./use-page-file-drop";
import {
buildVaultListApiSearch,
countActiveFilters,
documentDateKey,
expandRangeSelection,
parseVaultSearchParams,
vaultFiltersToSearch,
} from "./vault-utils";
interface ListPage {
documents: InboundDocumentDto[];
nextCursor: string | null;
}
export function DocumentsView() {
const { t } = useTranslations();
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const moduleEnabled = user?.modules?.inboundDocuments === true;
// UX redirect only — the API routes enforce the gate server-side.
useEffect(() => {
if (authLoading) return;
if (!isAuthenticated) {
router.push("/auth/login");
} else if (!moduleEnabled) {
router.push("/");
}
}, [authLoading, isAuthenticated, moduleEnabled, router]);
// ── URL-owned filter state ────────────────────────────────────────────
const filters = useMemo(
() => parseVaultSearchParams(new URLSearchParams(searchParams.toString())),
[searchParams],
);
const applyFilters = useCallback(
(next: DocumentVaultFilters, mode: "push" | "replace") => {
const search = vaultFiltersToSearch(next);
const href = search ? `${pathname}?${search}` : pathname;
if (mode === "push") {
router.push(href, { scroll: false });
} else {
router.replace(href, { scroll: false });
}
},
[pathname, router],
);
// Search draft debounces into the URL (200 ms); an external URL change
// (back button, deep link) re-seeds the draft — render-phase derived-state
// adjustment, not an effect.
const [searchDraft, setSearchDraft] = useState(filters.q ?? "");
const [lastUrlQ, setLastUrlQ] = useState(filters.q ?? "");
const searchInputRef = useRef<HTMLInputElement | null>(null);
if ((filters.q ?? "") !== lastUrlQ) {
setLastUrlQ(filters.q ?? "");
setSearchDraft(filters.q ?? "");
}
useEffect(() => {
const handle = setTimeout(() => {
const trimmed = searchDraft.trim();
if (trimmed === (filters.q ?? "")) return;
applyFilters(
{ ...filters, q: trimmed === "" ? undefined : trimmed },
"replace",
);
}, 200);
return () => clearTimeout(handle);
}, [searchDraft, filters, applyFilters]);
// `/` focuses the search from anywhere on the page.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey)
return;
const target = event.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable)
) {
return;
}
event.preventDefault();
searchInputRef.current?.focus();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const toggleKind = (kind: InboundDocumentKindValue) => {
const current = new Set(filters.kinds ?? []);
if (current.has(kind)) {
current.delete(kind);
} else {
current.add(kind);
}
applyFilters(
{
...filters,
kinds: current.size > 0 ? [...current].sort() : undefined,
},
"push",
);
};
const toggleEpisode = (episodeId: string) => {
applyFilters(
{
...filters,
episodeId: filters.episodeId === episodeId ? undefined : episodeId,
},
"push",
);
};
const toggleYear = (year: number) => {
applyFilters(
{ ...filters, year: filters.year === year ? undefined : year },
"push",
);
};
const clearFilters = () => {
setSearchDraft("");
applyFilters({}, "push");
};
// ── Data ──────────────────────────────────────────────────────────────
const usage = useQuery({
queryKey: queryKeys.inboundDocumentUsage(),
enabled: moduleEnabled,
queryFn: () => apiGet<DocumentUsageDto>("/api/documents/inbound/usage"),
});
const list = useInfiniteQuery({
queryKey: queryKeys.inboundDocumentList(filters),
enabled: moduleEnabled,
queryFn: ({ pageParam }) =>
apiGet<ListPage>(
`/api/documents/inbound?${buildVaultListApiSearch(filters, pageParam)}`,
),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const documents = useMemo(
() => list.data?.pages.flatMap((p) => p.documents) ?? [],
[list.data],
);
const upload = useDocumentUpload(
usage.data ? { maxFileBytes: usage.data.maxFileBytes } : undefined,
);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
// One intake path for every source (picker, zone drop, page-wide drop,
// clipboard paste): pre-links to the active episode filter and feeds the
// sr-only live region — the drop overlay itself is decorative.
const [uploadAnnouncement, setUploadAnnouncement] = useState("");
const enqueueRef = useRef(upload.enqueue);
useEffect(() => {
enqueueRef.current = upload.enqueue;
}, [upload.enqueue]);
const episodeIdFilter = filters.episodeId;
const enqueueFiles = useCallback(
(files: File[]) => {
if (files.length === 0) return;
enqueueRef.current(files, { episodeId: episodeIdFilter });
setUploadAnnouncement(
t("documents.upload.queuedAnnouncement", { count: files.length }),
);
},
[episodeIdFilter, t],
);
const { dropActive } = usePageFileDrop(enqueueFiles);
const episodes = useIllnessEpisodes(true);
// Condition chips: every episode carrying at least one live document
// link, served by the usage endpoint (NOT derived from the loaded corpus
// — an old linked document pages deep must still surface its chip), plus
// the actively filtered episode (a deep link must always show its own
// chip, even when its last link was just removed).
const conditionChips = useMemo<ConditionChip[]>(() => {
const byId = new Map<string, string>();
for (const link of usage.data?.linkedEpisodes ?? []) {
byId.set(link.episodeId, link.name);
}
if (filters.episodeId && !byId.has(filters.episodeId)) {
const episode = episodes.data?.find((e) => e.id === filters.episodeId);
byId.set(filters.episodeId, episode?.label ?? filters.episodeId);
}
return [...byId.entries()]
.map(([episodeId, name]) => ({ episodeId, name }))
.sort((a, b) => a.name.localeCompare(b.name));
}, [usage.data?.linkedEpisodes, filters.episodeId, episodes.data]);
// Year segmenter: years present in the loaded corpus (+ the active year).
const years = useMemo(() => {
const set = new Set<number>();
for (const doc of documents) {
set.add(Number(documentDateKey(doc).slice(0, 4)));
}
if (filters.year !== undefined) set.add(filters.year);
return [...set].sort((a, b) => b - a);
}, [documents, filters.year]);
// ── Selection + bulk actions ──────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(
new Set(),
);
// Anchor for shift-click ranges: the last plainly-toggled id.
const rangeAnchorRef = useRef<string | null>(null);
const toggleSelected = useCallback(
(id: string, range?: boolean) => {
setSelectedIds((prev) => {
if (range) {
return expandRangeSelection(
documents.map((d) => d.id),
prev,
rangeAnchorRef.current,
id,
);
}
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
if (!range) rangeAnchorRef.current = id;
},
[documents],
);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
rangeAnchorRef.current = null;
}, []);
// Escape clears the selection — unless a dialog/popover owns the key.
useEffect(() => {
if (selectedIds.size === 0) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
const target = event.target as HTMLElement | null;
if (target?.closest('[role="dialog"],[role="menu"]')) return;
clearSelection();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [selectedIds.size, clearSelection]);
/**
* One bulk POST per ≤100-id chunk (the endpoint's cap); per-id results
* are merged so a partial failure in any chunk surfaces once. The
* endpoint is no-op-success on already-in-state rows, so an undo toast
* firing twice is safe by contract.
*/
const bulk = useMutation({
mutationFn: async (input: {
ids: string[];
action: DocumentBulkAction;
kind?: InboundDocumentKindValue;
episodeId?: string;
}) => {
const results: DocumentBulkResultDto[] = [];
for (let i = 0; i < input.ids.length; i += DOCUMENT_BULK_MAX_IDS) {
const chunk = input.ids.slice(i, i + DOCUMENT_BULK_MAX_IDS);
const page = await apiPost<{ results: DocumentBulkResultDto[] }>(
"/api/documents/inbound/bulk",
{
ids: chunk,
action: input.action,
...(input.kind !== undefined && { kind: input.kind }),
...(input.episodeId !== undefined && {
episodeId: input.episodeId,
}),
},
);
results.push(...page.results);
}
return results;
},
onSettled: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
});
const reportBulkOutcome = useCallback(
(results: DocumentBulkResultDto[], successMessage: string) => {
const failed = results.filter((r) => !r.ok).length;
if (failed > 0) {
toast.error(
t("documents.bulk.partialFailure", {
failed,
total: results.length,
}),
);
} else {
toast.success(successMessage);
}
},
[t],
);
const runBulk = useCallback(
(
action: Exclude<DocumentBulkAction, "delete" | "restore">,
extra: { kind?: InboundDocumentKindValue; episodeId?: string },
) => {
const ids = [...selectedIds];
bulk.mutate(
{ ids, action, ...extra },
{
onSuccess: (results) => {
reportBulkOutcome(
results,
t("documents.bulk.updated", {
count: results.filter((r) => r.ok).length,
}),
);
clearSelection();
},
onError: () => toast.error(t("documents.bulk.failed")),
},
);
},
[selectedIds, bulk, reportBulkOutcome, clearSelection, t],
);
const restoreBulk = useCallback(
(ids: string[]) => {
bulk.mutate(
{ ids, action: "restore" },
{
onSuccess: (results) =>
reportBulkOutcome(
results,
t("documents.bulk.restored", {
count: results.filter((r) => r.ok).length,
}),
),
onError: () => toast.error(t("documents.toast.restoreFailed")),
},
);
},
[bulk, reportBulkOutcome, t],
);
const deleteBulk = useCallback(
(ids: string[]) => {
bulk.mutate(
{ ids, action: "delete" },
{
onSuccess: (results) => {
const okIds = results.filter((r) => r.ok).map((r) => r.id);
const failed = results.length - okIds.length;
if (failed > 0) {
toast.error(
t("documents.bulk.partialFailure", {
failed,
total: results.length,
}),
);
}
if (okIds.length > 0) {
// ONE aggregate undo for the whole batch (bulk restore).
toast.success(
t("documents.bulk.deleted", { count: okIds.length }),
{
action: {
label: t("common.undo"),
onClick: () => restoreBulk(okIds),
},
},
);
}
clearSelection();
},
onError: () => toast.error(t("documents.bulk.failed")),
},
);
},
[bulk, clearSelection, restoreBulk, t],
);
// ── Detail sheet ──────────────────────────────────────────────────────
const [detailId, setDetailId] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const openDetail = useCallback((id: string) => {
setDetailId(id);
setDetailOpen(true);
}, []);
// `?doc=<id>` deep link (used by the illness page's document rows): open
// the detail sheet once per value; closing the sheet strips the param so
// back/forward and refresh behave. Render-phase derived-state adjustment
// (the file's established pattern), consumed only once auth has resolved
// so a deep link never burns before the module flag is known. The id is
// shape-checked before it is interpolated into an API path.
const docParam = searchParams.get("doc");
const [consumedDocParam, setConsumedDocParam] = useState<string | null>(null);
if (!authLoading && moduleEnabled && docParam !== consumedDocParam) {
setConsumedDocParam(docParam);
if (docParam !== null && /^[a-zA-Z0-9_-]{1,40}$/.test(docParam)) {
setDetailId(docParam);
setDetailOpen(true);
}
}
const handleDetailOpenChange = useCallback(
(open: boolean) => {
setDetailOpen(open);
if (!open && searchParams.get("doc")) {
const sp = new URLSearchParams(searchParams.toString());
sp.delete("doc");
const search = sp.toString();
router.replace(search ? `${pathname}?${search}` : pathname, {
scroll: false,
});
}
},
[pathname, router, searchParams],
);
// Hover/focus intent prefetches the detail METADATA (never the blob —
// the blob fetch starts when the sheet mounts its preview element).
const prefetchDetail = useCallback(
(id: string) => {
void queryClient.prefetchQuery({
queryKey: queryKeys.inboundDocument(id),
queryFn: () =>
apiGet<InboundDocumentDetailDto>(`/api/documents/inbound/${id}`),
staleTime: 30_000,
});
},
[queryClient],
);
if (authLoading || !isAuthenticated || !moduleEnabled) {
return (
<div className="flex h-64 items-center justify-center" role="status">
<Loader2
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
aria-hidden
/>
<span className="sr-only">{t("nav.loadingScreen")}</span>
</div>
);
}
const activeCount = countActiveFilters(filters);
const isFiltered = activeCount > 0;
const showEmpty =
list.isSuccess && documents.length === 0 && upload.items.length === 0;
return (
<div className="space-y-6">
<PageHeader
title={t("documents.title")}
description={t("documents.subtitle")}
actions={
<Button onClick={() => uploadInputRef.current?.click()}>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
}
/>
<UploadZone
usage={usage.data}
inputRef={uploadInputRef}
onFiles={enqueueFiles}
/>
<DocumentFilterBar
searchValue={searchDraft}
onSearchChange={setSearchDraft}
searchInputRef={searchInputRef}
activeKinds={new Set(filters.kinds ?? [])}
onToggleKind={toggleKind}
conditionChips={conditionChips}
activeEpisodeId={filters.episodeId}
onToggleEpisode={toggleEpisode}
years={years}
activeYear={filters.year}
onToggleYear={toggleYear}
activeCount={activeCount}
onClearAll={clearFilters}
/>
{list.isPending ? (
<div
data-slot="documents-loading"
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
{Array.from({ length: 9 }, (_, i) => (
<Skeleton key={i} className="h-28 rounded-xl" />
))}
</div>
) : list.isError ? (
<QueryErrorCard
title={t("documents.list.loadError")}
onRetry={() => void list.refetch()}
/>
) : showEmpty && !isFiltered ? (
<EmptyState
icon={<FolderOpen className="size-6" aria-hidden />}
title={t("documents.empty.title")}
description={t("documents.empty.description")}
ctaSize="lg"
action={
<Button onClick={() => uploadInputRef.current?.click()}>
<Upload className="size-4" aria-hidden />
{t("documents.empty.action")}
</Button>
}
/>
) : showEmpty && isFiltered ? (
<EmptyState
icon={<SearchX className="size-6" aria-hidden />}
title={t("documents.empty.noMatchesTitle")}
description={t("documents.empty.noMatchesDescription")}
action={
<Button variant="outline" onClick={clearFilters}>
{t("documents.filter.clear")}
</Button>
}
/>
) : (
<DocumentTimeline
documents={documents}
uploadItems={upload.items}
onDismissUpload={upload.dismiss}
hasNextPage={list.hasNextPage}
isFetchingNextPage={list.isFetchingNextPage}
onLoadMore={() => void list.fetchNextPage()}
selectedIds={selectedIds}
onToggleSelected={toggleSelected}
onOpen={openDetail}
onDelete={(id) => deleteBulk([id])}
highlightId={upload.highlightId}
onPrefetch={prefetchDetail}
/>
)}
<DocumentDetailSheet
documentId={detailId}
open={detailOpen}
onOpenChange={handleDetailOpenChange}
/>
{selectedIds.size > 0 ? (
<DocumentBulkBar
selectedCount={selectedIds.size}
episodes={(episodes.data ?? []).map((e) => ({
id: e.id,
label: e.label,
}))}
busy={bulk.isPending}
onSetKind={(kind) => runBulk("setKind", { kind })}
onLinkEpisode={(episodeId) => runBulk("linkEpisode", { episodeId })}
onDelete={() => deleteBulk([...selectedIds])}
onClear={clearSelection}
/>
) : null}
{/* Page-wide drop overlay — pure decoration (aria-hidden); the intake
itself announces through the live region below. */}
{dropActive ? (
<div
aria-hidden
data-slot="document-drop-overlay"
className="bg-background/80 fixed inset-0 z-50 p-4 backdrop-blur-sm md:p-8"
>
<div className="border-primary bg-primary/5 flex h-full items-center justify-center rounded-xl border-2 border-dashed">
<div className="flex flex-col items-center gap-2 px-4 text-center">
<UploadCloud className="text-primary size-8" aria-hidden />
<p className="text-base font-medium">
{t("documents.dropOverlay.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("documents.dropOverlay.hint")}
</p>
</div>
</div>
</div>
) : null}
<p aria-live="polite" role="status" className="sr-only">
{uploadAnnouncement}
</p>
</div>
);
}
@@ -0,0 +1,179 @@
"use client";
/**
* The "Dokumente" section on an illness-episode detail page — the
* condition side of the condition ⇄ documents linking (the document side
* links back via the detail sheet's condition chips). Lists the episode's
* most recent linked documents as compact rows deep-linking straight into
* the vault (`?episode=…&doc=…` opens the detail sheet), plus the three
* affordances: link an existing document, upload pre-linked (deep link
* with the episode filter active — the vault threads it into the upload),
* and the filtered "show all" view.
*
* Renders nothing when the documents module is off for this account —
* module-gated surfaces never leak. An episode without documents shows a
* quiet one-line affordance, not a teaching empty state.
*/
import { useQuery } from "@tanstack/react-query";
import { FolderOpen, Link2, Upload } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { TileHeader } from "@/components/insights/tile-header";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ListRow } from "@/components/ui/list-row";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DocumentLinkPicker } from "./document-link-picker";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import { documentDateKey } from "./vault-utils";
/** Compact preview size — "Alle anzeigen" carries the long tail. */
const PREVIEW_LIMIT = 5;
interface ListPage {
documents: InboundDocumentDto[];
nextCursor: string | null;
}
export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
const { t } = useTranslations();
const format = useFormatters();
const { user } = useAuth();
const [pickerOpen, setPickerOpen] = useState(false);
const moduleEnabled = user?.modules?.inboundDocuments === true;
const list = useQuery({
queryKey: queryKeys.inboundDocumentEpisodePreview(episodeId),
enabled: moduleEnabled,
queryFn: () => {
const sp = new URLSearchParams();
sp.set("episodeId", episodeId);
sp.set("sort", "documentDate");
sp.set("order", "desc");
sp.set("limit", String(PREVIEW_LIMIT + 1));
return apiGet<ListPage>(`/api/documents/inbound?${sp.toString()}`);
},
});
if (!moduleEnabled) return null;
if (list.isError) {
return (
<QueryErrorCard
title={t("documents.episodeCard.loadError")}
onRetry={() => void list.refetch()}
/>
);
}
const documents = (list.data?.documents ?? []).slice(0, PREVIEW_LIMIT);
const hasMore =
(list.data?.documents.length ?? 0) > PREVIEW_LIMIT ||
list.data?.nextCursor != null;
const vaultHref = `/documents?episode=${encodeURIComponent(episodeId)}`;
return (
<>
<Card data-slot="episode-documents-card">
<CardContent className="space-y-4">
<TileHeader
icon={FolderOpen}
title={t("documents.episodeCard.title")}
right={
documents.length > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
{documents.length}
{hasMore ? "+" : ""}
</span>
) : undefined
}
/>
{list.isPending ? (
<div className="space-y-2">
{Array.from({ length: 2 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : documents.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("documents.episodeCard.emptyLine")}
</p>
) : (
<ul className="space-y-1.5">
{documents.map((doc) => {
const title =
doc.title ?? doc.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
return (
<li key={doc.id}>
<ListRow asChild>
<Link
href={`${vaultHref}&doc=${encodeURIComponent(doc.id)}`}
className="hover:bg-muted/50 focus-visible:ring-ring/50 flex items-center gap-3 focus-visible:ring-[3px] focus-visible:outline-none"
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{title}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
</span>
</Link>
</ListRow>
</li>
);
})}
</ul>
)}
<div className="flex flex-wrap items-center gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => setPickerOpen(true)}
>
<Link2 className="size-4" aria-hidden />
{t("documents.episodeCard.link")}
</Button>
<Button asChild variant="outline" size="sm">
<Link href={vaultHref}>
<Upload className="size-4" aria-hidden />
{t("documents.episodeCard.upload")}
</Link>
</Button>
{hasMore ? (
<Button
asChild
variant="ghost"
size="sm"
className="text-muted-foreground ml-auto"
>
<Link href={vaultHref}>
{t("documents.episodeCard.viewAll")}
</Link>
</Button>
) : null}
</div>
</CardContent>
</Card>
<DocumentLinkPicker
episodeId={episodeId}
open={pickerOpen}
onOpenChange={setPickerOpen}
/>
</>
);
}
File diff suppressed because it is too large. Load diff
-197
View File
@@ -1,197 +0,0 @@
/**
* v1.25 — pure helpers for the documents library (search-param building, date
* grouping, provider-unsupported classification). Kept out of the client
* component so they stay trivially unit-testable without a render harness.
*/
import { ApiError } from "@/lib/api/api-fetch";
import type { DocumentListParams } from "@/lib/query-keys/documents";
import {
DOCUMENT_LIST_DEFAULT_LIMIT,
type InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
/**
* Build the `/api/documents/inbound` query string from the active filters and
* a keyset cursor. Empty / undefined fields are omitted so the URL stays clean
* and the server applies its own defaults. The cursor is the `nextCursor` the
* previous page returned (null on the first page).
*/
export function buildDocumentListSearch(
params: DocumentListParams,
cursor: string | null,
limit: number = DOCUMENT_LIST_DEFAULT_LIMIT,
): string {
const sp = new URLSearchParams();
if (params.q && params.q.trim() !== "") sp.set("q", params.q.trim());
if (params.kind) sp.set("kind", params.kind);
if (params.from) sp.set("from", params.from);
if (params.to) sp.set("to", params.to);
sp.set("sort", params.sort);
sp.set("order", params.order);
sp.set("limit", String(limit));
if (cursor) sp.set("cursor", cursor);
return sp.toString();
}
/** The date a document files under — its user filing date, else its upload day. */
export function documentDateKey(doc: InboundDocumentDto): string {
if (doc.documentDate) return doc.documentDate;
// createdAt is an ISO timestamp; the leading YYYY-MM-DD is the upload day.
return doc.createdAt.slice(0, 10);
}
export interface DocumentDateGroup {
/** The YYYY-MM-DD key the group files under. */
key: string;
documents: InboundDocumentDto[];
}
/**
* Group an already-sorted document list into consecutive date buckets,
* preserving the server's sort order. A document's bucket is its `documentDate`
* (fallback: its upload day). The first time a date appears it opens a new
* group; later documents on the same date join it. Order is preserved exactly
* as received, so the server stays the single source of truth for sorting.
*/
export function groupDocumentsByDate(
documents: InboundDocumentDto[],
): DocumentDateGroup[] {
const groups: DocumentDateGroup[] = [];
let current: DocumentDateGroup | null = null;
for (const doc of documents) {
const key = documentDateKey(doc);
if (!current || current.key !== key) {
current = { key, documents: [doc] };
groups.push(current);
} else {
current.documents.push(doc);
}
}
return groups;
}
/**
* True when an error is the extract route's 422 "no document-scan provider
* configured" signal. The UI surfaces this as a calm inline note (configure a
* provider) rather than a hard error toast — the stored document is untouched.
*/
export function isProviderUnsupportedError(error: unknown): boolean {
return (
error instanceof ApiError &&
error.meta?.errorCode === "documents.inbound.providerUnsupported"
);
}
/**
* True when an error is the extract route's "already confirmed" signal. Two
* shapes reach here:
* - 409 `alreadyPartlyConfirmed` — some facts approved, the rest still pending;
* - 422 `alreadyConfirmed` — the whole document is CONFIRMED (a cross-tab race
* can fire Extract against a document a second tab just finished confirming).
* Re-extraction is refused server-side in both cases; the UI surfaces a calm
* inline note rather than a hard error toast, and hides the Extract control to
* match.
*/
export function isAlreadyConfirmedError(error: unknown): boolean {
if (!(error instanceof ApiError)) return false;
const code = error.meta?.errorCode;
return (
code === "documents.inbound.alreadyPartlyConfirmed" ||
code === "documents.inbound.alreadyConfirmed"
);
}
/**
* True when an extract failure is the route's "local OCR not enabled" signal
* (`documents.inbound.localOcrDisabled`, 422). The TEXT extract path refuses
* when in-browser OCR is switched off in settings; surface a clear message that
* names the setting rather than the generic "couldn't extract" toast.
*/
export function isLocalOcrDisabledError(error: unknown): boolean {
return (
error instanceof ApiError &&
error.meta?.errorCode === "documents.inbound.localOcrDisabled"
);
}
/**
* Map a per-fact commit-failure code to the i18n key for its plain-language
* reason. The confirm route returns HTTP 200 even when it rejects a fact into
* its `failed[]` array (e.g. a stated unit that disagrees with the saved
* marker), so the client must surface why — otherwise the fact silently stays
* PENDING with the user told nothing.
*/
export function commitFailureReasonKey(code: string): string {
switch (code) {
case "observation.unitMismatch":
return "documents.review.commitError.unitMismatch";
case "observation.unitRequired":
return "documents.review.commitError.unitRequired";
default:
return "documents.review.commitError.generic";
}
}
/**
* Pick the single reason key that best describes a batch of failed facts: the
* shared reason when every failure agrees, else a generic fallback. Used to
* build the partial-failure toast after a confirm that rejected one or more
* facts server-side.
*/
export function confirmFailureReasonKey(failed: { reason: string }[]): string {
const codes = new Set(failed.map((f) => f.reason));
if (codes.size === 1) return commitFailureReasonKey([...codes][0]);
return "documents.review.commitError.generic";
}
/**
* Client-side upload ceiling. Mirrors the server's `OCR_MAX_BYTES` (12 MB) so an
* oversized file is rejected before it is sent, instead of after a full upload
* round-trips and returns 413.
*/
export const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
/**
* The class of an upload (`POST /api/documents/inbound`) failure. The route
* reports precise, actionable codes the generic toast would throw away; this
* maps them to a stable kind so the view can show the right message and keep
* every `t()` key a literal call site.
*/
export type UploadErrorKind =
"tooLarge" | "fileType" | "rateLimited" | "invalidMetadata" | "generic";
/** Classify an upload failure by the route's `errorCode`, then HTTP status. */
export function classifyUploadError(error: unknown): UploadErrorKind {
if (!(error instanceof ApiError)) return "generic";
switch (error.meta?.errorCode) {
case "documents.inbound.fileTooLarge":
return "tooLarge";
case "documents.inbound.fileType":
return "fileType";
case "documents.inbound.rateLimited":
return "rateLimited";
case "documents.inbound.invalidMetadata":
return "invalidMetadata";
}
// A reverse proxy can strip the JSON body (and its `meta`) on some statuses;
// fall back to the HTTP code so size / type / rate-limit still classify.
if (error.status === 413) return "tooLarge";
if (error.status === 415) return "fileType";
if (error.status === 429) return "rateLimited";
return "generic";
}
/**
* Format a YYYY-MM-DD group key into a date label via the app's own date
* formatter (`useFormatters().date`), so the group headers follow the user's
* date-format preference (numeric) like the rest of the app instead of a textual
* `dateStyle: "medium"` month. The key is handed over as a noon-UTC instant so
* the calendar day never shifts under the display timezone.
*/
export function formatDateGroupLabel(
key: string,
formatDate: (value: string) => string,
): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) return key;
return formatDate(`${key}T12:00:00.000Z`);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
/**
* The vault's upload entry: a dashed drop-target card with a file button.
* The `accept` list comes verbatim from the usage endpoint (HEIC is
* deliberately absent so the iOS picker transcodes camera photos to JPEG),
* `multiple` is on, and there is NO `capture` attribute — mobile Safari
* then offers camera AND library. Dropping files onto the card feeds the
* same queue as the picker.
*
* The quota bar renders only above 80 % usage — a calm surface until
* storage actually becomes a topic.
*/
import { UploadCloud } from "lucide-react";
import { useRef, useState, type RefObject } from "react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
import { formatBytes } from "./vault-utils";
/** Usage fraction above which the quota bar appears. */
const QUOTA_BAR_THRESHOLD = 0.8;
export function UploadZone({
usage,
onFiles,
inputRef,
}: {
/** Limits + usage from `GET /api/documents/inbound/usage` (undefined while loading). */
usage: DocumentUsageDto | undefined;
onFiles: (files: File[]) => void;
/** Exposed so the page header's Upload button can open the same picker. */
inputRef: RefObject<HTMLInputElement | null>;
}) {
const { t, locale } = useTranslations();
const [dragOver, setDragOver] = useState(false);
const dragDepth = useRef(0);
const accept = usage?.acceptedExtensions.join(",");
const usedFraction =
usage && usage.quotaBytes > 0 ? usage.usedBytes / usage.quotaBytes : 0;
const pickFiles = (list: FileList | null) => {
if (!list || list.length === 0) return;
onFiles(Array.from(list));
};
return (
<div data-slot="document-upload-zone" className="space-y-2">
<div
onDragEnter={(e) => {
e.preventDefault();
dragDepth.current += 1;
setDragOver(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => {
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragOver(false);
}}
onDrop={(e) => {
e.preventDefault();
dragDepth.current = 0;
setDragOver(false);
pickFiles(e.dataTransfer.files);
}}
className={cn(
"border-border rounded-xl border-2 border-dashed px-4 py-6 text-center transition-colors",
dragOver && "border-primary bg-primary/5",
)}
>
<div className="flex flex-col items-center gap-2">
<UploadCloud
className={cn(
"text-muted-foreground size-6",
dragOver && "text-primary",
)}
aria-hidden
/>
<p className="text-sm font-medium">
{t("documents.upload.zoneTitle")}
</p>
{usage ? (
<p className="text-muted-foreground text-xs">
{t("documents.upload.zoneHint", {
maxSize: formatBytes(usage.maxFileBytes, locale),
})}
</p>
) : null}
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
onClick={() => inputRef.current?.click()}
>
{t("documents.upload.browse")}
</Button>
</div>
<input
ref={inputRef}
type="file"
multiple
accept={accept}
className="sr-only"
aria-label={t("documents.upload.browse")}
onChange={(e) => {
pickFiles(e.target.files);
// Allow re-selecting the same file (duplicate flow) immediately.
e.target.value = "";
}}
/>
</div>
{usage && usedFraction >= QUOTA_BAR_THRESHOLD ? (
<div data-slot="document-quota-bar" className="space-y-1">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">
{t("documents.upload.quotaAlmostFull")}
</p>
<p className="text-muted-foreground text-xs tabular-nums">
{t("documents.upload.quotaUsed", {
used: formatBytes(usage.usedBytes, locale),
quota: formatBytes(usage.quotaBytes, locale),
})}
</p>
</div>
<Progress
value={Math.min(100, usedFraction * 100)}
aria-label={t("documents.upload.quotaUsed", {
used: formatBytes(usage.usedBytes, locale),
quota: formatBytes(usage.quotaBytes, locale),
})}
/>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,282 @@
"use client";
/**
* The vault's upload manager: a client-side queue with concurrency 3, one
* multipart XHR POST per file (XHR because it exposes real upload-progress
* events with zero dependencies — fetch upload streaming is not portable),
* client-side image downscaling, and per-file terminal states.
*
* Perceived-speed contract: `enqueue()` adds pending entries synchronously
* (< 100 ms — before any network work), each carrying live progress. A
* finished file leaves the queue and the list/usage queries refetch; a
* failed file stays as an inline error card until dismissed (an over-limit
* file fails individually, the batch continues).
*
* Duplicate contract (§3.2): a same-user re-upload returns HTTP 200 with
* envelope-level `meta.duplicate: true` and the EXISTING row — the manager
* toasts "already stored" and hands the row id to the timeline for a brief
* highlight. This is the one call site that must read success-side `meta`;
* `parseUploadResponse` in `vault-utils.ts` owns that parse.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import {
parseUploadResponse,
type UploadFailure,
type UploadResult,
} from "./vault-utils";
/** Client-side parallelism — matches the plan's NAS memory envelope. */
const CONCURRENCY = 3;
/** Longest-edge ceiling for the client-side image downscale. */
const IMAGE_MAX_EDGE = 3000;
/** How long a duplicate-highlight ring stays on the existing row. */
const HIGHLIGHT_MS = 2600;
/** Only raster photo formats are re-encoded — never PDFs/Office, never GIF
* (re-encoding would flatten an animation and GIF photos don't exist). */
const DOWNSCALE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
export interface UploadQueueItem {
/** Local id — NOT a server id; stable for the item's queue lifetime. */
localId: string;
fileName: string;
byteSize: number;
status: "uploading" | "error";
/** 0..1 upload progress (transport bytes, not server processing). */
progress: number;
/** Terminal failure detail, present when `status === "error"`. */
failure?: UploadFailure;
}
export interface EnqueueOptions {
/** Pre-link every uploaded document to this episode (deep-link uploads). */
episodeId?: string;
}
interface UploadLimits {
maxFileBytes: number;
}
/**
* Downscale a raster photo to ≤ 3000 px longest edge (canvas, JPEG 0.85).
* Returns the original file untouched when the type is not a raster photo,
* the image is already small enough, or anything about decoding fails —
* a downscale must never block an upload.
*/
async function downscaleImage(file: File): Promise<File> {
if (!DOWNSCALE_TYPES.has(file.type)) return file;
try {
const bitmap = await createImageBitmap(file);
const longest = Math.max(bitmap.width, bitmap.height);
if (longest <= IMAGE_MAX_EDGE) {
bitmap.close();
return file;
}
const scale = IMAGE_MAX_EDGE / longest;
const width = Math.round(bitmap.width * scale);
const height = Math.round(bitmap.height * scale);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
bitmap.close();
return file;
}
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/jpeg", 0.85),
);
if (!blob) return file;
const stem = file.name.replace(/\.[^.]+$/u, "") || "photo";
return new File([blob], `${stem}.jpg`, { type: "image/jpeg" });
} catch {
return file;
}
}
/** One multipart POST via XHR, reporting upload progress. */
function uploadViaXhr(
file: File,
options: EnqueueOptions,
onProgress: (fraction: number) => void,
): Promise<UploadResult> {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/documents/inbound");
// Retry-safe by construction: the server honours Idempotency-Key, so a
// network blip that DID land server-side cannot double-store on retry.
xhr.setRequestHeader("Idempotency-Key", crypto.randomUUID());
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress(event.loaded / event.total);
}
});
xhr.addEventListener("load", () => {
resolve(parseUploadResponse(xhr.status, xhr.responseText));
});
xhr.addEventListener("error", () =>
resolve({ ok: false, reason: "generic" }),
);
xhr.addEventListener("abort", () =>
resolve({ ok: false, reason: "generic" }),
);
const fd = new FormData();
fd.append("file", file);
if (options.episodeId) fd.append("episodeIds", options.episodeId);
xhr.send(fd);
});
}
export interface DocumentUploadManager {
/** Live queue — uploading entries first-in-first, then error entries. */
items: UploadQueueItem[];
/** Add files to the queue; returns immediately (optimistic entries). */
enqueue: (files: File[], options?: EnqueueOptions) => void;
/** Remove a terminal (error) entry from the queue. */
dismiss: (localId: string) => void;
/** Row id to briefly highlight (duplicate upload → the existing row). */
highlightId: string | null;
}
export function useDocumentUpload(
limits: UploadLimits | undefined,
): DocumentUploadManager {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [items, setItems] = useState<UploadQueueItem[]>([]);
const [highlightId, setHighlightId] = useState<string | null>(null);
const activeRef = useRef(0);
const pendingRef = useRef<
{ localId: string; file: File; options: EnqueueOptions }[]
>([]);
const mountedRef = useRef(true);
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (highlightTimer.current) clearTimeout(highlightTimer.current);
};
}, []);
const patchItem = useCallback(
(localId: string, patch: Partial<UploadQueueItem>) => {
if (!mountedRef.current) return;
setItems((prev) =>
prev.map((it) => (it.localId === localId ? { ...it, ...patch } : it)),
);
},
[],
);
const removeItem = useCallback((localId: string) => {
if (!mountedRef.current) return;
setItems((prev) => prev.filter((it) => it.localId !== localId));
}, []);
const flashHighlight = useCallback((id: string) => {
if (!mountedRef.current) return;
setHighlightId(id);
if (highlightTimer.current) clearTimeout(highlightTimer.current);
highlightTimer.current = setTimeout(() => {
if (mountedRef.current) setHighlightId(null);
}, HIGHLIGHT_MS);
}, []);
const settle = useCallback(
(localId: string, result: UploadResult) => {
if (result.ok) {
removeItem(localId);
void invalidateKeys(queryClient, [queryKeys.documents()]);
if (result.duplicate) {
toast.info(t("documents.toast.duplicate"));
flashHighlight(result.document.id);
}
return;
}
patchItem(localId, { status: "error", failure: result, progress: 0 });
},
[flashHighlight, patchItem, queryClient, removeItem, t],
);
const pump = useCallback(() => {
while (
activeRef.current < CONCURRENCY &&
pendingRef.current.length > 0 &&
mountedRef.current
) {
const next = pendingRef.current.shift();
if (!next) return;
activeRef.current += 1;
void (async () => {
try {
const prepared = await downscaleImage(next.file);
const result = await uploadViaXhr(prepared, next.options, (f) =>
patchItem(next.localId, { progress: f }),
);
settle(next.localId, result);
} finally {
activeRef.current -= 1;
pump();
}
})();
}
}, [patchItem, settle]);
const enqueue = useCallback(
(files: File[], options: EnqueueOptions = {}) => {
if (files.length === 0) return;
const fresh: UploadQueueItem[] = [];
for (const file of files) {
const localId = crypto.randomUUID();
// Client pre-flight against the server's configured cap: an obvious
// giant (a CD image) fails instantly and locally — friendly reason,
// no wasted transfer. The server re-enforces regardless.
if (limits && file.size > limits.maxFileBytes) {
fresh.push({
localId,
fileName: file.name,
byteSize: file.size,
status: "error",
progress: 0,
failure: {
ok: false,
reason: "fileTooLarge",
maxFileBytes: limits.maxFileBytes,
},
});
continue;
}
fresh.push({
localId,
fileName: file.name,
byteSize: file.size,
status: "uploading",
progress: 0,
});
pendingRef.current.push({ localId, file, options });
}
setItems((prev) => [...prev, ...fresh]);
pump();
},
[limits, pump],
);
const dismiss = useCallback(
(localId: string) => removeItem(localId),
[removeItem],
);
return { items, enqueue, dismiss, highlightId };
}
@@ -0,0 +1,93 @@
"use client";
/**
* Page-wide file intake for the vault: dragging files anywhere over the
* window raises a full-page drop overlay (the whole page is the target —
* not just the upload zone card), and pasting files/screenshots from the
* clipboard feeds the same queue. Window-level listeners, mounted only
* while `/documents` is on screen.
*
* Details that matter:
* - Only FILE drags react (`dataTransfer.types` includes `"Files"`) — text
* selections and in-page drags never raise the overlay.
* - `dragenter`/`dragleave` fire per element; a depth counter tells "left
* the window" apart from "moved between children".
* - `dragover` must be prevented while a file drag is over the window or
* the browser navigates to the dropped file on a miss.
* - Escape dismisses the overlay (the OS drag itself cannot be cancelled
* from the page; a subsequent drop then falls through to the browser
* default outside the page contract).
* - Paste only intercepts when the clipboard actually carries files, so
* pasting text into the search input stays untouched.
*/
import { useEffect, useRef, useState } from "react";
export function usePageFileDrop(onFiles: (files: File[]) => void): {
dropActive: boolean;
} {
const [dropActive, setDropActive] = useState(false);
const depthRef = useRef(0);
const onFilesRef = useRef(onFiles);
useEffect(() => {
onFilesRef.current = onFiles;
}, [onFiles]);
useEffect(() => {
const isFileDrag = (event: DragEvent) =>
event.dataTransfer?.types?.includes("Files") ?? false;
const onDragEnter = (event: DragEvent) => {
if (!isFileDrag(event)) return;
event.preventDefault();
depthRef.current += 1;
setDropActive(true);
};
const onDragOver = (event: DragEvent) => {
if (!isFileDrag(event)) return;
// Without this the browser handles the drop itself (navigates away).
event.preventDefault();
};
const onDragLeave = (event: DragEvent) => {
if (!isFileDrag(event)) return;
depthRef.current = Math.max(0, depthRef.current - 1);
if (depthRef.current === 0) setDropActive(false);
};
const onDrop = (event: DragEvent) => {
if (!isFileDrag(event)) return;
event.preventDefault();
depthRef.current = 0;
setDropActive(false);
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length > 0) onFilesRef.current(files);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || depthRef.current === 0) return;
depthRef.current = 0;
setDropActive(false);
};
const onPaste = (event: ClipboardEvent) => {
const files = Array.from(event.clipboardData?.files ?? []);
if (files.length === 0) return;
event.preventDefault();
onFilesRef.current(files);
};
window.addEventListener("dragenter", onDragEnter);
window.addEventListener("dragover", onDragOver);
window.addEventListener("dragleave", onDragLeave);
window.addEventListener("drop", onDrop);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("paste", onPaste);
return () => {
window.removeEventListener("dragenter", onDragEnter);
window.removeEventListener("dragover", onDragOver);
window.removeEventListener("dragleave", onDragLeave);
window.removeEventListener("drop", onDrop);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("paste", onPaste);
};
}, []);
return { dropActive };
}
+339
View File
@@ -0,0 +1,339 @@
/**
* Pure helpers for the Dokumente vault surface: URL-facet parsing and
* serialisation (the page's filter state lives in the URL so every view is
* deep-linkable and back-button-safe), the API query-string builder, the
* month bucketing + row chunking the virtualized timeline renders from, the
* upload-response contract (including the HTTP-200 `meta.duplicate` path),
* and byte formatting. Kept out of the client components so they stay
* trivially unit-testable without a render harness.
*/
import {
DOCUMENT_LIST_DEFAULT_LIMIT,
INBOUND_DOCUMENT_KINDS,
type InboundDocumentDto,
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
const KIND_SET = new Set<string>(INBOUND_DOCUMENT_KINDS);
/**
* Parse the vault's URL search params (`?q&kind&episode&year`) into the
* filter object. Unknown kinds are dropped (a hand-edited URL never 422s the
* page), `kind` accepts repeats AND comma-separated values, and the result is
* normalised (kinds sorted, empties omitted) so the same view always produces
* the same TanStack cache key.
*/
export function parseVaultSearchParams(
params: URLSearchParams,
): DocumentVaultFilters {
const filters: DocumentVaultFilters = {};
const q = params.get("q")?.trim();
if (q) filters.q = q.slice(0, 100);
const kinds = params
.getAll("kind")
.flatMap((v) => v.split(","))
.map((v) => v.trim())
.filter((v): v is InboundDocumentKindValue => KIND_SET.has(v));
if (kinds.length > 0) filters.kinds = [...new Set(kinds)].sort();
const episode = params.get("episode")?.trim();
if (episode) filters.episodeId = episode.slice(0, 40);
const yearRaw = params.get("year");
if (yearRaw && /^\d{4}$/.test(yearRaw)) {
const year = Number(yearRaw);
if (year >= 1900 && year <= 9999) filters.year = year;
}
return filters;
}
/**
* Serialise the filter object back into the page URL's search string (no
* leading `?`; empty string for the default view). Inverse of
* `parseVaultSearchParams` — round-tripping is pinned by test so deep links
* from the illness page / labs (`?episode=`, `?kind=LAB_RESULT`) stay stable.
*/
export function vaultFiltersToSearch(filters: DocumentVaultFilters): string {
const sp = new URLSearchParams();
if (filters.q) sp.set("q", filters.q);
if (filters.kinds && filters.kinds.length > 0) {
sp.set("kind", [...filters.kinds].sort().join(","));
}
if (filters.episodeId) sp.set("episode", filters.episodeId);
if (filters.year !== undefined) sp.set("year", String(filters.year));
return sp.toString();
}
/** Count of active facets (search counts as one) for the clear-all pill. */
export function countActiveFilters(filters: DocumentVaultFilters): number {
let count = 0;
if (filters.q) count += 1;
if (filters.kinds && filters.kinds.length > 0) count += filters.kinds.length;
if (filters.episodeId) count += 1;
if (filters.year !== undefined) count += 1;
return count;
}
/**
* Build the `/api/documents/inbound` query string from the active facets and
* a keyset cursor. The sort is pinned to the filing date, newest first — the
* timeline IS the sort order. The API takes `episodeId` (the page URL uses
* the shorter `episode`).
*/
export function buildVaultListApiSearch(
filters: DocumentVaultFilters,
cursor: string | null,
limit: number = DOCUMENT_LIST_DEFAULT_LIMIT,
): string {
const sp = new URLSearchParams();
if (filters.q) sp.set("q", filters.q);
if (filters.kinds && filters.kinds.length > 0) {
sp.set("kind", filters.kinds.join(","));
}
if (filters.episodeId) sp.set("episodeId", filters.episodeId);
if (filters.year !== undefined) sp.set("year", String(filters.year));
sp.set("sort", "documentDate");
sp.set("order", "desc");
sp.set("limit", String(limit));
if (cursor) sp.set("cursor", cursor);
return sp.toString();
}
/** The date a document files under — its user filing date, else upload day. */
export function documentDateKey(doc: InboundDocumentDto): string {
if (doc.documentDate) return doc.documentDate;
// createdAt is an ISO timestamp; the leading YYYY-MM-DD is the upload day.
return doc.createdAt.slice(0, 10);
}
/** The YYYY-MM month bucket a document files under. */
export function documentMonthKey(doc: InboundDocumentDto): string {
return documentDateKey(doc).slice(0, 7);
}
/**
* One flat item in the virtualized timeline: a month section label or one
* grid row of up to `columns` documents. The virtualizer windows over this
* flat list so the mounted DOM stays bounded regardless of corpus size.
*/
export type TimelineItem =
| { type: "month"; key: string }
| { type: "row"; key: string; documents: InboundDocumentDto[] };
/**
* Bucket an already-sorted document list into consecutive YYYY-MM sections
* and chunk each section into rows of `columns` documents. Order is preserved
* exactly as received — the server stays the single source of truth for
* sorting; a month opens the first time its key appears.
*/
export function buildTimelineItems(
documents: InboundDocumentDto[],
columns: number,
): TimelineItem[] {
const cols = Math.max(1, columns);
const items: TimelineItem[] = [];
let month: string | null = null;
let row: InboundDocumentDto[] = [];
const flushRow = () => {
if (row.length > 0 && month !== null) {
items.push({ type: "row", key: `${month}:${row[0].id}`, documents: row });
row = [];
}
};
for (const doc of documents) {
const key = documentMonthKey(doc);
if (key !== month) {
flushRow();
month = key;
items.push({ type: "month", key });
}
row.push(doc);
if (row.length === cols) flushRow();
}
flushRow();
return items;
}
/**
* Format a YYYY-MM month key as a locale month label ("März 2026"). Handed
* over as a noon-UTC instant so the month never shifts under the display
* timezone.
*/
export function formatMonthLabel(key: string, locale: string): string {
if (!/^\d{4}-\d{2}$/.test(key)) return key;
return new Intl.DateTimeFormat(locale, {
month: "long",
year: "numeric",
timeZone: "UTC",
}).format(new Date(`${key}-15T12:00:00.000Z`));
}
/** Locale byte formatting: 0 B / 412 KB / 1.2 MB / 2.4 GB. */
export function formatBytes(bytes: number, locale: string): string {
const units = ["B", "KB", "MB", "GB"] as const;
let value = Math.max(0, bytes);
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
const digits = unit === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 1;
return `${new Intl.NumberFormat(locale, {
maximumFractionDigits: digits,
}).format(value)} ${units[unit]}`;
}
/**
* Shift-click range selection over the timeline's visual order. Selects
* every id between the last plainly-toggled anchor and the target
* (inclusive) — ADDITIVE, matching file-manager convention (a range gesture
* never deselects). Falls back to a plain toggle when the anchor is gone
* (filtered away, deleted) or was never set.
*/
export function expandRangeSelection(
orderedIds: readonly string[],
selected: ReadonlySet<string>,
anchorId: string | null,
targetId: string,
): Set<string> {
const next = new Set(selected);
const anchorIndex = anchorId === null ? -1 : orderedIds.indexOf(anchorId);
const targetIndex = orderedIds.indexOf(targetId);
if (anchorIndex === -1 || targetIndex === -1) {
if (next.has(targetId)) {
next.delete(targetId);
} else {
next.add(targetId);
}
return next;
}
const [from, to] =
anchorIndex <= targetIndex
? [anchorIndex, targetIndex]
: [targetIndex, anchorIndex];
for (let i = from; i <= to; i += 1) {
next.add(orderedIds[i]);
}
return next;
}
// ─── Upload response contract (§3.2) ───────────────────────────────────────
/**
* The §3.2 failure reasons the upload/restore paths translate client-side,
* plus the transport-level fallbacks. `rateLimited` maps 429; `generic`
* covers everything unclassifiable (proxy-stripped bodies included).
*/
export type UploadFailureReason =
| "fileTooLarge"
| "quotaExceeded"
| "unsupportedType"
| "purged"
| "duplicateExists"
| "rateLimited"
| "generic";
export interface UploadFailure {
ok: false;
reason: UploadFailureReason;
/** §3.2 — present on `fileTooLarge`. */
maxFileBytes?: number;
/** §3.2 — present on `quotaExceeded`. */
quotaBytes?: number;
usedBytes?: number;
/** §3.2 — present on `duplicateExists` (restore-conflict shape). */
existingId?: string;
}
export interface UploadSuccess {
ok: true;
document: InboundDocumentDto;
/**
* §3.2 — a same-user re-upload is HTTP 200 with envelope-level
* `meta.duplicate: true` and the EXISTING live row as `data`. Not an
* error: the UI toasts "already stored" and highlights that row.
*/
duplicate: boolean;
}
export type UploadResult = UploadSuccess | UploadFailure;
const REASONS: ReadonlySet<string> = new Set([
"fileTooLarge",
"quotaExceeded",
"unsupportedType",
"purged",
"duplicateExists",
]);
function numberOrUndefined(v: unknown): number | undefined {
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
}
/** Map a `{ status, meta }` failure onto the translated-reason union. */
export function classifyUploadFailure(
status: number,
meta: Record<string, unknown> | undefined,
): UploadFailure {
const reason = typeof meta?.reason === "string" ? meta.reason : "";
if (REASONS.has(reason)) {
return {
ok: false,
reason: reason as UploadFailureReason,
maxFileBytes: numberOrUndefined(meta?.maxFileBytes),
quotaBytes: numberOrUndefined(meta?.quotaBytes),
usedBytes: numberOrUndefined(meta?.usedBytes),
existingId:
typeof meta?.existingId === "string" ? meta.existingId : undefined,
};
}
// A reverse proxy can strip the JSON body (and its `meta`); fall back to
// the HTTP status so size / type / rate-limit still classify.
if (status === 413) return { ok: false, reason: "fileTooLarge" };
if (status === 415) return { ok: false, reason: "unsupportedType" };
if (status === 429) return { ok: false, reason: "rateLimited" };
return { ok: false, reason: "generic" };
}
/**
* Parse a completed upload's raw response (status + body text) against the
* envelope contract. This is the ONE place the duplicate contract lives:
* unlike every other read (which unwraps `data` and ignores `meta`), the
* upload MUST also read the success envelope's `meta.duplicate` flag.
*/
export function parseUploadResponse(
status: number,
bodyText: string,
): UploadResult {
let envelope: {
data?: unknown;
meta?: Record<string, unknown>;
} | null = null;
try {
const parsed: unknown = JSON.parse(bodyText);
if (parsed && typeof parsed === "object") {
envelope = parsed as { data?: unknown; meta?: Record<string, unknown> };
}
} catch {
envelope = null;
}
if (status >= 200 && status < 300) {
const doc = envelope?.data;
if (doc && typeof doc === "object" && "id" in doc) {
return {
ok: true,
document: doc as InboundDocumentDto,
duplicate: envelope?.meta?.duplicate === true,
};
}
return { ok: false, reason: "generic" };
}
return classifyUploadFailure(status, envelope?.meta);
}
@@ -10,6 +10,7 @@
*/
import { useState } from "react";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -143,6 +144,11 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
<IllnessCorrelationCard episodeId={episodeId} />
)}
{/* Condition ⇄ documents linking, condition side: the episode's
linked documents plus link/upload entries into the vault. Renders
nothing when the documents module is off for this account. */}
{episode ? <EpisodeDocumentsCard episodeId={episode.id} /> : null}
{episode ? (
<>
<LogDaySheet
+17 -1
View File
@@ -36,6 +36,13 @@ const PUBLIC_PATHS = [
"/c/",
];
// Routes that opt into the wide content container (`max-w-screen-2xl`
// instead of the standard `max-w-screen-xl`). The shell stays the single
// owner of the page frame — a wide page never re-implements paddings or
// margins; it is listed here instead. Currently: the Dokumente vault,
// whose multi-column document timeline earns the extra width.
const WIDE_CONTENT_PATHS = ["/documents"];
export function AuthShell({
children,
demoMode = false,
@@ -69,6 +76,9 @@ export function AuthShell({
pathname.startsWith("/about") ||
pathname.startsWith("/c/");
const isAdminPage = pathname.startsWith("/admin");
const isWidePage = WIDE_CONTENT_PATHS.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
const isOnboardingPage = pathname === "/onboarding";
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
@@ -333,7 +343,13 @@ export function AuthShell({
the desktop bottom-6 anchor), so the last line of content
can always scroll out from under the floating button.
*/}
<div className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6">
<div
className={
isWidePage
? "mx-auto max-w-screen-2xl px-4 pt-6 pb-20 md:px-6"
: "mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
}
>
{children}
</div>
</main>
@@ -20,8 +20,15 @@
* compact instead of as a long always-expanded checklist.
*/
import Link from "next/link";
import { useId, useState } from "react";
import { ChevronDown, Download, FileText, Loader2 } from "lucide-react";
import {
ChevronDown,
Download,
FileText,
FolderOpen,
Loader2,
} from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -32,6 +39,7 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -117,6 +125,7 @@ function buildSelectionSections(s: SectionState) {
export function HealthRecordExportPanel() {
const { t, locale } = useTranslations();
const { user } = useAuth();
const [format, setFormat] = useState<ExportFormat>("pdf");
const [days, setDays] = useState<number>(90);
const [practiceName, setPracticeName] = useState("");
@@ -250,6 +259,20 @@ export function HealthRecordExportPanel() {
<option value="180">{t("settings.healthRecord.range180")}</option>
<option value="365">{t("settings.healthRecord.range365")}</option>
</NativeSelect>
{/* Entry point into the document vault for the report period
(navigation only — attaching documents to the PDF is a later
phase). Year granularity is what the vault's URL filters
offer; the current year covers the bulk of any range. Only
rendered when the documents module is enabled. */}
{user?.modules?.inboundDocuments ? (
<Link
href={`/documents?year=${new Date().getFullYear()}`}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<FolderOpen className="size-3.5" aria-hidden />
{t("settings.healthRecord.documentsLink")}
</Link>
) : null}
</div>
</div>
Binary file not shown.
+99
View File
@@ -264,6 +264,105 @@ export function reencryptToActive(encoded: string): string {
return encrypt(decrypt(encoded));
}
// ─── Binary codec ("binary2") ────────────────────────────────────────────────
//
// AES-256-GCM over raw bytes, skipping the base64 detour the string codec
// takes (−33 % at rest, one fewer full copy in memory). Written for large
// binary payloads (the document vault's `contentEncrypted`); the consumer
// records which codec a row uses in an explicit column — the layout is never
// header-sniffed against the string codec.
//
// Layout: [version 0x02][keyIdLen u8][keyId ascii][iv 12][tag 16][ciphertext]
//
// Same versioned-key discipline as the string codec: encrypt always writes
// the active key id, decrypt is fail-closed on an unknown version, a
// malformed header, or an unconfigured key id.
/** Version byte identifying the binary2 layout. */
const BYTES_CODEC_VERSION = 0x02;
/** Encrypt raw bytes with the active key into the binary2 layout. */
export function encryptBytes(plaintext: Buffer): Buffer {
const { id, key } = getActiveKey();
const keyId = Buffer.from(id, "ascii");
if (keyId.byteLength < 1 || keyId.byteLength > 32) {
// loadKeys() already constrains ids to [A-Za-z0-9_-]{1,32}; belt-and-braces.
throw new Error(`Encryption key id '${id}' is not header-encodable`);
}
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
const header = Buffer.from([BYTES_CODEC_VERSION, keyId.byteLength]);
return Buffer.concat([header, keyId, iv, tag, ct]);
}
/** Parse a binary2 header. Throws on any malformation (fail-closed). */
function parseBytesHeader(payload: Buffer): {
keyId: string;
iv: Buffer;
tag: Buffer;
ct: Buffer;
} {
if (payload.byteLength < 2) {
throw new Error("binary2 payload is truncated (no header)");
}
if (payload[0] !== BYTES_CODEC_VERSION) {
throw new Error(
`Unknown binary ciphertext version ${payload[0]} (expected ${BYTES_CODEC_VERSION})`,
);
}
const keyIdLen = payload[1];
if (keyIdLen < 1 || keyIdLen > 32) {
throw new Error("binary2 payload has an invalid key-id length");
}
const fixed = 2 + keyIdLen + IV_LENGTH + AUTH_TAG_LENGTH;
if (payload.byteLength < fixed) {
throw new Error("binary2 payload is truncated");
}
const keyId = payload.subarray(2, 2 + keyIdLen).toString("ascii");
if (!/^[A-Za-z0-9_-]{1,32}$/.test(keyId)) {
throw new Error("binary2 payload carries a malformed key id");
}
const iv = payload.subarray(2 + keyIdLen, 2 + keyIdLen + IV_LENGTH);
const tag = payload.subarray(2 + keyIdLen + IV_LENGTH, fixed);
const ct = payload.subarray(fixed);
return { keyId, iv, tag, ct };
}
/**
* Decrypt a binary2 payload back to its raw bytes. Fail-closed: an unknown
* version, a malformed header, or an unconfigured key id throws — the caller
* must treat a throw as "cannot serve", never fall back to the ciphertext.
*/
export function decryptBytes(payload: Buffer): Buffer {
const { keyId, iv, tag, ct } = parseBytesHeader(payload);
const key = getKeyById(keyId);
if (!key) {
throw new Error(
`Encryption key id '${keyId}' is not configured. Add it to ` +
`ENCRYPTION_KEYS before decrypting rows written under that key.`,
);
}
const dec = createDecipheriv(ALGORITHM, key, iv);
dec.setAuthTag(tag);
return Buffer.concat([dec.update(ct), dec.final()]);
}
/** The key id a binary2 payload was written under, or null when unparsable. */
export function extractKeyIdFromBytes(payload: Buffer): string | null {
try {
return parseBytesHeader(payload).keyId;
} catch {
return null;
}
}
/** Re-encrypt a binary2 payload with the active key. Used by rotation. */
export function reencryptBytesToActive(payload: Buffer): Buffer {
return encryptBytes(decryptBytes(payload));
}
/** Returns the key id portion of a versioned ciphertext, or null for legacy. */
export function extractKeyId(encoded: string): string | null {
const dot = encoded.indexOf(".");
@@ -0,0 +1,276 @@
import { describe, it, expect, beforeEach } from "vitest";
import { Buffer } from "node:buffer";
import {
encrypt,
encryptBytes,
extractKeyIdFromBytes,
getActiveKeyId,
_resetCryptoCacheForTests,
} from "@/lib/crypto";
import {
rotateColumn,
scanColumn,
BLOB_ROTATION_BATCH_SIZE,
type CorpusClient,
} from "@/lib/crypto/encryption-corpus";
import type { EncryptedColumn } from "@/lib/crypto/encrypted-columns";
/**
* Codec-dispatched blob rotation (the document vault's `contentEncrypted`):
* the walk is id-cursor paginated with a bounded batch size — never an
* unbounded blob `findMany` — re-encrypts each row under its OWN codec,
* fails closed per row on an unknown codec, and resumes cleanly after an
* interrupted run because already-active rows are skipped.
*/
const KEY_V1 =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const KEY_V2 =
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
const DOC_COLUMN: EncryptedColumn = {
model: "InboundDocument",
field: "contentEncrypted",
kind: "bytes",
codecField: "contentCodec",
};
interface DocRow {
id: string;
contentEncrypted: Uint8Array;
contentCodec: string;
}
interface FindManyArgs {
select: Record<string, true>;
orderBy?: Record<string, "asc" | "desc">;
take?: number;
cursor?: Record<string, unknown>;
skip?: number;
}
/**
* Pagination-honouring fake delegate: applies orderBy(id) + cursor + skip +
* take exactly like Prisma keyset pagination, and records every findMany
* call so the test can assert the batch boundedness.
*/
function makeDocClient(rows: DocRow[]): {
client: CorpusClient;
store: DocRow[];
findManyCalls: FindManyArgs[];
failUpdatesFor: Set<string>;
} {
const store = [...rows];
const findManyCalls: FindManyArgs[] = [];
const failUpdatesFor = new Set<string>();
const client = {
inboundDocument: {
findMany: async (args: FindManyArgs) => {
findManyCalls.push(args);
let list = [...store].sort((a, b) => a.id.localeCompare(b.id));
if (args.cursor) {
const idx = list.findIndex((r) => r.id === args.cursor!.id);
list = list.slice(idx + (args.skip ?? 0));
}
if (args.take !== undefined) list = list.slice(0, args.take);
return list.map((row) => {
const out: Record<string, unknown> = {};
for (const f of Object.keys(args.select)) {
out[f] = row[f as keyof DocRow];
}
return out;
});
},
update: async ({
where,
data,
}: {
where: { id: string };
data: Record<string, unknown>;
}) => {
if (failUpdatesFor.has(where.id)) {
throw new Error("simulated interruption");
}
const row = store.find((r) => r.id === where.id);
if (row) Object.assign(row, data);
return row;
},
},
} as unknown as CorpusClient;
return { client, store, findManyCalls, failUpdatesFor };
}
function binaryUnder(keyId: "v1" | "v2", plaintext: Buffer): Uint8Array {
process.env.ENCRYPTION_ACTIVE_KEY_ID = keyId;
_resetCryptoCacheForTests();
const out = encryptBytes(plaintext);
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2";
_resetCryptoCacheForTests();
return new Uint8Array(out);
}
function legacyUnder(keyId: "v1" | "v2", plaintext: string): Uint8Array {
process.env.ENCRYPTION_ACTIVE_KEY_ID = keyId;
_resetCryptoCacheForTests();
const ct = encrypt(Buffer.from(plaintext).toString("base64"));
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2";
_resetCryptoCacheForTests();
return new Uint8Array(Buffer.from(ct, "utf8"));
}
function pad(i: number): string {
return `doc-${String(i).padStart(5, "0")}`;
}
beforeEach(() => {
process.env.ENCRYPTION_KEYS = JSON.stringify({ v1: KEY_V1, v2: KEY_V2 });
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2";
delete process.env.ENCRYPTION_KEY;
_resetCryptoCacheForTests();
});
describe("codec-dispatched blob rotation (bounded batches)", () => {
it("walks a 500-row corpus in bounded id-cursor batches and rotates both codecs", async () => {
const rows: DocRow[] = [];
// 200 binary2 under the stale v1, 150 legacy base64v1 under v1,
// 100 binary2 already on v2 (skipped), 50 legacy already on v2 (skipped).
for (let i = 0; i < 200; i++) {
rows.push({
id: pad(i),
contentEncrypted: binaryUnder("v1", Buffer.from(`bin-${i}`)),
contentCodec: "binary2",
});
}
for (let i = 200; i < 350; i++) {
rows.push({
id: pad(i),
contentEncrypted: legacyUnder("v1", `legacy-${i}`),
contentCodec: "base64v1",
});
}
for (let i = 350; i < 450; i++) {
rows.push({
id: pad(i),
contentEncrypted: binaryUnder("v2", Buffer.from(`bin-${i}`)),
contentCodec: "binary2",
});
}
for (let i = 450; i < 500; i++) {
rows.push({
id: pad(i),
contentEncrypted: legacyUnder("v2", `legacy-${i}`),
contentCodec: "base64v1",
});
}
const { client, store, findManyCalls } = makeDocClient(rows);
const result = await rotateColumn(client, DOC_COLUMN);
expect(result.scanned).toBe(500);
expect(result.rotated).toBe(350);
expect(result.errors).toBe(0);
// BOUNDED: every page requested at most the batch size, and the walk
// needed ceil(500 / batch) full pages (+1 terminating empty page, since
// 500 is an exact multiple) — never one unbounded findMany.
expect(findManyCalls.length).toBe(
Math.ceil(500 / BLOB_ROTATION_BATCH_SIZE) + 1,
);
for (const call of findManyCalls) {
expect(call.take).toBe(BLOB_ROTATION_BATCH_SIZE);
expect(call.orderBy).toEqual({ id: "asc" });
}
// Cursor advanced between pages.
expect(findManyCalls[0].cursor).toBeUndefined();
expect(findManyCalls[1].cursor).toEqual({ id: pad(24) });
// Every row now reads under the active key, each in its OWN codec.
for (const row of store) {
if (row.contentCodec === "binary2") {
expect(extractKeyIdFromBytes(Buffer.from(row.contentEncrypted))).toBe(
getActiveKeyId(),
);
} else {
expect(
Buffer.from(row.contentEncrypted).toString("utf8").startsWith("v2."),
).toBe(true);
}
}
// IDEMPOTENT: the second pass rotates nothing.
const second = await rotateColumn(client, DOC_COLUMN);
expect(second.rotated).toBe(0);
expect(second.errors).toBe(0);
});
it("resumes after an interrupted run (already-rotated rows are skipped)", async () => {
const rows: DocRow[] = [];
for (let i = 0; i < 60; i++) {
rows.push({
id: pad(i),
contentEncrypted: binaryUnder("v1", Buffer.from(`bin-${i}`)),
contentCodec: "binary2",
});
}
const { client, failUpdatesFor } = makeDocClient(rows);
// First run: the last 20 updates fail (simulated interruption) — they
// are counted, never dropped.
for (let i = 40; i < 60; i++) failUpdatesFor.add(pad(i));
const first = await rotateColumn(client, DOC_COLUMN);
expect(first.rotated).toBe(40);
expect(first.errors).toBe(20);
// Second run with the interruption cleared: exactly the remainder
// rotates — the 40 already-active rows are skipped.
failUpdatesFor.clear();
const second = await rotateColumn(client, DOC_COLUMN);
expect(second.rotated).toBe(20);
expect(second.errors).toBe(0);
});
it("fails closed per row on an unknown codec and leaves the row untouched", async () => {
const strange = new Uint8Array(Buffer.from("???strange???"));
const { client, store } = makeDocClient([
{
id: pad(0),
contentEncrypted: binaryUnder("v1", Buffer.from("fine")),
contentCodec: "binary2",
},
{ id: pad(1), contentEncrypted: strange, contentCodec: "codec9" },
]);
const result = await rotateColumn(client, DOC_COLUMN);
expect(result.rotated).toBe(1);
expect(result.errors).toBe(1);
expect(store.find((r) => r.id === pad(1))!.contentEncrypted).toBe(strange);
});
it("scanColumn buckets blob rows per codec-aware key id in bounded batches", async () => {
const { client, findManyCalls } = makeDocClient([
{
id: pad(0),
contentEncrypted: binaryUnder("v1", Buffer.from("a")),
contentCodec: "binary2",
},
{
id: pad(1),
contentEncrypted: legacyUnder("v1", "b"),
contentCodec: "base64v1",
},
{
id: pad(2),
contentEncrypted: binaryUnder("v2", Buffer.from("c")),
contentCodec: "binary2",
},
]);
const scan = await scanColumn(client, DOC_COLUMN);
expect(scan.total).toBe(3);
expect(scan.byKeyId.v1).toBe(2);
expect(scan.byKeyId.v2).toBe(1);
expect(scan.legacy).toBe(0);
for (const call of findManyCalls) {
expect(call.take).toBe(BLOB_ROTATION_BATCH_SIZE);
}
});
});
+22 -5
View File
@@ -34,6 +34,15 @@ export interface EncryptedColumn {
/** Prisma field name (camelCase, as declared in schema.prisma). */
readonly field: string;
readonly kind: EncryptedColumnKind;
/**
* Present when the column's ciphertext layout is dispatched by a sibling
* codec column (the document vault's `contentCodec`: "base64v1" = the
* `encrypt()`-string-as-UTF-8 shape every other Bytes column uses,
* "binary2" = the binary `encryptBytes()` layout). A codec-dispatched
* column also rotates in bounded id-cursor batches — its rows are
* multi-megabyte blobs, so an unbounded `findMany` would balloon memory.
*/
readonly codecField?: string;
}
/**
@@ -179,11 +188,19 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "Allergy", field: "notesEncrypted", kind: "bytes" },
{ model: "FamilyHistoryEntry", field: "notesEncrypted", kind: "bytes" },
// ───── 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. The most sensitive blob in the wave
// (a full clinical document); never logged, never in wide-event meta.
{ model: "InboundDocument", field: "contentEncrypted", kind: "bytes" },
// ───── Inbound clinical document (Bytes column, codec-dispatched) ─────
// The raw uploaded document (the most sensitive blob in the corpus; never
// logged, never in wide-event meta). Two layouts, recorded per row in
// `contentCodec`: "base64v1" (pre-vault rows — base64-of-binary →
// AES-256-GCM string → UTF-8 bytes) and "binary2" (vault uploads — the
// binary `encryptBytes()` layout). Rotation walks this column in bounded
// id-cursor batches and re-encrypts under the row's own codec.
{
model: "InboundDocument",
field: "contentEncrypted",
kind: "bytes",
codecField: "contentCodec",
},
// The staged extracted-fact payloads: the FHIR-staged clinical values
// (diagnosis text / lab values / medication names / stated codes) and the
// verbatim source-span provenance. Both are PHI transcribed from the source
+139 -14
View File
@@ -30,7 +30,14 @@
* protects against dropping a legacy key too early.
*/
import { Buffer } from "node:buffer";
import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto";
import {
decrypt,
encrypt,
extractKeyId,
extractKeyIdFromBytes,
getActiveKeyId,
reencryptBytesToActive,
} from "@/lib/crypto";
import {
ENCRYPTED_COLUMNS,
encryptedColumnKey,
@@ -40,10 +47,21 @@ import {
/** Sentinel bucket for legacy (unversioned) ciphertext under `byKeyId`. */
export const LEGACY_BUCKET = "legacy";
/**
* Batch size for codec-dispatched blob columns (`codecField` present). The
* document vault's rows are up to cap-sized ciphertexts, so the walk is
* id-cursor paginated — at most this many blobs are in memory at once.
*/
export const BLOB_ROTATION_BATCH_SIZE = 25;
/** Minimal Prisma delegate shape this module needs. */
interface ColumnDelegate {
findMany: (args: {
select: Record<string, true>;
orderBy?: Record<string, "asc" | "desc">;
take?: number;
cursor?: Record<string, unknown>;
skip?: number;
}) => Promise<Array<Record<string, unknown>>>;
update: (args: {
where: { id: string };
@@ -98,6 +116,78 @@ function shouldRotate(ciphertext: string): boolean {
return extractKeyId(ciphertext) !== getActiveKeyId();
}
// ─── Codec-dispatched blob columns (document vault) ─────────────────────────
/**
* The key id a codec-dispatched blob row was written under, or null when
* legacy/unparsable. "binary2" parses the binary header; every other codec
* value (notably "base64v1") is the `encrypt()`-string-as-UTF-8 shape.
*/
function blobKeyId(value: Uint8Array, codec: string): string | null {
const buf = Buffer.from(value);
if (codec === "binary2") return extractKeyIdFromBytes(buf);
return extractKeyId(buf.toString("utf8"));
}
/** Re-encrypt one codec-dispatched blob under its OWN codec (never converts). */
function reencryptBlob(value: Uint8Array, codec: string): Uint8Array {
const buf = Buffer.from(value);
if (codec === "binary2") {
const rotated = reencryptBytesToActive(buf);
const next = new Uint8Array(new ArrayBuffer(rotated.byteLength));
next.set(rotated);
return next;
}
if (codec === "base64v1") {
const rotated = encrypt(decrypt(buf.toString("utf8")));
const encoded = Buffer.from(rotated, "utf8");
const next = new Uint8Array(new ArrayBuffer(encoded.byteLength));
next.set(encoded);
return next;
}
// FAIL-CLOSED: an unknown codec is counted as an error and left untouched.
throw new Error(`Unknown content codec '${codec}'`);
}
/**
* Walk a codec-dispatched blob column in bounded id-cursor batches, invoking
* `onRow` per non-empty row. At most `BLOB_ROTATION_BATCH_SIZE` blobs are in
* memory per step; an interrupted run resumes safely on re-invocation because
* processing is idempotent (already-active rows are skipped by the callers).
*/
async function walkBlobColumn(
delegate: ColumnDelegate,
col: EncryptedColumn,
onRow: (row: {
id: string;
value: Uint8Array;
codec: string;
}) => Promise<void> | void,
): Promise<void> {
const codecField = col.codecField!;
let cursor: string | null = null;
for (;;) {
const rows = await delegate.findMany({
select: { id: true, [col.field]: true, [codecField]: true },
orderBy: { id: "asc" },
take: BLOB_ROTATION_BATCH_SIZE,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
if (rows.length === 0) break;
for (const row of rows) {
const value = row[col.field] as Uint8Array | null;
if (!value || value.byteLength === 0) continue;
await onRow({
id: row.id as string,
value,
codec: String(row[codecField] ?? ""),
});
}
cursor = rows[rows.length - 1]!.id as string;
if (rows.length < BLOB_ROTATION_BATCH_SIZE) break;
}
}
export interface ColumnScan {
model: string;
field: string;
@@ -116,17 +206,27 @@ export async function scanColumn(
col: EncryptedColumn,
): Promise<ColumnScan> {
const delegate = getDelegate(client, col.model);
const rows = await delegate.findMany({
select: { id: true, [col.field]: true },
});
const byKeyId: Record<string, number> = {};
let total = 0;
for (const row of rows) {
const ciphertext = toCiphertext(row[col.field], col.kind);
if (ciphertext == null) continue;
total += 1;
const id = extractKeyId(ciphertext) ?? LEGACY_BUCKET;
byKeyId[id] = (byKeyId[id] ?? 0) + 1;
if (col.codecField) {
// Codec-dispatched blob column: bounded batches, per-row codec.
await walkBlobColumn(delegate, col, ({ value, codec }) => {
total += 1;
const id = blobKeyId(value, codec) ?? LEGACY_BUCKET;
byKeyId[id] = (byKeyId[id] ?? 0) + 1;
});
} else {
const rows = await delegate.findMany({
select: { id: true, [col.field]: true },
});
for (const row of rows) {
const ciphertext = toCiphertext(row[col.field], col.kind);
if (ciphertext == null) continue;
total += 1;
const id = extractKeyId(ciphertext) ?? LEGACY_BUCKET;
byKeyId[id] = (byKeyId[id] ?? 0) + 1;
}
}
return {
model: col.model,
@@ -192,16 +292,41 @@ export async function rotateColumn(
col: EncryptedColumn,
): Promise<RotationResult> {
const delegate = getDelegate(client, col.model);
const rows = await delegate.findMany({
select: { id: true, [col.field]: true },
});
const result: RotationResult = {
model: col.model,
field: col.field,
scanned: rows.length,
scanned: 0,
rotated: 0,
errors: 0,
};
if (col.codecField) {
// Codec-dispatched blob column: bounded id-cursor batches (never an
// unbounded blob findMany), re-encrypted under each row's OWN codec.
// Idempotent — rows already on the active key are skipped, so an
// interrupted run resumes cleanly on the next invocation.
await walkBlobColumn(delegate, col, async ({ id, value, codec }) => {
result.scanned += 1;
if (blobKeyId(value, codec) === getActiveKeyId()) return;
try {
await delegate.update({
where: { id },
data: { [col.field]: reencryptBlob(value, codec) },
});
result.rotated += 1;
} catch {
// FAIL-CLOSED: unknown codec or a no-longer-configured key throws;
// count it and leave the row untouched rather than dropping data.
result.errors += 1;
}
});
return result;
}
const rows = await delegate.findMany({
select: { id: true, [col.field]: true },
});
result.scanned = rows.length;
for (const row of rows) {
const ciphertext = toCiphertext(row[col.field], col.kind);
if (ciphertext == null || !shouldRotate(ciphertext)) continue;
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Buffer } from "node:buffer";
/**
* Document content codec dispatch: new uploads write "binary2", pre-vault
* rows keep decoding via the legacy "base64v1" string path, and an unknown
* codec value fails closed instead of guessing.
*/
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
import { _resetCryptoCacheForTests } from "@/lib/crypto";
import {
ACTIVE_DOCUMENT_CODEC,
decryptDocumentContent,
decryptDocumentFromBytes,
encryptDocumentContent,
encryptDocumentToBytes,
} from "../store";
const KEY_V1 =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const SAMPLE = Buffer.concat([
Buffer.from("%PDF-1.7\n"),
Buffer.from([0x00, 0xff, 0x80, 0x7f]),
Buffer.alloc(2048, 0xab),
]);
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", KEY_V1);
_resetCryptoCacheForTests();
});
describe("document content codecs", () => {
it("writes new uploads with the binary2 codec and round-trips them", () => {
const { content, codec } = encryptDocumentContent(SAMPLE);
expect(codec).toBe("binary2");
expect(codec).toBe(ACTIVE_DOCUMENT_CODEC);
expect(Buffer.from(content).equals(SAMPLE)).toBe(false);
expect(decryptDocumentContent(content, codec).equals(SAMPLE)).toBe(true);
});
it("still decodes legacy base64v1 rows byte-identically", () => {
// A pre-vault row: base64-of-binary → encrypt() string → UTF-8 bytes.
const legacy = encryptDocumentToBytes(SAMPLE);
expect(decryptDocumentContent(legacy, "base64v1").equals(SAMPLE)).toBe(
true,
);
// The legacy helper itself stays the exact inverse.
expect(decryptDocumentFromBytes(legacy).equals(SAMPLE)).toBe(true);
});
it("binary2 is ~25 % smaller at rest than the base64 detour", () => {
const legacy = encryptDocumentToBytes(SAMPLE);
const { content } = encryptDocumentContent(SAMPLE);
expect(content.byteLength).toBeLessThan(legacy.byteLength * 0.8);
});
it("fails closed on an unknown codec value", () => {
const { content } = encryptDocumentContent(SAMPLE);
expect(() => decryptDocumentContent(content, "codec9")).toThrow(
/unknown document content codec/i,
);
expect(() => decryptDocumentContent(content, "")).toThrow();
});
it("fails closed when codecs are crossed (no silent fallback)", () => {
const legacy = encryptDocumentToBytes(SAMPLE);
const { content: binary } = encryptDocumentContent(SAMPLE);
// Legacy bytes read as binary2 → header parse fails.
expect(() => decryptDocumentContent(legacy, "binary2")).toThrow();
// Binary bytes read as base64v1 → string decrypt fails.
expect(() => decryptDocumentContent(binary, "base64v1")).toThrow();
});
});
@@ -0,0 +1,326 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* Document-vault policy layer: exhaustive accept-matrix coverage.
*
* Every Class A and Class B format is detected from real magic-byte
* fixtures, every deny case is denied, the wire Content-Type never plays a
* role (the detector does not even receive it), container ambiguity (ZIP →
* OOXML only under an Office extension) is pinned, adversarial polyglots
* behave as designed, and the limits resolver clamps to the hard ceiling.
*/
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
import { prisma } from "@/lib/db";
import {
DOCUMENT_DEFAULT_MAX_FILE_BYTES,
DOCUMENT_DEFAULT_QUOTA_BYTES,
DOCUMENT_HARD_MAX_FILE_BYTES,
detectDocumentType,
resolveDocumentLimits,
servingClassFor,
} from "../upload-policy";
// ─── Magic-byte fixtures ─────────────────────────────────────────────────────
const PDF = Buffer.from("%PDF-1.7\n%âãÏÓ\n1 0 obj\n", "latin1");
const JPEG = Buffer.concat([
Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
Buffer.alloc(16, 0x11),
]);
const PNG = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.alloc(16, 0x22),
]);
const WEBP = Buffer.concat([
Buffer.from("RIFF"),
Buffer.from([0x24, 0x00, 0x00, 0x00]),
Buffer.from("WEBPVP8 "),
Buffer.alloc(8, 0x33),
]);
const GIF87 = Buffer.concat([Buffer.from("GIF87a"), Buffer.alloc(16, 0x44)]);
const GIF89 = Buffer.concat([Buffer.from("GIF89a"), Buffer.alloc(16, 0x44)]);
const ZIP = Buffer.concat([
Buffer.from([0x50, 0x4b, 0x03, 0x04]),
Buffer.alloc(32, 0x55),
]);
const CFB = Buffer.concat([
Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]),
Buffer.alloc(32, 0x66),
]);
const RTF = Buffer.from("{\\rtf1\\ansi Hello}", "latin1");
const TIFF_LE = Buffer.concat([
Buffer.from([0x49, 0x49, 0x2a, 0x00]),
Buffer.alloc(16, 0x77),
]);
const TIFF_BE = Buffer.concat([
Buffer.from([0x4d, 0x4d, 0x00, 0x2a]),
Buffer.alloc(16, 0x77),
]);
/** ISO-BMFF ftyp box with the given major + compatible brands. */
function ftyp(major: string, compat: string[] = []): Buffer {
const brands = Buffer.from([major, "0000", ...compat].join(""), "ascii");
const len = Buffer.alloc(4);
len.writeUInt32BE(8 + brands.byteLength);
return Buffer.concat([len, Buffer.from("ftyp"), brands, Buffer.alloc(8)]);
}
const TEXT = Buffer.from("Befund: alles unauffällig.\nZeile 2\n", "utf8");
const MZ = Buffer.concat([Buffer.from("MZ"), Buffer.alloc(64, 0x90)]);
const ELF = Buffer.concat([
Buffer.from([0x7f, 0x45, 0x4c, 0x46]),
Buffer.alloc(32, 0),
]);
const MACHO = Buffer.concat([
Buffer.from([0xcf, 0xfa, 0xed, 0xfe]),
Buffer.alloc(32, 0),
]);
describe("detectDocumentType — Class A (inline; magic bytes only)", () => {
it.each([
["report.pdf", PDF, "application/pdf"],
["photo.jpg", JPEG, "image/jpeg"],
["scan.png", PNG, "image/png"],
["scan.webp", WEBP, "image/webp"],
["anim87.gif", GIF87, "image/gif"],
["anim89.gif", GIF89, "image/gif"],
])("%s → %s inline", (name, bytes, mime) => {
expect(detectDocumentType(bytes, name)).toEqual({
mimeType: mime,
servingClass: "inline",
});
});
it("classifies by bytes, not extension (JPEG named .dat is still a JPEG)", () => {
expect(detectDocumentType(JPEG, "export.dat")).toEqual({
mimeType: "image/jpeg",
servingClass: "inline",
});
});
});
describe("detectDocumentType — Class B (attachment-only)", () => {
it.each([
[
"letter.docx",
ZIP,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
],
[
"values.xlsx",
ZIP,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
],
[
"slides.pptx",
ZIP,
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
],
["letter.doc", CFB, "application/msword"],
["values.xls", CFB, "application/vnd.ms-excel"],
["slides.ppt", CFB, "application/vnd.ms-powerpoint"],
["note.rtf", RTF, "application/rtf"],
["scan.tif", TIFF_LE, "image/tiff"],
["scan.tiff", TIFF_BE, "image/tiff"],
["befund.txt", TEXT, "text/plain"],
["befund.md", TEXT, "text/plain"],
["werte.csv", TEXT, "text/csv"],
["export.xml", TEXT, "application/xml"],
["export.json", TEXT, "application/json"],
])("%s → %s attachment", (name, bytes, mime) => {
expect(detectDocumentType(bytes, name)).toEqual({
mimeType: mime,
servingClass: "attachment",
});
});
it.each([
["heic (major brand)", ftyp("heic")],
["heix (major brand)", ftyp("heix")],
["hevc (major brand)", ftyp("hevc")],
["mif1 (major brand)", ftyp("mif1")],
["msf1 (major brand)", ftyp("msf1")],
["compatible-brand heic", ftyp("isom", ["heic"])],
])("HEIC/HEIF %s → image/heic attachment", (_label, bytes) => {
expect(detectDocumentType(bytes, "photo.heic")).toEqual({
mimeType: "image/heic",
servingClass: "attachment",
});
});
});
describe("detectDocumentType — deny", () => {
it.each([
["MZ executable", MZ, "payload.pdf"],
["ELF executable", ELF, "scan.png"],
["Mach-O executable", MACHO, "report.txt"],
])("%s is denied regardless of a friendly extension", (_l, bytes, name) => {
expect(detectDocumentType(bytes, name)).toBeNull();
});
it.each([
".exe",
".dll",
".bat",
".cmd",
".sh",
".ps1",
".js",
".mjs",
".jar",
".apk",
".html",
".htm",
".xhtml",
".svg",
".zip",
".7z",
".rar",
".tar",
".gz",
".iso",
".img",
".dmg",
])("extension %s is denied even with harmless text bytes", (ext) => {
expect(detectDocumentType(TEXT, `file${ext}`)).toBeNull();
});
it("denies a ZIP container without an Office extension (incl. no name)", () => {
expect(detectDocumentType(ZIP, "archive.zip")).toBeNull();
expect(detectDocumentType(ZIP, "archive")).toBeNull();
expect(detectDocumentType(ZIP, null)).toBeNull();
// CFB without a legacy-Office extension is equally refused.
expect(detectDocumentType(CFB, "blob.bin")).toBeNull();
});
it("denies text-extension files smuggling NUL bytes", () => {
const binaryish = Buffer.concat([TEXT, Buffer.from([0x00]), TEXT]);
expect(detectDocumentType(binaryish, "befund.txt")).toBeNull();
});
it("denies unidentifiable bytes and empty files", () => {
expect(detectDocumentType(Buffer.from([1, 2, 3, 4, 5, 6]), "x.bin")).toBe(
null,
);
expect(detectDocumentType(Buffer.alloc(0), "empty.txt")).toBeNull();
// Text-like bytes under a non-text extension stay unidentifiable.
expect(detectDocumentType(TEXT, "notes.abc")).toBeNull();
});
it("adversarial: SVG content cannot sneak in under an image extension", () => {
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>');
expect(detectDocumentType(svg, "image.svg")).toBeNull(); // ext deny
expect(detectDocumentType(svg, "image.png")).toBeNull(); // no PNG magic, no text ext
});
it("adversarial: HTML under a text extension stays attachment-only", () => {
// Accepted as text/plain — but Class B never renders inline, so the
// markup can never execute in-origin.
const html = Buffer.from("<!doctype html><script>alert(1)</script>");
expect(detectDocumentType(html, "page.txt")).toEqual({
mimeType: "text/plain",
servingClass: "attachment",
});
expect(detectDocumentType(html, "page.html")).toBeNull();
});
it("adversarial: a PDF with an HTML head is still a PDF (sandboxed inline)", () => {
const polyglot = Buffer.concat([
Buffer.from("%PDF-1.4\n"),
Buffer.from("<script>alert(1)</script>"),
]);
expect(detectDocumentType(polyglot, "poly.pdf")).toEqual({
mimeType: "application/pdf",
servingClass: "inline",
});
});
});
describe("servingClassFor", () => {
it("marks exactly the browser-native passive set inline", () => {
for (const mime of [
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
]) {
expect(servingClassFor(mime)).toBe("inline");
}
});
it("marks every other stored type attachment (incl. unknowns)", () => {
for (const mime of [
"image/heic",
"image/tiff",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"text/plain",
"text/csv",
"application/rtf",
"application/xml",
"application/json",
"text/html", // must never happen at rest, but even then: attachment
"image/svg+xml",
"application/octet-stream",
]) {
expect(servingClassFor(mime)).toBe("attachment");
}
});
});
describe("resolveDocumentLimits", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("falls back to the defaults with no settings row and no override", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
await expect(resolveDocumentLimits("u1")).resolves.toEqual({
maxFileBytes: DOCUMENT_DEFAULT_MAX_FILE_BYTES,
quotaBytes: DOCUMENT_DEFAULT_QUOTA_BYTES,
});
});
it("clamps the per-file cap to the hard 100 MiB ceiling", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
documentMaxFileBytes: 999_999_999_999,
documentQuotaBytes: BigInt(DOCUMENT_DEFAULT_QUOTA_BYTES),
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
const limits = await resolveDocumentLimits("u1");
expect(limits.maxFileBytes).toBe(DOCUMENT_HARD_MAX_FILE_BYTES);
});
it("prefers the per-user quota override over the instance default", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
documentMaxFileBytes: 26_214_400,
documentQuotaBytes: BigInt(2_000_000_000),
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentQuotaBytes: BigInt(50_000_000),
} as never);
const limits = await resolveDocumentLimits("u1");
expect(limits.quotaBytes).toBe(50_000_000);
});
it("uses the instance quota when the user carries no override", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
documentMaxFileBytes: 26_214_400,
documentQuotaBytes: BigInt(2_000_000_000),
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentQuotaBytes: null,
} as never);
const limits = await resolveDocumentLimits("u1");
expect(limits.quotaBytes).toBe(2_000_000_000);
});
});
+83
View File
@@ -0,0 +1,83 @@
/**
* Document ⇄ condition link helpers shared by the vault routes.
*
* One grouped query per page (no N+1), owner-scoped everywhere: every episode
* id a client sends is re-narrowed against the caller's live episodes before
* it can land in `document_condition_links`.
*/
import { prisma } from "@/lib/db";
import type { DocumentConditionLinkDto } from "@/lib/validations/inbound-documents";
/**
* Load the condition links for a page of documents in ONE grouped query.
* Returns a map documentId → link DTOs (documents without links are absent).
*/
export async function loadConditionLinks(
userId: string,
documentIds: string[],
): Promise<Map<string, DocumentConditionLinkDto[]>> {
const map = new Map<string, DocumentConditionLinkDto[]>();
if (documentIds.length === 0) return map;
const links = await prisma.documentConditionLink.findMany({
where: { userId, documentId: { in: documentIds } },
select: {
documentId: true,
episodeId: true,
episode: { select: { label: true } },
},
orderBy: { createdAt: "asc" },
});
for (const link of links) {
const entry = map.get(link.documentId) ?? [];
entry.push({ episodeId: link.episodeId, name: link.episode.label });
map.set(link.documentId, entry);
}
return map;
}
/**
* Narrow a client-sent episode-id list to the caller's LIVE episodes.
* Returns the deduplicated owned ids, or null when any id is unknown /
* foreign / deleted — the caller answers with a 404-shaped refusal (an
* attacker probing another user's episode ids learns nothing beyond
* "not found").
*/
export async function narrowOwnedEpisodeIds(
userId: string,
episodeIds: string[],
): Promise<string[] | null> {
const unique = [...new Set(episodeIds)];
if (unique.length === 0) return [];
const owned = await prisma.illnessEpisode.findMany({
where: { id: { in: unique }, userId, deletedAt: null },
select: { id: true },
});
if (owned.length !== unique.length) return null;
return unique;
}
/**
* Replace-set a document's condition links inside the given transaction-ish
* client: delete links no longer in the set, insert the missing ones.
* `episodeIds` MUST already be owner-narrowed (`narrowOwnedEpisodeIds`).
*/
export async function replaceConditionLinks(
tx: Pick<typeof prisma, "documentConditionLink">,
userId: string,
documentId: string,
episodeIds: string[],
): Promise<void> {
await tx.documentConditionLink.deleteMany({
where: { userId, documentId, episodeId: { notIn: episodeIds } },
});
if (episodeIds.length > 0) {
await tx.documentConditionLink.createMany({
data: episodeIds.map((episodeId) => ({
documentId,
episodeId,
userId,
})),
skipDuplicates: true,
});
}
}
+59 -6
View File
@@ -9,14 +9,16 @@
import { Buffer } from "node:buffer";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { decrypt, encrypt } from "@/lib/crypto";
import { decrypt, decryptBytes, encrypt, encryptBytes } from "@/lib/crypto";
import type {
ExtractedFact,
ExtractedFactStatus,
InboundDocument,
InboundDocumentStatus,
} from "@/generated/prisma/client";
import { servingClassFor } from "@/lib/documents/upload-policy";
import type {
DocumentConditionLinkDto,
ExtractedFactDto,
FactData,
FactProvenance,
@@ -26,6 +28,20 @@ import type {
InboundFactType,
} from "@/lib/validations/inbound-documents";
/**
* The two storage codecs `InboundDocument.contentEncrypted` may carry,
* recorded per row in the explicit `contentCodec` column (never sniffed):
* - "base64v1" — the legacy string path (base64-of-binary → `encrypt()`
* string → UTF-8 bytes). Pre-vault rows only; kept read-compatible.
* - "binary2" — the binary AES-256-GCM layout (`encryptBytes()`), no
* base64 detour. Every new upload writes this.
*/
export const DOCUMENT_CONTENT_CODECS = ["base64v1", "binary2"] as const;
export type DocumentContentCodec = (typeof DOCUMENT_CONTENT_CODECS)[number];
/** The codec every NEW upload is written with. */
export const ACTIVE_DOCUMENT_CODEC: DocumentContentCodec = "binary2";
/** Encrypt the raw document bytes into the `Bytes` payload the schema stores. */
export function encryptDocumentToBytes(bytes: Buffer): Uint8Array<ArrayBuffer> {
const ciphertext = encrypt(bytes.toString("base64"));
@@ -49,6 +65,31 @@ export function decryptDocumentFromBytes(buf: Uint8Array): Buffer {
return Buffer.from(base64, "base64");
}
/**
* Encrypt raw document bytes with the ACTIVE codec (binary2). Returns the
* `Bytes` payload plus the codec label the row must persist alongside it.
*/
export function encryptDocumentContent(bytes: Buffer): {
content: Uint8Array<ArrayBuffer>;
codec: DocumentContentCodec;
} {
const encrypted = encryptBytes(bytes);
const out = new Uint8Array(new ArrayBuffer(encrypted.byteLength));
out.set(encrypted);
return { content: out, codec: ACTIVE_DOCUMENT_CODEC };
}
/**
* Decrypt a stored document's payload, dispatching on the row's persisted
* codec. Fail-closed: an unknown codec value throws — a silent fallback to
* either codec would either return garbage or mask data corruption.
*/
export function decryptDocumentContent(buf: Uint8Array, codec: string): Buffer {
if (codec === "base64v1") return decryptDocumentFromBytes(buf);
if (codec === "binary2") return decryptBytes(Buffer.from(buf));
throw new Error(`Unknown document content codec '${codec}'`);
}
/**
* Encrypt a staged fact's FHIR-staged payload into the `Bytes` column the
* schema stores. The structured clinical values (diagnosis text, lab values,
@@ -80,10 +121,19 @@ export function decryptFactProvenance(buf: Uint8Array): FactProvenance {
return JSON.parse(decryptFromBytes(buf)) as FactProvenance;
}
/** Map a persisted document row (+ counts) to the list/detail DTO. */
/**
* The row fields the serialisers read. Deliberately WITHOUT
* `contentEncrypted`: the list path must never select the blob column
* (hardening — see the list route's `omit`), so the DTO mappers cannot
* require it either.
*/
export type SerialisableDocument = Omit<InboundDocument, "contentEncrypted">;
/** Map a persisted document row (+ counts + links) to the list/detail DTO. */
export function serialiseDocument(
doc: InboundDocument,
doc: SerialisableDocument,
counts: { factCount: number; pendingCount: number },
conditionLinks: DocumentConditionLinkDto[] = [],
): InboundDocumentDto {
return {
id: doc.id,
@@ -103,6 +153,8 @@ export function serialiseDocument(
errorReason: doc.errorReason,
factCount: counts.factCount,
pendingCount: counts.pendingCount,
conditionLinks,
servingClass: servingClassFor(doc.mimeType),
createdAt: doc.createdAt.toISOString(),
updatedAt: doc.updatedAt.toISOString(),
};
@@ -123,17 +175,18 @@ export function serialiseFact(fact: ExtractedFact): ExtractedFactDto {
};
}
/** Assemble the detail DTO (document + its staged facts). */
/** Assemble the detail DTO (document + its staged facts + links). */
export function serialiseDocumentDetail(
doc: InboundDocument,
doc: SerialisableDocument,
facts: ExtractedFact[],
conditionLinks: DocumentConditionLinkDto[] = [],
): InboundDocumentDetailDto {
const pendingCount = facts.filter((f) => f.status === "PENDING").length;
// `factCount` excludes REJECTED facts (a rejected fact is discarded, not part
// of the document's tally) so the badge matches the list query.
const factCount = facts.filter((f) => f.status !== "REJECTED").length;
return {
...serialiseDocument(doc, { factCount, pendingCount }),
...serialiseDocument(doc, { factCount, pendingCount }, conditionLinks),
facts: facts.map(serialiseFact),
};
}
+370
View File
@@ -0,0 +1,370 @@
/**
* Document-vault upload policy: the accept matrix, magic-byte sniffers, the
* serving-class function, and the admin-tunable limits resolver.
*
* Three classes, closed deny-list, serving posture as the security boundary:
*
* - Class A (inline): PDF, JPEG, PNG, WebP, GIF — browser-native passive
* formats the serve route renders `Content-Disposition: inline` (PDF
* additionally under a `Content-Security-Policy: sandbox` response
* header).
* - Class B (attachment-only): Office (OOXML + legacy CFB), text/CSV/RTF/
* Markdown, TIFF, HEIC/HEIF, XML/JSON — stored verbatim, served ONLY as
* `application/octet-stream` + `Content-Disposition: attachment` +
* `nosniff`. A misclassified Class B file therefore cannot execute in
* the app origin.
* - Deny: executables, scripts, HTML/XHTML/SVG (the only classes whose
* inline render executes script — zero medical-artefact value), generic
* archives / disk images, anything unidentifiable.
*
* The wire Content-Type is NEVER trusted anywhere — detection is magic-bytes
* first, extension only where a container format is ambiguous (ZIP → OOXML,
* CFB → legacy Office) or where no magic exists (plain-text formats). We
* never parse, rewrite, or "sanitise" stored documents: the original is the
* artefact; safety comes from what the serve route will and won't serve
* inline.
*
* The labs OCR path (`src/lib/labs/ocr-upload.ts`) keeps its own 12 MiB cap
* and narrow allowlist unchanged; this module only reuses its bounded-read
* primitive at the route layer.
*/
import { detectAvatarMimeType } from "@/lib/avatar";
import { prisma } from "@/lib/db";
// ─── Limits (§3.3) ──────────────────────────────────────────────────────────
/** Default per-file cap: 25 MiB (a ~50-page 300-dpi scan PDF, any phone photo). */
export const DOCUMENT_DEFAULT_MAX_FILE_BYTES = 26_214_400;
/**
* Hard server ceiling for the admin-tunable per-file cap: 100 MiB. Single-shot
* AES-256-GCM + bounded in-memory reads are load-bearing; past this the
* architecture (chunking, streaming crypto) would have to change, so the
* setting cannot be pushed there.
*/
export const DOCUMENT_HARD_MAX_FILE_BYTES = 104_857_600;
/** Default per-user quota: 1 GiB. */
export const DOCUMENT_DEFAULT_QUOTA_BYTES = 1_073_741_824;
export interface DocumentLimits {
/** Per-file upload cap in bytes (admin-tunable, clamped to the ceiling). */
maxFileBytes: number;
/** Per-user storage quota in bytes (user override ?? instance default). */
quotaBytes: number;
}
/**
* Resolve the effective limits for a user: `AppSettings.documentMaxFileBytes`
* clamped to the hard ceiling, and quota = `User.documentQuotaBytes` ??
* `AppSettings.documentQuotaBytes`. Missing rows fall back to the defaults.
*/
export async function resolveDocumentLimits(
userId: string,
): Promise<DocumentLimits> {
const [settings, user] = await Promise.all([
prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { documentMaxFileBytes: true, documentQuotaBytes: true },
}),
prisma.user.findUnique({
where: { id: userId },
select: { documentQuotaBytes: true },
}),
]);
const rawMax =
settings?.documentMaxFileBytes ?? DOCUMENT_DEFAULT_MAX_FILE_BYTES;
const maxFileBytes = Math.min(
Math.max(rawMax, 1),
DOCUMENT_HARD_MAX_FILE_BYTES,
);
const quotaBytes = Number(
user?.documentQuotaBytes ??
settings?.documentQuotaBytes ??
DOCUMENT_DEFAULT_QUOTA_BYTES,
);
return { maxFileBytes, quotaBytes };
}
// ─── Accept matrix (§3.1) ───────────────────────────────────────────────────
export type DocumentServingClass = "inline" | "attachment";
/** Class A — browser-native passive formats served inline. */
const CLASS_A_MIMES: ReadonlySet<string> = new Set([
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
]);
/**
* The one source of truth the serve route uses: Class A renders inline with
* its true Content-Type; EVERYTHING else stored leaves the origin only as an
* opaque attachment download.
*/
export function servingClassFor(mimeType: string): DocumentServingClass {
return CLASS_A_MIMES.has(mimeType) ? "inline" : "attachment";
}
/**
* Extensions the upload UI's `accept` attribute mirrors (Class A + B minus
* HEIC — its exclusion makes the iOS picker auto-transcode camera photos to
* JPEG so the common path stays inline-previewable). Server-side policy
* remains the enforcement; this list is advisory for pickers.
*/
export const DOCUMENT_ACCEPTED_EXTENSIONS: readonly string[] = [
".pdf",
".jpg",
".jpeg",
".png",
".webp",
".gif",
".docx",
".xlsx",
".pptx",
".doc",
".xls",
".ppt",
".txt",
".md",
".csv",
".rtf",
".tif",
".tiff",
".xml",
".json",
];
/**
* Extension deny-list: executables and scripts (regardless of content),
* HTML/XHTML/SVG (inline render executes script), generic archives and disk
* images. A ZIP container is allowed ONLY under an Office extension.
*/
const DENY_EXTENSIONS: ReadonlySet<string> = new Set([
".exe",
".dll",
".bat",
".cmd",
".sh",
".ps1",
".js",
".mjs",
".jar",
".apk",
".html",
".htm",
".xhtml",
".svg",
".zip",
".7z",
".rar",
".tar",
".gz",
".iso",
".img",
".dmg",
]);
/** OOXML extensions → stored MIME (ZIP magic + Office extension required). */
const OOXML_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
[
".docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
],
[
".xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
],
[
".pptx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
],
]);
/** Legacy Office extensions → stored MIME (CFB magic + extension required). */
const CFB_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
[".doc", "application/msword"],
[".xls", "application/vnd.ms-excel"],
[".ppt", "application/vnd.ms-powerpoint"],
]);
/** Text-format extensions → stored MIME (heuristic-gated, no magic bytes). */
const TEXT_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
[".txt", "text/plain"],
[".md", "text/plain"],
[".csv", "text/csv"],
[".xml", "application/xml"],
[".json", "application/json"],
]);
/** Lower-cased extension (with dot) of a filename, or "" when absent. */
function extensionOf(filename: string | null | undefined): string {
if (!filename) return "";
const idx = filename.lastIndexOf(".");
if (idx <= 0 || idx === filename.length - 1) return "";
return filename.slice(idx).toLowerCase();
}
function startsWith(buffer: Buffer, bytes: readonly number[]): boolean {
if (buffer.length < bytes.length) return false;
for (let i = 0; i < bytes.length; i++) {
if (buffer[i] !== bytes[i]) return false;
}
return true;
}
/** `%PDF-` */
function isPdf(buffer: Buffer): boolean {
return startsWith(buffer, [0x25, 0x50, 0x44, 0x46, 0x2d]);
}
/** `GIF87a` / `GIF89a` */
function isGif(buffer: Buffer): boolean {
return (
startsWith(buffer, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
startsWith(buffer, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
);
}
/** ZIP local-file header `PK\x03\x04` (OOXML containers; also generic ZIPs). */
function isZip(buffer: Buffer): boolean {
return startsWith(buffer, [0x50, 0x4b, 0x03, 0x04]);
}
/** Compound File Binary (legacy Office) `D0 CF 11 E0 A1 B1 1A E1`. */
function isCfb(buffer: Buffer): boolean {
return startsWith(buffer, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
}
/** RTF `{\rtf` */
function isRtf(buffer: Buffer): boolean {
return startsWith(buffer, [0x7b, 0x5c, 0x72, 0x74, 0x66]);
}
/** TIFF `II*\0` (little-endian) or `MM\0*` (big-endian). */
function isTiff(buffer: Buffer): boolean {
return (
startsWith(buffer, [0x49, 0x49, 0x2a, 0x00]) ||
startsWith(buffer, [0x4d, 0x4d, 0x00, 0x2a])
);
}
/** HEIC/HEIF — ISO-BMFF `ftyp` box with a HEIF-family brand. */
const HEIF_BRANDS: ReadonlySet<string> = new Set([
"heic",
"heix",
"hevc",
"mif1",
"msf1",
]);
function isHeif(buffer: Buffer): boolean {
if (buffer.length < 12) return false;
if (buffer.toString("ascii", 4, 8) !== "ftyp") return false;
const major = buffer.toString("ascii", 8, 12).toLowerCase();
if (HEIF_BRANDS.has(major)) return true;
// Compatible-brand scan inside the ftyp box (bounded by the box length).
const boxLen = Math.min(buffer.readUInt32BE(0), buffer.length);
for (let off = 16; off + 4 <= boxLen; off += 4) {
const brand = buffer.toString("ascii", off, off + 4).toLowerCase();
if (HEIF_BRANDS.has(brand)) return true;
}
return false;
}
/** Executable magic: MZ (PE), ELF, Mach-O (32/64, both endians, fat). */
function isExecutable(buffer: Buffer): boolean {
if (startsWith(buffer, [0x4d, 0x5a])) return true; // MZ
if (startsWith(buffer, [0x7f, 0x45, 0x4c, 0x46])) return true; // ELF
const machO = [
[0xfe, 0xed, 0xfa, 0xce],
[0xfe, 0xed, 0xfa, 0xcf],
[0xce, 0xfa, 0xed, 0xfe],
[0xcf, 0xfa, 0xed, 0xfe],
[0xca, 0xfe, 0xba, 0xbe],
] as const;
return machO.some((m) => startsWith(buffer, m));
}
/**
* Plain-text heuristic for the extension-gated text formats: no NUL byte in
* the first 8 KiB and the sample decodes as UTF-8 or Latin-1 (any byte
* sequence is valid Latin-1, so the effective gate is "no NUL" — a binary
* payload smuggled under `.txt` almost always carries NULs; if one does not,
* it is still stored Class B and can only ever leave as an opaque download).
*/
function looksLikeText(buffer: Buffer): boolean {
if (buffer.length === 0) return false;
const sample = buffer.subarray(0, 8192);
return !sample.includes(0x00);
}
export interface DetectedDocumentType {
/** The MIME type the row stores (sniffed, never the wire Content-Type). */
mimeType: string;
/** Derived serving class (same as `servingClassFor(mimeType)`). */
servingClass: DocumentServingClass;
}
/**
* Classify an upload from its bytes (+ the claimed filename, used only where
* a container/text format needs an extension gate). Returns null for every
* deny case: executables, script/markup extensions, generic archives, ZIPs
* without an Office extension, and anything unidentifiable.
*/
export function detectDocumentType(
buffer: Buffer,
filename: string | null | undefined,
): DetectedDocumentType | null {
const ext = extensionOf(filename);
// Hard denies first: content-level executables, then the extension
// deny-list (an .exe named payload is refused even if its bytes look like
// text; HTML/SVG are refused regardless of content).
if (isExecutable(buffer)) return null;
if (DENY_EXTENSIONS.has(ext)) return null;
// ── Class A (magic bytes only — extension is irrelevant) ──
if (isPdf(buffer)) {
return { mimeType: "application/pdf", servingClass: "inline" };
}
if (isGif(buffer)) {
return { mimeType: "image/gif", servingClass: "inline" };
}
const image = detectAvatarMimeType(buffer);
if (image) {
return { mimeType: image, servingClass: "inline" };
}
// ── Class B (magic where it exists; containers need the extension) ──
if (isZip(buffer)) {
// A ZIP container is acceptable ONLY as an OOXML Office file; a generic
// archive (any other extension, incl. none) is denied.
const ooxml = OOXML_BY_EXTENSION.get(ext);
return ooxml ? { mimeType: ooxml, servingClass: "attachment" } : null;
}
if (isCfb(buffer)) {
const legacy = CFB_BY_EXTENSION.get(ext);
return legacy ? { mimeType: legacy, servingClass: "attachment" } : null;
}
if (isRtf(buffer)) {
return { mimeType: "application/rtf", servingClass: "attachment" };
}
if (isTiff(buffer)) {
return { mimeType: "image/tiff", servingClass: "attachment" };
}
if (isHeif(buffer)) {
return { mimeType: "image/heic", servingClass: "attachment" };
}
// ── Text formats (extension + heuristic; no magic bytes exist) ──
const text = TEXT_BY_EXTENSION.get(ext);
if (text && looksLikeText(buffer)) {
return { mimeType: text, servingClass: "attachment" };
}
// Unidentifiable → deny.
return null;
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Daily physical purge for soft-deleted document-vault rows.
*
* Delete in the vault is undo-able: the DELETE route tombstones
* (`deletedAt`), the UI's undo toast and the restore endpoint clear the
* tombstone, and THIS job hard-deletes tombstones older than the 30-day
* grace window — returning the TOAST space the encrypted blob holds (a
* tombstoned document keeps counting against the quota until purged, so
* "deleted" bytes are never invisible backup weight). Staged facts cascade
* via the FK. Restore-after-purge answers 409 at the API.
*/
import { type Job } from "pg-boss";
import { withBackgroundEvent } from "@/lib/logging/background";
import { getWorkerPrisma } from "@/lib/jobs/reminder/shared";
import type { PrismaClient } from "@/generated/prisma/client";
export const DOCUMENT_PURGE_QUEUE = "document-tombstone-purge";
/** Daily at 04:10 Europe/Berlin — after the med-notes backfill tick (04:05). */
export const DOCUMENT_PURGE_CRON = "10 4 * * *";
/** Days a tombstoned document stays restorable before the purge claims it. */
export const DOCUMENT_PURGE_GRACE_DAYS = 30;
const DAY_MS = 86_400_000;
export interface DocumentPurgePayload {
triggeredAt?: string;
}
/**
* Hard-delete tombstoned `inbound_documents` rows whose `deletedAt` is older
* than the grace horizon. Returns the number of rows purged. Rows inside the
* grace window and live rows are never touched.
*/
export async function purgeExpiredDocumentTombstones(
prisma: PrismaClient,
now: Date = new Date(),
): Promise<number> {
const cutoff = new Date(now.getTime() - DOCUMENT_PURGE_GRACE_DAYS * DAY_MS);
const { count } = await prisma.inboundDocument.deleteMany({
where: { deletedAt: { not: null, lt: cutoff } },
});
return count;
}
export async function handleDocumentPurge(jobs: Job<DocumentPurgePayload>[]) {
void jobs;
await withBackgroundEvent("job.document_tombstone_purge", async (evt) => {
const prisma = getWorkerPrisma();
try {
const purged = await purgeExpiredDocumentTombstones(prisma);
evt.setAction({ name: "documents.vault.purge" });
evt.addMeta("document_purge_deleted", purged);
} catch (err) {
evt.addWarning(`document-tombstone-purge failed: ${err}`);
}
});
}
@@ -124,6 +124,12 @@ import {
handleMedicationInventoryExpire,
handleIntakeAutoSkip,
} from "./medication-maintenance";
import {
DOCUMENT_PURGE_QUEUE,
DOCUMENT_PURGE_CRON,
handleDocumentPurge,
type DocumentPurgePayload,
} from "@/lib/jobs/document-purge";
const DATA_BACKUP_QUEUE = "data-backup";
@@ -287,6 +293,11 @@ const allQueues = [
// plaintext medication note; without this entry pg-boss never provisions the
// queue and both the boot enqueue and the cron silently no-op.
MED_NOTES_ENCRYPTION_BACKFILL_QUEUE,
// Document vault — daily physical purge for tombstones past the 30-day
// undo grace (returns the encrypted blob's TOAST space). Without this entry
// the daily schedule silently no-ops and "deleted" documents hold backup
// weight forever.
DOCUMENT_PURGE_QUEUE,
];
const schedules: ScheduleEntry[] = [
@@ -352,6 +363,9 @@ const schedules: ScheduleEntry[] = [
// fan out one per-user job per account still holding a plaintext medication
// note.
[MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, MED_NOTES_ENCRYPTION_BACKFILL_CRON],
// Document vault — daily 04:10 Europe/Berlin purge for tombstoned
// documents past the 30-day undo grace.
[DOCUMENT_PURGE_QUEUE, DOCUMENT_PURGE_CRON],
];
/**
@@ -448,6 +462,14 @@ export async function registerMaintenanceQueues(
{ localConcurrency: 1 },
handleMcpTokenCleanup,
);
// Document vault — daily purge of tombstoned documents past the 30-day
// undo grace. Single-flight like every other cleanup queue; the underlying
// deleteMany is idempotent so a duplicate tick is a no-op.
await boss.work<DocumentPurgePayload>(
DOCUMENT_PURGE_QUEUE,
{ localConcurrency: 1 },
handleDocumentPurge,
);
await boss.work<PrDetectionPayload>(
PR_DETECTION_QUEUE,
{ localConcurrency: PR_DETECTION_CONCURRENCY },
+11 -12
View File
@@ -374,22 +374,21 @@ describe("registry — opt-in marker", () => {
});
});
describe("resolveModuleEnabled — inboundDocuments module (disabled in code)", () => {
// v1.25.3 — `inboundDocuments` is switched off in code pending a rebuild
// (`CODE_DISABLED_MODULE_KEYS`). It is hard-off ahead of both the operator
// and the per-user layer: no stored preference or availability blob can
// re-surface it. The registry entry + `optIn` marker stay intact.
it("is flagged as code-disabled in the registry", () => {
expect(isCodeDisabledModule("inboundDocuments")).toBe(true);
describe("resolveModuleEnabled — inboundDocuments module (document vault)", () => {
// Parked in code from v1.25.3; the document vault re-enabled it. Normal
// two-layer opt-in resolution applies again: dark by default, ON only on
// an explicit per-user opt-in, and an operator `false` still wins.
it("is no longer flagged as code-disabled in the registry", () => {
expect(isCodeDisabledModule("inboundDocuments")).toBe(false);
});
it("is OFF when no preference is recorded", () => {
it("is OFF when no preference is recorded (opt-in ships dark)", () => {
expect(
resolveModuleEnabled("inboundDocuments", inputs(), false, ALL_AVAILABLE),
).toBe(false);
});
it("stays OFF even when the user opted in (the in-code switch wins)", () => {
it("is ON when the user opted in", () => {
expect(
resolveModuleEnabled(
"inboundDocuments",
@@ -397,16 +396,16 @@ describe("resolveModuleEnabled — inboundDocuments module (disabled in code)",
false,
ALL_AVAILABLE,
),
).toBe(false);
).toBe(true);
});
it("stays OFF when the user opted in and the operator left it available", () => {
it("stays OFF when the operator disabled it, even with a user opt-in", () => {
expect(
resolveModuleEnabled(
"inboundDocuments",
inputs({ modulePreferences: { inboundDocuments: true } }),
false,
operator({ inboundDocuments: true }),
operator({ inboundDocuments: false }),
),
).toBe(false);
});
+5 -5
View File
@@ -338,12 +338,12 @@ export function isModuleKey(key: string): key is ModuleKey {
* migration) stays intact — re-enabling the module is a one-line removal
* from this list once the rebuild lands.
*
* v1.25.3 — `inboundDocuments` is parked here: the inbound-documents surface
* is withdrawn from this release and returns later with more depth. Its
* registry entry, `optIn` marker, routes, view, and migration 0222 are all
* preserved; only the user-reachable switches are removed.
* Currently empty. `inboundDocuments` was parked here from v1.25.3 until the
* document vault re-enabled it (per-user opt-in + operator availability
* resume their normal two-layer resolution; the module still ships dark by
* default via `optIn`).
*/
export const CODE_DISABLED_MODULE_KEYS = ["inboundDocuments"] as const;
export const CODE_DISABLED_MODULE_KEYS = [] as const;
const CODE_DISABLED_MODULE_SET: ReadonlySet<string> = new Set(
CODE_DISABLED_MODULE_KEYS,
+190 -9
View File
@@ -18,6 +18,7 @@ import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
documentBulkSchema,
documentUpdateSchema,
inboundConfirmSchema,
inboundFactEditSchema,
@@ -45,7 +46,13 @@ inboundFactEditSchema.meta({
documentUpdateSchema.meta({
id: "DocumentUpdateRequest",
description:
"Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.",
"Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.",
});
documentBulkSchema.meta({
id: "DocumentBulkRequest",
description:
"One bulk action over up to 100 owner-scoped documents: `setKind` (requires `kind`), `linkEpisode` / `unlinkEpisode` (require `episodeId`), `delete` (tombstone, undo-able for 30 days), `restore` (clear tombstone). Partial failures never abort the batch — see the per-id result array.",
});
const factProvenance = z.object({
@@ -72,6 +79,17 @@ const extractedFact = z
"One STATED fact transcribed from the document, FHIR-staged (Condition / Observation / MedicationStatement) with per-field provenance + a confidence gate. STATED status only — codes (SNOMED/ICD-10/LOINC/RxNorm/ATC) are present only when the document wrote them; no range-flag, no inferred links. `needsReview` is true when the fact scored below the confidence floor (it cannot be approved until edited).",
});
const conditionLink = z
.object({
episodeId: z.string(),
name: z.string(),
})
.meta({
id: "DocumentConditionLink",
description:
"A link to one of the caller's illness/condition episodes. `name` is the episode's user-facing label.",
});
const inboundDocument = z
.object({
id: z.string(),
@@ -87,13 +105,15 @@ const inboundDocument = z
errorReason: z.string().nullable(),
factCount: z.number(),
pendingCount: z.number(),
conditionLinks: z.array(conditionLink),
servingClass: z.enum(["inline", "attachment"]),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "InboundDocument",
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).",
"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). `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.",
});
const inboundDocumentDetail = inboundDocument
@@ -132,6 +152,13 @@ const confirmResponse = z
const kindValues = [...INBOUND_DOCUMENT_KINDS];
// Shared by the POST 200 (duplicate) and 201 (created) responses — the
// envelope id must be registered exactly once.
const inboundDocumentEnvelope = dataEnvelope(
inboundDocument,
"InboundDocumentEnvelope",
);
export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/documents/inbound": {
get: {
@@ -151,8 +178,30 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
name: "kind",
in: "query",
required: false,
schema: { type: "string", enum: kindValues },
description: "Category filter.",
style: "form",
explode: true,
schema: {
type: "array",
items: { type: "string", enum: kindValues },
},
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" },
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",
@@ -219,7 +268,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Documents"],
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`.",
'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` 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: {
@@ -228,7 +277,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
file: z.string().meta({
format: "binary",
description:
"The document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.",
"The document. Validated by magic-byte MIME sniff, not the wire Content-Type.",
}),
title: z
.string()
@@ -241,16 +290,110 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description:
"Optional user filing date (YYYY-MM-DD). Defaults to the upload day when omitted so the library sort/filter/display stay aligned.",
}),
episodeIds: z.array(z.string()).optional().meta({
description:
"Optional condition pre-links (repeat the field per id). Every id must be a live episode of the caller.",
}),
}),
},
},
},
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: inboundDocumentEnvelope,
},
},
},
"201": {
description: "The stored document.",
content: {
"application/json": {
schema: dataEnvelope(inboundDocument, "InboundDocumentEnvelope"),
schema: inboundDocumentEnvelope,
},
},
},
"413": {
description:
"Too large: `meta.reason` is `fileTooLarge` (with `maxFileBytes`) or `quotaExceeded` (with `quotaBytes` + `usedBytes`).",
content: { "application/json": { schema: errorEnvelope } },
},
"415": {
description:
'Unsupported or unidentifiable type (`meta.reason = "unsupportedType"`). Executables, HTML/SVG, and generic archives are always refused.',
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/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: dataEnvelope(
z
.object({
usedBytes: z.number(),
quotaBytes: z.number(),
maxFileBytes: z.number(),
acceptedExtensions: z.array(z.string()),
linkedEpisodes: z.array(
z.object({
episodeId: z.string(),
name: z.string(),
}),
),
})
.meta({ id: "DocumentUsage" }),
"DocumentUsageEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/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: documentBulkSchema } },
},
responses: {
"200": {
description: "Per-id outcomes.",
content: {
"application/json": {
schema: dataEnvelope(
z
.object({
results: z.array(
z.object({
id: z.string(),
ok: z.boolean(),
error: z.string().nullable(),
}),
),
})
.meta({ id: "DocumentBulkResponse" }),
"DocumentBulkEnvelope",
),
},
},
},
@@ -348,12 +491,47 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/documents/inbound/{id}/restore": {
post: {
tags: ["Documents"],
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",
required: true,
schema: { type: "string" },
},
],
responses: {
"200": {
description: "The restored document + its staged facts.",
content: {
"application/json": {
schema: dataEnvelope(
inboundDocumentDetail,
"InboundRestoreEnvelope",
),
},
},
},
"409": {
description:
"Not restorable: purged past the grace window, or an identical live document exists.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/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. 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.",
"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",
@@ -365,7 +543,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
responses: {
"200": {
description:
"The decrypted original document bytes with the stored MIME type.",
"The decrypted original document bytes. Inline types carry the stored MIME type; attachment types are served as application/octet-stream.",
content: {
"application/pdf": {
schema: { type: "string", format: "binary" },
@@ -373,6 +551,9 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"image/*": {
schema: { type: "string", format: "binary" },
},
"application/octet-stream": {
schema: { type: "string", format: "binary" },
},
},
},
...stdResponses,
+43 -24
View File
@@ -1,42 +1,61 @@
/**
* Query keys — clinical documents library (v1.25, W-DOCS-IN): the browsable
* document list (search / category filter / sort, keyset-paginated) and a
* single document's detail (document + staged facts). Part of the centralized
* factory; aggregated in `./index.ts`. Any document write (upload / edit /
* extract / confirm / discard) invalidates the `["documents"]` prefix so the
* list and the open detail repaint in lockstep.
* Query keys — the Dokumente vault: the filtered timeline list (keyset-
* paginated), a single document's detail, and the usage/limits read the
* upload path pre-flights against. Part of the centralized factory;
* aggregated in `./index.ts`. Any document write (upload / edit / link /
* delete / restore / bulk) invalidates the `["documents"]` prefix so the
* timeline, an open detail sheet, and the quota bar repaint in lockstep.
*/
import type {
DocumentListSort,
InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
/**
* The parameter set that distinguishes one library list view from another.
* Lives in the key so two different filter/sort combinations never share a
* cache bucket. `undefined` fields are dropped by TanStack's stable hash, so a
* bare `{ sort, order }` and `{ q: undefined, kind: undefined, sort, order }`
* resolve to the same key.
* The parameter set that distinguishes one vault timeline view from another —
* mirrors the URL facets (`?q&kind&episode&year`) one-to-one so a deep link
* and an in-page filter change resolve to the same cache bucket. `undefined`
* fields are dropped by TanStack's stable hash, so an all-defaults view and
* an explicit `{ q: undefined, … }` share a key. Sort is pinned server-side
* (filing date, newest first) and is deliberately NOT part of the key.
*/
export interface DocumentListParams {
export interface DocumentVaultFilters {
q?: string;
kind?: InboundDocumentKindValue;
from?: string;
to?: string;
sort: DocumentListSort;
order: "asc" | "desc";
/** Type facet — OR inside the facet, sorted before keying by the caller. */
kinds?: InboundDocumentKindValue[];
/** Only documents linked to this illness/condition episode. */
episodeId?: string;
/** Only documents whose filing date falls in this calendar year. */
year?: number;
}
export const documentKeys = {
documents: () => ["documents"] as const,
inboundDocuments: () => ["documents", "inbound"] as const,
/**
* The parameterised library list (keyset-paginated via `useInfiniteQuery`).
* The parameterised vault list (keyset-paginated via `useInfiniteQuery`).
* The literal `"list"` segment keeps it distinct from a single-document key
* (`inboundDocument(id)`); cuid ids never collide with it. Stays under the
* `["documents", "inbound"]` prefix so prefix invalidation still reaches it.
*/
inboundDocumentList: (params: DocumentListParams) =>
["documents", "inbound", "list", params] as const,
inboundDocumentList: (filters: DocumentVaultFilters) =>
["documents", "inbound", "list", filters] as const,
inboundDocument: (id: string) => ["documents", "inbound", id] as const,
/**
* The illness-episode detail's compact document preview (single page,
* small limit). Deliberately NOT `inboundDocumentList({ episodeId })` —
* that key belongs to the vault's `useInfiniteQuery` and a plain query
* under the same key would poison the cache with a different shape.
*/
inboundDocumentEpisodePreview: (episodeId: string) =>
["documents", "inbound", "episode-preview", episodeId] as const,
/**
* The link-existing-documents picker (searchable single page). Own key
* for the same shape-collision reason as the episode preview.
*/
inboundDocumentPicker: (q: string) =>
["documents", "inbound", "picker", q] as const,
/**
* Storage usage + effective limits (`GET /api/documents/inbound/usage`).
* Invalidated through the `["documents"]` prefix after every upload /
* delete so the quota bar tracks reality.
*/
inboundDocumentUsage: () => ["documents", "inbound", "usage"] as const,
};
+17
View File
@@ -67,6 +67,23 @@ export const adminSettingsSchema = z
reminderLateMinutes: z.number().int().min(15).max(480).optional(),
reminderMissedMinutes: z.number().int().min(30).max(720).optional(),
moodLogGlobal: z.boolean().optional(),
// Document vault — the two admin-tunable limits. The per-file cap is
// bounded by the hard 100 MiB ceiling (single-shot GCM + bounded
// in-memory reads are load-bearing past that; the runtime resolver
// clamps again as defence-in-depth). The quota accepts up to 1 TiB —
// BYTEA rides pg_dump, so anything larger is an operator error.
documentMaxFileBytes: z
.number()
.int()
.min(1_048_576)
.max(104_857_600)
.optional(),
documentQuotaBytes: z
.number()
.int()
.min(10_485_760)
.max(1_099_511_627_776)
.optional(),
// v1.4.25 W7 — server-wide default timezone for new signups
// that don't carry a browser-detected zone. The route validates
// against Intl.supportedValuesOf at runtime; this schema just
+110 -4
View File
@@ -52,6 +52,7 @@ export const INBOUND_DOCUMENT_KINDS = [
"LAB_RESULT",
"IMAGING",
"PRESCRIPTION",
"REFERRAL",
"INSURANCE",
"VACCINATION",
"OTHER",
@@ -196,6 +197,13 @@ export interface ExtractedFactDto {
committedRecordType: string | null;
}
/** One condition link on a document DTO (chip → `/illness/[id]`). */
export interface DocumentConditionLinkDto {
episodeId: string;
/** The episode's user-facing label. */
name: string;
}
/** The document DTO (list + detail). */
export interface InboundDocumentDto {
id: string;
@@ -216,6 +224,10 @@ export interface InboundDocumentDto {
factCount: number;
/** Count of staged facts still PENDING review. */
pendingCount: number;
/** Linked illness/condition episodes (empty when unlinked). */
conditionLinks: DocumentConditionLinkDto[];
/** How the serve route delivers the original: render inline or download. */
servingClass: "inline" | "attachment";
createdAt: string;
updatedAt: string;
}
@@ -343,16 +355,26 @@ const isoDateString = z
export const DOCUMENT_TITLE_MAX = 200;
/** Max condition links a single document may carry / receive per request. */
export const DOCUMENT_MAX_EPISODE_LINKS = 20;
const episodeIdList = z
.array(z.string().trim().min(1).max(40))
.max(DOCUMENT_MAX_EPISODE_LINKS);
/**
* The store-only upload metadata (the multipart form fields beside the file).
* Every field is optional — a bare file upload is valid and lands as a STORED
* document with no title / category / filing date. No `userId` field; it is
* always narrowed from the session.
* document with no title / category / filing date. `episodeIds` pre-links the
* document to the caller's illness/condition episodes (repeated `episodeIds`
* form fields; ownership is re-checked in the route). No `userId` field; it
* is always narrowed from the session.
*/
export const documentCreateSchema = z.object({
title: z.string().trim().min(1).max(DOCUMENT_TITLE_MAX).optional(),
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
documentDate: isoDateString.optional(),
episodeIds: episodeIdList.optional(),
});
export type DocumentCreateInput = z.infer<typeof documentCreateSchema>;
@@ -374,12 +396,19 @@ export const documentUpdateSchema = z
.transform((v) => (v === "" ? null : v)),
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
documentDate: isoDateString.nullable().optional(),
/**
* Replace-set condition links: the document's links become exactly this
* set (an empty array unlinks everything). Ownership of every episode id
* is re-checked in the route against the caller's episodes.
*/
episodeIds: episodeIdList.optional(),
})
.refine(
(d) =>
d.title !== undefined ||
d.kind !== undefined ||
d.documentDate !== undefined,
d.documentDate !== undefined ||
d.episodeIds !== undefined,
{ message: "Provide at least one field to update" },
);
@@ -403,7 +432,15 @@ export const DOCUMENT_LIST_DEFAULT_LIMIT = 50;
*/
export const documentListQuerySchema = z.object({
q: z.string().trim().max(100).optional(),
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
/**
* Category filter — OR inside the facet. Repeated `kind` params or a
* comma-separated value; the route normalises to an array before parsing.
*/
kind: z.array(z.enum(INBOUND_DOCUMENT_KINDS)).max(16).optional(),
/** Only documents linked to this illness/condition episode. */
episodeId: z.string().trim().min(1).max(40).optional(),
/** Only documents whose filing date falls in this calendar year (UTC). */
year: z.coerce.number().int().min(1900).max(9999).optional(),
from: isoDateString.optional(),
to: isoDateString.optional(),
sort: z.enum(DOCUMENT_LIST_SORTS).default("documentDate"),
@@ -418,3 +455,72 @@ export const documentListQuerySchema = z.object({
});
export type DocumentListQuery = z.infer<typeof documentListQuerySchema>;
// ─── Vault: bulk actions ───────────────────────────────────────────────────
/** Max document ids one bulk request may touch. */
export const DOCUMENT_BULK_MAX_IDS = 100;
export const DOCUMENT_BULK_ACTIONS = [
"setKind",
"linkEpisode",
"unlinkEpisode",
"delete",
"restore",
] as const;
export type DocumentBulkAction = (typeof DOCUMENT_BULK_ACTIONS)[number];
/**
* One bulk action over up to 100 owner-scoped documents. `setKind` requires
* `kind`; `linkEpisode` / `unlinkEpisode` require `episodeId` (ownership
* re-checked in the route). Per-id outcomes ride the response so a partial
* failure never aborts the batch. No `userId` field — narrowed from the
* session.
*/
export const documentBulkSchema = z
.object({
ids: z
.array(z.string().trim().min(1).max(40))
.min(1)
.max(DOCUMENT_BULK_MAX_IDS),
action: z.enum(DOCUMENT_BULK_ACTIONS),
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
episodeId: z.string().trim().min(1).max(40).optional(),
})
.refine((d) => d.action !== "setKind" || d.kind !== undefined, {
message: "Action 'setKind' requires a kind",
path: ["kind"],
})
.refine(
(d) =>
(d.action !== "linkEpisode" && d.action !== "unlinkEpisode") ||
d.episodeId !== undefined,
{
message: "Episode actions require an episodeId",
path: ["episodeId"],
},
);
export type DocumentBulkInput = z.infer<typeof documentBulkSchema>;
/** Per-id outcome in the bulk response. */
export interface DocumentBulkResultDto {
id: string;
ok: boolean;
/** Short machine reason when `ok` is false (e.g. "notFound", "conflict"). */
error: string | null;
}
/** The usage endpoint payload the UI reads before offering an upload. */
export interface DocumentUsageDto {
usedBytes: number;
quotaBytes: number;
maxFileBytes: number;
acceptedExtensions: string[];
/**
* Episodes carrying at least one LIVE document link — the vault filter
* bar's condition chips. Sourced server-side so a chip exists even when
* every linked document sits pages deep in the timeline.
*/
linkedEpisodes: DocumentConditionLinkDto[];
}
+33 -4
View File
@@ -301,9 +301,24 @@ export function proxy(request: NextRequest) {
// Request ID for log correlation
response.headers.set("x-request-id", requestId);
// The document vault's decrypt-and-serve route is framed SAME-ORIGIN by
// the document detail preview (an <iframe> for inline-class PDFs). The
// blanket `X-Frame-Options: DENY` + `frame-ancestors 'none'` below would
// refuse that embed, and the blanket page CSP would clobber the route's
// own `Content-Security-Policy: sandbox` (middleware headers win over
// route headers). Scope: exactly the `/original` path — list/detail API
// routes keep the full DENY posture. The replacement CSP keeps the
// sandbox (the served bytes are user-uploaded and must never script in
// the app origin) and narrows framing to 'self' instead of dropping it.
const isDocumentServeRoute =
/^\/api\/documents\/inbound\/[^/]+\/original$/.test(pathname);
// Security headers
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set(
"X-Frame-Options",
isDocumentServeRoute ? "SAMEORIGIN" : "DENY",
);
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set(
"Permissions-Policy",
@@ -390,9 +405,23 @@ export function proxy(request: NextRequest) {
// page-load. Avatars now live on the User row and serve from
// same-origin `/api/user/avatar/{id}`, so `img-src 'self'` covers
// them.
const csp = isDev
? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self';`
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
const csp = isDocumentServeRoute
? // Vault serve route (see the X-Frame-Options note above): the
// response is a user-uploaded document, not an app page.
// `default-src 'none'` — a served document may load nothing as if
// it were a page — and `frame-ancestors 'self'` permits exactly the
// same-origin preview iframe. Deliberately NO `sandbox` directive:
// Chromium refuses to render PDFs in sandboxed documents (it
// force-downloads them), which would kill the inline preview
// outright. The load-bearing boundaries for user-uploaded bytes
// stay: magic-byte classification at upload, true Content-Type +
// `nosniff` at serve (a PDF can never be reinterpreted as HTML),
// HTML/SVG denied entirely, and attachment-only posture for every
// non-passive format.
`default-src 'none'; frame-ancestors 'self';`
: isDev
? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self';`
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
response.headers.set("Content-Security-Policy", csp);
// Production-only headers. HSTS carries `preload` so the domain stays
+726
View File
@@ -0,0 +1,726 @@
/**
* Document vault — end-to-end contract against a real Postgres.
*
* Covers the Wave-1 hardenings the unit mocks cannot pin:
*
* 1. Upload → binary2-encrypted row + plaintext sha256; /original
* round-trips the exact bytes with the class-correct headers.
* 2. Policy error contract: 413 fileTooLarge / quotaExceeded (with limits
* in meta), 415 unsupportedType.
* 3. sha256 dedupe: a same-user re-upload returns 200 + meta.duplicate and
* the existing row id; the partial unique index holds at the DB level.
* 4. Idempotency-Key replay returns the cached response without a second
* row.
* 5. Quota accounting is tombstone-inclusive (a soft-deleted row still
* counts against usage until purged).
* 6. episodeIds pre-link + PATCH replace-set + list filters (kind[],
* episodeId, year) — and the list never leaks the blob.
* 7. Restore: clears the tombstone; 409 after purge; 409 on a live
* duplicate of the same bytes.
* 8. Bulk endpoint per-id semantics.
* 9. Usage endpoint reflects overrides + tombstone-inclusive usage.
* 10. The purge job hard-deletes only past-grace tombstones.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const PNG_1X1 = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==",
"base64",
);
const PDF_SMALL = Buffer.from("%PDF-1.7\n1 0 obj\n<<>>\nendobj\n%%EOF\n");
async function seedVaultUser(username: string) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role: "USER",
timezone: "Europe/Berlin",
// Opt-in module: the vault is dark until the user flips it on.
modulePreferencesJson: { inboundDocuments: true },
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 600_000) },
});
cookieJar.set("healthlog_session", session.id);
return user;
}
function uploadRequest(
bytes: Buffer,
filename: string,
fields: Record<string, string | string[]> = {},
headers: Record<string, string> = {},
): Request {
const payload = new Uint8Array(bytes.byteLength);
payload.set(bytes);
const formData = new FormData();
formData.append("file", new Blob([payload]), filename);
for (const [k, v] of Object.entries(fields)) {
for (const item of Array.isArray(v) ? v : [v]) formData.append(k, item);
}
return new Request("http://localhost/api/documents/inbound", {
method: "POST",
body: formData,
headers,
});
}
type RouteCtx = { params: Promise<{ id: string }> };
const ctx = (id: string): RouteCtx => ({ params: Promise.resolve({ id }) });
async function routes() {
const list = await import("@/app/api/documents/inbound/route");
const byId = await import("@/app/api/documents/inbound/[id]/route");
const original =
await import("@/app/api/documents/inbound/[id]/original/route");
const restore =
await import("@/app/api/documents/inbound/[id]/restore/route");
const bulk = await import("@/app/api/documents/inbound/bulk/route");
const usage = await import("@/app/api/documents/inbound/usage/route");
return {
post: list.POST as unknown as (r: Request) => Promise<Response>,
get: list.GET as unknown as (r: Request) => Promise<Response>,
patch: byId.PATCH as unknown as (
r: Request,
c: RouteCtx,
) => Promise<Response>,
del: byId.DELETE as unknown as (
r: Request,
c: RouteCtx,
) => Promise<Response>,
getOriginal: original.GET as unknown as (
r: Request,
c: RouteCtx,
) => Promise<Response>,
postRestore: restore.POST as unknown as (
r: Request,
c: RouteCtx,
) => Promise<Response>,
postBulk: bulk.POST as unknown as (r: Request) => Promise<Response>,
getUsage: usage.GET as unknown as (r: Request) => Promise<Response>,
};
}
function jsonRequest(url: string, method: string, body: unknown): Request {
return new Request(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("document vault — upload + serve round-trip", () => {
it("stores binary2-encrypted with sha256 and serves the exact bytes back", async () => {
await seedVaultUser("vault-roundtrip");
const { post, getOriginal } = await routes();
const res = await post(
uploadRequest(PNG_1X1, "scan.png", { title: "Röntgen Knie" }),
);
expect(res.status).toBe(201);
const { data } = await res.json();
expect(data.servingClass).toBe("inline");
// At rest: binary2 codec, plaintext sha256, ciphertext ≠ plaintext.
const row = await getPrismaClient().inboundDocument.findFirstOrThrow();
expect(row.contentCodec).toBe("binary2");
expect(row.contentSha256).toMatch(/^[0-9a-f]{64}$/);
expect(Buffer.from(row.contentEncrypted).equals(PNG_1X1)).toBe(false);
// Round-trip through the serve route: exact bytes + Class A posture.
const orig = await getOriginal(
new Request(`http://localhost/api/documents/inbound/${data.id}/original`),
ctx(data.id),
);
expect(orig.status).toBe(200);
expect(orig.headers.get("Content-Type")).toBe("image/png");
expect(orig.headers.get("Content-Disposition")).toContain("inline");
expect(orig.headers.get("X-Content-Type-Options")).toBe("nosniff");
// The response CSP is owned by the proxy's serve-route carve-out
// (`default-src 'none'; frame-ancestors 'self'` — middleware headers win
// over route headers), so the route itself sets none. Pinned by
// `src/__tests__/proxy-document-serve-framing.test.ts`.
expect(orig.headers.get("Content-Security-Policy")).toBeNull();
const served = Buffer.from(await orig.arrayBuffer());
expect(served.equals(PNG_1X1)).toBe(true);
});
it("serves a Class B upload attachment-only as octet-stream", async () => {
await seedVaultUser("vault-classb");
const { post, getOriginal } = await routes();
const res = await post(
uploadRequest(Buffer.from("Blutdruck 120/80 morgens\n"), "werte.csv"),
);
expect(res.status).toBe(201);
const { data } = await res.json();
expect(data.mimeType).toBe("text/csv");
expect(data.servingClass).toBe("attachment");
const orig = await getOriginal(
new Request(`http://localhost/api/documents/inbound/${data.id}/original`),
ctx(data.id),
);
expect(orig.status).toBe(200);
expect(orig.headers.get("Content-Type")).toBe("application/octet-stream");
expect(
(orig.headers.get("Content-Disposition") ?? "").startsWith("attachment;"),
).toBe(true);
expect(orig.headers.get("Content-Security-Policy")).toBeNull();
});
});
describe("document vault — policy error contract", () => {
it("415s an unsupported type with meta.reason", async () => {
await seedVaultUser("vault-415");
const { post } = await routes();
const res = await post(
uploadRequest(Buffer.from([0x01, 0x02, 0x03, 0x04]), "blob.bin"),
);
expect(res.status).toBe(415);
const body = await res.json();
expect(body.meta.reason).toBe("unsupportedType");
});
it("413s over the admin-set per-file cap with the limit in meta", async () => {
await seedVaultUser("vault-cap");
await getPrismaClient().appSettings.upsert({
where: { id: "singleton" },
update: { documentMaxFileBytes: 20 },
create: { id: "singleton", documentMaxFileBytes: 20 },
});
const { post } = await routes();
const res = await post(uploadRequest(PNG_1X1, "big.png"));
expect(res.status).toBe(413);
const body = await res.json();
expect(body.meta.reason).toBe("fileTooLarge");
expect(body.meta.maxFileBytes).toBe(20);
expect(await getPrismaClient().inboundDocument.count()).toBe(0);
});
it("413s past the quota — and tombstoned rows still count against it", async () => {
const user = await seedVaultUser("vault-quota");
// Quota fits exactly one PNG (70 B) but not two.
await getPrismaClient().user.update({
where: { id: user.id },
data: { documentQuotaBytes: BigInt(PNG_1X1.byteLength + 10) },
});
const { post, del } = await routes();
const first = await post(uploadRequest(PNG_1X1, "one.png"));
expect(first.status).toBe(201);
const firstId = (await first.json()).data.id as string;
// Soft-delete the first upload: the tombstone KEEPS holding quota.
const delRes = await del(
new Request(`http://localhost/api/documents/inbound/${firstId}`, {
method: "DELETE",
}),
ctx(firstId),
);
expect(delRes.status).toBe(200);
const second = await post(uploadRequest(PDF_SMALL, "two.pdf"));
expect(second.status).toBe(413);
const body = await second.json();
expect(body.meta.reason).toBe("quotaExceeded");
expect(body.meta.usedBytes).toBe(PNG_1X1.byteLength);
expect(body.meta.quotaBytes).toBe(PNG_1X1.byteLength + 10);
});
it("cannot be overshot by concurrent uploads racing the quota gate", async () => {
// Four DISTINCT same-sized files fired in parallel against a quota that
// fits exactly two. Without the per-user advisory lock in the upload
// transaction every request reads the same pre-insert SUM and all four
// land — a burst could overshoot the quota by rate-limit × cap.
const user = await seedVaultUser("vault-quota-race");
const files = Array.from({ length: 4 }, (_, i) => {
const filler = Buffer.alloc(2048, i + 1);
return Buffer.concat([Buffer.from("%PDF-1.7\n"), filler]);
});
const size = files[0].byteLength;
await getPrismaClient().user.update({
where: { id: user.id },
data: { documentQuotaBytes: BigInt(size * 2 + 10) },
});
const { post } = await routes();
const results = await Promise.all(
files.map((bytes, i) => post(uploadRequest(bytes, `race-${i}.pdf`))),
);
const statuses = results.map((r) => r.status).sort();
expect(statuses).toEqual([201, 201, 413, 413]);
const rows = await getPrismaClient().inboundDocument.findMany({
where: { userId: user.id },
select: { byteSize: true },
});
const stored = rows.reduce((sum, r) => sum + r.byteSize, 0);
expect(stored).toBeLessThanOrEqual(size * 2 + 10);
});
});
describe("document vault — dedupe + idempotency", () => {
it("returns the existing live row + meta.duplicate on a same-bytes re-upload", async () => {
await seedVaultUser("vault-dedupe");
const { post } = await routes();
const first = await post(uploadRequest(PNG_1X1, "scan.png"));
expect(first.status).toBe(201);
const firstId = (await first.json()).data.id as string;
const second = await post(uploadRequest(PNG_1X1, "scan-copy.png"));
expect(second.status).toBe(200);
const body = await second.json();
expect(body.meta.duplicate).toBe(true);
expect(body.data.id).toBe(firstId);
expect(await getPrismaClient().inboundDocument.count()).toBe(1);
});
it("collapses two racing identical uploads onto one row", async () => {
// Same bytes fired twice in parallel: the fast-path dedupe check misses
// (neither row exists yet), so the partial unique index must close the
// race — one 201, one 200 + meta.duplicate pointing at the winner.
await seedVaultUser("vault-dedupe-race");
const { post } = await routes();
const [a, b] = await Promise.all([
post(uploadRequest(PNG_1X1, "race-a.png")),
post(uploadRequest(PNG_1X1, "race-b.png")),
]);
const statuses = [a.status, b.status].sort();
expect(statuses).toEqual([200, 201]);
const bodies = await Promise.all([a.json(), b.json()]);
const winner = bodies.find((x) => x.meta?.duplicate !== true);
const loser = bodies.find((x) => x.meta?.duplicate === true);
expect(winner).toBeDefined();
expect(loser).toBeDefined();
expect(loser!.data.id).toBe(winner!.data.id);
expect(await getPrismaClient().inboundDocument.count()).toBe(1);
});
it("dedupes per user, not globally", async () => {
await seedVaultUser("vault-user-a");
const { post } = await routes();
expect((await post(uploadRequest(PNG_1X1, "a.png"))).status).toBe(201);
cookieJar.clear();
await seedVaultUser("vault-user-b");
expect((await post(uploadRequest(PNG_1X1, "b.png"))).status).toBe(201);
expect(await getPrismaClient().inboundDocument.count()).toBe(2);
});
it("replays an Idempotency-Key without creating a second row", async () => {
await seedVaultUser("vault-idem");
const { post } = await routes();
const key = "vault-upload-abc123";
const first = await post(
uploadRequest(PDF_SMALL, "brief.pdf", {}, { "Idempotency-Key": key }),
);
expect(first.status).toBe(201);
const firstBody = await first.json();
const replay = await post(
uploadRequest(PDF_SMALL, "brief.pdf", {}, { "Idempotency-Key": key }),
);
expect(replay.status).toBe(201);
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
const replayBody = await replay.json();
expect(replayBody.data.id).toBe(firstBody.data.id);
expect(await getPrismaClient().inboundDocument.count()).toBe(1);
});
});
describe("document vault — links + list filters", () => {
it("pre-links at upload, replace-sets via PATCH, filters by kind/episode/year", async () => {
const user = await seedVaultUser("vault-links");
const prisma = getPrismaClient();
const knie = await prisma.illnessEpisode.create({
data: {
userId: user.id,
label: "Knie",
type: "OTHER",
onsetAt: new Date("2025-09-01T00:00:00.000Z"),
},
});
const ruecken = await prisma.illnessEpisode.create({
data: {
userId: user.id,
label: "Rücken",
type: "OTHER",
onsetAt: new Date("2025-01-01T00:00:00.000Z"),
},
});
const { post, get, patch } = await routes();
const created = await post(
uploadRequest(PDF_SMALL, "mrt-knie.pdf", {
title: "MRT Knie",
kind: "IMAGING",
documentDate: "2025-10-04",
episodeIds: [knie.id],
}),
);
expect(created.status).toBe(201);
const doc = (await created.json()).data;
expect(doc.conditionLinks).toEqual([{ episodeId: knie.id, name: "Knie" }]);
// Second (unlinked, different year) document for filter contrast.
const other = await post(
uploadRequest(PNG_1X1, "impfpass.png", {
kind: "VACCINATION",
documentDate: "2024-03-01",
}),
);
expect(other.status).toBe(201);
// kind[] facet (OR inside), episode filter, year filter — AND across.
const filtered = await get(
new Request(
`http://localhost/api/documents/inbound?kind=IMAGING,LAB_RESULT&episodeId=${knie.id}&year=2025`,
),
);
expect(filtered.status).toBe(200);
const page = (await filtered.json()).data;
expect(page.documents).toHaveLength(1);
expect(page.documents[0].title).toBe("MRT Knie");
// The list DTO never carries blob material.
expect("contentEncrypted" in page.documents[0]).toBe(false);
// Year filter alone excludes the 2025 document.
const y2024 = await get(
new Request("http://localhost/api/documents/inbound?year=2024"),
);
expect((await y2024.json()).data.documents).toHaveLength(1);
expect(
(
await (
await get(new Request("http://localhost/api/documents/inbound"))
).json()
).data.documents,
).toHaveLength(2);
// PATCH replace-set: Knie → Rücken (relink), then [] (unlink-all).
const relinked = await patch(
jsonRequest(`http://localhost/api/documents/inbound/${doc.id}`, "PATCH", {
episodeIds: [ruecken.id],
}),
ctx(doc.id),
);
expect(relinked.status).toBe(200);
expect((await relinked.json()).data.conditionLinks).toEqual([
{ episodeId: ruecken.id, name: "Rücken" },
]);
const unlinked = await patch(
jsonRequest(`http://localhost/api/documents/inbound/${doc.id}`, "PATCH", {
episodeIds: [],
}),
ctx(doc.id),
);
expect((await unlinked.json()).data.conditionLinks).toEqual([]);
// Foreign episode id → 404-shaped refusal.
cookieJar.clear();
const stranger = await seedVaultUser("vault-links-stranger");
void stranger;
const foreign = await patch(
jsonRequest(`http://localhost/api/documents/inbound/${doc.id}`, "PATCH", {
episodeIds: [knie.id],
}),
ctx(doc.id),
);
expect(foreign.status).toBe(404);
});
});
describe("document vault — delete / restore / purge", () => {
it("restore clears the tombstone; purge-then-restore 409s; duplicate restore 409s", async () => {
await seedVaultUser("vault-restore");
const prisma = getPrismaClient();
const { post, del, postRestore } = await routes();
const created = await post(uploadRequest(PNG_1X1, "scan.png"));
const docId = (await created.json()).data.id as string;
await del(
new Request(`http://localhost/api/documents/inbound/${docId}`, {
method: "DELETE",
}),
ctx(docId),
);
expect(
(await prisma.inboundDocument.findFirstOrThrow()).deletedAt,
).not.toBeNull();
// Restore inside the grace window succeeds and the row is live again.
const restored = await postRestore(
new Request(`http://localhost/api/documents/inbound/${docId}/restore`, {
method: "POST",
}),
ctx(docId),
);
expect(restored.status).toBe(200);
const row = await prisma.inboundDocument.findFirstOrThrow();
expect(row.deletedAt).toBeNull();
expect(row.status).toBe("STORED");
// Tombstone again, upload the same bytes fresh (allowed — the partial
// index only guards LIVE rows), then the tombstone's restore conflicts.
await del(
new Request(`http://localhost/api/documents/inbound/${docId}`, {
method: "DELETE",
}),
ctx(docId),
);
const reupload = await post(uploadRequest(PNG_1X1, "scan-again.png"));
expect(reupload.status).toBe(201);
const conflicted = await postRestore(
new Request(`http://localhost/api/documents/inbound/${docId}/restore`, {
method: "POST",
}),
ctx(docId),
);
expect(conflicted.status).toBe(409);
expect((await conflicted.json()).meta.reason).toBe("duplicateExists");
// Hard-purge the tombstone → restore now answers 409 (undo window over).
await prisma.inboundDocument.delete({ where: { id: docId } });
const gone = await postRestore(
new Request(`http://localhost/api/documents/inbound/${docId}/restore`, {
method: "POST",
}),
ctx(docId),
);
expect(gone.status).toBe(409);
expect((await gone.json()).meta.reason).toBe("purged");
});
it("the purge job hard-deletes only past-grace tombstones", async () => {
const user = await seedVaultUser("vault-purge");
const prisma = getPrismaClient();
const { post, del } = await routes();
const { purgeExpiredDocumentTombstones } =
await import("@/lib/jobs/document-purge");
const a = (await (await post(uploadRequest(PNG_1X1, "old.png"))).json())
.data.id as string;
const b = (await (await post(uploadRequest(PDF_SMALL, "fresh.pdf"))).json())
.data.id as string;
const c = (
await (
await post(uploadRequest(Buffer.from("text file\n"), "live.txt"))
).json()
).data.id as string;
// a: tombstoned 31 days ago (past grace). b: tombstoned now. c: live.
for (const id of [a, b]) {
await del(
new Request(`http://localhost/api/documents/inbound/${id}`, {
method: "DELETE",
}),
ctx(id),
);
}
await prisma.inboundDocument.update({
where: { id: a },
data: { deletedAt: new Date(Date.now() - 31 * 86_400_000) },
});
const purged = await purgeExpiredDocumentTombstones(prisma);
expect(purged).toBe(1);
const remaining = await prisma.inboundDocument.findMany({
where: { userId: user.id },
select: { id: true },
orderBy: { id: "asc" },
});
expect(remaining.map((r) => r.id).sort()).toEqual([b, c].sort());
});
});
describe("document vault — bulk + usage", () => {
it("bulk applies per-id with partial-failure results", async () => {
const user = await seedVaultUser("vault-bulk");
const prisma = getPrismaClient();
const episode = await prisma.illnessEpisode.create({
data: {
userId: user.id,
label: "Knie",
type: "OTHER",
onsetAt: new Date("2025-09-01T00:00:00.000Z"),
},
});
const { post, postBulk } = await routes();
const id1 = (await (await post(uploadRequest(PNG_1X1, "a.png"))).json())
.data.id as string;
const id2 = (await (await post(uploadRequest(PDF_SMALL, "b.pdf"))).json())
.data.id as string;
// setKind over one real + one unknown id → per-id ok/notFound.
const setKind = await postBulk(
jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", {
ids: [id1, "does-not-exist"],
action: "setKind",
kind: "LAB_RESULT",
}),
);
expect(setKind.status).toBe(200);
const { results } = (await setKind.json()).data;
expect(results).toEqual([
{ id: id1, ok: true, error: null },
{ id: "does-not-exist", ok: false, error: "notFound" },
]);
expect(
(await prisma.inboundDocument.findUniqueOrThrow({ where: { id: id1 } }))
.kind,
).toBe("LAB_RESULT");
// linkEpisode + delete + restore round-trip through bulk.
const link = await postBulk(
jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", {
ids: [id1, id2],
action: "linkEpisode",
episodeId: episode.id,
}),
);
expect(
(await link.json()).data.results.every((r: { ok: boolean }) => r.ok),
).toBe(true);
expect(await prisma.documentConditionLink.count()).toBe(2);
const bulkDelete = await postBulk(
jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", {
ids: [id1, id2],
action: "delete",
}),
);
expect(bulkDelete.status).toBe(200);
expect(
await prisma.inboundDocument.count({ where: { deletedAt: null } }),
).toBe(0);
const bulkRestore = await postBulk(
jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", {
ids: [id1, id2],
action: "restore",
}),
);
expect(bulkRestore.status).toBe(200);
expect(
await prisma.inboundDocument.count({ where: { deletedAt: null } }),
).toBe(2);
// Cap: 101 ids → 422 (schema bound).
const over = await postBulk(
jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", {
ids: Array.from({ length: 101 }, (_, i) => `id-${i}`),
action: "delete",
}),
);
expect(over.status).toBe(422);
});
it("usage reflects the override + tombstone-inclusive accounting", async () => {
const user = await seedVaultUser("vault-usage");
const prisma = getPrismaClient();
await prisma.user.update({
where: { id: user.id },
data: { documentQuotaBytes: BigInt(123_456_789) },
});
const { post, del, getUsage } = await routes();
const id = (await (await post(uploadRequest(PNG_1X1, "a.png"))).json()).data
.id as string;
await del(
new Request(`http://localhost/api/documents/inbound/${id}`, {
method: "DELETE",
}),
ctx(id),
);
// Condition chips source: an episode linked to a LIVE document surfaces
// in `linkedEpisodes`; an episode whose only link sits on the tombstoned
// row does not.
const liveEpisode = await prisma.illnessEpisode.create({
data: {
userId: user.id,
label: "Knie",
type: "INJURY",
onsetAt: new Date("2026-01-01T00:00:00Z"),
},
});
const deadEpisode = await prisma.illnessEpisode.create({
data: {
userId: user.id,
label: "Erkältung",
type: "INFECTION",
onsetAt: new Date("2026-02-01T00:00:00Z"),
},
});
await post(
uploadRequest(PDF_SMALL, "knie.pdf", { episodeIds: [liveEpisode.id] }),
);
await prisma.documentConditionLink.create({
data: { documentId: id, episodeId: deadEpisode.id, userId: user.id },
});
const res = await getUsage(
new Request("http://localhost/api/documents/inbound/usage"),
);
expect(res.status).toBe(200);
const { data } = await res.json();
// Tombstoned bytes still count until the purge reclaims them.
expect(data.usedBytes).toBe(PNG_1X1.byteLength + PDF_SMALL.byteLength);
expect(data.quotaBytes).toBe(123_456_789);
expect(data.maxFileBytes).toBe(26_214_400);
expect(data.acceptedExtensions).toContain(".pdf");
expect(data.acceptedExtensions).not.toContain(".heic");
expect(data.linkedEpisodes).toEqual([
{ episodeId: liveEpisode.id, name: "Knie" },
]);
});
});