feat(cases): link provider-owned evidence references
Module Package Release / publish-packages (push) Successful in 11s

This commit is contained in:
2026-08-21 16:10:48 +02:00
parent eab2f4b3b2
commit f236034e88
11 changed files with 951 additions and 5 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/cases-webui",
"version": "0.1.18",
"version": "0.1.19",
"private": true,
"type": "module",
"main": "src/index.ts",
+54
View File
@@ -21,6 +21,17 @@ export type EvidenceReference = {
evidence_id: string;
tenant_id: string;
version?: string | null;
source_ref?: string | null;
derived_from?: string[];
};
export type OwnerReference = {
ownerModule: string;
kind: string;
objectId: string;
tenantId: string;
version?: string | null;
path?: string | null;
};
export type CaseRecord = {
@@ -230,6 +241,49 @@ export function updateCase(
});
}
export function linkCaseEvidence(
settings: ApiSettings,
caseId: string,
payload: {
expected_revision: number;
recorded_at: string;
change_reason: string;
idempotency_key: string;
reference: {
owner_module: string;
kind: string;
object_id: string;
tenant_id: string;
version: string;
path: string;
};
}
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/evidence-links`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export function unlinkCaseEvidence(
settings: ApiSettings,
caseId: string,
evidenceId: string,
payload: {
expected_revision: number;
recorded_at: string;
change_reason: string;
idempotency_key: string;
}
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/evidence-links/${encodeURIComponent(evidenceId)}/unlink`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export function caseShareTargetProvider(
settings: ApiSettings,
caseId: string,
+198 -2
View File
@@ -1,4 +1,4 @@
import { Archive, ArrowLeft, Save, Scale, Share2 } from "lucide-react";
import { Archive, ArrowLeft, ExternalLink, Save, Scale, Share2, Unlink } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router";
import { ActionToolbar,
@@ -11,23 +11,29 @@ import { ActionToolbar,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
QUICK_ACCESS_RESULT_EVENT,
hasScope,
usePlatformModuleInstalled,
useGuardedNavigate,
useRegisterActiveObject,
useUnsavedDraftGuard,
type PlatformRouteContext
type PlatformRouteContext,
type QuickAccessLaunchContext,
type QuickAccessResult
} from "@govoplan/core-webui";
import {
caseHistory,
caseTimeline,
getCase,
linkCaseEvidence,
listCaseDecisions,
listCaseCatalog,
updateCase,
unlinkCaseEvidence,
type CaseCatalog,
type CaseRecord,
type CaseTimelineEntry,
type EvidenceReference,
type FormalDecision,
type InstitutionalReference
} from "../../api/cases";
@@ -56,6 +62,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
const [error, setError] = useState("");
const [shareOpen, setShareOpen] = useState(false);
const [decisionOpen, setDecisionOpen] = useState(false);
const [linkingEvidence, setLinkingEvidence] = useState(false);
const idempotencyKey = useRef(crypto.randomUUID());
const canUpdate = hasScope(auth, "cases:case:update");
const canClose = hasScope(auth, "cases:case:close");
@@ -64,6 +71,9 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
const mandatesAvailable = usePlatformModuleInstalled("mandates");
const accessAvailable = usePlatformModuleInstalled("access");
const recordsAvailable = usePlatformModuleInstalled("records");
const filesAvailable = usePlatformModuleInstalled("files");
const mailAvailable = usePlatformModuleInstalled("mail");
const campaignsAvailable = usePlatformModuleInstalled("campaigns");
const canReadDecisions = decisionsAvailable && hasScope(auth, "decisions:decision:read");
const decisionDisabledReason = !canUpdate
? "Your account may not update this Case."
@@ -132,6 +142,69 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
const changed = Boolean(record && (title.trim() !== record.title || status !== record.status_key));
const draftDirty = Boolean(record && canUpdate && (changed || changeReason.trim()));
useEffect(() => {
if (!record || !canUpdate) return;
const receiveReference = (event: Event) => {
const detail = (event as CustomEvent<{
launchContext?: QuickAccessLaunchContext;
result?: QuickAccessResult;
}>).detail;
const sourceCase = detail?.launchContext?.activeObject;
const result = detail?.result;
if (
!sourceCase
|| sourceCase.ownerModule !== "cases"
|| sourceCase.kind !== "case"
|| sourceCase.objectId !== record.reference.object_id
|| sourceCase.tenantId !== record.reference.tenant_id
|| result?.outcome !== "completed"
|| result.action !== "selected"
|| !result.reference
) return;
if (draftDirty) {
setError(
"Save or discard the current Case edits before linking the selected reference."
);
return;
}
const reference = result.reference;
if (!reference.version || !reference.path) {
setError("The selected owner reference is not an exact versioned link.");
return;
}
setLinkingEvidence(true);
setError("");
void linkCaseEvidence(settings, caseId, {
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: `Linked ${reference.ownerModule}.${reference.kind} through Quick Access.`,
idempotency_key: crypto.randomUUID(),
reference: {
owner_module: reference.ownerModule,
kind: reference.kind,
object_id: reference.objectId,
tenant_id: reference.tenantId,
version: reference.version,
path: reference.path
}
}).then((saved) => {
setRecord(saved);
setTitle(saved.title);
setStatus(saved.status_key);
return load();
}).catch((reason) => {
setError(reason instanceof Error
? reason.message
: "The selected reference could not be linked to this Case.");
}).finally(() => setLinkingEvidence(false));
};
window.addEventListener(QUICK_ACCESS_RESULT_EVENT, receiveReference);
return () => window.removeEventListener(
QUICK_ACCESS_RESULT_EVENT,
receiveReference
);
}, [canUpdate, caseId, draftDirty, load, record, settings]);
function discardDraft() {
if (!record) return;
setTitle(record.title);
@@ -173,6 +246,36 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
}
}
async function unlinkEvidence(evidence: EvidenceReference): Promise<void> {
if (!record || draftDirty || linkingEvidence) return;
if (!window.confirm("Remove this owner reference from the Case? The owner data is not deleted.")) return;
setLinkingEvidence(true);
setError("");
try {
const saved = await unlinkCaseEvidence(
settings,
caseId,
evidence.evidence_id,
{
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: `Unlinked ${evidence.owner_module} reference from the Case.`,
idempotency_key: crypto.randomUUID()
}
);
setRecord(saved);
setTitle(saved.title);
setStatus(saved.status_key);
await load();
} catch (reason) {
setError(reason instanceof Error
? reason.message
: "The owner reference could not be removed from this Case.");
} finally {
setLinkingEvidence(false);
}
}
useUnsavedDraftGuard({
dirty: draftDirty,
title: "i18n:govoplan-cases.unsaved_title",
@@ -306,6 +409,16 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<ReferenceSection title="Parties" references={record.party_refs} />
<ReferenceSection title="Assignments" references={record.assignment_refs} />
<EvidenceSection
references={record.evidence_refs}
providers={{
files: filesAvailable,
mail: mailAvailable,
campaigns: campaignsAvailable
}}
canUnlink={canUpdate && !draftDirty && !linkingEvidence}
onUnlink={(evidence) => void unlinkEvidence(evidence)}
/>
{canReadDecisions ? (
<DecisionSection
decisions={decisions}
@@ -410,6 +523,89 @@ function ReferenceSection({ title, references }: { title: string; references: In
);
}
function EvidenceSection({
references,
providers,
canUnlink,
onUnlink
}: {
references: EvidenceReference[];
providers: Record<string, boolean>;
canUnlink: boolean;
onUnlink: (reference: EvidenceReference) => void;
}) {
return (
<section className="case-reference-section case-evidence-section">
<h2>Linked files, mail, and campaigns</h2>
<p className="muted small-note">
Select an authorized exact item in Quick Access to link it. Cases stores
only the owner reference; the owning module checks access again when opened.
</p>
{references.length ? (
<div className="case-reference-list">
{references.map((reference) => {
const source = linkedEvidenceSource(reference);
const available = source
? providers[source.owner_module] !== false
: providers[reference.owner_module] !== false;
return (
<span key={`${reference.owner_module}:${reference.evidence_id}:${reference.version ?? "current"}`}>
<strong>{humanize(source?.kind ?? reference.kind)}</strong>
<small>{reference.owner_module} · version {reference.version ?? "not pinned"}</small>
{source && available ? (
<a href={source.path} className="btn btn-secondary">
<ExternalLink size={15} aria-hidden="true" /> Open in owner
</a>
) : source ? (
<small>Owner module unavailable; the reference remains in Case history.</small>
) : null}
{source ? (
<Button
variant="danger"
disabled={!canUnlink}
disabledReason={!canUnlink
? "Save or discard Case edits before removing a link."
: undefined}
onClick={() => onUnlink(reference)}
>
<Unlink size={15} aria-hidden="true" /> Unlink
</Button>
) : null}
</span>
);
})}
</div>
) : (
<p className="muted">No owner references are linked.</p>
)}
</section>
);
}
function linkedEvidenceSource(reference: EvidenceReference): {
owner_module: string;
kind: string;
path: string;
} | null {
if (!reference.source_ref) return null;
try {
const value = JSON.parse(reference.source_ref) as Record<string, unknown>;
const owner = String(value.owner_module ?? "");
const kind = String(value.kind ?? "");
const path = String(value.path ?? "");
if (
value.contract !== "cases.quick-access-reference.v1"
|| !owner
|| !kind
|| !path.startsWith("/")
|| path.startsWith("//")
) return null;
return { owner_module: owner, kind, path };
} catch {
return null;
}
}
function DecisionSection({
decisions,
canFile,
+9
View File
@@ -176,6 +176,15 @@
border-bottom: 1px solid var(--border);
}
.case-evidence-section .case-reference-list > span {
align-items: center;
flex-wrap: wrap;
}
.case-evidence-section .case-reference-list small {
color: var(--text-soft);
}
.case-timeline {
margin: 0;
padding: 0;