Mark a document as an exhibit
The Exhibit Register could be read (Story-1886) and a
marking renumbered (Story-1895) — but a marking
could never be created in product code. The UAT found the
missing half: counsel had no way to actually tender a document. The
backend POST shipped in Story-1899 and the contract is synced
(codegen-sync #1900); this Story wires the human affordance on top of it.
What counsel sees
On the Filings tab's Exhibit Register subtab there is now a "Mark a document" action. It opens a small form: pick a document, choose the party series (which pre-fills an editable display label), and give it an exhibit number. On success the new marking appears in the register with its two-identity row; the dialog closes and a toast confirms. The action is disabled when the matter has no markable documents (honest empty state) — not hidden, so its existence is discoverable.
The data path (browser never touches the gateway secret)
useAppMarkDocumentAsExhibit (src/gen, mutation)
-> POST /app/matters/{id}/proceedings/{pid}/exhibit-register
-> toBrowserRoute() rewrites onto /api/internal/... (kubb-client.ts, existing `exhibit-register` rule)
-> POST handler in exhibit-register/route.ts
- parse body with exhibitMarkingCreateRequestSchema (HR-21 trust boundary)
- filingsGateway.markDocumentAsExhibit() -> gateway.post (server-side bearer)
- assertContract(documentExhibitMarkingViewSchema, ...) on the 201
-> 201 DocumentExhibitMarkingView
-> invalidate appGetProceedingExhibitRegisterQueryKey -> register refetches
The POST handler lives beside the existing GET in the
same exhibit-register/route.ts and mirrors the Story-1895 PATCH
renumber route for JSON parsing, contract re-assert, and the
GatewayError passthrough. No toBrowserRoute change
was needed — the bare exhibit-register path already maps,
and POST rides the same rule the GET uses.
The two correctable failures, surfaced inline
A label already used in the same party series (the DB-enforced
uniqueness key) returns 409. The dialog stays open and shows
the backend's localized message inline; counsel picks another number.
A privileged work-product document is never markable (DB trigger,
surfaced as 422). Defence in depth: privileged documents are
already filtered out of the picker server-side, so this is only reachable
via a stale list — and even then it fails safe with an inline note.
Privileged documents are filtered at the source
The picker is fed from mattersGateway.listDocuments() in the
Filings page.tsx (server-side), with
privilegeFlag === true rows dropped before the list ever reaches
the browser. The API rejects them too, so the rule holds even if the list is
stale. A documents-fetch blip degrades to an empty picker (disabled action),
never a broken tab.
Human-only by contract
The route is matters:write gated and the service stamps
source='human' on the create — an agent can never reach
this path. The broker exhibit-proposal handler reaches the same
service create path with source='agent', which is why
agent-proposed markings still show the Blake review chrome (Story-1889) and
human-created ones do not.
Reuse & cleanup
The ErrorResponse message extractor was duplicated between the
renumber cell and this dialog; it is now one shared helper
(src/lib/filings/error-message.ts) both import. The party-series
option list lives in src/lib/filings/presentation.ts (no copy
literals in the client). Buttons go through PrimaryButton;
copy is in both en and hi; no color literals.
Verification
| Check | Result |
|---|---|
pnpm exec tsc --noEmit | clean |
pnpm lint | clean (only a pre-existing avatar warning) |
pnpm build | green — POST route compiled |
pnpm test | 960 passed |
pnpm check:routes | registry fresh |
| Storybook | MarkAsExhibitDialog: Default + NoDocuments stories |
Scope held to the affordance + its POST proxy. Admission / rejection remain tribunal acts (DB-enforced human-only) and are out of this CREATE contract.
Files changed
11 file(s), +565 / -20 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +30 | -0 |
| messages/hi.json | Config | +30 | -0 |
| src/app/[locale]/(workspace)/matters/[matterId]/filings/page.tsx | App route | +9 | -1 |
| src/app/api/internal/matters/[matterId]/proceedings/[proceedingId]/exhibit-register/route.ts | App route | +90 | -0 |
| src/components/matters/filings/exhibit-label-cell.tsx | Component | +2 | -18 |
| src/components/matters/filings/filings-tab.tsx | Component | +28 | -1 |
| src/components/matters/filings/mark-as-exhibit-dialog.stories.tsx | Component | +79 | -0 |
| src/components/matters/filings/mark-as-exhibit-dialog.tsx | Component | +230 | -0 |
| src/lib/filings/error-message.ts | Library | +23 | -0 |
| src/lib/filings/gateway.ts | Library | +25 | -0 |
| src/lib/filings/presentation.ts | Library | +19 | -0 |