PR: #481: feat(filings): Mark-as-exhibit UI action + POST proxy [Story-1901] by @ankitmaheshwari-com
View PR →
Story-1901

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

409 — duplicate

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.

422 — privileged

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

CheckResult
pnpm exec tsc --noEmitclean
pnpm lintclean (only a pre-existing avatar warning)
pnpm buildgreen — POST route compiled
pnpm test960 passed
pnpm check:routesregistry fresh
StorybookMarkAsExhibitDialog: 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.jsonConfig+30-0
messages/hi.jsonConfig+30-0
src/app/[locale]/(workspace)/matters/[matterId]/filings/page.tsxApp route+9-1
src/app/api/internal/matters/[matterId]/proceedings/[proceedingId]/exhibit-register/route.tsApp route+90-0
src/components/matters/filings/exhibit-label-cell.tsxComponent+2-18
src/components/matters/filings/filings-tab.tsxComponent+28-1
src/components/matters/filings/mark-as-exhibit-dialog.stories.tsxComponent+79-0
src/components/matters/filings/mark-as-exhibit-dialog.tsxComponent+230-0
src/lib/filings/error-message.tsLibrary+23-0
src/lib/filings/gateway.tsLibrary+25-0
src/lib/filings/presentation.tsLibrary+19-0