Commit Graph
23 Commits
Author SHA1 Message Date
Marc-André Bombeck 8f442c3d6c feat(documents): keyboard-first vault timeline
Roving tabindex over the card grid: arrows move (grid-aware by measured
column count), Home/End jump, Enter opens, Space selects, Delete removes
with the undo toast; focus rides the virtualizer window (scroll-to-row,
bounded retry focus). The container carries list semantics with a
translated label, and the transient upload queue stays a polite live
region. Selection state is conveyed by the per-card checkbox, which the
focus ring reveals for keyboard users.

Refs: document-vault plan W3-T3
2026-07-07 13:24:07 +02:00
Marc-André Bombeck aef2003d4c feat(documents): multi-select bulk actions on the vault timeline
Selection grows file-manager gestures — shift-click ranges (additive,
anchored on the last plain toggle) and press-and-hold on touch — and a
floating toolbar appears with the selected count and the bulk verbs:
change type, link condition, delete, clear. All verbs drive the bulk
endpoint in <=100-id chunks with merged per-id results; partial failures
surface as one counted toast, and a bulk delete gets one aggregate undo
that bulk-restores the succeeded rows (both sides no-op-safe by
contract). Escape clears the selection unless a dialog owns the key.

Refs: document-vault plan W3-T2
2026-07-07 13:21:16 +02:00
Marc-André Bombeck a8a733fc4b feat(documents): page-wide drop and clipboard paste feed the upload queue
Dragging files anywhere over /documents raises a dimmed full-page drop
overlay (Escape dismisses); dropping or pasting files/screenshots feeds
the same upload queue as the picker, pre-linked to the active condition
filter. The overlay is decorative (aria-hidden) — intake announces
through an sr-only live region. Copy translated in all six locales.

Refs: document-vault plan W3-T1
2026-07-07 13:15:54 +02:00
Marc-André Bombeck f9ca7fef79 fix(proxy): allow same-origin framing of the document serve route
The blanket X-Frame-Options: DENY + frame-ancestors 'none' refused the
detail preview's same-origin <iframe>, and the middleware page CSP
clobbered whatever the serve route set — the inline PDF preview rendered
as a permanent skeleton. Carve out exactly the /original path:
SAMEORIGIN framing, frame-ancestors 'self', and a document CSP of
default-src 'none'. Deliberately NO sandbox directive and no sandbox
attribute on the preview iframe — Chromium force-downloads PDFs in
sandboxed documents instead of rendering them (verified against a real
Chromium channel; headless test builds ship no PDF viewer at all). The
load-bearing boundaries for user-uploaded bytes are unchanged:
magic-byte classification at upload, true Content-Type + nosniff at
serve, HTML/SVG denied entirely, attachment-only posture for every
non-passive format. Tests pin the carve-out and that every neighbouring
path keeps the DENY posture.

Plan-Task: W2-T3 (preview integration)
2026-07-07 12:57:12 +02:00
Marc-André Bombeck 2b7a6544d6 feat(documents): retire the v1.25 extraction-review surface
The parked inbound-documents view (upload form + staged-fact review UI)
and its helpers leave the tree; /documents renders only the vault. The
extraction routes stay dark server-side with no UI entry points, per the
release plan. Orphaned i18n keys left with the locale rewrite in the
previous commit; the reverse-coverage guard pins the result.

Plan-Task: W2-T7
2026-07-07 12:26:07 +02:00
Marc-André Bombeck b8009d2e73 feat(documents): the vault surface
The page becomes one wide, calm filing-cabinet view: PageHeader with an
Upload action, a drop-target upload zone whose accept list and per-file
cap come from the usage endpoint (quota bar only above 80 %), a sticky
filter rail (200 ms debounced search focused by '/', nine type chips,
condition chips from linked episodes, a year segmenter, one-tap clear),
and the virtualized timeline below. Filter state lives in the URL so
every view is shareable and back-button-safe; uploads filtered to an
episode arrive pre-linked. Hovering a card prefetches detail metadata —
never the blob. Designed states: a skeleton grid that matches the loaded
layout, a teaching empty state, a distinct no-matches state, and a
retryable error card. /documents joins the shell's wide-container list;
the shell stays the single owner of the page frame. Locale bundles gain
the vault vocabulary in all six languages and drop the retired
extraction-review strings.

