Commit Graph

93 Commits

Author SHA1 Message Date
Brummel 54a48691d1 audit: cycle copy-close tidy — reconcile spec to shipped structure
Cycle-close audit for the case-page copy+close cycle (spec 0002, plan
0003, feat 0308245). Architect drift review + scripts/check.sh.

Regression gate (scripts/check.sh): exit 0 — 0 failures across 19
stages (common/server/clients-desktop/experiments fmt+clippy+build+test,
wearos assembleDebug+lintDebug+testDebugUnitTest), 74s. The single
`wearos :: lintDebug` WARN is pre-existing and untouched by this
frontend-only cycle; not introduced here, carried forward.

Architect findings and resolution:
- [high] template emits two separate close forms, not the spec's single
  #copy-close-form with a has_document label switch. Resolved as a SPEC
  defect, not a code defect: the spec's single-form snippet was
  infeasible — #doc-body exists only in the has-document match arm, so a
  form anchored after it can never serve the documentless case, and it
  must precede the copy <script> to be bound. The shipped two-form split
  is canonical; spec 0002 §Concrete code shapes + §Data flow reconciled
  to it (this commit).
- [high] the spec's `if (!getElementById("doc-body")) return` guard is
  absent from the shipped handler. Not a defect: the no-document form
  carries no id, so the handler (which binds #copy-close-form) never
  attaches to it — the guard is structurally unnecessary. Spec script
  block + prose updated to state this mechanism.
- [medium] the documentless form's distinctness was unpinned. Added an
  assertion to case_page_empty_case_shows_placeholder that the empty
  case renders no #copy-close-form (it is the bare close form).
- [low] plan counter 0003 vs spec counter 0002: not drift — the naming
  policy is per-directory counters (stable_per_directory_4digit over
  [docs/specs, docs/plans]); independent sequences are expected.
- [note] deferred case-list half of #14 leaves a coherent state
  (refs #14, my_cases.html untouched). No drift.

Recommendation: carry-on. No fix iteration needed; the high items were
spec-vs-code reconciliation (docs) and a test guard, both done here.

refs #14
2026-06-01 14:40:06 +02:00
Brummel 0308245524 feat(web): fuse copy + close into one control on the case page
A finished case is handled in one gesture: a text button below the
document copies the document text to the clipboard and then closes the
case, landing back on /cases. Replaces the icon-only trash-can close
that sat in the H1 title row.

Behaviour:
- Open case with a document: "Kopieren + Schließen" below the document.
  The submit handler awaits the clipboard write inside the user gesture,
  then submits the close form; a FAILED copy aborts the close and reveals
  an inline error, so a finished case never disappears from the default
  list while the clipboard is empty.
- Open case without a document: a bare "Schließen" button (nothing to
  copy) keeps the close affordance the removed trash-can used to provide.
- Closed case: neither button renders; the reopen affordance is
  untouched.

Frontend-only. No Rust route/handler change: POST /cases/{id}/close
already strips the case-page Referer to /cases, so "back to list" needs
no new server work. The two-path clipboard routine (async Clipboard API
+ textarea/execCommand fallback) is extracted into a shared copyDocText()
used by both the standalone icon copy button (kept, copy-without-close)
and the new combined control — one source, no drift.

Tests (RED-first, server/tests/case_page_test.rs): open+document renders
the copy-close form with the close action and CSRF token; closed case
hides it; the H1 trash-can is gone on an open case; the documentless
open case keeps a bare Schließen. Full server suite green, clippy clean,
fmt no-op. The JS submit-handler ordering (copy-before-navigate,
abort-on-fail) has no Rust render harness — same inherent layer gap as
the pre-existing #copy-btn — and is verified manually.

Covers the case-page half of #14; the per-row case-list copy+close is
deferred.

refs #14
2026-06-01 14:35:17 +02:00
Brummel f957d9ddc6 fix(web): disable reverse-proxy buffering on SSE /events
Reverse proxies (OpenResty in front of minerva) buffer text/event-stream
by default, holding events until the response ends. An SSE stream never
ends, so the browser stays silent and WebUI pages never live-reload when
reached via app.doctate.de — while a direct minerva.lan:3000 client
(bypassing the proxy) works, explaining the desktop/Chromebook split.

Emit X-Accel-Buffering: no on the /events response so the proxy streams
it unbuffered; a direct client ignores the header. Return type widens
from Sse<..> to impl IntoResponse to carry the header tuple.

Regression guard: sse_integration asserts the header is present.

refs #12
2026-06-01 13:22:42 +02:00
Brummel 5e86cb59b0 fix(web): make closed cases fully viewable (discovery-gate, not access-gate)
Follow-up to 6d1ca71: that commit made the recordings sub-page render for a
closed case by threading ?show_closed=1, but the page's own links and the
audio player still 404'd — playback was dead and the back link led nowhere.
Root cause: the `.closed` marker was implemented as an ACCESS gate (every
case-scoped read 404s a closed case unless the opt-in flag is threaded
through), while it is documented as a DISCOVERY gate ("hidden from the
default listing"). Threading the flag through every link/redirect is
whack-a-mole; the next new link forgets it.

Resolved in favour of discovery-gate semantics: the `.closed` marker only
filters the default case LIST (scan_user_cases). Direct/deep-link access to
a known, owned case and all its sub-resources is closed-agnostic. IDOR is
unchanged — it comes from the per-user root + existence check, orthogonal
to closed-ness.

Scope 1 — closed cases are fully viewable:
- handle_audio: drop the is_closed -> 404 gate (web.rs). Audio is a
  capability URL behind the same per-user path + slug/UUID/filename
  validation as an open case.
- handle_case_page / handle_case_recordings: always resolve via
  locate_closed_case_or_404; show_closed is no longer an access gate, so
  back links (recordings -> case, case -> list) reach live pages without
  threading the flag.
- the "back to list" links carry ?show_closed=1 when the case is closed
  (computed server-side from the marker) so the user returns to the
  closed-inclusive list, not the default list that hides the case.

Scope 2 — closed means read-only:
- case_page.html withholds the analyze / reset / re-analyze / retry forms
  on closed cases; only the reopen affordance remains.
- case_recordings.html withholds the per-recording delete form on closed
  cases (CaseRecordingsTemplate gains is_closed).
- the mutating handlers (analyze/reset/delete/oneliner) keep rejecting
  closed cases via locate_case_or_404 as defense-in-depth behind the
  now-hidden buttons.

Tests (RED-first):
- web_test: audio serves a closed case's file (200, not 404).
- case_page_test: closed case page + recordings render without
  ?show_closed=1; closed case hides analyze/reset; open case still shows
  delete; closed case hides delete.
- analyze_test: closed_case_returns_404_on_detail rewritten to
  closed_case_detail_renders_read_only — the old test pinned the
  now-superseded access-gate contract.

cargo test (440 passed), clippy, and fmt all clean.

closes #17
2026-06-01 01:42:25 +02:00
Brummel 6d1ca71d61 fix(web): serve closed-case recordings page instead of 404
Following "Aufnahmen anzeigen" on a closed case returned HTTP 404
{"error":"Case not found"} — an apparently empty page. handle_case_recordings
unconditionally resolved the case via locate_case_or_404, which rejects any
case carrying the .closed marker, so the recordings (which stay on disk after
a close — only a marker is written) became unreachable.

Fix mirrors handle_case_page: the handler now takes Query<CaseListQuery> and
branches to locate_closed_case_or_404 when show_closed=1 is requested, falling
back to the IDOR-guarded locate_case_or_404 otherwise. The in-app link in
case_page.html now carries ?show_closed=1 for closed cases (mirroring
my_cases.html), so navigation from a closed case's detail page no longer drops
the flag and 404s.

Reuses the existing CaseListQuery/include_closed() extractor; no parallel type.
Minimal fix, no surrounding refactor.

RED+GREEN combined: regression test case_recordings_honours_show_closed_for_closed_case
in server/tests/case_page_test.rs seeds a closed case with a recording and
asserts the route returns 200 with the recording listed, not 404. Full
case_page_test suite green (18 passed); cargo clippy clean; cargo fmt clean.

closes #17
2026-06-01 01:11:28 +02:00
Brummel a37a1ae363 feat(web): make server WebUI usable on phone viewports
The server-rendered pages worked on desktop but were near-unusable on a
phone: no viewport meta tag (mobile browsers laid out in a ~980px canvas
and zoomed out, rendering everything tiny) and a fixed `max-width: 900px`
desktop body with zero responsive breakpoints.

Fix is CSS-only and purely additive:
- Add `<meta name="viewport" content="width=device-width, initial-scale=1">`
  to all four page templates (each owns its own <head> — askama uses
  import/include here, no shared base layout, so the tag lands in 4 places).
- Append a single `@media (max-width: 640px)` block to the three
  desktop-width pages (my_cases, case_recordings, case_page): case rows
  and action bars reflow to a single column, the audio player goes
  full-width, and primary touch targets (delete/reopen/play) get a 44px
  minimum. login.html already has a 360px column layout, so it needs the
  viewport tag only.

Desktop layout is preserved by construction: a `max-width: 640px` query
never matches at >=900px, so the desktop render is byte-identical. The
diff adds lines only — no existing rule is touched.

Breakpoint chosen at 640px (well below the 900px body width) so all
portrait phones, including the ~390px target, fall under it while
tablets keep the desktop layout.

New integration test server/tests/responsive_test.rs pins the two
textual acceptance criteria that survive without a headless browser:
every page ships the viewport meta, and the three desktop-width pages
ship an @media breakpoint. The visual criteria (operable at 390px, 44px
touch targets) are CSS-driven and verified by eye — deliberately not
covered by a browser-automation dependency.

Verified: cargo test -p doctate-server --test responsive_test (4/4
green); web/login/case_page regression suites green; diff is additive.

closes #10
2026-05-30 15:44:53 +02:00
Brummel 76bf65be1a Refactor: Track analysis jobs via InFlight struct
This commit replaces the disk-based `analysis_input.json` marker for
in-progress analysis jobs with an in-memory
`Arc<RwLock<HashSet<PathBuf>>>`.

Previously, the existence of `analysis_input.json` was used as a signal
that a case was queued or being analyzed. This led to stale "Queued"
states after server restarts, as the disk file would persist while the
server process tracking it had vanished.

The new `InFlight` struct, held in `AppState`, provides a process-local
marker. This ensures that:

- Server restarts correctly reset the state, as the `HashSet` is
  cleared.
- Race conditions for triggering analysis are handled by the `try_claim`
  method, replacing the previous reliance on file system `O_EXCL` flags.
- The `AnalyzeJob` payload now travels with the job over the channel,
  eliminating the need for workers to re-read `analysis_input.json` from
  disk.

This change simplifies state management and improves the reliability of
analysis job tracking.
2026-05-05 20:01:07 +02:00
Brummel 16ef0bbe78 Introduce boot_time to evaluate_state
The `boot_time` parameter is introduced to `evaluate_state` and its
callers. This parameter acts as a lower bound for the "seen at"
timestamp used in determining the `Idle` state for cases.

Previously, the `elapsed` duration was calculated as
`now.duration_since(latest_mtime)`. This meant that a server restart
could immediately cause old recordings to be classified as `Idle` if
their modification times were significantly in the past, potentially
triggering a large number of LLM calls upon the first render of the
`/cases` page.

By using `seen_at = std::cmp::max(latest_mtime, boot_time)`, the idle
calculation now ensures that a fresh server start respects a grace
period. Old recordings will not satisfy the idle threshold until the
elapsed time since the server's boot (or the recording's modification
time, whichever is later) exceeds the `idle_threshold`.

`SystemTime::UNIX_EPOCH` is used as the default `boot_time` in tests and
in `create_router_and_session_store_with_settings`. This preserves
existing test behavior where the idle grace period is effectively
disabled. For production, `main.rs` now captures the server's actual
boot time and passes it down.

This change also refactors `evaluate_case` and `try_enqueue` to accept
the new `boot_time` parameter. `try_enqueue_all_for_user` is updated to
pass `boot_time` along. `boot_scan_state` and `compute_state_for_user`
in `recovery.rs` also receive `boot_time`.

Additionally, a new test case
`analyze_required_endpoint_enqueues_only_required_cases` is added to
verify the behavior of the `/cases/analyze-required` endpoint. This
endpoint now correctly enqueues only `Required`
2026-05-05 15:12:08 +02:00
Brummel 0660727faf Refactor auto-trigger and recovery logic
This commit introduces a significant refactor to the auto-trigger and
recovery mechanisms for the analysis pipeline. The core change is the
introduction of a more granular `CaseAnalysisState` enum, replacing the
simpler `AutoDecision`.

The `evaluate_state` function now computes the full lifecycle state of a
case, considering factors like the presence of recordings, pending
transcripts, queued jobs, failure markers, document staleness, and an
idle threshold. This provides a richer understanding of the case's
status.

The `evaluate_case` function is retained as a backwards-compatible
adapter for existing callsites and tests. It maps the new
`CaseAnalysisState` to the old `AutoDecision` enum, simplifying the
decision to "enqueue" or "skip."

The `recovery` module is also refactored. The `scan_and_enqueue`
function is replaced by `boot_scan_state`, which is now purely
observational. It aggregates statistics about cases in different states
at boot time and logs them, but it no longer attempts to re-enqueue
jobs. The responsibility for triggering analysis now lies with the
per-render `try_enqueue_all_for_user` logic, which utilizes the new
typed state machine.

This refactoring aims to provide more clarity and control over the
analysis pipeline's state management, particularly in handling cases
that have previously failed or are waiting for further input.
2026-05-05 14:26:05 +02:00
Brummel 9e8db7518d Refactor document handling to JSON envelope
The `document.md` file has been replaced by `document.json`. This new
format acts as a JSON envelope, containing the original markdown content
along with metadata such as `covered_mtime`, `written_at`, and
`backend_id`.

This change addresses a bug where new recordings arriving during an LLM
analysis call could be ignored. The `covered_mtime` field in the new
JSON envelope ensures that the document's metadata accurately reflects
the state of recordings at the time of analysis, preventing incorrect
"analysis current" states.

Additionally, the `analysis_input.json` file is now removed upon
successful analysis, and a failure marker is used to indicate analysis
failures. This ensures that a case is re-analyzed if necessary.

The `analysis_idle_threshold_secs` configuration option has been
introduced to control the delay before a case in the `Required` state is
automatically promoted to `Idle` and enqueued for LLM analysis.
2026-05-05 14:01:43 +02:00
Brummel 44e93f08ee feat(analyze): structured LLM failure diagnostics + SSE reload on failure
Failure markers now carry finish_reason and usage tokens (prompt /
completion / reasoning) pulled from the chat-completions response, so
the case-page "Technische Details" block can show why an analysis
truncated — e.g. gpt-oss-120b hitting max_completion_tokens with the
chain-of-thought before producing any visible answer.

The worker also emits a new CaseEventKind::AnalysisFailed after writing
the marker, so subscribed browsers reload immediately instead of leaving
the page on the "Wird analysiert …" placeholder.
2026-05-05 11:01:29 +02:00
Brummel 2c6062a53e refactor: drop /web/ URL prefix from browser routes
The /web/ prefix predated the /api/ split; today it just clutters every URL
without disambiguating anything. All 16 browser routes move to the apex
(/cases, /login, /magic, /events, /audio/...). The 6 /api/* routes are
unchanged. A new /->>/cases redirect closes the apex 404.

The open-redirect guard in magic.rs and case_actions.rs flips from a
positive whitelist (starts_with("/web/")) to a deny-list: same-origin path,
not protocol-relative, not under /api/, no \. The /api/ exclusion is now
load-bearing and covered by tests.

Pre-production: no transition redirects.
2026-05-04 18:36:10 +02:00
Brummel 2fc7f8ddd2 feat: Add display name to user profiles
Introduce `display_name` field to `User` struct for human-readable names
in the UI.
This falls back to the user's `slug` if not provided.

Update `AuthenticatedWebUser` and `MyCasesTemplate` to use this new
field for rendering.
Add tests for parsing `display_name` and ensure fallback logic works
correctly.
Update `users.toml.example` to document the new field.
2026-05-04 11:46:19 +02:00
Brummel 1b6f4bde67 feat: Handle analyze retry with failure marker
When a previous analysis attempt fails and leaves a failure marker, the
retry mechanism now correctly identifies this state. It removes the
stale
input file, allowing the retry to proceed without a conflict error. This
ensures users can recover from analysis failures more gracefully.
2026-05-03 19:33:09 +02:00
Brummel 23ef84d9d8 feat: multi-backend LLM layer with per-request choice on case page
Replace the single [llm] block in settings.toml with a curated catalog
of LLM "backends" (provider + model + sampling params + system prompt)
in server/src/analyze/backend.rs. Two backends ship initially:
gpt_oss_120b (default, reasoning_effort=medium) and llama_3_1_405b
(uses response_format: json_schema to sidestep the <|eot_id|> leak).

The case page now renders one submit button per available backend; the
clicked button's name=backend value rides through AnalysisInput.backend_id
to the worker, which looks it up via find_backend() with default fallback.
A submitted unknown backend id is rejected as 400. .analysis_failed.json
records the failed backend and the failure banner surfaces its label.

The API key now comes from IONOS_API_KEY (env), not settings.toml; the
[llm] section is read into a discarded field so old configs still load.
Backends without a satisfied requires_api_key are filtered from the UI
(any_backend_available()). Three end-to-end tests that mocked the LLM
endpoint via settings.llm.url are #[ignore]'d with a clear note — they
need per-test backend injection (Arc<Vec<LlmBackend>> through the worker)
before they can be re-enabled.
2026-05-03 14:57:21 +02:00
Brummel cbb072d0cc feat: Show LLM failure banner and retry button
When an LLM analysis fails, a `.analysis_failed.json` marker is created.
If no document exists yet and the auto-trigger is blocked by this
marker,
the case page must display a banner. This banner provides the failure
reason and a button to retry the analysis, ensuring users are not left
in a dead-end state.

The `read_failure_marker` function is made public to allow the web layer
to access this failure information. The `CasePageTemplate` is updated to
include `analysis_failed` data, which conditionally renders the new
`.failure-banner` HTML.

This change prevents cases from becoming unrecoverable due to transient
LLM errors.
2026-05-03 13:53:55 +02:00
Brummel bb584b6ea0 Refactor: Remove unused Whisper variants
This commit removes the `whisper_variant` and `whisper_hotwords_variant`
fields from the `run_id` generation and the `print_meta_summary`
function.

These variants are no longer used as the project is shifting focus to
LLM-based generation. The `run_full_case.rs` example has also been
updated to reflect this change.
2026-04-30 16:56:12 +02:00
Brummel 427a28ac6b Add loudness data to recording metadata
This commit introduces the `Loudness` struct to store replay-gain data
(mean, max, and calculated gain in dB) derived from `ffmpeg`'s
`volumedetect`.

The `RecordingMeta` struct is updated to include an optional `loudness`
field. This allows for consistent playback volume across recordings with
varying microphone levels by applying gain in the browser.

Additionally, new `server/src/loudness.rs` and
`server/src/transcribe/ffmpeg.rs` modules are added. The former defines
constants for replay-gain targets and logic to compute gain, while the
latter handles audio analysis using `ffmpeg` to extract loudness
metrics.

The `#[serde(default)]` attribute on the `loudness` field in
`RecordingMeta` ensures backward compatibility with older metadata files
that do not contain this field. Tests are included to verify round-trip
serialization and parsing of older formats.
2026-04-27 16:11:02 +02:00
Brummel cd662141e0 Add input validation for web endpoints
Introduces a new `validate` module to centralize input validation for
web
requests. This module contains functions to check the shape and format
of
various user-supplied strings before they are processed by the main
application logic.

Specifically, this commit adds validation for:
- Login slugs: Ensures slugs conform to a strict pattern (lowercase
  alphanumerics, `_`, `-`) to prevent path traversal and other attacks.
- Magic link tokens: Validates token length and character set to ensure
  they are URL-safe and within expected bounds.
- Recorded at timestamps: Enforces a strict RFC3339 format with second
  granularity (`YYYY-MM-DDTHH:MM:SSZ`) to prevent filename parsing
  issues
  and ensure consistent data grouping.

These validators are integrated into the `handle_login_submit`,
`handle_consume` (magic link), and `handle_upload` routes. This change
enhances security by preventing malformed inputs from reaching sensitive
parts of the application and improves robustness by catching data format
errors early.

Unit and integration tests have been added to verify the functionality
of
these validators and their integration into the respective endpoints.
2026-04-27 15:19:23 +02:00
Brummel c15590f3e0 Refactor transcript file handling to use JSON metadata
This commit changes the way transcriptions are stored and accessed.
Instead of using plain text files (`.transcript.txt`), transcriptions
will now be part of a JSON metadata file (`<stem>.json`). This allows
for richer metadata to be stored alongside the transcript, such as
duration, and provides a more robust mechanism for tracking
transcription states.

The changes include:
- Updating documentation and code to reflect the new `.json` file
  extension.
- Modifying file handling logic to read and write JSON metadata.
- Adjusting tests to accommodate the new file format.
2026-04-27 13:08:36 +02:00
Brummel 66b3b7e4c8 Refactor recording metadata to JSON sidecar
Replaces the `.transcript.txt` sidecar with a structured `.json` file
for recording metadata. This change consolidates transcript text,
duration, and other potential metadata into a single, extensible JSON
object.

This also refactors the `TranscriptState` enum to better represent the
on-disk state (absence of file means pending) and the in-memory
representation. The `Transcript` enum now specifically models the
terminal outcomes of the transcriber (`Silent` or `Content`).

The commit includes updates to documentation, data structures, path
handling, and various tests to align with the new metadata format.
2026-04-27 12:48:25 +02:00
Brummel a510c20e75 refactor(server): plumb Arc<Settings> through workers and routes
Config sheds its 14 Bucket-B fields (retention, whisper, ollama, llm) and
gains a sibling Arc<Settings> in AppState, loaded from settings.toml at
startup via Settings::load_or_default. Workers (transcribe, analyze,
recovery, auto_trigger) and routes (health, bulk, case_actions,
user_web) take settings as a separate parameter or State<> extractor;
Config::llm_configured moves to Settings::llm_configured.

TestConfig::build_pair() returns (Arc<Config>, Arc<Settings>); the new
create_router_with_settings / create_router_and_session_store_with_settings
helpers wire both into integration tests that exercise LLM/Whisper/Ollama.
The legacy single-Arc create_router falls back to Settings::default()
so the 17 non-LLM tests stay untouched.

Verified: cargo build clean, clippy --all-targets clean, 318 tests pass.
2026-04-27 11:48:41 +02:00
Brummel 9a00b592f0 fix: Preserve manual oneliner across delete and reset paths
Consolidate the sticky-Manual rule for oneliner.json into a single
helper paths::delete_oneliner_unless_manual that holds the per-case
lock, reads the state, keeps OnelinerState::Manual, and removes any
LLM-derived variant. Three previously-direct deletion paths now route
through it: handle_delete_recording, reset_case_artefacts (admin
single-reset), and bulk_reset (admin bulk action). Before this fix a
clinician's manual override was wiped by a recording delete, letting
the LLM auto-regen path overwrite it on the next view.

Tests: 4 unit tests for the wrapper contract, integration tests for
the recording-delete and case-reset endpoints (Manual preserved,
Ready wiped), and a static-scan invariant that fails on direct
remove_file(... ONELINER_FILENAME) calls outside paths.rs.
2026-04-26 22:09:08 +02:00
Brummel eaed8f0dcd fix: Inline recording-delete confirm + stable redirects
- Replace native confirm() in the recording list with an inline
  two-step confirm row; hide the player via visibility:hidden
  while open so the row height stays stable.
- Hard-redirect recording delete back to /web/cases/{id}/recordings
  instead of relying on the Referer header (which silently fell
  back to /web/cases when stripped).
- Switch analyze/reset to a hidden return_to form field so the
  source page (case detail vs. case list) is preserved reliably,
  with same-origin /web/ prefix validation.
- Tighten delete_recording_test on the concrete Location header;
  adjust analyze/reset redirect expectations to the new
  case-detail fallback.
2026-04-26 17:52:29 +02:00
Brummel 03736b6993 feat: Add web UI for manual oneliner overrides
Adds a new POST endpoint for web-based oneliner overrides and updates
the UI to allow manual editing. The person icon replaces the pencil icon
for doctor-authored titles, signifying manual authorship rather than
editability.
2026-04-26 17:17:09 +02:00
Brummel c769abe3d5 feat: Add per-case dir oneliner locks
Introduces `OnelinerLocks` to serialize read-modify-write operations on
`oneliner.json`. This prevents race conditions between the manual
override API (`PUT /api/cases/{case_id}/oneliner`) and the transcription
worker's auto-regeneration process.

The manual override now acquires a lock specific to the `case_dir`
before writing. The transcription worker's `update_oneliner` function
also acquires the same lock around its post-LLM re-read-and-write
sequence. This ensures that a doctor's manual edit always takes
precedence, even if it arrives while an auto-regeneration is in
progress.

This change also includes:
- A new `routes::oneliner_override` module for the PUT handler.
- Validation for the oneliner text length and emptiness.
- Integration tests for the override functionality, covering happy path,
  error conditions, early latching, race conditions, and parallel PUTs.
2026-04-26 16:05:01 +02:00
Brummel 813fb896ff refactor: consolidate bulk actions, URL assembly, and case-artefact filenames
Pulls three pattern groups into shared helpers so a rename or wire-format
tweak edits one file instead of 10+:

- BulkAction enum in doctate-common replaces the "close"/"analyze"/"reset"
  string literals that were duplicated between the bulk handler and 3
  attack/CSRF test files. FromStr preserves the exact "Unbekannte Aktion"
  error shape; the handler match is now exhaustive over the enum.
- join_url helper in doctate-common absorbs 7 identical
  trim_end_matches+format! call sites across client-core, client-desktop,
  server/transcribe (ollama, whisper), and server/analyze (llm).
- server/tests/common/artefacts.rs re-exports ONELINER_FILENAME
  (doctate-common), DOCUMENT_FILE + ANALYSIS_INPUT_FILE
  (doctate-server::analyze), and CLOSE_MARKER (doctate-server::paths) so
  test files reference the canonical name instead of inlining literals.

Also lifts client-desktop local duplication:
- paths.rs: project_path helper collapses 4 identical ProjectDirs chains
- main.rs: or_die helper replaces 4 eprintln!+exit(1) blocks
- app.rs: named RecordingContext struct replaces (Uuid, String) tuple
  at 3 sites around the ffmpeg-flush finalization path

Verification: 397 tests pass (baseline was 389; +8 for new unit tests on
BulkAction and join_url), 0 failed, 4 ignored (unchanged). clippy clean.
2026-04-22 12:09:46 +02:00
Brummel af3377a6bc refactor(tests): migrate remaining batches to tests/common/
Finishes the lift of shared helpers into `tests/common/`. Covered:
- health, web, oneliners_api, upload (31 tests; upload exercises the
  new `multipart_upload_body` helper driven by doctate_common field
  constants)
- sse_integration, sse_cleanup, transcribe, oneliner_heal_decoupled,
  silent_case_empty, failed_only_case_empty_oneliner,
  transient_failure_retries (24 tests)

Also applied cargo fmt across the test tree and fixed one clippy
needless_borrows_for_generic_args warning in analyze_test.

All 387 tests pass; 3 ignored (as before).

Side effect: health_test previously used a hardcoded `/tmp/doctate-test`
data path, which parallel `cargo test` runs could collide on. The
migration replaces it with the common unique-tmpdir pattern, removing
a latent flake.
2026-04-22 10:51:33 +02:00
Brummel f438e972d4 refactor(tests): introduce shared tests/common/ support module
Lift duplicated test-harness code (User factories, TestConfig builder,
login/CSRF flow, form/json helpers, case-directory seeding, URL path
builders) into `server/tests/common/` so future API changes touch one
file instead of every integration test.

Migrated batches: csrf_attack, auth, login, magic_link, security_headers
(28 tests); analyze, case_page, delete_recording, close_watermark,
retention_sweep (67 tests). All 95 tests still green.

Net line delta across these 10 files: +1113 / -1896 (~780 lines removed),
plus ~500 lines of new shared infrastructure under tests/common/.
2026-04-22 10:42:51 +02:00
Brummel 2e3b5efe86 feat: render CSRF token into state-changing forms
Adds the csrf_token hidden field to every POST form in the web UI
(logout, bulk, purge-closed, close, reopen, analyze, reset,
delete-recording). Login stays without a token — SameSite=Strict
already blocks the relevant attack shapes and forced-login CSRF
has no impact here.

Shape:
- New askama macro partials/csrf_field.html renders the hidden
  input; 3 templates import + {% call csrf::field(csrf_token) %}
  inside each <form method="POST">.
- AuthenticatedWebUser carries csrf_token (cloned out of the
  session once during extraction) so render handlers don't need a
  second store lookup.
- 3 template structs (MyCasesTemplate, CasePageTemplate,
  CaseRecordingsTemplate) gain the field; render sites pass
  user.csrf_token through.

Safety-net tests:
- Each rendered page must contain the exact session csrf_token in
  a hidden input — catches anyone adding a new form without the
  macro.
- Happy-path round-trip: fetch page, parse token from HTML, POST
  with token → 303. Catches drift between rendered and accepted
  token formats.
2026-04-22 10:04:21 +02:00
Brummel bf3e9ba6cc feat: server-side CSRF token validation
Introduces CsrfForm<T>: a FromRequest extractor that looks up the
session's csrf_token and constant-time-compares it to a csrf_token
field in the form body. Drop-in replacement for Form<T> on every
state-changing /web/ POST handler. Missing or expired session →
redirect to /web/login; malformed body → 400; token mismatch → 403.

WebSession carries csrf_token, minted alongside the session token at
login and magic-link consume. Not rotated per request — rotation
would break multi-tab use and buys little over SameSite=Strict
cookies.

Handlers: bulk, purge-closed, reset, close, reopen, analyze,
delete-recording, logout all now require CsrfForm<_>. Login stays
unprotected (SameSite=Strict alone is sufficient — forced-login CSRF
has no impact on this codebase). /api/... is header-auth, exempt.

Constant-time compare via subtle::ConstantTimeEq avoids timing
oracles on the token.

Template work is NOT in this commit — browser forms still post
without csrf_token, so the live web UI will 403 until Schritt 6
(templates render the hidden field).

Tests: all 12 red specs in csrf_attack_test.rs now green, #[ignore]
removed. Integration tests that POSTed to protected endpoints
switched to a new create_router_and_session_store entrypoint which
returns a handle to the store so tests can read csrf_token for the
active session.
2026-04-22 09:59:17 +02:00
Brummel f3d0380dbd feat: add defense-in-depth security-header layer
Attaches a SetResponseHeaderLayer stack to create_router_with_state
(not main.rs) so tests observe the same response shape as production.
`if_not_present` mode so per-route overrides (magic.rs already sets
its own Referrer-Policy) are preserved.

Sets: X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
Content-Security-Policy, Permissions-Policy. HSTS is deliberately
omitted until TLS termination is in place — a cached max-age on a
plain-HTTP deployment is irreversible.

CSP uses 'unsafe-inline' for script/style since templates contain
inline scripts; revisit if any user input ever renders unescaped.

Removes #[ignore] from the 10 attack-confirming header tests; two
regression anchors (no-HSTS, no-duplicated magic header) were already
green.
2026-04-22 09:33:46 +02:00
Brummel 34aa633d32 feat: Add CSRF and security headers tests
Adds comprehensive tests for CSRF protection and security headers to
ensure robust defense against common web attacks.
2026-04-22 09:19:25 +02:00
Brummel 1182f4817e Enforce admin-only for bulk and purge actions
Move admin checks from individual bulk actions to the main handler for
`bulk.rs` and `case_actions.rs`. This consolidates the authorization
logic for these sensitive operations.

Additionally, refine the HTML template to conditionally render bulk
action UI elements and the purge form only when the user is an admin.
This ensures that non-admin users do not see or have access to these
administrative functions.

Update tests to reflect these changes, ensuring that non-admin users are
correctly rejected for these actions and that the UI is properly hidden.
2026-04-21 19:48:24 +02:00
Brummel 661ea6215e Add analysis preview to case list
Introduce a `preview_lines` setting in `users.toml` to control the
number of visible lines for the analysis preview in the case list. This
feature extracts the first paragraph of the `document.md` file, strips
markdown formatting, and displays it, capped by the configured
`preview_lines`. The actual line clamping is handled client-side via CSS
`line-clamp`.
2026-04-21 17:25:14 +02:00
Brummel 3218fc62bd feat: server-authoritative case window via users.toml
The /api/oneliners time window is now read per-user from users.toml
(window_hours, default 72h). Clients no longer carry a window:
client.toml oneliner_window_hours, SyncConfig.window_hours, the
?hours=N query param, and the render_case_list cutoff filter are
gone. ETag suffix keeps the effective hours so an admin edit to
users.toml invalidates client caches on the next request.
OnelinersResponse.window_hours stays in the wire format, but now
exists solely to anchor client reconciliation.
2026-04-21 16:48:01 +02:00
Brummel 1f32d4dd23 fix: separate transient from permanent transcribe failures
Before, every Whisper error (5xx, timeout, Minerva down, corrupt audio,
4xx) renamed `<ts>.m4a` to `<ts>.m4a.failed` uniformly, turning transient
outages into permanent sackgassen that only a manual admin reset could
undo. Cases whose every recording was `.m4a.failed` stuck without a
persisted oneliner state; the UI masked them via a fallback in
`compute_oneliner_display`.

The core insight: a recoverable error is not an error. Transient Whisper
failures now leave the audio as plain `.m4a` so the existing page-load
heal (`enqueue_pending_for_user`) re-enqueues it on the next refresh —
which is exactly what happens when Minerva comes back. No new sidecar,
no retry count, no scheduler.

Changes:
- `WhisperError::is_transient` classifies `Http`, 5xx, 408, 429 as
  transient; `Io` and other 4xx as permanent.
- Transcribe worker: transient → info log + continue (audio stays .m4a);
  permanent → `mark_failed` as before.
- `has_any_transcript` counts `.m4a.failed` as terminal, so
  `update_oneliner` runs for failed-only cases and settles
  `OnelinerState::Empty` (analogous to the silent-only fix in 4531f85).
- New verdrängender `fehler`-Badge (#c00) in case list and detail when
  at least one `.m4a.failed` exists; recording-level message shortened
  to plain "Transkription fehlgeschlagen".

Tests:
- New integration: transient 503 leaves `.m4a` intact, heal recovers.
- New integration: `.m4a.failed`-only case settles to
  `OnelinerState::Empty` without calling Ollama.
- New unit: `is_transient` table test across relevant status codes.
- New unit: `has_any_transcript` returns true for `.m4a.failed`-only
  case and false for pending-only case.
- Existing worker test retargeted from 500 to 400 and renamed; added
  companion `worker_leaves_m4a_intact_on_transient_whisper_error`.
2026-04-21 14:39:31 +02:00
Brummel 4531f85b13 fix: settle silent-only cases to Empty oneliner state
Introduce `TranscriptState { Pending, Silent, Content }` in
doctate-common as the canonical three-way state of a recording's
`.transcript.txt` sidecar. Previously each call-site projected the
raw `Option<String>` / `.exists()` onto its own 2-state view and the
projections disagreed: the UI treated a 0-byte silent transcript like
`Content` while the oneliner worker treated it like `Pending`,
leaving silent-only cases stuck on "generiere Titel …" forever with
no persisted oneliner.json.

`update_oneliner` now settles a silent-only case to
`OnelinerState::Empty` when no `Content` transcript exists and no
recording is still `Pending`, so the UI resolves to "unbenannt" and
recovery treats it as terminal.

Single reader: `paths::read_transcript_state`. All call-sites
(scan_recordings, compute_oneliner_display, has_pending_recordings,
all_transcripts_joined, read_recordings, enqueue_pending_for_user,
scan_m4as, cases_needing_oneliner_in, case_recordings.html) go
through the same typed abstraction and must handle `Silent` via
exhaustive match.

Regression tests:
- silent_case_empty_test: heal path settles silent-only case to
  Empty without calling Ollama
- case_page_silent_only_shows_empty_not_generating: UI renders
  "unbenannt", not "generiere Titel …"
2026-04-21 13:37:51 +02:00
Brummel 3d42d1da82 fix: decouple oneliner-heal from request handler tasks
heal_orphans_if_idle called regenerate_missing_oneliners_for_user
inline on the request task, blocking the browser for up to N×60s
when orphans existed (observed with 16 cases after manual cleanup).

Now: CAS on new OnelinerHealBusy flag + tokio::spawn + BusyGuard
reset-on-drop. Handler returns immediately; the existing
OnelinerUpdated SSE event delivers results via the usual reload.
Startup recovery shares the flag so a concurrent page-load during
boot cannot fire a parallel regen loop against Ollama.

Regression test uses wiremock + timeout(150ms) + .expect(5) to
verify both non-blocking return and dedup of parallel invocations.
2026-04-21 12:56:02 +02:00
Brummel 8b1f58ba23 fix: preserve show_closed view + ungrey closed-case controls
Three UX polish items for the show-closed listing, grouped because
they are the same root problem in three surfaces:

* handle_reopen_case now redirects via resolve_return_path(&headers)
  instead of hard-coding /web/cases, so a reopen from the
  ?show_closed=1 listing stays in that view. Matches the pattern
  already used by analyze/reset/delete-recording.

* handle_close_case now redirects via the new helper
  resolve_list_return_path(&headers), which wraps resolve_return_path
  and additionally collapses a /web/cases/{uuid} detail path to
  /web/cases while keeping the query string. A close from the detail
  page previously would have tried to redirect back to the now-404
  detail, or when triggered from ?show_closed=1 would have dropped
  the query.

* Closed cases in my_cases.html now render the checkbox,
  Analysieren/Neu analysieren and Reset controls in disabled state
  instead of being hidden. Layout stays consistent with mixed-state
  listings, and the user can see what actions would be available
  after reopen. The handlers remain the authoritative guard via
  locate_case_or_404 -> 404 for closed cases, so the HTML disabled
  is a hint only.

Adds four unit tests for resolve_list_return_path and two integration
tests for the new redirect behaviour (close from listing with query,
close from detail with query).
2026-04-21 11:20:17 +02:00
Brummel 23e828df45 feat: show-closed toggle, closed badge, reopen button, bulk purge
GET /web/cases now honours ?show_closed=1: the toggle link flips the
listing between open-only (default) and open+closed (muted styling,
grey "geschlossen" badge, countdown "wird in N Tagen entfernt" when
auto_delete_days > 0). Each closed row shows a Lucide rotate-ccw
reopen button in the same slot where the close button sits on open
cases; selection checkboxes are suppressed so bulk-close cannot touch
closed cases.

GET /web/cases/:id honours ?show_closed=1 too: without the query a
closed case still 404s (keeps the default listing clean), with it the
detail page renders the reopen form in the header. The case_page
template swaps the icon based on is_closed.

The purge-closed bulk button from the earlier commit finally gets a
UI: it appears below the listing when show_closed=1 is active AND at
least one closed case is visible, with a JS confirm() + the
server-side confirm=yes guard. Two integration tests (listing +
detail) cover the happy path.
2026-04-21 10:58:00 +02:00
Brummel f358da215d feat: lazy retention sweep on /web/cases
New module retention::sweep_user_retention is invoked at the top of
handle_my_cases, using the current user's retention policy. Per case:
step 1 auto-closes when the newest .m4a mtime is older than
auto_close_days; step 2 auto-purges when closed_at is older than
auto_delete_days. Order matters - a case auto-closed in the same sweep
has closed_at ~= now and naturally survives the purge check,
preserving the full grace period after a vacation.

Race guard: purge re-reads the close marker immediately before
remove_dir_all so a concurrent upload that reopens the case cancels
the purge. Every automatic action emits an info! line with reason and
threshold; failures warn but never abort the sweep.

Seven integration tests cover both threshold paths, the disable-via-0
escape hatch, the newest-recording rule, the vacation scenario, and
the open-case skip. Tests age the comparison objects (mtime via
filetime, closed_at as handwritten RFC3339) instead of faking "now" -
no clock abstraction needed.
2026-04-21 10:51:27 +02:00
Brummel e5a62d8f10 feat: add per-user retention settings to users.toml
New [user.retention] TOML sub-block with auto_close_days and
auto_delete_days. Both default to 0, which disables the respective
sweep — a conservative default that preserves current behaviour for
existing deployments and lets admins test auto-close in isolation.

Only parsing + the test User literals are touched here; the actual
lazy sweep consuming these values lands in the next commit.
2026-04-21 10:48:24 +02:00
Brummel 8173ff2f26 feat: add POST /web/cases/purge-closed for bulk hard-delete
Iterates the user's data dir and removes every case directory that
carries a .closed marker. Requires form field confirm=yes as a server-
side guard against accidental browser-history re-POSTs. Emits CasePurged
per successfully removed case; per-case failures are logged but do not
abort the sweep.

UI button is deferred to the show-closed step so the endpoint is only
reachable after an explicit navigation. Three integration tests cover
the happy path, missing-confirm, and coexistence with open cases.
2026-04-21 10:46:07 +02:00
Brummel 396565a571 refactor: replace delete/undo flow with close/reopen endpoints
- POST /web/cases/:id/delete renamed to /close; emits CaseClosed.
- New POST /web/cases/:id/reopen removes the marker; emits CaseReopened.
- Bulk action "delete" renamed to "close"; the shared closed_at timestamp
  still groups the selection for future UI features.
- POST /web/cases/undo-delete removed along with summarize_latest_close_group,
  latest_close_timestamp and restore_close_group. Per-case reopen makes
  the group-undo banner obsolete.
- CaseEventKind::CaseDeleted -> CaseClosed, CaseRestored -> CaseReopened.
- Templates updated to Lucide trash-2 icon, German label "Fall schliessen".
- Tests migrated; delete_watermark_test.rs moved to close_watermark_test.rs.
2026-04-21 10:43:39 +02:00
Brummel 9410d6daaa refactor: rename soft-delete marker from .deleted to .closed
Internal rename: DELETE_MARKER -> CLOSE_MARKER, DeleteMarker ->
CloseMarker, is_deleted -> is_closed. The batch UUID is dropped;
undo now groups cases by their exact closed_at timestamp, which
bulk-close writes identically across its selection while per-case
close writes a unique stamp.

Behavior unchanged. No migration (project is pre-production);
legacy .deleted files in tmpdata were removed manually.
2026-04-21 10:36:55 +02:00
Brummel 5effa7c96e feat: Add endpoint to delete recordings
This commit introduces a new API endpoint for deleting recordings. The
endpoint
handles the removal of the audio file and its associated sidecar files
(transcript,
duration). It also invalidates derived artifacts like oneliner.json,
document.md,
and analysis_input.json. Additionally, it includes checks for path
traversal,
correct file extensions, and case ownership to ensure security and data
integrity.
A new `RecordingDeleted` event is also added to the `CaseEventKind`
enum.
2026-04-20 15:48:34 +02:00
Brummel d65720c671 Refactor oneliner storage to use enum
The oneliner is now stored as `OnelinerState` enum which can represent
three states: `Ready`, `Empty` or `Error`. This allows the client to
differentiate between a case with no medical content and a case where
the oneliner generation failed.

The `OnelinerState` enum is serialized to JSON and stored in
`oneliner.json` file. The `OnelinerEntry` struct has been updated to
reflect this change.

The client-desktop application has been updated to handle the new
`OnelinerState` enum and display appropriate UI elements for each state.

The server-side code has also been updated to read and write the
`OnelinerState` enum, and to handle the new file format.
The `reset_case_artefacts` function in
`server/src/routes/case_actions.rs` has been updated to remove
`oneliner.txt` and create `oneliner.json` instead.

The tests have been updated to reflect these changes.
2026-04-20 12:37:48 +02:00
Brummel 224ee60363 feat: Handle empty Ollama responses gracefully
Introduce `OllamaError::EmptyResponse` to represent cases where the
Ollama
model returns an empty result due to silence rules or lack of keywords.

This change prevents treating an empty response as a parse error and
logs
it at a lower severity level. A new integration test verifies this
behavior.
2026-04-20 11:24:44 +02:00
Brummel 691f5d0c2b Add test for SSE connection pool leak 2026-04-20 09:18:37 +02:00