diff --git a/pyproject.toml b/pyproject.toml index 01e5ffa..fb33b44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/govoplan_cases/backend/evidence_links.py b/src/govoplan_cases/backend/evidence_links.py new file mode 100644 index 0000000..9e135a5 --- /dev/null +++ b/src/govoplan_cases/backend/evidence_links.py @@ -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", +] diff --git a/src/govoplan_cases/backend/manifest.py b/src/govoplan_cases/backend/manifest.py index 409ccfa..a813156 100644 --- a/src/govoplan_cases/backend/manifest.py +++ b/src/govoplan_cases/backend/manifest.py @@ -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", diff --git a/src/govoplan_cases/backend/router.py b/src/govoplan_cases/backend/router.py index 63f2c7b..a445753 100644 --- a/src/govoplan_cases/backend/router.py +++ b/src/govoplan_cases/backend/router.py @@ -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, diff --git a/src/govoplan_cases/backend/schemas.py b/src/govoplan_cases/backend/schemas.py index 61452ea..97449c4 100644 --- a/src/govoplan_cases/backend/schemas.py +++ b/src/govoplan_cases/backend/schemas.py @@ -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", diff --git a/tests/test_evidence_links.py b/tests/test_evidence_links.py new file mode 100644 index 0000000..53bef40 --- /dev/null +++ b/tests/test_evidence_links.py @@ -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() diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py index c5bfd33..79de8dc 100644 --- a/tests/test_interface_documentation_contract.py +++ b/tests/test_interface_documentation_contract.py @@ -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 ( diff --git a/webui/package.json b/webui/package.json index 836f518..6bbe968 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/cases-webui", - "version": "0.1.18", + "version": "0.1.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/src/api/cases.ts b/webui/src/api/cases.ts index 7f47a73..0de8e6a 100644 --- a/webui/src/api/cases.ts +++ b/webui/src/api/cases.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 { + return apiFetch( + 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 { + return apiFetch( + settings, + `/api/v1/cases/${encodeURIComponent(caseId)}/evidence-links/${encodeURIComponent(evidenceId)}/unlink`, + { method: "POST", body: JSON.stringify(payload) } + ); +} + export function caseShareTargetProvider( settings: ApiSettings, caseId: string, diff --git a/webui/src/features/cases/CaseDetailPage.tsx b/webui/src/features/cases/CaseDetailPage.tsx index 8c42b27..4c42a9d 100644 --- a/webui/src/features/cases/CaseDetailPage.tsx +++ b/webui/src/features/cases/CaseDetailPage.tsx @@ -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 { + 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) + void unlinkEvidence(evidence)} + /> {canReadDecisions ? ( ; + canUnlink: boolean; + onUnlink: (reference: EvidenceReference) => void; +}) { + return ( +
+

Linked files, mail, and campaigns

+

+ 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. +

+ {references.length ? ( +
+ {references.map((reference) => { + const source = linkedEvidenceSource(reference); + const available = source + ? providers[source.owner_module] !== false + : providers[reference.owner_module] !== false; + return ( + + {humanize(source?.kind ?? reference.kind)} + {reference.owner_module} ยท version {reference.version ?? "not pinned"} + {source && available ? ( + + + ) : source ? ( + Owner module unavailable; the reference remains in Case history. + ) : null} + {source ? ( + + ) : null} + + ); + })} +
+ ) : ( +

No owner references are linked.

+ )} +
+ ); +} + +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; + 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, diff --git a/webui/src/styles/cases.css b/webui/src/styles/cases.css index 9c252ba..82e1b18 100644 --- a/webui/src/styles/cases.css +++ b/webui/src/styles/cases.css @@ -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;