54a48691d1
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
271 lines
11 KiB
Markdown
271 lines
11 KiB
Markdown
# 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).
|
||
|
||
**Two physically separate forms, not one with a label switch**
|
||
(reconciled to the shipped implementation at audit). `#doc-body` only
|
||
exists in the `{% when Some %}` (has-document) arm of the template's
|
||
`{% match document_html %}`, so a single form anchored after it could
|
||
never serve the documentless case — and it must be emitted *before* the
|
||
copy `<script>` so the submit handler can bind it. The two cases
|
||
therefore live in their two respective match arms:
|
||
|
||
In the has-document arm, after `#doc-body` — the combined control,
|
||
wired to `copyDocText()`:
|
||
|
||
```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) %}
|
||
<button type="submit" class="copy-close-btn">Kopieren + Schließen</button>
|
||
</form>
|
||
<p id="copy-close-error" class="copy-error" role="alert" hidden>
|
||
Kopieren fehlgeschlagen — Fall nicht geschlossen.
|
||
</p>
|
||
</div>
|
||
{% endif %}
|
||
```
|
||
|
||
In the no-document arm — a bare close form. It carries **no** `id`, so
|
||
the submit handler below (which binds `#copy-close-form`) never attaches
|
||
to it and it submits normally (nothing to copy):
|
||
|
||
```html
|
||
{% if !is_closed %}
|
||
<div class="doc-footer-actions">
|
||
<form method="post" action="/cases/{{ case_id }}/close">
|
||
{% call csrf::field(csrf_token) %}
|
||
<button type="submit" class="copy-close-btn">Schließen</button>
|
||
</form>
|
||
</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.
|
||
// Only the has-document form carries id="copy-close-form", so this
|
||
// handler binds only there; the bare no-document "Schließen" form is
|
||
// never matched and submits normally (no copy).
|
||
const closeForm = document.getElementById("copy-close-form");
|
||
if (closeForm) {
|
||
closeForm.addEventListener("submit", async (e) => {
|
||
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 438–458):
|
||
|
||
```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) lives in a
|
||
separate form with no `id`, so the submit handler never binds it; it
|
||
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`).
|