spec: copy-close on case page

Design spec for the case-page half of #14: a single "Kopieren +
Schließen" text button below the document that copies the document
text to the clipboard and then closes the case, landing on /cases.
The H1 trash-can close button is removed; the standalone icon copy
button stays. A failed clipboard write aborts the close so a finished
case never disappears with an empty clipboard.

Frontend-only (one Askama template + its inline script); no Rust
route change — the close handler already strips the case-page Referer
to /cases. The case-list half of #14 is deferred.

refs #14
This commit is contained in:
2026-06-01 14:20:14 +02:00
parent a4ecad08ba
commit 43ff2f20e1
+247
View File
@@ -0,0 +1,247 @@
# Copy + Close on the Case Page — Design Spec
**Date:** 2026-06-01
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude
## Goal
Give a user finishing a case a single gesture on the case detail page
(`server/templates/case_page.html`): copy the document text to the
clipboard, close the case, and land back on the case list (`/cases`).
Today these are two separate controls — the icon copy button
(`#copy-btn`, above the document) and the trash-can close form (in the
`<h1>` title row). The close already redirects to `/cases` from a case
page (see Data flow), so "back to list" needs no new server work; what
is missing is fusing copy + close into one button.
Scope is **the case page only**. Issue #14 also asks for a per-row
copy+close on the case list (`my_cases.html`); that part is explicitly
deferred — this cycle leaves the list untouched and the issue stays
open (`refs #14`, not `closes`).
## Architecture
Frontend-only. One template file (`server/templates/case_page.html`)
and its inline `<script>`. **No Rust change** — no route, handler, or
struct is touched. The `POST /cases/{case_id}/close` endpoint
(`server/src/routes/case_actions.rs:437`) and its `Referer`-derived
redirect stay exactly as they are; the combined button merely submits
the existing close form after the clipboard write completes.
This keeps every server-side invariant fixed: the `.closed`
discovery-gate semantics (commit `5e86cb5`), the IDOR guard, the CSRF
contract, and the close redirect target are all unchanged because no
server code moves.
## Concrete code shapes
### User-facing artefact (the headline)
What renders directly under the document for an **open** case. For a
case *with* a document the button copies then closes; for an open case
*without* a document it is a bare close (nothing to copy):
```html
<div id="doc-body">{{ html|safe }}</div>
{% if !is_closed %}
<div class="doc-footer-actions">
<form id="copy-close-form" method="post" action="/cases/{{ case_id }}/close">
{% call csrf::field(csrf_token) %}
{% if has_document %}
<button type="submit" class="copy-close-btn">Kopieren + Schließen</button>
{% else %}
<button type="submit" class="copy-close-btn">Schließen</button>
{% endif %}
</form>
<p id="copy-close-error" class="copy-error" role="alert" hidden>
Kopieren fehlgeschlagen — Fall nicht geschlossen.
</p>
</div>
{% endif %}
```
The inline script, with the two-path clipboard write extracted into a
shared function used by both the icon copy button and the close form:
```js
(() => {
// Shared two-path clipboard write: async Clipboard API in secure
// contexts, textarea + execCommand fallback otherwise. Returns
// whether the copy actually succeeded so callers can gate the
// follow-up close on it.
async function copyDocText() {
const body = document.getElementById("doc-body");
if (!body) return false; // no document → nothing to copy
const text = body.innerText;
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
ok = true;
}
} catch (_) {
/* fall through to execCommand */
}
if (!ok) {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.top = "-1000px";
document.body.appendChild(ta);
ta.select();
try {
ok = document.execCommand("copy");
} catch (_) {}
ta.remove();
}
return ok;
}
const copyBtn = document.getElementById("copy-btn");
if (copyBtn) {
copyBtn.hidden = false;
copyBtn.addEventListener("click", async () => {
if (await copyDocText()) {
copyBtn.classList.add("copied");
setTimeout(() => copyBtn.classList.remove("copied"), 1500);
}
});
}
// Combined "copy then close": the clipboard write must finish inside
// the user-gesture context before the form navigates, and a failed
// copy aborts the close so the user never loses a document they
// believed was on the clipboard.
const closeForm = document.getElementById("copy-close-form");
if (closeForm) {
closeForm.addEventListener("submit", async (e) => {
// Bare "Schließen" (no document) submits normally, no copy.
if (!document.getElementById("doc-body")) return;
e.preventDefault();
if (await copyDocText()) {
closeForm.submit(); // close → 303 → /cases
} else {
document.getElementById("copy-close-error").hidden = false;
}
});
}
})();
```
### Implementation shape (supporting — before → after)
**Remove the H1 trash-can close form** (open-case branch). The
`{% if is_closed %}` reopen branch is untouched.
Before (`server/templates/case_page.html`, the `{% else %}` arm of the
title-row form, ~lines 438458):
```html
{% else %}
<form class="delete-form" method="post" action="/cases/{{ case_id }}/close">
{% call csrf::field(csrf_token) %}
<button type="submit" class="delete-btn" title="Fall schließen" ...>
<svg ...><!-- trash icon --></svg>
</button>
</form>
{% endif %}
```
After: the `{% else %}` arm is dropped entirely — for an open case the
title row carries no close affordance (it moves below the document).
The `{% if is_closed %}` reopen arm and the `{% endif %}` remain.
**Refactor the copy IIFE:** the existing anonymous click handler that
inlines the clipboard logic becomes `copyDocText()` (shown above); the
icon button keeps its exact current behaviour by calling it.
## Components
- **H1 title row** — open-case trash-close form removed; closed-case
reopen form unchanged.
- **`doc-footer-actions` block** (new, after `#doc-body`, `!is_closed`
only) — one close form whose button label is `Kopieren + Schließen`
when `has_document`, else `Schließen`; plus a hidden error paragraph.
- **Inline script** — `copyDocText()` shared by the icon copy button
and the close form's submit handler.
No new template variables: `is_closed`, `has_document`, `case_id`, and
`csrf_token` are already in the template's context.
## Data flow
1. User clicks **Kopieren + Schließen**.
2. `submit` event fires → handler `preventDefault()`s.
3. `await copyDocText()` runs the clipboard write inside the gesture.
4. On `true`: `form.submit()``POST /cases/{id}/close` → handler
writes the `.closed` marker and returns `303` to
`resolve_list_return_path(headers)`. The `Referer` is the case page
`/cases/{uuid}`, which that function strips to `/cases`. Browser
lands on the list.
5. On `false`: `#copy-close-error` is unhidden; no submit; case stays
open.
The bare **Schließen** button (open case, no document) has no
`#doc-body`, so the handler returns early and the form submits
normally → same close → `/cases`.
## Error handling
- **Copy failure** (both clipboard paths fail — e.g. insecure context
and no `execCommand`): close is aborted, the case stays open, and
`#copy-close-error` is shown. The user never ends up with a closed
(list-hidden) case and an empty clipboard.
- **CSRF**: the new close form carries `{% call csrf::field(csrf_token) %}`,
identical to the form it replaces.
- **Closed case**: neither button renders (`{% if !is_closed %}`);
closing an already-closed case is impossible. The reopen affordance
is the only state-changing control, unchanged.
## Testing strategy
RED→GREEN in `server/tests/case_page_test.rs`, which renders the page
and asserts on the HTML. The JS gesture ordering (copy-before-navigate,
abort-on-failure) is **not** covered by Rust tests — the project has no
headless-browser harness — and is verified manually.
New / extended assertions:
- **Open case with document** renders `id="copy-close-form"`, the text
`Kopieren + Schließen`, and a CSRF token; and **no longer** renders
the H1 trash-can close button (assert its absence so the removal is
pinned).
- **Open case without document** (`case_page_empty_case_shows_placeholder`,
extended): the close form with `action="…/close"` is still present and
its label is `Schließen`.
- **Closed case** renders no `copy-close-form`; the reopen control is
still present.
- **`case_page_document_has_copy_button`** stays green — the icon
copy button survives the refactor.
Whole suite green via `cargo test --manifest-path server/Cargo.toml -j 4`;
`cargo clippy` and `cargo fmt` clean.
## Acceptance criteria
A medical user finishing a case naturally reaches for "put the letter
on the clipboard and be done with this case" in one click — the
`Kopieren + Schließen` button above is that gesture, and is the
empirical evidence for the criterion. It introduces no new failure
class: a failed copy aborts the close (no false sense of a saved
document), CSRF is preserved, and the closed-case discovery-gate and
IDOR guarantees are untouched because no server code changes.
Checklist (the case-page half of issue #14):
- [ ] Case page: one control copies the document to the clipboard,
closes the case, and lands on `/cases`, with the clipboard write
guaranteed to complete before navigation.
- [ ] On copy failure the case is **not** closed and an error is shown.
- [ ] Open case without a document keeps a working close control.
- [ ] The standalone icon copy button (copy without close) still works.
- [ ] The clipboard `execCommand` fallback path still applies.
- [ ] Closed cases render neither button; reopen unchanged.
- [ ] Case list (`my_cases.html`) is untouched this cycle (`refs #14`).