Plan-Task: W2-T1, W2-T4 (zone), W2-T5 (prefetch), W2-T6
2026-07-07 12:26:06 +02:00
Marc-André Bombeck 4014d77a21 feat(documents): detail sheet with inline preview and undo-able delete
ResponsiveSheet (bottom sheet on phones, dialog on desktop): inline-class
documents preview against the owner-scoped serve route — skeleton first,
internal scroll only; attachment-class documents get no fake preview,
just glyph, filename, size, and a prominent Download. Title edits
click-to-edit and commit on Enter/blur, kind and filing date save on
change, condition links PATCH as a replace-set with chips navigating to
the episode page. Delete soft-deletes behind an Undo toast; a refused
restore translates the purged / duplicate reasons. Mutation errors stay
inline. No share affordance — deliberately.

Plan-Task: W2-T3
2026-07-07 12:26:06 +02:00
Marc-André Bombeck 53d0d8cc33 feat(documents): virtualized month timeline and document cards
The timeline windows a flat item list (month labels + chunked card rows)
with @tanstack/react-virtual over the shell's scroll container — single
scroll owner, bounded DOM at four-digit corpus sizes. Columns are
measured by a ResizeObserver so the row model and the painted grid never
disagree. Cards are compact-density: kind glyph, foreground title, one
muted meta line, condition pills, a download-only badge for attachment-
class formats, and a hover/focus selection checkbox above the whole-card
click target. In-flight and failed uploads share the same footprint with
a live progress ring and translated failure copy.

Plan-Task: W2-T2, W2-T4 (upload cards)
2026-07-07 12:26:06 +02:00
Marc-André Bombeck 5536749985 feat(documents): client upload queue with progress and duplicate handling
One multipart XHR POST per file (real upload-progress events, no new
dependency), concurrency capped at 3 to match the server's memory
envelope, an Idempotency-Key per file so a retried blip cannot
double-store, and a canvas downscale to 3000 px longest edge for raster
photos only — PDFs, Office files, and GIFs upload verbatim. Entries
appear synchronously before any network work; an over-limit file fails
locally against the configured cap while the batch continues; a
duplicate resolves to the existing row and hands its id to the timeline
for a brief highlight.

Plan-Task: W2-T4 (manager half)
2026-07-07 12:25:45 +02:00
Marc-André Bombeck 37e9d933e2 feat(documents): vault list contract on the client
URL-backed facets (q / kind / episode / year) parse and serialise through
one pure helper pair so deep links round-trip byte-stable; the API search
builder pins the timeline sort. Month bucketing + row chunking feed the
virtualized grid as a flat item list. The upload-response parser owns the
duplicate contract: HTTP 200 with envelope-level meta.duplicate returns
the existing row, every 413/415 reason maps to a translated-copy class,
and proxy-stripped bodies fall back to the HTTP status. Query keys move
to the vault filter shape and gain the usage read.

Plan-Task: W2-T1 (URL state), W2-T5 (keys)
2026-07-07 12:25:45 +02:00
Marc-André Bombeck b76873ee1f feat(documents): rebuild upload and list on the vault contract
POST /api/documents/inbound now runs the policy layer end to end: bounded
read aborts at the admin-set cap (413 + meta.reason=fileTooLarge with the
limit), magic-byte classification (415 + unsupportedType), per-user quota
checked inside the insert transaction over every non-purged row —
tombstones keep counting, so deleted bytes are never invisible weight
(413 + quotaExceeded with the figures), sha256 dedupe (a same-bytes
re-upload answers 200 + meta.duplicate with the existing live row; the
partial unique index closes the race), an honoured Idempotency-Key, the
binary2 codec on write, and optional episodeIds[] pre-linking narrowed to
the caller's live episodes.

GET stops fetching the encrypted blob per row (omit: contentEncrypted —
a 50-row page dragged up to 50 × cap ciphertext before), learns kind[] /
episodeId / year facets on top of q/from/to, and returns conditionLinks +
servingClass on every DTO via one grouped link query (no N+1). PATCH gains
replace-set episodeIds with the same 404-shaped refusal for a foreign
episode. The extract path decrypts through the codec dispatcher.

The document-kind labels gain REFERRAL in all six locales.

