feat(cases): link provider-owned evidence references
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-cases"
|
||||
version = "0.1.18"
|
||||
version = "0.1.19"
|
||||
description = "GovOPlaN administrative case context module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from urllib.parse import parse_qs, quote, urlsplit
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.institutional import EvidenceReference
|
||||
from govoplan_cases.backend.domain import CaseRecord
|
||||
from govoplan_cases.backend.service import CaseStoreError, get_case, update_case
|
||||
|
||||
|
||||
_SOURCE_CONTRACT = "cases.quick-access-reference.v1"
|
||||
_SUPPORTED_REFERENCES = {
|
||||
("files", "file-version"): "document",
|
||||
("mail", "message"): "event",
|
||||
("campaigns", "campaign"): "event",
|
||||
}
|
||||
|
||||
|
||||
def link_case_evidence(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
case_id: str,
|
||||
expected_revision: int,
|
||||
reference: Mapping[str, object],
|
||||
recorded_at: datetime,
|
||||
change_reason: str,
|
||||
idempotency_key: str,
|
||||
) -> CaseRecord:
|
||||
base = _base_revision(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
evidence = _validated_evidence(reference, tenant_id=base.reference.tenant_id)
|
||||
if any(item.evidence_id == evidence.evidence_id for item in base.evidence_refs):
|
||||
raise CaseStoreError("The owner reference is already linked to this Case.")
|
||||
return update_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
expected_revision=expected_revision,
|
||||
changes={"evidence_refs": (*base.evidence_refs, evidence)},
|
||||
recorded_at=recorded_at,
|
||||
change_reason=change_reason,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
|
||||
def unlink_case_evidence(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
case_id: str,
|
||||
evidence_id: str,
|
||||
expected_revision: int,
|
||||
recorded_at: datetime,
|
||||
change_reason: str,
|
||||
idempotency_key: str,
|
||||
) -> CaseRecord:
|
||||
base = _base_revision(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
evidence = next(
|
||||
(item for item in base.evidence_refs if item.evidence_id == evidence_id),
|
||||
None,
|
||||
)
|
||||
if evidence is None or linked_source(evidence) is None:
|
||||
raise CaseStoreError("The linked owner reference was not found.")
|
||||
return update_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
expected_revision=expected_revision,
|
||||
changes={
|
||||
"evidence_refs": tuple(
|
||||
item for item in base.evidence_refs if item.evidence_id != evidence_id
|
||||
)
|
||||
},
|
||||
recorded_at=recorded_at,
|
||||
change_reason=change_reason,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
|
||||
def linked_source(evidence: EvidenceReference) -> Mapping[str, str] | None:
|
||||
if evidence.source_ref is None:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(evidence.source_ref)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not isinstance(value, Mapping) or value.get("contract") != _SOURCE_CONTRACT:
|
||||
return None
|
||||
result = {
|
||||
key: str(value.get(key) or "")
|
||||
for key in ("owner_module", "kind", "object_id", "path")
|
||||
}
|
||||
if (
|
||||
not all(result.values())
|
||||
or (result["owner_module"], result["kind"]) not in _SUPPORTED_REFERENCES
|
||||
):
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def _base_revision(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
case_id: str,
|
||||
expected_revision: int,
|
||||
) -> CaseRecord:
|
||||
base = get_case(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
revision=expected_revision,
|
||||
)
|
||||
if base is None:
|
||||
raise LookupError("Case revision not found.")
|
||||
return base
|
||||
|
||||
|
||||
def _validated_evidence(
|
||||
value: Mapping[str, object],
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> EvidenceReference:
|
||||
owner_module = _bounded(value.get("owner_module"), "Owner module", 120)
|
||||
reference_kind = _bounded(value.get("kind"), "Owner reference kind", 120)
|
||||
object_id = _bounded(value.get("object_id"), "Owner object id", 1_000)
|
||||
reference_tenant = _bounded(value.get("tenant_id"), "Owner tenant id", 255)
|
||||
version = _bounded(value.get("version"), "Owner version", 255)
|
||||
path = _bounded(value.get("path"), "Owner path", 2_000)
|
||||
evidence_kind = _SUPPORTED_REFERENCES.get((owner_module, reference_kind))
|
||||
if evidence_kind is None:
|
||||
raise CaseStoreError("The owner reference kind is not supported by Cases.")
|
||||
if reference_tenant != tenant_id:
|
||||
raise CaseStoreError("Owner references cannot cross Case tenants.")
|
||||
_validate_owner_path(
|
||||
owner_module=owner_module,
|
||||
reference_kind=reference_kind,
|
||||
object_id=object_id,
|
||||
version=version,
|
||||
path=path,
|
||||
)
|
||||
canonical = {
|
||||
"contract": _SOURCE_CONTRACT,
|
||||
"owner_module": owner_module,
|
||||
"kind": reference_kind,
|
||||
"object_id": object_id,
|
||||
"path": path,
|
||||
}
|
||||
encoded = json.dumps(
|
||||
canonical,
|
||||
ensure_ascii=True,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
digest = hashlib.sha256(
|
||||
f"{tenant_id}\x1f{encoded}\x1f{version}".encode()
|
||||
).hexdigest()
|
||||
return EvidenceReference(
|
||||
kind=evidence_kind, # type: ignore[arg-type]
|
||||
owner_module=owner_module,
|
||||
evidence_id=f"linked-{digest[:40]}",
|
||||
tenant_id=tenant_id,
|
||||
version=version,
|
||||
source_ref=encoded,
|
||||
derived_from=(_SOURCE_CONTRACT,),
|
||||
)
|
||||
|
||||
|
||||
def _validate_owner_path(
|
||||
*,
|
||||
owner_module: str,
|
||||
reference_kind: str,
|
||||
object_id: str,
|
||||
version: str,
|
||||
path: str,
|
||||
) -> None:
|
||||
parsed = urlsplit(path)
|
||||
if parsed.scheme or parsed.netloc or not path.startswith("/") or path.startswith("//"):
|
||||
raise CaseStoreError("Owner paths must be local platform routes.")
|
||||
if (owner_module, reference_kind) == ("files", "file-version"):
|
||||
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||
if (
|
||||
parsed.path != "/files"
|
||||
or query.get("versionId") != [object_id]
|
||||
or version != object_id
|
||||
or not query.get("fileId", [""])[0]
|
||||
):
|
||||
raise CaseStoreError("Files links require an exact authorized version path.")
|
||||
return
|
||||
if (owner_module, reference_kind) == ("mail", "message"):
|
||||
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||
profile = query.get("profile", [""])[0]
|
||||
folder = query.get("folder", [""])[0]
|
||||
message = query.get("message", [""])[0]
|
||||
expected_id = f"{profile}:{folder}:{message}"
|
||||
if (
|
||||
parsed.path != "/mail"
|
||||
or not profile
|
||||
or not folder
|
||||
or not message
|
||||
or object_id != expected_id
|
||||
or version != message
|
||||
):
|
||||
raise CaseStoreError("Mail links require an exact mailbox message path.")
|
||||
return
|
||||
expected_path = f"/campaigns/{quote(object_id, safe='')}"
|
||||
if path != expected_path:
|
||||
raise CaseStoreError("Campaign links require an exact Campaign path.")
|
||||
|
||||
|
||||
def _bounded(value: object, label: str, maximum: int) -> str:
|
||||
result = str(value or "").strip()
|
||||
if not result:
|
||||
raise CaseStoreError(f"{label} is required.")
|
||||
if len(result) > maximum or any(ord(character) < 32 for character in result):
|
||||
raise CaseStoreError(f"{label} is invalid.")
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"link_case_evidence",
|
||||
"linked_source",
|
||||
"unlink_case_evidence",
|
||||
]
|
||||
@@ -64,7 +64,7 @@ from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
MODULE_ID = "cases"
|
||||
MODULE_VERSION = "0.1.18"
|
||||
MODULE_VERSION = "0.1.19"
|
||||
READ_SCOPE = "cases:case:read"
|
||||
CREATE_SCOPE = "cases:case:create"
|
||||
UPDATE_SCOPE = "cases:case:update"
|
||||
@@ -151,6 +151,10 @@ manifest = ModuleManifest(
|
||||
"forms_runtime",
|
||||
"workflow_engine",
|
||||
"records",
|
||||
"files",
|
||||
"mail",
|
||||
"campaigns",
|
||||
"quick_access",
|
||||
),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_PARTY_RESOLVER,
|
||||
@@ -413,6 +417,58 @@ manifest = ModuleManifest(
|
||||
),
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="cases.workflow.link-owner-references",
|
||||
title="Link exact Files, Mail, and Campaign references to a Case",
|
||||
summary=(
|
||||
"Append or remove an owner-authorized reference without copying "
|
||||
"the provider's content into Cases."
|
||||
),
|
||||
body=(
|
||||
"On an active Case, Files, Mail, and Campaigns may return an exact "
|
||||
"tenant-bound selection through the versioned Quick Access result "
|
||||
"contract. Cases accepts only the supported owner and reference-kind "
|
||||
"pairs, a matching tenant, a pinned version, and the owner's canonical "
|
||||
"local route. Linking and unlinking recheck Case update authority, use "
|
||||
"expected-revision concurrency and idempotency, and append immutable "
|
||||
"Case history. Cases stores only a typed owner identifier, exact version, "
|
||||
"and owner route; it never copies file bytes, message headers or bodies, "
|
||||
"recipients, campaign content, or a source access decision. Opening a "
|
||||
"link always enters the owning module, which rechecks current access. "
|
||||
"If the owner module is disabled or source access is later revoked, the "
|
||||
"Case retains the historical reference but does not disclose source "
|
||||
"content. Unlinking removes only the reference from the next Case revision "
|
||||
"and never deletes the owner object. Unsaved Case edits must be saved or "
|
||||
"discarded before a Quick Access result can be linked."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("case_manager", "operator", "module_admin", "auditor"),
|
||||
related_modules=("quick_access", "files", "mail", "campaigns"),
|
||||
order=12,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("cases",),
|
||||
required_scopes=(UPDATE_SCOPE,),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||
DocumentationLink(
|
||||
label="Quick Access architecture",
|
||||
href="govoplan/docs/architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": ["cases.detail.references"],
|
||||
"consequence_classes": {
|
||||
"link_owner_reference": "Appends an immutable Case revision containing only the exact typed owner reference.",
|
||||
"unlink_owner_reference": "Appends an immutable Case revision and leaves the owner object unchanged.",
|
||||
},
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="cases.data-subject-requests",
|
||||
title="Case data-subject requests",
|
||||
|
||||
@@ -33,6 +33,8 @@ from govoplan_cases.backend.manifest import (
|
||||
)
|
||||
from govoplan_cases.backend.schemas import (
|
||||
CaseDecisionRequest,
|
||||
CaseEvidenceLinkRequest,
|
||||
CaseEvidenceUnlinkRequest,
|
||||
CaseHistoryResponse,
|
||||
CaseListResponse,
|
||||
CaseStatusWriteRequest,
|
||||
@@ -41,6 +43,10 @@ from govoplan_cases.backend.schemas import (
|
||||
CaseUpdateRequest,
|
||||
CaseWriteRequest,
|
||||
)
|
||||
from govoplan_cases.backend.evidence_links import (
|
||||
link_case_evidence,
|
||||
unlink_case_evidence,
|
||||
)
|
||||
from govoplan_cases.backend.decision_path import (
|
||||
CaseDecisionCommand,
|
||||
CaseDecisionError,
|
||||
@@ -349,6 +355,72 @@ def api_update_case(
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.post("/{case_id}/evidence-links", response_model=dict[str, Any])
|
||||
def api_link_case_evidence(
|
||||
case_id: str,
|
||||
payload: CaseEvidenceLinkRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, UPDATE_SCOPE)
|
||||
try:
|
||||
item = link_case_evidence(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
reference=payload.reference.model_dump(),
|
||||
recorded_at=payload.recorded_at,
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
CaseStoreError,
|
||||
InstitutionalContextError,
|
||||
LookupError,
|
||||
PermissionError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{case_id}/evidence-links/{evidence_id}/unlink",
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
def api_unlink_case_evidence(
|
||||
case_id: str,
|
||||
evidence_id: str,
|
||||
payload: CaseEvidenceUnlinkRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, UPDATE_SCOPE)
|
||||
try:
|
||||
item = unlink_case_evidence(
|
||||
session,
|
||||
principal,
|
||||
case_id=case_id,
|
||||
evidence_id=evidence_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
recorded_at=payload.recorded_at,
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
CaseStoreError,
|
||||
InstitutionalContextError,
|
||||
LookupError,
|
||||
PermissionError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{case_id}/share-target-options",
|
||||
response_model=ReferenceOptionListResponse,
|
||||
|
||||
@@ -82,6 +82,36 @@ class CaseUpdateRequest(BaseModel):
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CaseOwnerReferenceRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
owner_module: str = Field(min_length=1, max_length=120)
|
||||
kind: str = Field(min_length=1, max_length=120)
|
||||
object_id: str = Field(min_length=1, max_length=1_000)
|
||||
tenant_id: str = Field(min_length=1, max_length=255)
|
||||
version: str = Field(min_length=1, max_length=255)
|
||||
path: str = Field(min_length=1, max_length=2_000)
|
||||
|
||||
|
||||
class CaseEvidenceLinkRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
recorded_at: datetime
|
||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
reference: CaseOwnerReferenceRequest
|
||||
|
||||
|
||||
class CaseEvidenceUnlinkRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
recorded_at: datetime
|
||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class CaseDecisionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -113,6 +143,8 @@ class CaseTimelineResponse(BaseModel):
|
||||
__all__ = [
|
||||
"CaseGrantRequest",
|
||||
"CaseDecisionRequest",
|
||||
"CaseEvidenceLinkRequest",
|
||||
"CaseEvidenceUnlinkRequest",
|
||||
"CaseHistoryResponse",
|
||||
"CaseListResponse",
|
||||
"CaseStatusWriteRequest",
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.institutional import (
|
||||
GovernedContextEnvelope,
|
||||
InstitutionalReference,
|
||||
TemporalRevision,
|
||||
)
|
||||
from govoplan_cases.backend.db.models import (
|
||||
CaseAccessGrant,
|
||||
CaseIdentity,
|
||||
CaseRecordRevision,
|
||||
CaseStatusDefinition,
|
||||
CaseTimelineEntry,
|
||||
CaseTypeDefinition,
|
||||
)
|
||||
from govoplan_cases.backend.domain import CaseRecord
|
||||
from govoplan_cases.backend.evidence_links import (
|
||||
link_case_evidence,
|
||||
linked_source,
|
||||
unlink_case_evidence,
|
||||
)
|
||||
from govoplan_cases.backend.service import (
|
||||
CaseStoreError,
|
||||
case_history,
|
||||
case_timeline,
|
||||
create_case,
|
||||
update_case,
|
||||
upsert_case_status,
|
||||
upsert_case_type,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 21, 14, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Principal:
|
||||
tenant_id: str = "tenant-1"
|
||||
account_id: str = "account-1"
|
||||
scopes: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def ref(kind: str, object_id: str, owner: str) -> InstitutionalReference:
|
||||
return InstitutionalReference(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
owner_module=owner,
|
||||
object_id=object_id,
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
valid_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def case_record() -> CaseRecord:
|
||||
case_ref = ref("case", "case-1", "cases")
|
||||
return CaseRecord(
|
||||
reference=case_ref,
|
||||
case_number="PERMIT-2026-0001",
|
||||
case_type_key="permit",
|
||||
status_key="open",
|
||||
title="Resident permit",
|
||||
context=GovernedContextEnvelope(
|
||||
tenant_id="tenant-1",
|
||||
temporal=TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=NOW,
|
||||
recorded_at=NOW,
|
||||
change_reason="Case intake.",
|
||||
),
|
||||
case_ref=case_ref,
|
||||
),
|
||||
opened_at=NOW,
|
||||
recorded_at=NOW,
|
||||
change_reason="Case created.",
|
||||
)
|
||||
|
||||
|
||||
def file_reference(**changes: str) -> dict[str, str]:
|
||||
result = {
|
||||
"owner_module": "files",
|
||||
"kind": "file-version",
|
||||
"object_id": "version-1",
|
||||
"tenant_id": "tenant-1",
|
||||
"version": "version-1",
|
||||
"path": "/files?fileId=file-1&versionId=version-1",
|
||||
}
|
||||
result.update(changes)
|
||||
return result
|
||||
|
||||
|
||||
class CaseEvidenceLinkTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
for table in (
|
||||
CaseStatusDefinition.__table__,
|
||||
CaseTypeDefinition.__table__,
|
||||
CaseIdentity.__table__,
|
||||
CaseRecordRevision.__table__,
|
||||
CaseAccessGrant.__table__,
|
||||
CaseTimelineEntry.__table__,
|
||||
):
|
||||
table.create(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.principal = Principal()
|
||||
upsert_case_status(
|
||||
self.session,
|
||||
self.principal,
|
||||
status_key="open",
|
||||
label="Open",
|
||||
)
|
||||
upsert_case_type(
|
||||
self.session,
|
||||
self.principal,
|
||||
type_key="permit",
|
||||
label="Permit",
|
||||
initial_status_key="open",
|
||||
)
|
||||
create_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=case_record(),
|
||||
idempotency_key="create-case",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def link(self, **reference_changes: str) -> CaseRecord:
|
||||
return link_case_evidence(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
expected_revision=1,
|
||||
reference=file_reference(**reference_changes),
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
change_reason="Linked exact file version.",
|
||||
idempotency_key="link-file-1",
|
||||
)
|
||||
|
||||
def test_link_appends_minimized_immutable_revision_and_audit_event(self) -> None:
|
||||
linked = self.link()
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(2, linked.revision)
|
||||
self.assertEqual(1, len(linked.evidence_refs))
|
||||
evidence = linked.evidence_refs[0]
|
||||
self.assertEqual("files", evidence.owner_module)
|
||||
self.assertEqual("version-1", evidence.version)
|
||||
self.assertTrue(evidence.evidence_id.startswith("linked-"))
|
||||
source = linked_source(evidence)
|
||||
self.assertEqual("file-version", source["kind"])
|
||||
self.assertEqual("/files?fileId=file-1&versionId=version-1", source["path"])
|
||||
self.assertNotIn("label", evidence.source_ref or "")
|
||||
self.assertNotIn("content", evidence.source_ref or "")
|
||||
|
||||
history = case_history(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
)
|
||||
timeline = case_timeline(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
)
|
||||
self.assertEqual([2, 1], [item.revision for item in history])
|
||||
self.assertEqual("cases.case.updated", timeline[0]["event_type"])
|
||||
self.assertNotIn(evidence.evidence_id, str(timeline[0]["payload"]))
|
||||
|
||||
def test_link_is_replay_safe_and_rejects_duplicate_or_stale_changes(self) -> None:
|
||||
first = self.link()
|
||||
replay = self.link()
|
||||
self.assertEqual(first.reference, replay.reference)
|
||||
self.session.commit()
|
||||
|
||||
with self.assertRaisesRegex(CaseStoreError, "already linked"):
|
||||
link_case_evidence(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
expected_revision=2,
|
||||
reference=file_reference(),
|
||||
recorded_at=NOW + timedelta(minutes=2),
|
||||
change_reason="Duplicate link.",
|
||||
idempotency_key="duplicate-link",
|
||||
)
|
||||
|
||||
updated = update_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
expected_revision=2,
|
||||
changes={"title": "Updated permit"},
|
||||
recorded_at=NOW + timedelta(minutes=2),
|
||||
change_reason="Updated title.",
|
||||
idempotency_key="update-title",
|
||||
)
|
||||
self.assertEqual(3, updated.revision)
|
||||
with self.assertRaisesRegex(CaseStoreError, "stale"):
|
||||
link_case_evidence(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
expected_revision=2,
|
||||
reference={
|
||||
"owner_module": "campaigns",
|
||||
"kind": "campaign",
|
||||
"object_id": "campaign-1",
|
||||
"tenant_id": "tenant-1",
|
||||
"version": "campaign-version-1",
|
||||
"path": "/campaigns/campaign-1",
|
||||
},
|
||||
recorded_at=NOW + timedelta(minutes=3),
|
||||
change_reason="Stale link.",
|
||||
idempotency_key="stale-link",
|
||||
)
|
||||
|
||||
def test_rejects_cross_tenant_unsupported_and_malformed_owner_paths(self) -> None:
|
||||
for changes, message in (
|
||||
({"tenant_id": "tenant-2"}, "cross Case tenants"),
|
||||
({"owner_module": "unknown"}, "not supported"),
|
||||
({"path": "/files?versionId=version-1"}, "exact authorized"),
|
||||
({"path": "https://example.invalid/file"}, "local platform"),
|
||||
):
|
||||
with self.subTest(changes=changes):
|
||||
with self.assertRaisesRegex(CaseStoreError, message):
|
||||
self.link(**changes)
|
||||
|
||||
def test_unlink_only_removes_cases_owned_links_and_is_replay_safe(self) -> None:
|
||||
linked = self.link()
|
||||
self.session.commit()
|
||||
evidence_id = linked.evidence_refs[0].evidence_id
|
||||
unlinked = unlink_case_evidence(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
evidence_id=evidence_id,
|
||||
expected_revision=2,
|
||||
recorded_at=NOW + timedelta(minutes=2),
|
||||
change_reason="Removed obsolete link.",
|
||||
idempotency_key="unlink-file-1",
|
||||
)
|
||||
replay = unlink_case_evidence(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
evidence_id=evidence_id,
|
||||
expected_revision=2,
|
||||
recorded_at=NOW + timedelta(minutes=2),
|
||||
change_reason="Removed obsolete link.",
|
||||
idempotency_key="unlink-file-1",
|
||||
)
|
||||
self.assertEqual(3, unlinked.revision)
|
||||
self.assertEqual(unlinked.reference, replay.reference)
|
||||
self.assertEqual((), unlinked.evidence_refs)
|
||||
|
||||
with self.assertRaisesRegex(CaseStoreError, "not found"):
|
||||
unlink_case_evidence(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
evidence_id="ordinary-evidence",
|
||||
expected_revision=3,
|
||||
recorded_at=NOW + timedelta(minutes=3),
|
||||
change_reason="Invalid unlink.",
|
||||
idempotency_key="invalid-unlink",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -46,12 +46,17 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
topics = {topic.id: topic for topic in get_manifest().documentation}
|
||||
context = topics["cases.institutional-context"]
|
||||
reference = topics["cases.reference.lifecycle-access-and-evidence"]
|
||||
owner_links = topics["cases.workflow.link-owner-references"]
|
||||
|
||||
self.assertIn("cases.state.read-only", context.metadata["help_contexts"])
|
||||
self.assertIn("cases.field.access-grant", reference.metadata["help_contexts"])
|
||||
self.assertIn("update_case", reference.metadata["consequence_classes"])
|
||||
self.assertIn("close_case", reference.metadata["consequence_classes"])
|
||||
self.assertIn("change_access", reference.metadata["consequence_classes"])
|
||||
self.assertIn(
|
||||
"link_owner_reference",
|
||||
owner_links.metadata["consequence_classes"],
|
||||
)
|
||||
|
||||
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
|
||||
list_page = (
|
||||
@@ -70,6 +75,9 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
"DocumentationHelpLink",
|
||||
"FormField",
|
||||
"useUnsavedDraftGuard",
|
||||
"QUICK_ACCESS_RESULT_EVENT",
|
||||
"linkCaseEvidence",
|
||||
"unlinkCaseEvidence",
|
||||
):
|
||||
self.assertIn(component, detail_page)
|
||||
for component in (
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/cases-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user