feat(cases): link provider-owned evidence references
Module Package Release / publish-packages (push) Successful in 11s
Module Package Release / publish-packages (push) Successful in 11s
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-cases"
|
name = "govoplan-cases"
|
||||||
version = "0.1.18"
|
version = "0.1.19"
|
||||||
description = "GovOPlaN administrative case context module."
|
description = "GovOPlaN administrative case context module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
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_ID = "cases"
|
||||||
MODULE_VERSION = "0.1.18"
|
MODULE_VERSION = "0.1.19"
|
||||||
READ_SCOPE = "cases:case:read"
|
READ_SCOPE = "cases:case:read"
|
||||||
CREATE_SCOPE = "cases:case:create"
|
CREATE_SCOPE = "cases:case:create"
|
||||||
UPDATE_SCOPE = "cases:case:update"
|
UPDATE_SCOPE = "cases:case:update"
|
||||||
@@ -151,6 +151,10 @@ manifest = ModuleManifest(
|
|||||||
"forms_runtime",
|
"forms_runtime",
|
||||||
"workflow_engine",
|
"workflow_engine",
|
||||||
"records",
|
"records",
|
||||||
|
"files",
|
||||||
|
"mail",
|
||||||
|
"campaigns",
|
||||||
|
"quick_access",
|
||||||
),
|
),
|
||||||
optional_capabilities=(
|
optional_capabilities=(
|
||||||
CAPABILITY_PARTY_RESOLVER,
|
CAPABILITY_PARTY_RESOLVER,
|
||||||
@@ -413,6 +417,58 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
documentation=(
|
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(
|
DocumentationTopic(
|
||||||
id="cases.data-subject-requests",
|
id="cases.data-subject-requests",
|
||||||
title="Case data-subject requests",
|
title="Case data-subject requests",
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ from govoplan_cases.backend.manifest import (
|
|||||||
)
|
)
|
||||||
from govoplan_cases.backend.schemas import (
|
from govoplan_cases.backend.schemas import (
|
||||||
CaseDecisionRequest,
|
CaseDecisionRequest,
|
||||||
|
CaseEvidenceLinkRequest,
|
||||||
|
CaseEvidenceUnlinkRequest,
|
||||||
CaseHistoryResponse,
|
CaseHistoryResponse,
|
||||||
CaseListResponse,
|
CaseListResponse,
|
||||||
CaseStatusWriteRequest,
|
CaseStatusWriteRequest,
|
||||||
@@ -41,6 +43,10 @@ from govoplan_cases.backend.schemas import (
|
|||||||
CaseUpdateRequest,
|
CaseUpdateRequest,
|
||||||
CaseWriteRequest,
|
CaseWriteRequest,
|
||||||
)
|
)
|
||||||
|
from govoplan_cases.backend.evidence_links import (
|
||||||
|
link_case_evidence,
|
||||||
|
unlink_case_evidence,
|
||||||
|
)
|
||||||
from govoplan_cases.backend.decision_path import (
|
from govoplan_cases.backend.decision_path import (
|
||||||
CaseDecisionCommand,
|
CaseDecisionCommand,
|
||||||
CaseDecisionError,
|
CaseDecisionError,
|
||||||
@@ -349,6 +355,72 @@ def api_update_case(
|
|||||||
return item.to_dict()
|
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(
|
@router.get(
|
||||||
"/{case_id}/share-target-options",
|
"/{case_id}/share-target-options",
|
||||||
response_model=ReferenceOptionListResponse,
|
response_model=ReferenceOptionListResponse,
|
||||||
|
|||||||
@@ -82,6 +82,36 @@ class CaseUpdateRequest(BaseModel):
|
|||||||
metadata: dict[str, Any] | None = None
|
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):
|
class CaseDecisionRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -113,6 +143,8 @@ class CaseTimelineResponse(BaseModel):
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"CaseGrantRequest",
|
"CaseGrantRequest",
|
||||||
"CaseDecisionRequest",
|
"CaseDecisionRequest",
|
||||||
|
"CaseEvidenceLinkRequest",
|
||||||
|
"CaseEvidenceUnlinkRequest",
|
||||||
"CaseHistoryResponse",
|
"CaseHistoryResponse",
|
||||||
"CaseListResponse",
|
"CaseListResponse",
|
||||||
"CaseStatusWriteRequest",
|
"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}
|
topics = {topic.id: topic for topic in get_manifest().documentation}
|
||||||
context = topics["cases.institutional-context"]
|
context = topics["cases.institutional-context"]
|
||||||
reference = topics["cases.reference.lifecycle-access-and-evidence"]
|
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.state.read-only", context.metadata["help_contexts"])
|
||||||
self.assertIn("cases.field.access-grant", reference.metadata["help_contexts"])
|
self.assertIn("cases.field.access-grant", reference.metadata["help_contexts"])
|
||||||
self.assertIn("update_case", reference.metadata["consequence_classes"])
|
self.assertIn("update_case", reference.metadata["consequence_classes"])
|
||||||
self.assertIn("close_case", reference.metadata["consequence_classes"])
|
self.assertIn("close_case", reference.metadata["consequence_classes"])
|
||||||
self.assertIn("change_access", 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:
|
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
|
||||||
list_page = (
|
list_page = (
|
||||||
@@ -70,6 +75,9 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
"DocumentationHelpLink",
|
"DocumentationHelpLink",
|
||||||
"FormField",
|
"FormField",
|
||||||
"useUnsavedDraftGuard",
|
"useUnsavedDraftGuard",
|
||||||
|
"QUICK_ACCESS_RESULT_EVENT",
|
||||||
|
"linkCaseEvidence",
|
||||||
|
"unlinkCaseEvidence",
|
||||||
):
|
):
|
||||||
self.assertIn(component, detail_page)
|
self.assertIn(component, detail_page)
|
||||||
for component in (
|
for component in (
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/cases-webui",
|
"name": "@govoplan/cases-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ export type EvidenceReference = {
|
|||||||
evidence_id: string;
|
evidence_id: string;
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
version?: string | null;
|
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 = {
|
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(
|
export function caseShareTargetProvider(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
caseId: string,
|
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
import { ActionToolbar,
|
import { ActionToolbar,
|
||||||
@@ -11,23 +11,29 @@ import { ActionToolbar,
|
|||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
QUICK_ACCESS_RESULT_EVENT,
|
||||||
hasScope,
|
hasScope,
|
||||||
usePlatformModuleInstalled,
|
usePlatformModuleInstalled,
|
||||||
useGuardedNavigate,
|
useGuardedNavigate,
|
||||||
useRegisterActiveObject,
|
useRegisterActiveObject,
|
||||||
useUnsavedDraftGuard,
|
useUnsavedDraftGuard,
|
||||||
type PlatformRouteContext
|
type PlatformRouteContext,
|
||||||
|
type QuickAccessLaunchContext,
|
||||||
|
type QuickAccessResult
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
caseHistory,
|
caseHistory,
|
||||||
caseTimeline,
|
caseTimeline,
|
||||||
getCase,
|
getCase,
|
||||||
|
linkCaseEvidence,
|
||||||
listCaseDecisions,
|
listCaseDecisions,
|
||||||
listCaseCatalog,
|
listCaseCatalog,
|
||||||
updateCase,
|
updateCase,
|
||||||
|
unlinkCaseEvidence,
|
||||||
type CaseCatalog,
|
type CaseCatalog,
|
||||||
type CaseRecord,
|
type CaseRecord,
|
||||||
type CaseTimelineEntry,
|
type CaseTimelineEntry,
|
||||||
|
type EvidenceReference,
|
||||||
type FormalDecision,
|
type FormalDecision,
|
||||||
type InstitutionalReference
|
type InstitutionalReference
|
||||||
} from "../../api/cases";
|
} from "../../api/cases";
|
||||||
@@ -56,6 +62,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [shareOpen, setShareOpen] = useState(false);
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
const [decisionOpen, setDecisionOpen] = useState(false);
|
const [decisionOpen, setDecisionOpen] = useState(false);
|
||||||
|
const [linkingEvidence, setLinkingEvidence] = useState(false);
|
||||||
const idempotencyKey = useRef(crypto.randomUUID());
|
const idempotencyKey = useRef(crypto.randomUUID());
|
||||||
const canUpdate = hasScope(auth, "cases:case:update");
|
const canUpdate = hasScope(auth, "cases:case:update");
|
||||||
const canClose = hasScope(auth, "cases:case:close");
|
const canClose = hasScope(auth, "cases:case:close");
|
||||||
@@ -64,6 +71,9 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
const mandatesAvailable = usePlatformModuleInstalled("mandates");
|
const mandatesAvailable = usePlatformModuleInstalled("mandates");
|
||||||
const accessAvailable = usePlatformModuleInstalled("access");
|
const accessAvailable = usePlatformModuleInstalled("access");
|
||||||
const recordsAvailable = usePlatformModuleInstalled("records");
|
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 canReadDecisions = decisionsAvailable && hasScope(auth, "decisions:decision:read");
|
||||||
const decisionDisabledReason = !canUpdate
|
const decisionDisabledReason = !canUpdate
|
||||||
? "Your account may not update this Case."
|
? "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 changed = Boolean(record && (title.trim() !== record.title || status !== record.status_key));
|
||||||
const draftDirty = Boolean(record && canUpdate && (changed || changeReason.trim()));
|
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() {
|
function discardDraft() {
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
setTitle(record.title);
|
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({
|
useUnsavedDraftGuard({
|
||||||
dirty: draftDirty,
|
dirty: draftDirty,
|
||||||
title: "i18n:govoplan-cases.unsaved_title",
|
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="Parties" references={record.party_refs} />
|
||||||
<ReferenceSection title="Assignments" references={record.assignment_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 ? (
|
{canReadDecisions ? (
|
||||||
<DecisionSection
|
<DecisionSection
|
||||||
decisions={decisions}
|
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({
|
function DecisionSection({
|
||||||
decisions,
|
decisions,
|
||||||
canFile,
|
canFile,
|
||||||
|
|||||||
@@ -176,6 +176,15 @@
|
|||||||
border-bottom: 1px solid var(--border);
|
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 {
|
.case-timeline {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user