Tasks: W1-T5, W1-T6, W1-T7.
2026-07-07 11:05:12 +02:00
Marc-André Bombeck 88474adcd0 refactor(ui): finish the page-header adoption and align the demote label
Route the notifications main view and the documents header through the
canonical page-header vocabulary the rest of the release adopted, and
align the admin demote-role label to the standard Nutzer term.
2026-07-02 00:30:19 +02:00
Marc-André Bombeck c9f8eb9423 fix(ui): replace native date pickers and tokenize caution colors
Swap the remaining native date inputs (allergy onset, environment
travel/backfill ranges, inbound-document dates) for the app DateField
so they no longer render in the browser's locale order. Route the
medication caution amber and the document info banner through the
warning/info tokens so they adapt to the theme.
2026-07-02 00:00:38 +02:00
Marc-André Bombeck 679f8a05c9 fix(documents): surface confirm failures and harden the library edges
Close the gaps the final verification pass flagged on the documents surface:

- Confirm no longer claims success when the route rejects a fact into its
  `failed[]` array (HTTP 200). A unit-mismatch fact left silently PENDING with
  a green toast now raises a warning naming how many couldn't be saved and why.
- Unify the German documents copy on "Angaben": the discard-confirm body, the
  provider-unsupported note, and the module description still said "Fakten".
- After upload, sort by date-added rather than document date so a back-dated
  upload always lands on the first page where its detail panel can open.
- Give the list its own load-error state with a retry instead of rendering the
  "no documents yet" empty state on a failed fetch.
- Classify the local-OCR-disabled extract error to a clear message.
- Drop the orphaned settings.sections.notifications.inboxLink key (no call site).
2026-06-29 01:03:44 +02:00
Marc-André Bombeck e7683c9f54 fix(documents): reconcile library quality-of-life passes
- Reword the upload toasts to the store-only reality ("stored", not "read"),
  so the success message no longer claims AI ran and the panel it opens no
  longer contradicts it.
- Classify upload failures (too large / wrong type / rate-limited / invalid
  metadata) into specific messages; add a client-side 12 MB pre-check and show
  the accepted formats and size ceiling beside the file field.
- Clear filters, search, and sort on a successful upload so the new document is
  in the list the detail panel opens against, and add a "Clear filters" action
  on the no-results state.
- List approved facts read-only after review so the link between a document and
  the records it contributed stays visible instead of "Nothing left to review".
- Format date-group headers through the app's own date formatter, so they honour
  the user's date-format preference instead of a textual medium style.
- Stack the toolbar until md and let it wrap; wrap the fact badge row on narrow
  screens.
- Tie the disabled Approve to a plain-language reason, label the confidence
  percentage, and render it through the locale percent formatter.
- Treat a fully-confirmed re-extract (422) as the calm already-reviewed note,
  matching the partly-confirmed case.
- German: unify on "Angaben" for the extracted items and drop the anglicism.
2026-06-29 00:33:30 +02:00
Marc-André Bombeck ae9ac13213 fix(documents): align the re-extraction guard error code between client and server 2026-06-28 23:50:21 +02:00
Marc-André Bombeck 884323d787 fix(documents): accessibility and review-flow polish for the library UI
Tighten the documents library review surface after a QA pass:

- Surface the provider-unsupported and already-confirmed notes as polite
  live regions (role=status / aria-live) so keyboard and screen-reader
  users hear them when the suppressed-toast paths fire.
- Render a document's detail panel directly after the row that opened it,
  and tie the row's aria-controls to the panel id, fixing reading and
  tab order.
- Gate Discard behind a confirm dialog and toast on failure.
- Disable Upload until a file is chosen, with an inline hint.
- Namespace per-fact editor field ids so simultaneously-open editors no
  longer share DOM ids (label-for associations stay correct).
- Hide Extract once any fact is approved, mirroring the server guard, and
  treat the already-confirmed response as a calm inline note.
- Add a document-date range filter, a kind fallback for the detail title,
  and a screen-reader hint on the new-tab original link.
- Calmer, neutral needs-review badge and more legible row badges.

Adds the new strings across all six locales.
2026-06-28 23:47:23 +02:00
Marc-André Bombeck c6a7de8f2a feat(documents): build the Documents library UI
Evolve the inbound-documents screen into a real document library on top of
the store-first backend.

