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.
This commit is contained in:
2026-04-26 17:17:09 +02:00
parent d732fdd8ec
commit 03736b6993
9 changed files with 531 additions and 49 deletions
+6
View File
@@ -61,6 +61,12 @@ pub fn recording_delete(case_id: &str) -> String {
format!("/web/cases/{case_id}/recordings/delete")
}
/// `POST /web/cases/{case_id}/oneliner` — session-authenticated manual
/// oneliner override (browser web UI).
pub fn case_oneliner_web(case_id: &str) -> String {
format!("/web/cases/{case_id}/oneliner")
}
/// `GET /web/magic?token={token}` — magic-link consumption.
pub fn magic(token: &str) -> String {
format!("/web/magic?token={token}")
+242 -2
View File
@@ -34,7 +34,10 @@ use doctate_server::{
AnalyzeBusy, AppState, OnelinerHealBusy, TranscribeBusy, analyze, transcribe, web_session,
};
use common::{TestConfig, body_json, seed_case, seed_oneliner_state, test_user};
use common::{
TestConfig, body_json, csrf_form_post, form_post, login, login_with_csrf, paths, seed_case,
seed_oneliner_state, test_user, write_closed_marker,
};
const SLUG: &str = "dr_a";
const API_KEY: &str = "key-dr_a";
@@ -54,6 +57,17 @@ fn put_request(case_id: &str, body: &str, api_key: Option<&str>) -> Request<Body
/// state (for direct access to `oneliner_locks`, `events_tx`, etc.)
/// and a router that wraps it.
fn build_app(cfg: Arc<doctate_server::config::Config>) -> AppState {
build_app_with_events_tx(cfg, doctate_server::events::channel())
}
/// Variant of [`build_app`] that lets the caller pre-create the events
/// channel — needed when a test wants to subscribe BEFORE the request
/// fires, since the broadcast channel only retains messages for live
/// subscribers.
fn build_app_with_events_tx(
cfg: Arc<doctate_server::config::Config>,
events_tx: doctate_server::events::EventSender,
) -> AppState {
let (transcribe_tx, _transcribe_rx) = transcribe::channel();
let (analyze_tx, _analyze_rx) = analyze::channel();
AppState {
@@ -66,7 +80,7 @@ fn build_app(cfg: Arc<doctate_server::config::Config>) -> AppState {
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
oneliner_heal_busy: OnelinerHealBusy(Arc::new(AtomicBool::new(false))),
oneliner_locks: OnelinerLocks::new(),
events_tx: doctate_server::events::channel(),
events_tx,
http_client: reqwest::Client::new(),
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
}
@@ -400,3 +414,229 @@ async fn parallel_puts_serialize_via_mutex() {
other => panic!("expected Manual, got {other:?}"),
}
}
// =====================================================================
// 9-15. Web-UI endpoint: POST /web/cases/{case_id}/oneliner
//
// Same disk semantics as the API PUT (both call `apply_manual_override`),
// but with browser auth: session cookie + CSRF form field. The closed-
// case test pins down the only intentional behavioural divergence —
// the web handler rejects closed cases via `locate_case_or_404`,
// whereas the API endpoint accepts them.
// =====================================================================
#[tokio::test]
async fn web_put_oneliner_writes_manual_state() {
let cfg = TestConfig::new()
.with_label("oneliner-web-ok")
.with_user(test_user(SLUG))
.build();
let case_id = Uuid::new_v4().to_string();
let case_dir = seed_case(&cfg.data_path, SLUG, &case_id);
let state = build_app(cfg);
let store = state.session_store.clone();
let app = doctate_server::create_router_with_state(state);
let (cookie, csrf) = login_with_csrf(&app, &store, SLUG, "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_oneliner_web(&case_id),
&cookie,
&csrf,
"text=42+J.%2C+R%C3%BCckenschmerz",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["kind"], "manual");
assert_eq!(body["text"], "42 J., Rückenschmerz");
let bytes = tokio::fs::read(case_dir.join(ONELINER_FILENAME))
.await
.expect("oneliner.json missing");
let on_disk: OnelinerState = serde_json::from_slice(&bytes).unwrap();
assert!(
matches!(on_disk, OnelinerState::Manual { ref text, .. } if text == "42 J., Rückenschmerz")
);
}
#[tokio::test]
async fn web_put_oneliner_without_csrf_returns_403() {
let cfg = TestConfig::new()
.with_label("oneliner-web-no-csrf")
.with_user(test_user(SLUG))
.build();
let case_id = Uuid::new_v4().to_string();
seed_case(&cfg.data_path, SLUG, &case_id);
let app = doctate_server::create_router_with_state(build_app(cfg));
let cookie = login(&app, SLUG, "s").await;
let resp = app
.oneshot(form_post(
paths::case_oneliner_web(&case_id),
&cookie,
"text=x",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn web_put_oneliner_without_session_redirects_to_login() {
let cfg = TestConfig::new()
.with_label("oneliner-web-no-session")
.with_user(test_user(SLUG))
.build();
let case_id = Uuid::new_v4().to_string();
seed_case(&cfg.data_path, SLUG, &case_id);
let app = doctate_server::create_router_with_state(build_app(cfg));
// No Cookie header at all.
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(paths::case_oneliner_web(&case_id))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from("text=x&csrf_token=any"))
.unwrap(),
)
.await
.unwrap();
// The CsrfForm extractor maps a missing session to a 302 redirect
// to /web/login (`AppError::Redirect`). Browsers follow it as a
// GET, which is exactly what we want — the user lands on the login
// page instead of staring at a 401.
assert_eq!(resp.status(), StatusCode::FOUND);
let loc = resp
.headers()
.get(header::LOCATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(loc, "/web/login");
}
#[tokio::test]
async fn web_put_oneliner_empty_text_returns_400() {
let cfg = TestConfig::new()
.with_label("oneliner-web-empty")
.with_user(test_user(SLUG))
.build();
let case_id = Uuid::new_v4().to_string();
seed_case(&cfg.data_path, SLUG, &case_id);
let state = build_app(cfg);
let store = state.session_store.clone();
let app = doctate_server::create_router_with_state(state);
let (cookie, csrf) = login_with_csrf(&app, &store, SLUG, "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_oneliner_web(&case_id),
&cookie,
&csrf,
"text=%20%20%20",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn web_put_oneliner_unknown_case_returns_404() {
let cfg = TestConfig::new()
.with_label("oneliner-web-unknown")
.with_user(test_user(SLUG))
.build();
let unknown_case = Uuid::new_v4().to_string();
let state = build_app(cfg);
let store = state.session_store.clone();
let app = doctate_server::create_router_with_state(state);
let (cookie, csrf) = login_with_csrf(&app, &store, SLUG, "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_oneliner_web(&unknown_case),
&cookie,
&csrf,
"text=x",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn web_put_oneliner_on_closed_case_returns_404() {
let cfg = TestConfig::new()
.with_label("oneliner-web-closed")
.with_user(test_user(SLUG))
.build();
let case_id = Uuid::new_v4().to_string();
let case_dir = seed_case(&cfg.data_path, SLUG, &case_id);
write_closed_marker(&case_dir, "2026-04-25T10:00:00Z");
let state = build_app(cfg);
let store = state.session_store.clone();
let app = doctate_server::create_router_with_state(state);
let (cookie, csrf) = login_with_csrf(&app, &store, SLUG, "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_oneliner_web(&case_id),
&cookie,
&csrf,
"text=x",
))
.await
.unwrap();
// `locate_case_or_404` rejects closed cases — intentional asymmetry
// with the X-API-Key endpoint, which would have accepted it.
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn web_put_oneliner_emits_event() {
let cfg = TestConfig::new()
.with_label("oneliner-web-event")
.with_user(test_user(SLUG))
.build();
let case_id = Uuid::new_v4().to_string();
seed_case(&cfg.data_path, SLUG, &case_id);
// Pre-create the broadcast channel so we can subscribe BEFORE the
// request fires — broadcast channels do not buffer for absent
// subscribers, so a subscribe-after-emit would miss the event.
let events_tx = doctate_server::events::channel();
let mut events_rx = events_tx.subscribe();
let state = build_app_with_events_tx(cfg, events_tx);
let store = state.session_store.clone();
let app = doctate_server::create_router_with_state(state);
let (cookie, csrf) = login_with_csrf(&app, &store, SLUG, "s").await;
let resp = app
.oneshot(csrf_form_post(
paths::case_oneliner_web(&case_id),
&cookie,
&csrf,
"text=hello",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let evt = tokio::time::timeout(Duration::from_secs(1), events_rx.recv())
.await
.expect("no event within 1s")
.expect("event channel closed");
assert_eq!(evt.user_slug, SLUG);
assert_eq!(evt.case_id, case_id);
assert!(matches!(
evt.kind,
doctate_server::events::CaseEventKind::OnelinerUpdated
));
}