mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #427 from MBombeck/release/v1.27.17
Release v1.27.17 — the document vault
This commit is contained in:
83 files changed
+10557
-3249
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+20
@@ -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
@@ -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
@@ -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/*`
|
||||
|
||||
@@ -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'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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) ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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&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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user