- Upload stays store-only and always works: the form now files a document
  with an optional title, category, and document date in one step. Extraction
  is a separate, explicit action.
- List becomes an organized library: documents grouped by date, with a title
  (filename fallback), category and status badges, and pending/fact counts. A
  toolbar adds title/filename search, a category filter, and a sort control
  (document date / date added / title, asc/desc). Keyset pagination loads more
  via the returned cursor through useInfiniteQuery.
- Detail panel: view or download the original, edit metadata (rename,
  recategorise, set the document date), optionally run extraction, then the
  existing review-then-confirm flow. When no scan provider is configured,
  extraction surfaces a calm inline note instead of a hard error; the stored
  document is untouched. Discard stays.
- Add a parameterised list key to the query-key factory so each filter/sort
  combination keeps its own cache bucket while staying under the documents
  prefix for invalidation.
- Rename the surface to "Documents" across all six locales (title, subtitle,
  module label/description) and add the new toolbar, edit, extract, and
  date-group strings. Route, model, and module key stay internal.

Add component and unit coverage for the grouped list, the detail/review
surface, the metadata edit form, the provider-unsupported note, and the
search-param/grouping/error-classification helpers.
2026-06-28 23:26:01 +02:00
Marc-André Bombeck 4af13a24e4 Merge v1.25.1: fold the inbound-document disclaimer into the page subtitle
# Conflicts:
#	src/components/documents/inbound-documents-view.tsx
2026-06-28 21:48:48 +02:00
Marc-André Bombeck c10eae2c4e fix(documents): fold the inbound-document disclaimer into the page subtitle
Remove the standalone amber disclaimer card from the inbound-documents
review screen. The non-diagnostic essence now reads as the plain muted
subtitle line under the page title: upload a clinical document and review
the extracted details. Drop the now-unused disclaimer.title /
disclaimer.body keys across all six locales and the AlertTriangle /
CardDescription imports left orphaned by the removal.
2026-06-28 21:35:30 +02:00
Marc-André Bombeck 08314c7c09 feat(documents): view and download the original inbound document
The inbound-document store keeps the uploaded doctor letter encrypted at
rest (InboundDocument.contentEncrypted, AES-256-GCM), but there was no way
to read the original back — the decrypt helper had been dropped as unused.

- Restore decryptDocumentFromBytes in the document store, fail-closed on a
  bad / missing key id.
- Add GET /api/documents/inbound/[id]/original: owner-scoped (userId-narrowed
  query), module-gated, returns the decrypted bytes with the stored MIME
  type. A PDF / image is served inline so the browser can render it; other
  types download as an attachment. The filename is sanitised before it
  reaches the Content-Disposition header. The bytes are PHI — never logged,
  never served cross-user, and a decrypt error fails closed (500) rather than
  returning ciphertext. Cache-Control is private, no-store.
- Surface a 'View original' action in the document review screen.
- Document the route in the OpenAPI registry and regenerate the spec.
- Cover the route with a test: owner-scoped success, attachment fallback,
  404 for another user's document, and fail-closed on decrypt error.

i18n: viewOriginal across all six locales.
2026-06-28 20:33:29 +02:00
Marc-André Bombeck a3b32524f7 fix(documents): skeleton loading, calmer review badge, larger touch targets
The inbound-document list and review panels used centered spinners; swap
them for the app's Skeleton pattern so the resolved content lands without
a layout jump. The low-confidence review badge moves from destructive red
to a calm amber, matching the surface's non-alarming posture. Approve /
reject / edit / save controls grow to the 44px touch floor, the upload
file input gains an accessible label, and each document row exposes
aria-expanded.
2026-06-28 18:29:45 +02:00
Marc-André Bombeck 422778f679 feat(documents): module gate, nav, and review-then-confirm UI
Add the opt-in inboundDocuments module (off by default, like the MCP
endpoint): ingesting a clinical document egresses it to the OCR/vision
provider, so the surface ships dark and the user turns it on from the
Modules hub. The born-gated /documents page carries the prominent
non-diagnostic disclaimer (we read, we don't interpret; nothing is saved
until you approve it; only your clinician can interpret these results),
uploads a document, and walks each extracted fact: source provenance and
confidence are shown, low-confidence facts are blocked from approval until
edited, and the confirm step commits the approved facts.
2026-06-28 16:57:16 +02:00