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
+7 -1
View File
@@ -622,7 +622,13 @@ impl DoctateApp {
}; };
let resp = ui.add(label); let resp = ui.add(label);
if matches!(marker.oneliner, Some(OnelinerState::Manual { .. })) { if matches!(marker.oneliner, Some(OnelinerState::Manual { .. })) {
ui.colored_label(egui::Color32::from_rgb(0, 110, 0), ""); // Person-glyph marks doctor-authored titles.
// Replaces an earlier pencil icon: the pencil
// suggested an edit affordance, but on this row
// the marker is informational only — clicking
// the title text already opens the editor.
ui.colored_label(egui::Color32::GRAY, "👤")
.on_hover_text("manuell bearbeitet");
} }
if resp.clicked() { if resp.clicked() {
// Pre-fill the editor with the visible text // Pre-fill the editor with the visible text
+4
View File
@@ -59,6 +59,10 @@ pub fn api_router() -> Router<AppState> {
"/web/cases/{case_id}/reset", "/web/cases/{case_id}/reset",
post(case_actions::handle_reset_case), post(case_actions::handle_reset_case),
) )
.route(
"/web/cases/{case_id}/oneliner",
post(oneliner_override::handle_web_put_oneliner),
)
.route( .route(
"/web/cases/purge-closed", "/web/cases/purge-closed",
post(case_actions::handle_purge_closed), post(case_actions::handle_purge_closed),
+111 -35
View File
@@ -1,45 +1,65 @@
//! `PUT /api/cases/{case_id}/oneliner` — doctor's manual oneliner override. //! Doctor's manual oneliner override — two entry points sharing one core.
//! //!
//! Writes [`OnelinerState::Manual`], which the transcribe worker's //! - `PUT /api/cases/{case_id}/oneliner` (X-API-Key) for native clients.
//! `update_oneliner` treats as terminal: the manual text is sticky //! - `POST /web/cases/{case_id}/oneliner` (session + CSRF) for the
//! across new recordings until another manual edit (or explicit reset) //! browser-based web UI.
//! replaces it. See plan //!
//! Both routes funnel into [`apply_manual_override`], which validates
//! the text, takes [`OnelinerLocks::lock_for`] for the duration of the
//! disk write, persists [`OnelinerState::Manual`], and emits an
//! `OnelinerUpdated` event so SSE listeners can reflect the change.
//!
//! `Manual` is treated as terminal by the transcribe worker's
//! `update_oneliner`: the manual text is sticky across new recordings
//! until another manual edit replaces it. See plan
//! `plane-die-erweiterung-oneliner-override-pure-goblet.md` for the //! `plane-die-erweiterung-oneliner-override-pure-goblet.md` for the
//! full concurrency rationale. //! full concurrency rationale.
//!
//! Authentication: `X-API-Key` header, shared with `/api/upload` and use std::path::Path;
//! `/api/oneliners` (the GET counterpart). use std::sync::Arc;
//!
//! Concurrency: holds [`OnelinerLocks::lock_for`] for the duration of
//! the disk write. The worker's auto-regen path takes the same lock
//! around its post-LLM re-read-and-write sequence, so a doctor's PUT
//! arriving while the LLM is still thinking always wins.
use axum::Json; use axum::Json;
use axum::extract::State; use axum::extract::State;
use doctate_common::oneliners::{OnelinerOverrideRequest, OnelinerState}; use doctate_common::oneliners::{OnelinerOverrideRequest, OnelinerState};
use serde::Deserialize;
use tracing::info; use tracing::info;
use crate::auth::AuthenticatedUser; use crate::auth::{AuthenticatedUser, AuthenticatedWebUser};
use crate::case_id::CaseIdPath; use crate::case_id::CaseIdPath;
use crate::config::Config;
use crate::csrf::{CsrfForm, HasCsrfToken};
use crate::error::AppError; use crate::error::AppError;
use crate::events::{self, CaseEventKind, EventSender}; use crate::events::{self, CaseEventKind, EventSender};
use crate::oneliner_locks::OnelinerLocks; use crate::oneliner_locks::OnelinerLocks;
use crate::paths; use crate::paths;
use crate::routes::user_web::locate_case_or_404;
/// Hard cap on accepted text length. A oneliner is a single sentence /// Hard cap on accepted text length. A oneliner is a single sentence
/// (typical 3080 chars); 500 leaves comfortable headroom while /// (typical 3080 chars); 500 leaves comfortable headroom while
/// preventing accidental dumps from a runaway client. /// preventing accidental dumps from a runaway client.
const MAX_ONELINER_TEXT_LEN: usize = 500; const MAX_ONELINER_TEXT_LEN: usize = 500;
pub async fn handle_put_oneliner( /// Persist a doctor's manual override for a case. Shared by the
user: AuthenticatedUser, /// X-API-Key API handler and the session-authenticated web handler.
State(locks): State<OnelinerLocks>, ///
State(events_tx): State<EventSender>, /// Validates and trims `raw_text`, takes the per-case lock for the
CaseIdPath(case_id): CaseIdPath, /// disk-write window (so a concurrent auto-regen cannot overwrite the
Json(req): Json<OnelinerOverrideRequest>, /// manual edit), writes [`OnelinerState::Manual`], logs, and emits
) -> Result<Json<OnelinerState>, AppError> { /// `OnelinerUpdated`. Returns the persisted state so the caller can
let trimmed = req.text.trim(); /// echo it back to the client.
///
/// Pre-condition: `case_dir` must already exist. Both call sites check
/// for this — the API handler with `try_exists`, the web handler via
/// `locate_case_or_404` — so the helper itself stays focused on the
/// override semantics.
pub(crate) async fn apply_manual_override(
user_slug: &str,
case_dir: &Path,
raw_text: &str,
locks: &OnelinerLocks,
events_tx: &EventSender,
) -> Result<OnelinerState, AppError> {
let trimmed = raw_text.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"oneliner text must not be empty".into(), "oneliner text must not be empty".into(),
@@ -51,12 +71,6 @@ pub async fn handle_put_oneliner(
))); )));
} }
let case_id_str = case_id.to_string();
let case_dir = user.data_dir.join(&case_id_str);
if !tokio::fs::try_exists(&case_dir).await.unwrap_or(false) {
return Err(AppError::NotFound(format!("case {case_id_str} not found")));
}
let state = OnelinerState::Manual { let state = OnelinerState::Manual {
text: trimmed.to_owned(), text: trimmed.to_owned(),
set_at: doctate_common::now_rfc3339(), set_at: doctate_common::now_rfc3339(),
@@ -65,22 +79,84 @@ pub async fn handle_put_oneliner(
// RAII guard — released on function exit. Same lock the worker takes // RAII guard — released on function exit. Same lock the worker takes
// for its re-check-then-write window, so a concurrent auto-regen // for its re-check-then-write window, so a concurrent auto-regen
// cannot overwrite this manual edit. // cannot overwrite this manual edit.
let _guard = locks.lock_for(&case_dir).await; let _guard = locks.lock_for(case_dir).await;
paths::write_oneliner_state(&case_dir, &state).await?; paths::write_oneliner_state(case_dir, &state).await?;
let case_id = case_dir
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("?")
.to_owned();
info!( info!(
user = %user.slug, user = %user_slug,
case_id = %case_id_str, case_id = %case_id,
len = trimmed.chars().count(), len = trimmed.chars().count(),
"oneliner manual override set" "oneliner manual override set"
); );
events::emit( events::emit(
&events_tx, events_tx,
user.slug.clone(), user_slug.to_owned(),
case_id_str, case_id,
CaseEventKind::OnelinerUpdated, CaseEventKind::OnelinerUpdated,
); );
Ok(state)
}
/// `PUT /api/cases/{case_id}/oneliner` — X-API-Key authenticated.
pub async fn handle_put_oneliner(
user: AuthenticatedUser,
State(locks): State<OnelinerLocks>,
State(events_tx): State<EventSender>,
CaseIdPath(case_id): CaseIdPath,
Json(req): Json<OnelinerOverrideRequest>,
) -> Result<Json<OnelinerState>, AppError> {
let case_id_str = case_id.to_string();
let case_dir = user.data_dir.join(&case_id_str);
if !tokio::fs::try_exists(&case_dir).await.unwrap_or(false) {
return Err(AppError::NotFound(format!("case {case_id_str} not found")));
}
let state = apply_manual_override(&user.slug, &case_dir, &req.text, &locks, &events_tx).await?;
Ok(Json(state))
}
/// Form body for the browser-based web edit endpoint. CSRF token is
/// required; `text` carries the new oneliner content.
#[derive(Deserialize)]
pub struct OnelinerWebForm {
#[serde(default)]
pub text: String,
#[serde(default)]
pub csrf_token: String,
}
impl HasCsrfToken for OnelinerWebForm {
fn csrf_token(&self) -> &str {
&self.csrf_token
}
}
/// `POST /web/cases/{case_id}/oneliner` — session + CSRF authenticated.
///
/// Mirrors [`handle_put_oneliner`] but uses the web auth stack so the
/// browser never has to expose an X-API-Key. Closed cases are rejected
/// at `locate_case_or_404` (consistent with analyze/close/reopen).
pub async fn handle_web_put_oneliner(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(locks): State<OnelinerLocks>,
State(events_tx): State<EventSender>,
CaseIdPath(case_id): CaseIdPath,
CsrfForm(form): CsrfForm<OnelinerWebForm>,
) -> Result<Json<OnelinerState>, AppError> {
let user_root = config.data_path.join(&user.slug);
let case_dir =
locate_case_or_404(&user_root, &case_id, &user.slug, "oneliner override").await?;
let state =
apply_manual_override(&user.slug, &case_dir, &form.text, &locks, &events_tx).await?;
Ok(Json(state)) Ok(Json(state))
} }
+39 -9
View File
@@ -69,6 +69,12 @@ struct UserCaseView {
/// the server's, e.g. server on Bahamas, doctor in Germany). /// the server's, e.g. server on Bahamas, doctor in Germany).
recorded_at_iso: String, recorded_at_iso: String,
oneliner: OnelinerDisplay, oneliner: OnelinerDisplay,
/// True iff the persisted oneliner is `OnelinerState::Manual { .. }`
/// AND the displayed text actually shows that manual content (i.e.
/// not while we're rendering a Pending/Generating placeholder). The
/// template uses this to render a 👤 marker next to the title so the
/// doctor can tell at a glance which titles they themselves wrote.
oneliner_is_manual: bool,
recordings_count: usize, recordings_count: usize,
analyzing: bool, analyzing: bool,
has_document: bool, has_document: bool,
@@ -307,6 +313,9 @@ struct CasePageTemplate {
case_id: String, case_id: String,
case_id_short: String, case_id_short: String,
oneliner: OnelinerDisplay, oneliner: OnelinerDisplay,
/// Mirror of `UserCaseView::oneliner_is_manual`. See that field for
/// the suppression-during-progress rationale.
oneliner_is_manual: bool,
/// RFC3339 UTC timestamp of the most recent recording. `None` iff the /// RFC3339 UTC timestamp of the most recent recording. `None` iff the
/// case has no recordings at all. Drives the datetime header under the /// case has no recordings at all. Drives the datetime header under the
/// title; browser JS formats it to "Heute 11:34" / "13.12.2022 11:34". /// title; browser JS formats it to "Heute 11:34" / "13.12.2022 11:34".
@@ -622,7 +631,7 @@ pub async fn handle_case_page(
.map(|md| crate::analyze::render::md_to_html(&md)); .map(|md| crate::analyze::render::md_to_html(&md));
let recordings = scan_recordings(&case_dir).await; let recordings = scan_recordings(&case_dir).await;
let oneliner = compute_oneliner_display(&case_dir, &recordings).await; let (oneliner, oneliner_is_manual) = compute_oneliner_display(&case_dir, &recordings).await;
let a_busy = pipeline.analyze_busy.0.load(Ordering::Acquire); let a_busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await; let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await;
@@ -648,6 +657,7 @@ pub async fn handle_case_page(
case_id: case_id_str, case_id: case_id_str,
case_id_short, case_id_short,
oneliner, oneliner,
oneliner_is_manual,
recorded_at_iso, recorded_at_iso,
time_hms_utc, time_hms_utc,
document_html, document_html,
@@ -884,7 +894,7 @@ async fn compute_case_view(
.filter(|r| !r.failed) .filter(|r| !r.failed)
.all(|r| r.transcript.has_file()); .all(|r| r.transcript.has_file());
let oneliner = compute_oneliner_display(case_path, &recordings).await; let (oneliner, oneliner_is_manual) = compute_oneliner_display(case_path, &recordings).await;
let has_document = any_document_exists(case_path).await; let has_document = any_document_exists(case_path).await;
let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await; let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await;
@@ -913,6 +923,7 @@ async fn compute_case_view(
time_hms_utc, time_hms_utc,
recorded_at_iso, recorded_at_iso,
oneliner, oneliner,
oneliner_is_manual,
recordings_count, recordings_count,
analyzing, analyzing,
has_document, has_document,
@@ -961,14 +972,21 @@ async fn ready_text(case_dir: &Path) -> Option<String> {
} }
} }
/// Compute the 5-state UI display for the oneliner. Identical logic to /// Compute the 5-state UI display for the oneliner plus a flag
/// what `compute_case_view` uses for the case list — extracted so the /// indicating manual authorship. Identical logic to what
/// case page can render the same states instead of a plain "Fall" /// `compute_case_view` uses for the case list — extracted so the case
/// fallback. /// page can render the same states instead of a plain "Fall" fallback.
///
/// Returns `(display, is_manual)`. `is_manual` is `true` iff the
/// persisted state is `OnelinerState::Manual { .. }` AND the case is
/// in a stable state where that text is actually being shown
/// (i.e. not `Pending`/`Generating` — those override the persisted
/// content with progress placeholders, so the manual flag would be
/// misleading there).
async fn compute_oneliner_display( async fn compute_oneliner_display(
case_dir: &Path, case_dir: &Path,
recordings: &[RecordingView], recordings: &[RecordingView],
) -> OnelinerDisplay { ) -> (OnelinerDisplay, bool) {
let non_failed_count = recordings.iter().filter(|r| !r.failed).count(); let non_failed_count = recordings.iter().filter(|r| !r.failed).count();
let all_transcribed = non_failed_count > 0 let all_transcribed = non_failed_count > 0
&& recordings && recordings
@@ -976,8 +994,9 @@ async fn compute_oneliner_display(
.filter(|r| !r.failed) .filter(|r| !r.failed)
.all(|r| r.transcript.has_file()); .all(|r| r.transcript.has_file());
let (state, _) = paths::read_oneliner_state(case_dir).await; let (state, _) = paths::read_oneliner_state(case_dir).await;
let is_manual = matches!(state, Some(OnelinerState::Manual { .. }));
if non_failed_count == 0 { let display = if non_failed_count == 0 {
// All recordings failed — stable end state, no LLM will ever run. // All recordings failed — stable end state, no LLM will ever run.
// Missing state collapses to Empty ("unbenannt"): no title is // Missing state collapses to Empty ("unbenannt"): no title is
// expected, regardless of why. // expected, regardless of why.
@@ -1007,7 +1026,17 @@ async fn compute_oneliner_display(
None => OnelinerDisplay::Pending, None => OnelinerDisplay::Pending,
Some(_) => OnelinerDisplay::Generating, Some(_) => OnelinerDisplay::Generating,
} }
} };
// Suppress the manual marker while we're showing a progress
// placeholder ("transkribiere…", "generiere Titel…"): the displayed
// string isn't the manual text anyway, so a 👤 next to it would lie.
let show_manual = is_manual
&& !matches!(
display,
OnelinerDisplay::Pending | OnelinerDisplay::Generating
);
(display, show_manual)
} }
#[cfg(test)] #[cfg(test)]
@@ -1021,6 +1050,7 @@ mod tests {
time_hms_utc: String::new(), time_hms_utc: String::new(),
recorded_at_iso: String::new(), recorded_at_iso: String::new(),
oneliner: OnelinerDisplay::Empty, oneliner: OnelinerDisplay::Empty,
oneliner_is_manual: false,
recordings_count: 1, recordings_count: 1,
analyzing: false, analyzing: false,
has_document: false, has_document: false,
+44 -1
View File
@@ -42,6 +42,38 @@
h1 .delete-form { h1 .delete-form {
margin: 0 0 0 auto; margin: 0 0 0 auto;
} }
.oneliner-edit {
cursor: pointer;
}
.oneliner-edit:hover .oneliner-text {
text-decoration: underline dotted;
text-underline-offset: 0.2em;
}
.oneliner-edit.editing {
cursor: default;
}
.oneliner-input {
font: inherit;
padding: 0.1em 0.3em;
min-width: 18em;
border: 1px solid #4a90e2;
border-radius: 3px;
background: white;
}
.oneliner-edit.saving .oneliner-input {
opacity: 0.6;
}
.oneliner-edit.error .oneliner-input {
border-color: #e24a4a;
background: #fff0f0;
}
.manual-marker {
font-size: 0.7em;
opacity: 0.55;
margin-left: 0.1em;
cursor: help;
vertical-align: middle;
}
.case-time { .case-time {
color: #666; color: #666;
font-size: 1.05em; font-size: 1.05em;
@@ -260,7 +292,15 @@
</header> </header>
<h1> <h1>
<span class="title"> <span class="title">
{% call ol::render(oneliner) %} {% if has_failed_recording {% if is_closed %}
<span class="oneliner-text">{% call ol::render(oneliner) %}</span>
{% else %}
<span class="oneliner-edit" data-case-id="{{ case_id }}" data-csrf="{{ csrf_token }}">
<span class="oneliner-text">{% call ol::render(oneliner) %}</span>
</span>
{% endif %}
{% if oneliner_is_manual %}<span class="manual-marker" title="manuell bearbeitet" aria-label="manuell bearbeitet">👤</span>{% endif %}
{% if has_failed_recording
%}<span class="status-badge fehler">Fehler</span>{% else if %}<span class="status-badge fehler">Fehler</span>{% else if
has_document %}<span class="status-badge done">ausgewertet</span has_document %}<span class="status-badge done">ausgewertet</span
>{% else %}<span class="status-badge open">offen</span>{% endif >{% else %}<span class="status-badge open">offen</span>{% endif
@@ -558,5 +598,8 @@
}); });
})(); })();
</script> </script>
<script>
{% include "partials/oneliner_edit.js" %}
</script>
</body> </body>
</html> </html>
+2 -1
View File
@@ -32,6 +32,7 @@ html.admin-view-off .case-list.has-bulk .case-row { grid-template-columns: 1fr a
.case-row .case-link:hover { text-decoration: underline; } .case-row .case-link:hover { text-decoration: underline; }
.case-row .recordings-link { color: #4a90e2; text-decoration: none; font-size: 0.9em; white-space: nowrap; } .case-row .recordings-link { color: #4a90e2; text-decoration: none; font-size: 0.9em; white-space: nowrap; }
.case-row .recordings-link:hover { text-decoration: underline; } .case-row .recordings-link:hover { text-decoration: underline; }
.case-row .manual-marker { font-size: 0.9em; opacity: 0.55; margin-left: 0.15em; cursor: help; }
.case-row .analysis { margin-top: 0.55em; padding-top: 0.45em; border-top: 1px solid #e5e5e5; display: flex; align-items: flex-start; gap: 0.4em; cursor: pointer; } .case-row .analysis { margin-top: 0.55em; padding-top: 0.45em; border-top: 1px solid #e5e5e5; display: flex; align-items: flex-start; gap: 0.4em; cursor: pointer; }
.case-row .analysis .arrow { flex-shrink: 0; color: #888; line-height: 1.2; transition: transform 0.15s ease; } .case-row .analysis .arrow { flex-shrink: 0; color: #888; line-height: 1.2; transition: transform 0.15s ease; }
.case-row .analysis.open .arrow { transform: rotate(90deg); } .case-row .analysis.open .arrow { transform: rotate(90deg); }
@@ -111,7 +112,7 @@ try {
{% if is_admin %}<div class="check admin-only"><input type="checkbox" name="case_id" value="{{ case.case_id }}"{% if case.is_closed %} disabled{% endif %}></div>{% endif %} {% if is_admin %}<div class="check admin-only"><input type="checkbox" name="case_id" value="{{ case.case_id }}"{% if case.is_closed %} disabled{% endif %}></div>{% endif %}
<div class="body"> <div class="body">
<div class="line1"> <div class="line1">
<a class="case-link" href="/web/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}{% if case.has_failed_recording %} <span class="status-badge fehler">Fehler</span>{% else if case.has_document %} <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %} <span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</a> <a class="case-link" href="/web/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}{% if case.oneliner_is_manual %} <span class="manual-marker" title="manuell bearbeitet" aria-label="manuell bearbeitet">👤</span>{% endif %}{% if case.has_failed_recording %} <span class="status-badge fehler">Fehler</span>{% else if case.has_document %} <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %} <span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</a>
<a class="recordings-link" href="/web/cases/{{ case.case_id }}/recordings{% if case.is_closed %}?show_closed=1{% endif %}">{{ case.recordings_count }} Aufnahmen</a> <a class="recordings-link" href="/web/cases/{{ case.case_id }}/recordings{% if case.is_closed %}?show_closed=1{% endif %}">{{ case.recordings_count }} Aufnahmen</a>
</div> </div>
{% match case.analysis_html %}{% when Some with (html) %}<div class="analysis" data-expand><span class="arrow"></span><div class="analysis-content">{{ html|safe }}</div></div>{% when None %}{% endmatch %} {% match case.analysis_html %}{% when Some with (html) %}<div class="analysis" data-expand><span class="arrow"></span><div class="analysis-content">{{ html|safe }}</div></div>{% when None %}{% endmatch %}
@@ -0,0 +1,76 @@
// Inline tap-to-edit for .oneliner-edit blocks. Click swaps the inner
// .oneliner-text for an <input>; Enter POSTs to /web/cases/{id}/oneliner;
// Escape (or blur) cancels. The SSE-driven reload picks up the persisted
// state, so no DOM patching is needed on success.
(() => {
const handleClick = (host) => {
if (host.classList.contains('editing')) return;
const span = host.querySelector('.oneliner-text');
if (!span) return;
const original = span.textContent.trim();
host.classList.add('editing');
const input = document.createElement('input');
input.type = 'text';
input.value = original;
input.maxLength = 500;
input.className = 'oneliner-input';
span.replaceWith(input);
input.focus();
input.select();
const restore = () => {
const restoreSpan = document.createElement('span');
restoreSpan.className = 'oneliner-text';
restoreSpan.textContent = original;
input.replaceWith(restoreSpan);
host.classList.remove('editing', 'saving', 'error');
};
const submit = async () => {
const text = input.value.trim();
if (text === '' || text === original) { restore(); return; }
host.classList.remove('error');
host.classList.add('saving');
const body = new URLSearchParams({
text,
csrf_token: host.dataset.csrf,
});
try {
const r = await fetch(
`/web/cases/${host.dataset.caseId}/oneliner`,
{ method: 'POST', body, credentials: 'same-origin' }
);
if (!r.ok) {
host.classList.remove('saving');
host.classList.add('error');
input.focus();
return;
}
// SSE will trigger reload; keep input visible until then.
} catch (_) {
host.classList.remove('saving');
host.classList.add('error');
input.focus();
}
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); submit(); }
else if (e.key === 'Escape') { e.preventDefault(); restore(); }
});
input.addEventListener('blur', () => {
// Blur acts as cancel unless we're already saving (where blur
// happens implicitly when the SSE reload swaps the DOM) or in
// an error state (so the user can re-focus and retry).
if (host.classList.contains('saving') || host.classList.contains('error')) return;
restore();
});
};
document.addEventListener('click', (e) => {
const host = e.target.closest('.oneliner-edit');
if (!host) return;
if (e.target.closest('a, button, input')) return;
handleClick(host);
});
})();
+6
View File
@@ -61,6 +61,12 @@ pub fn recording_delete(case_id: &str) -> String {
format!("/web/cases/{case_id}/recordings/delete") 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. /// `GET /web/magic?token={token}` — magic-link consumption.
pub fn magic(token: &str) -> String { pub fn magic(token: &str) -> String {
format!("/web/magic?token={token}") format!("/web/magic?token={token}")
+242 -2
View File
@@ -34,7 +34,10 @@ use doctate_server::{
AnalyzeBusy, AppState, OnelinerHealBusy, TranscribeBusy, analyze, transcribe, web_session, 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 SLUG: &str = "dr_a";
const API_KEY: &str = "key-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.) /// state (for direct access to `oneliner_locks`, `events_tx`, etc.)
/// and a router that wraps it. /// and a router that wraps it.
fn build_app(cfg: Arc<doctate_server::config::Config>) -> AppState { 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 (transcribe_tx, _transcribe_rx) = transcribe::channel();
let (analyze_tx, _analyze_rx) = analyze::channel(); let (analyze_tx, _analyze_rx) = analyze::channel();
AppState { AppState {
@@ -66,7 +80,7 @@ fn build_app(cfg: Arc<doctate_server::config::Config>) -> AppState {
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))), transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
oneliner_heal_busy: OnelinerHealBusy(Arc::new(AtomicBool::new(false))), oneliner_heal_busy: OnelinerHealBusy(Arc::new(AtomicBool::new(false))),
oneliner_locks: OnelinerLocks::new(), oneliner_locks: OnelinerLocks::new(),
events_tx: doctate_server::events::channel(), events_tx,
http_client: reqwest::Client::new(), http_client: reqwest::Client::new(),
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()), 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:?}"), 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
));
}