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).
This commit is contained in:
2026-04-21 11:20:17 +02:00
parent 23e828df45
commit 8b1f58ba23
3 changed files with 191 additions and 7 deletions
+70 -4
View File
@@ -122,6 +122,30 @@ fn resolve_return_path(headers: &HeaderMap) -> String {
}
}
/// Like `resolve_return_path`, but collapses a `/web/cases/{uuid}` detail
/// path to `/web/cases` while preserving the query string. Used by
/// handlers that remove the case from the detail view (close, purge) —
/// a redirect back to the now-404 detail page would be a dead end, but
/// losing `?show_closed=1` would eject the user from the show-closed
/// view they were in.
fn resolve_list_return_path(headers: &HeaderMap) -> String {
let raw = resolve_return_path(headers);
let Some(tail) = raw.strip_prefix("/web/cases/") else {
return raw;
};
let (case_part, query) = match tail.split_once('?') {
Some((c, q)) => (c, Some(q)),
None => (tail, None),
};
if uuid::Uuid::parse_str(case_part).is_err() {
return raw;
}
match query {
Some(q) => format!("/web/cases?{q}"),
None => "/web/cases".into(),
}
}
/// Build an `AnalysisInput` for the given case directory.
/// Collects all `.m4a` (excluding `.m4a.failed`), reads the matching
/// `.transcript.txt` for each, skips blank transcripts, and computes
@@ -282,11 +306,17 @@ pub(crate) async fn reset_case_artefacts(case_dir: &Path) -> std::io::Result<()>
/// The case immediately disappears from listings and detail views (404).
/// Reversible via `handle_reopen_case` (explicit button) or by uploading
/// a new recording for the same case (automatic via `handle_upload`).
///
/// Redirects back to the `Referer`-derived list path (see
/// `resolve_list_return_path`) so a close from the `?show_closed=1`
/// view keeps the user there — but drops the case-id segment if the
/// referer was the case detail, since that page 404s after the close.
pub async fn handle_close_case(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(events_tx): State<EventSender>,
CaseIdPath(case_id): CaseIdPath,
headers: HeaderMap,
) -> Result<Redirect, AppError> {
let user_root = config.data_path.join(&user.slug);
let case_dir = locate_case_or_404(&user_root, &case_id, &user.slug, "close").await?;
@@ -310,7 +340,7 @@ pub async fn handle_close_case(
CaseEventKind::CaseClosed,
);
Ok(Redirect::to("/web/cases"))
Ok(Redirect::to(&resolve_list_return_path(&headers)))
}
/// POST /web/cases/{case_id}/reopen
@@ -319,14 +349,20 @@ pub async fn handle_close_case(
/// immediately back in the default listing. A reopened case is
/// indistinguishable from one that was never closed — no status
/// remainders on disk. Idempotent: an already-open case is a no-op.
///
/// Redirects back to the `Referer`-path (filtered via
/// `resolve_return_path`) so a reopen from the `?show_closed=1` view
/// stays in that view instead of jumping back to the default listing.
pub async fn handle_reopen_case(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(events_tx): State<EventSender>,
CaseIdPath(case_id): CaseIdPath,
headers: HeaderMap,
) -> Result<Redirect, AppError> {
let user_root = config.data_path.join(&user.slug);
let case_dir = user_root.join(case_id.to_string());
let return_path = resolve_return_path(&headers);
if !crate::paths::is_closed(&case_dir).await {
info!(
@@ -334,7 +370,7 @@ pub async fn handle_reopen_case(
case_id = %case_id,
"reopen requested but case is not closed"
);
return Ok(Redirect::to("/web/cases"));
return Ok(Redirect::to(&return_path));
}
match tokio::fs::remove_file(case_dir.join(CLOSE_MARKER)).await {
@@ -356,7 +392,7 @@ pub async fn handle_reopen_case(
CaseEventKind::CaseReopened,
);
Ok(Redirect::to("/web/cases"))
Ok(Redirect::to(&return_path))
}
/// POST /web/cases/{case_id}/reset
@@ -547,7 +583,7 @@ async fn remove_if_exists(path: &Path) -> Result<(), AppError> {
#[cfg(test)]
mod tests {
use super::resolve_return_path;
use super::{resolve_list_return_path, resolve_return_path};
use axum::http::{HeaderMap, HeaderValue, header::REFERER};
fn headers_with_referer(value: &str) -> HeaderMap {
@@ -605,4 +641,34 @@ mod tests {
h.insert(REFERER, HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap());
assert_eq!(resolve_return_path(&h), "/web/cases");
}
// `resolve_list_return_path` is `resolve_return_path` plus a case-
// detail collapse. It leaves non-detail paths alone and strips the
// `/{uuid}` segment from a detail path while preserving the query.
#[test]
fn list_return_path_preserves_list_referer() {
let h = headers_with_referer("/web/cases?show_closed=1");
assert_eq!(resolve_list_return_path(&h), "/web/cases?show_closed=1");
}
#[test]
fn list_return_path_collapses_detail_with_query() {
let h =
headers_with_referer("/web/cases/11111111-1111-1111-1111-111111111111?show_closed=1");
assert_eq!(resolve_list_return_path(&h), "/web/cases?show_closed=1");
}
#[test]
fn list_return_path_collapses_bare_detail() {
let h = headers_with_referer("/web/cases/11111111-1111-1111-1111-111111111111");
assert_eq!(resolve_list_return_path(&h), "/web/cases");
}
#[test]
fn list_return_path_leaves_non_uuid_segment_alone() {
// Non-UUID `/web/cases/<x>` is not a detail page — don't touch it.
let h = headers_with_referer("/web/cases/undo-delete");
assert_eq!(resolve_list_return_path(&h), "/web/cases/undo-delete");
}
}
+3 -3
View File
@@ -92,7 +92,7 @@ try {
<ul class="case-list">
{% for case in g.cases %}
<li class="case-row {% if case.is_closed %}closed{% else if case.has_document %}done{% else %}open{% endif %}">
<div class="check">{% if !case.is_closed %}<input type="checkbox" name="case_id" value="{{ case.case_id }}">{% endif %}</div>
<div class="check"><input type="checkbox" name="case_id" value="{{ case.case_id }}"{% if case.is_closed %} disabled{% endif %}></div>
<div class="body">
<a href="/web/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}">
<div class="line1"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}</div>
@@ -101,8 +101,8 @@ try {
</a>
</div>
<div class="actions">
{% if !case.is_closed && is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze" class="admin-only"{% if !case.can_analyze %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
<button type="submit" formaction="/web/cases/{{ case.case_id }}/reset" class="admin-only">Reset</button>{% endif %}
{% if is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze" class="admin-only"{% if !case.can_analyze || case.is_closed %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
<button type="submit" formaction="/web/cases/{{ case.case_id }}/reset" class="admin-only"{% if case.is_closed %} disabled{% endif %}>Reset</button>{% endif %}
{% if case.is_closed %}
<button type="submit" formaction="/web/cases/{{ case.case_id }}/reopen" class="reopen-btn" title="Fall wiedereröffnen" aria-label="Fall wiedereröffnen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg></button>
{% else %}
+118
View File
@@ -851,6 +851,124 @@ async fn closed_case_detail_with_show_closed_returns_200() {
);
}
#[tokio::test]
async fn close_redirects_back_to_referer_query() {
// Close from the show-closed listing must keep ?show_closed=1 so
// the user stays in the view they were in.
let config = config_with_llm(unique_tmp("close-ref-q"), "http://unused".into());
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("ok"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/web/cases/{case_id}/close"))
.header(header::COOKIE, &cookie)
.header(header::REFERER, "/web/cases?show_closed=1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/web/cases?show_closed=1"
);
}
#[tokio::test]
async fn close_from_detail_redirects_to_list_preserving_query() {
// Close from /web/cases/{uuid}?show_closed=1 must NOT redirect back
// to the detail page (it 404s after the close). The uuid segment
// is stripped; the query survives so the user lands in show-closed.
let config = config_with_llm(unique_tmp("close-ref-det"), "http://unused".into());
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("ok"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/web/cases/{case_id}/close"))
.header(header::COOKIE, &cookie)
.header(
header::REFERER,
format!("/web/cases/{case_id}?show_closed=1"),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/web/cases?show_closed=1",
"close from detail must redirect to the LIST with the query intact"
);
}
#[tokio::test]
async fn reopen_redirects_back_to_referer() {
// User reopens a case from the show-closed listing. The redirect
// must preserve `?show_closed=1` so the user stays in that view
// instead of being ejected back to the default open-only listing.
let config = config_with_llm(unique_tmp("reopen-ref"), "http://unused".into());
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("ok"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let _ = app
.clone()
.oneshot(close_request(case_id, &cookie))
.await
.unwrap();
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/web/cases/{case_id}/reopen"))
.header(header::COOKIE, &cookie)
.header(header::REFERER, "/web/cases?show_closed=1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/web/cases?show_closed=1",
"reopen must redirect back to the referer path (preserving the query)"
);
}
#[tokio::test]
async fn reopen_is_noop_on_open_case() {
let config = config_with_llm(unique_tmp("reopen-2"), "http://unused".into());