Integrate formal decisions with cases
This commit is contained in:
+20
-2
@@ -163,6 +163,15 @@ Cases links formal Decision records and may retain a current outcome/status
|
|||||||
projection. It does not own decision authority, rule versions, reasoning,
|
projection. It does not own decision authority, rule versions, reasoning,
|
||||||
correction, revocation, or remedy semantics.
|
correction, revocation, or remedy semantics.
|
||||||
|
|
||||||
|
The Case detail decision path is a guarded composition boundary. It resolves an
|
||||||
|
exact Case revision, rechecks Case update access, verifies the current acting
|
||||||
|
assignment through Access, and requires exactly one effective Mandate for
|
||||||
|
`cases.formal_decision` and the selected Decision type. Only then does it call
|
||||||
|
the optional Decisions registry and append the exact Decision reference to a
|
||||||
|
new Case revision in the same transaction. Deterministic identifiers make an
|
||||||
|
unchanged retry safe, while changed replay payloads and stale Case revisions
|
||||||
|
fail closed. Cases still does not store the protected result or reasoning.
|
||||||
|
|
||||||
Evidence links should store only stable module/resource references and display
|
Evidence links should store only stable module/resource references and display
|
||||||
metadata snapshots. The owning module remains responsible for the real object.
|
metadata snapshots. The owning module remains responsible for the real object.
|
||||||
|
|
||||||
@@ -173,6 +182,12 @@ valid interval, recorded time, canonical snapshot digest, and launch link.
|
|||||||
Records owns the filing decision and chronology; Cases remains authoritative
|
Records owns the filing decision and chronology; Cases remains authoritative
|
||||||
for the case and its revision history.
|
for the case and its revision history.
|
||||||
|
|
||||||
|
When Search is enabled, `cases.cases` indexes current Case titles, numbers,
|
||||||
|
types, states, and the existing bounded search projection. It includes no
|
||||||
|
provider-owned evidence content or protected Decision reasoning. Restricted
|
||||||
|
Case candidates carry bounded ACL tokens and every result is checked again
|
||||||
|
against the current Case object-access decision before disclosure.
|
||||||
|
|
||||||
## WebUI
|
## WebUI
|
||||||
|
|
||||||
Initial route contributions:
|
Initial route contributions:
|
||||||
@@ -180,8 +195,11 @@ Initial route contributions:
|
|||||||
- `/cases`
|
- `/cases`
|
||||||
- `/cases/:caseId`
|
- `/cases/:caseId`
|
||||||
|
|
||||||
The initial detail view renders the module-owned record, stable references,
|
The detail view renders the module-owned record, stable references, history,
|
||||||
history, and timeline. Future linked forms, files, tasks, workflow state,
|
timeline, and provider-resolved formal Decisions when currently authorized. It
|
||||||
|
can record a direct officer Decision when Access, Mandates, and Decisions are
|
||||||
|
enabled and can launch exact Case or Decision filing when Records is enabled.
|
||||||
|
Future linked forms, files, tasks, workflow state,
|
||||||
appointments, documents, communication, payment evidence, and richer audit
|
appointments, documents, communication, payment evidence, and richer audit
|
||||||
panels must arrive through declarative extension points, without direct UI
|
panels must arrive through declarative extension points, without direct UI
|
||||||
imports from sibling modules.
|
imports from sibling modules.
|
||||||
|
|||||||
@@ -0,0 +1,478 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.access import CAPABILITY_ACCESS_SEMANTIC_DIRECTORY
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_DECISION_REGISTRY,
|
||||||
|
CAPABILITY_MANDATE_RESOLVER,
|
||||||
|
ActorRepresentationReference,
|
||||||
|
DecisionRegistry,
|
||||||
|
FormalDecision,
|
||||||
|
GovernedContextEnvelope,
|
||||||
|
InstitutionalReference,
|
||||||
|
MandateDefinition,
|
||||||
|
MandateResolution,
|
||||||
|
MandateResolutionRequest,
|
||||||
|
MandateResolver,
|
||||||
|
TemporalRevision,
|
||||||
|
resolve_mandate_candidates,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.domain import CaseRecord
|
||||||
|
from govoplan_cases.backend.service import (
|
||||||
|
can_access_case,
|
||||||
|
get_case,
|
||||||
|
update_case,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DECISION_READ_SCOPE = "decisions:decision:read"
|
||||||
|
DECISION_SENSITIVE_READ_SCOPE = "decisions:decision:read_sensitive"
|
||||||
|
DECISION_WRITE_SCOPE = "decisions:decision:write"
|
||||||
|
_DECISION_NAMESPACE = uuid.uuid5(
|
||||||
|
uuid.NAMESPACE_URL,
|
||||||
|
"https://govoplan.add-ideas.de/contracts/cases/formal-decision/v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseDecisionError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CaseDecisionUnavailable(CaseDecisionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CaseDecisionCommand:
|
||||||
|
expected_case_revision: int
|
||||||
|
effective_at: datetime
|
||||||
|
decision_type: str
|
||||||
|
operative_result: str
|
||||||
|
reasoning: str
|
||||||
|
change_reason: str
|
||||||
|
idempotency_key: str
|
||||||
|
conditions: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CaseDecisionResult:
|
||||||
|
case: CaseRecord
|
||||||
|
decision: FormalDecision
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"case": self.case.to_dict(),
|
||||||
|
"decision": self.decision.to_dict(include_protected=True),
|
||||||
|
"replayed": self.replayed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CaseDecisionPath:
|
||||||
|
"""Record a formal outcome and its exact Case reference atomically."""
|
||||||
|
|
||||||
|
def __init__(self, registry: object | None = None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def record(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
case_id: str,
|
||||||
|
command: CaseDecisionCommand,
|
||||||
|
) -> CaseDecisionResult:
|
||||||
|
_validate_command(command)
|
||||||
|
source_case = get_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
revision=command.expected_case_revision,
|
||||||
|
)
|
||||||
|
current = get_case(session, principal, case_id=case_id)
|
||||||
|
if source_case is None or current is None:
|
||||||
|
raise LookupError("Case not found.")
|
||||||
|
if not can_access_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
permission="update",
|
||||||
|
):
|
||||||
|
raise PermissionError("Case update access is denied.")
|
||||||
|
|
||||||
|
decision_id = _decision_id(source_case, command.idempotency_key)
|
||||||
|
existing_ref = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in current.decision_refs
|
||||||
|
if item.owner_module == "decisions" and item.object_id == decision_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if existing_ref is None and current.revision != command.expected_case_revision:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
"Case revision conflict: reload the Case before recording a Decision."
|
||||||
|
)
|
||||||
|
|
||||||
|
decision_registry = _decision_registry(self._registry)
|
||||||
|
mandate = _resolve_mandate(
|
||||||
|
self._registry,
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case=source_case,
|
||||||
|
command=command,
|
||||||
|
)
|
||||||
|
actor = _resolve_actor(
|
||||||
|
self._registry,
|
||||||
|
principal,
|
||||||
|
case=source_case,
|
||||||
|
mandate=mandate,
|
||||||
|
effective_at=command.effective_at,
|
||||||
|
)
|
||||||
|
decision = _decision(
|
||||||
|
source_case,
|
||||||
|
command=command,
|
||||||
|
decision_id=decision_id,
|
||||||
|
mandate=mandate,
|
||||||
|
actor=actor,
|
||||||
|
)
|
||||||
|
persisted = decision_registry.record_decision(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
decision=decision,
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_ref is not None:
|
||||||
|
return CaseDecisionResult(
|
||||||
|
case=current,
|
||||||
|
decision=persisted,
|
||||||
|
replayed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
linked = update_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
expected_revision=current.revision,
|
||||||
|
changes={"decision_refs": (*current.decision_refs, persisted.reference)},
|
||||||
|
recorded_at=command.effective_at,
|
||||||
|
change_reason=command.change_reason,
|
||||||
|
idempotency_key=f"formal-decision:{command.idempotency_key}",
|
||||||
|
)
|
||||||
|
return CaseDecisionResult(case=linked, decision=persisted)
|
||||||
|
|
||||||
|
def linked(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
case_id: str,
|
||||||
|
) -> tuple[FormalDecision, ...]:
|
||||||
|
case = get_case(session, principal, case_id=case_id)
|
||||||
|
if case is None:
|
||||||
|
raise LookupError("Case not found.")
|
||||||
|
registry = _decision_registry(self._registry)
|
||||||
|
resolved: list[FormalDecision] = []
|
||||||
|
for reference in case.decision_refs:
|
||||||
|
if reference.owner_module != "decisions":
|
||||||
|
continue
|
||||||
|
item = registry.get_decision(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
if item is not None:
|
||||||
|
resolved.append(item)
|
||||||
|
return tuple(resolved)
|
||||||
|
|
||||||
|
|
||||||
|
def _decision(
|
||||||
|
case: CaseRecord,
|
||||||
|
*,
|
||||||
|
command: CaseDecisionCommand,
|
||||||
|
decision_id: str,
|
||||||
|
mandate: MandateDefinition,
|
||||||
|
actor: ActorRepresentationReference,
|
||||||
|
) -> FormalDecision:
|
||||||
|
evidence = tuple(
|
||||||
|
dict.fromkeys((*case.evidence_refs, *case.context.evidence, *mandate.evidence))
|
||||||
|
)
|
||||||
|
legal_bases = tuple(
|
||||||
|
dict.fromkeys((*case.context.legal_bases, *mandate.legal_bases))
|
||||||
|
)
|
||||||
|
if not evidence:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
"A formal Decision requires evidence linked to the exact Case revision."
|
||||||
|
)
|
||||||
|
if not legal_bases:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
"A formal Decision requires an effective legal-basis version."
|
||||||
|
)
|
||||||
|
decision_ref = InstitutionalReference(
|
||||||
|
kind="decision",
|
||||||
|
owner_module="decisions",
|
||||||
|
object_id=decision_id,
|
||||||
|
tenant_id=case.reference.tenant_id,
|
||||||
|
version="1",
|
||||||
|
valid_at=command.effective_at,
|
||||||
|
)
|
||||||
|
temporal = TemporalRevision(
|
||||||
|
revision="1",
|
||||||
|
valid_from=command.effective_at,
|
||||||
|
recorded_at=command.effective_at,
|
||||||
|
change_reason=command.change_reason,
|
||||||
|
)
|
||||||
|
authority_context: GovernedContextEnvelope = replace(
|
||||||
|
case.context,
|
||||||
|
temporal=temporal,
|
||||||
|
actor=actor,
|
||||||
|
mandate_ref=mandate.reference,
|
||||||
|
case_ref=case.reference,
|
||||||
|
party_refs=case.party_refs or case.context.party_refs,
|
||||||
|
decision_ref=decision_ref,
|
||||||
|
record_refs=case.record_refs,
|
||||||
|
legal_bases=legal_bases,
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
return FormalDecision(
|
||||||
|
reference=decision_ref,
|
||||||
|
temporal=temporal,
|
||||||
|
decision_type=command.decision_type.strip(),
|
||||||
|
subject_refs=case.party_refs or (case.reference,),
|
||||||
|
state="decided",
|
||||||
|
authority_context=authority_context,
|
||||||
|
fact_evidence=evidence,
|
||||||
|
legal_bases=legal_bases,
|
||||||
|
operative_result=command.operative_result.strip(),
|
||||||
|
reasoning=command.reasoning.strip(),
|
||||||
|
conditions=tuple(item.strip() for item in command.conditions if item.strip()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_mandate(
|
||||||
|
registry: object | None,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
case: CaseRecord,
|
||||||
|
command: CaseDecisionCommand,
|
||||||
|
) -> MandateDefinition:
|
||||||
|
resolver = _capability(registry, CAPABILITY_MANDATE_RESOLVER)
|
||||||
|
if not isinstance(resolver, MandateResolver):
|
||||||
|
raise CaseDecisionUnavailable(
|
||||||
|
"Formal Decisions require an enabled Mandates resolver."
|
||||||
|
)
|
||||||
|
request = MandateResolutionRequest(
|
||||||
|
tenant_id=case.reference.tenant_id,
|
||||||
|
effective_at=command.effective_at,
|
||||||
|
task_type="cases.formal_decision",
|
||||||
|
authority_type=command.decision_type.strip(),
|
||||||
|
organization_unit_ref=case.context.organization_unit_ref,
|
||||||
|
function_ref=case.context.function_ref,
|
||||||
|
jurisdiction_refs=case.context.jurisdiction_refs,
|
||||||
|
)
|
||||||
|
resolution: MandateResolution = resolver.resolve_mandate(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
if not resolution.competent:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
resolution.explanation or "The acting function is not competent to decide."
|
||||||
|
)
|
||||||
|
if resolution.conflict_refs:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
"Mandate resolution has unresolved conflicts: "
|
||||||
|
+ ", ".join(resolution.conflict_refs)
|
||||||
|
)
|
||||||
|
verified = resolve_mandate_candidates(request, resolution.mandates)
|
||||||
|
if not verified.competent or len(verified.mandates) != 1:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
verified.explanation
|
||||||
|
or "A formal Decision requires exactly one effective active Mandate."
|
||||||
|
)
|
||||||
|
return verified.mandates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_actor(
|
||||||
|
registry: object | None,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
case: CaseRecord,
|
||||||
|
mandate: MandateDefinition,
|
||||||
|
effective_at: datetime,
|
||||||
|
) -> ActorRepresentationReference:
|
||||||
|
directory = _capability(registry, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY)
|
||||||
|
lookup = getattr(directory, "get_function_assignment", None)
|
||||||
|
if not callable(lookup):
|
||||||
|
raise CaseDecisionUnavailable(
|
||||||
|
"Formal Decisions require the Access semantic directory to verify the acting assignment."
|
||||||
|
)
|
||||||
|
acting_id = _text(getattr(principal, "acting_assignment_id", None))
|
||||||
|
granted_ids = tuple(
|
||||||
|
sorted(
|
||||||
|
{
|
||||||
|
str(item).strip()
|
||||||
|
for item in (getattr(principal, "function_assignment_ids", ()) or ())
|
||||||
|
if str(item).strip()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if acting_id and acting_id not in granted_ids:
|
||||||
|
raise PermissionError("The selected acting assignment is not active for this account.")
|
||||||
|
candidate_ids = (acting_id,) if acting_id else granted_ids
|
||||||
|
matching = []
|
||||||
|
for assignment_id in candidate_ids:
|
||||||
|
assignment = lookup(assignment_id)
|
||||||
|
if assignment is None or not _assignment_matches(
|
||||||
|
assignment,
|
||||||
|
principal,
|
||||||
|
case=case,
|
||||||
|
effective_at=effective_at,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
matching.append(assignment)
|
||||||
|
if not matching:
|
||||||
|
raise PermissionError(
|
||||||
|
"No active acting assignment matches the Case organization and function."
|
||||||
|
)
|
||||||
|
if len(matching) > 1:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
"Select one acting assignment in the title bar before recording the Decision."
|
||||||
|
)
|
||||||
|
assignment = matching[0]
|
||||||
|
return ActorRepresentationReference(
|
||||||
|
tenant_id=case.reference.tenant_id,
|
||||||
|
account_id=_required_principal_text(principal, "account_id"),
|
||||||
|
identity_id=_text(getattr(principal, "identity_id", None))
|
||||||
|
or _text(getattr(assignment, "identity_id", None)),
|
||||||
|
represented_account_id=_text(
|
||||||
|
getattr(principal, "acting_for_account_id", None)
|
||||||
|
),
|
||||||
|
represented_function_ref=case.context.function_ref,
|
||||||
|
function_assignment_ref=InstitutionalReference(
|
||||||
|
kind="function_assignment",
|
||||||
|
owner_module="access",
|
||||||
|
object_id=str(getattr(assignment, "id")),
|
||||||
|
tenant_id=case.reference.tenant_id,
|
||||||
|
valid_at=effective_at,
|
||||||
|
),
|
||||||
|
delegation_ref=_text(
|
||||||
|
getattr(assignment, "delegated_from_assignment_id", None)
|
||||||
|
),
|
||||||
|
mandate_ref=mandate.reference,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_matches(
|
||||||
|
assignment: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
case: CaseRecord,
|
||||||
|
effective_at: datetime,
|
||||||
|
) -> bool:
|
||||||
|
organization = case.context.organization_unit_ref
|
||||||
|
function = case.context.function_ref
|
||||||
|
if organization is None or function is None:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
"The Case lacks the responsible organization and function required for a formal Decision."
|
||||||
|
)
|
||||||
|
if str(getattr(assignment, "tenant_id", "")) != case.reference.tenant_id:
|
||||||
|
return False
|
||||||
|
if str(getattr(assignment, "account_id", "")) != _required_principal_text(
|
||||||
|
principal, "account_id"
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if str(getattr(assignment, "function_id", "")) != function.object_id:
|
||||||
|
return False
|
||||||
|
if str(getattr(assignment, "organization_unit_id", "")) != organization.object_id:
|
||||||
|
return False
|
||||||
|
if str(getattr(assignment, "status", "active")) != "active":
|
||||||
|
return False
|
||||||
|
valid_from = getattr(assignment, "valid_from", None)
|
||||||
|
valid_until = getattr(assignment, "valid_until", None)
|
||||||
|
return not (
|
||||||
|
(valid_from is not None and effective_at < valid_from)
|
||||||
|
or (valid_until is not None and effective_at >= valid_until)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _decision_registry(registry: object | None) -> DecisionRegistry:
|
||||||
|
provider = _capability(registry, CAPABILITY_DECISION_REGISTRY)
|
||||||
|
if not isinstance(provider, DecisionRegistry):
|
||||||
|
raise CaseDecisionUnavailable(
|
||||||
|
"Formal Decisions require an enabled Decision registry."
|
||||||
|
)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def _decision_id(case: CaseRecord, idempotency_key: str) -> str:
|
||||||
|
return str(
|
||||||
|
uuid.uuid5(
|
||||||
|
_DECISION_NAMESPACE,
|
||||||
|
":".join(
|
||||||
|
(
|
||||||
|
case.reference.tenant_id,
|
||||||
|
case.reference.object_id,
|
||||||
|
str(case.revision),
|
||||||
|
idempotency_key.strip(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_command(command: CaseDecisionCommand) -> None:
|
||||||
|
for value, label in (
|
||||||
|
(command.decision_type, "Decision type"),
|
||||||
|
(command.operative_result, "Operative result"),
|
||||||
|
(command.reasoning, "Decision reasoning"),
|
||||||
|
(command.change_reason, "Decision change reason"),
|
||||||
|
(command.idempotency_key, "Decision idempotency key"),
|
||||||
|
):
|
||||||
|
if not value.strip():
|
||||||
|
raise CaseDecisionError(f"{label} is required.")
|
||||||
|
if command.expected_case_revision < 1:
|
||||||
|
raise CaseDecisionError("Expected Case revision must be positive.")
|
||||||
|
if command.effective_at.tzinfo is None or command.effective_at.utcoffset() is None:
|
||||||
|
raise CaseDecisionError("Decision effective_at must include a timezone.")
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(registry: object | None, name: str) -> object | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: object | None) -> str | None:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def _required_principal_text(principal: object, name: str) -> str:
|
||||||
|
value = _text(getattr(principal, name, None))
|
||||||
|
if value is None:
|
||||||
|
raise PermissionError(f"The current principal has no {name.replace('_', ' ')}.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CaseDecisionCommand",
|
||||||
|
"CaseDecisionError",
|
||||||
|
"CaseDecisionPath",
|
||||||
|
"CaseDecisionResult",
|
||||||
|
"CaseDecisionUnavailable",
|
||||||
|
"DECISION_READ_SCOPE",
|
||||||
|
"DECISION_SENSITIVE_READ_SCOPE",
|
||||||
|
"DECISION_WRITE_SCOPE",
|
||||||
|
]
|
||||||
@@ -6,7 +6,12 @@ from govoplan_core.core.module_guards import (
|
|||||||
drop_table_retirement_provider,
|
drop_table_retirement_provider,
|
||||||
persistent_table_uninstall_guard,
|
persistent_table_uninstall_guard,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.institutional import CAPABILITY_PARTY_RESOLVER
|
from govoplan_core.core.access import CAPABILITY_ACCESS_SEMANTIC_DIRECTORY
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_DECISION_REGISTRY,
|
||||||
|
CAPABILITY_MANDATE_RESOLVER,
|
||||||
|
CAPABILITY_PARTY_RESOLVER,
|
||||||
|
)
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
CapabilityDocumentation,
|
CapabilityDocumentation,
|
||||||
DocumentationCondition,
|
DocumentationCondition,
|
||||||
@@ -24,6 +29,7 @@ from govoplan_core.core.modules import (
|
|||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||||
from govoplan_core.core.provider_governance import (
|
from govoplan_core.core.provider_governance import (
|
||||||
ModuleArchitectureDeclaration,
|
ModuleArchitectureDeclaration,
|
||||||
ModuleArchitectureDocumentation,
|
ModuleArchitectureDocumentation,
|
||||||
@@ -51,6 +57,7 @@ from govoplan_cases.backend.record_source import (
|
|||||||
CAPABILITY_RECORD_SOURCE_CASES,
|
CAPABILITY_RECORD_SOURCE_CASES,
|
||||||
create_cases_record_source,
|
create_cases_record_source,
|
||||||
)
|
)
|
||||||
|
from govoplan_cases.backend.search_source import create_cases_search_source
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -138,7 +145,12 @@ manifest = ModuleManifest(
|
|||||||
"workflow_engine",
|
"workflow_engine",
|
||||||
"records",
|
"records",
|
||||||
),
|
),
|
||||||
optional_capabilities=(CAPABILITY_PARTY_RESOLVER,),
|
optional_capabilities=(
|
||||||
|
CAPABILITY_PARTY_RESOLVER,
|
||||||
|
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||||
|
CAPABILITY_MANDATE_RESOLVER,
|
||||||
|
CAPABILITY_DECISION_REGISTRY,
|
||||||
|
),
|
||||||
permissions=(
|
permissions=(
|
||||||
_permission(READ_SCOPE, "View cases", "Read tenant cases and their governed history."),
|
_permission(READ_SCOPE, "View cases", "Read tenant cases and their governed history."),
|
||||||
_permission(CREATE_SCOPE, "Create cases", "Open a case from a configured case type or service intake."),
|
_permission(CREATE_SCOPE, "Create cases", "Open a case from a configured case type or service intake."),
|
||||||
@@ -282,6 +294,14 @@ manifest = ModuleManifest(
|
|||||||
parent_id="cases.detail",
|
parent_id="cases.detail",
|
||||||
order=60,
|
order=60,
|
||||||
),
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="cases.detail.decision",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="action",
|
||||||
|
label="Prepare formal decision",
|
||||||
|
parent_id="cases.detail",
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
@@ -295,6 +315,8 @@ manifest = ModuleManifest(
|
|||||||
ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||||
ModuleInterfaceRequirement(name="parties.procedure", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
ModuleInterfaceRequirement(name="parties.procedure", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||||
ModuleInterfaceRequirement(name="parties.representation", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
ModuleInterfaceRequirement(name="parties.representation", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||||
|
ModuleInterfaceRequirement(name="mandates.resolution", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||||
|
ModuleInterfaceRequirement(name="decisions.formal_outcome", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_CASES_SERVICE_INTAKE: _service_intake,
|
CAPABILITY_CASES_SERVICE_INTAKE: _service_intake,
|
||||||
@@ -358,8 +380,57 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
resource_acl_providers=(CaseAclProvider(),),
|
resource_acl_providers=(CaseAclProvider(),),
|
||||||
|
search_sources=(
|
||||||
|
SearchSourceProviderRegistration(
|
||||||
|
id="cases.cases",
|
||||||
|
factory=create_cases_search_source,
|
||||||
|
),
|
||||||
|
),
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="cases.workflow.record-formal-decision",
|
||||||
|
title="Record a formal Decision from a Case",
|
||||||
|
summary="Revalidate acting authority and preserve an exact Decision revision in the Case history.",
|
||||||
|
body=(
|
||||||
|
"The Case detail action accepts only the operative result, reasoning, conditions, effective time, and change reason. "
|
||||||
|
"Cases resolves the exact Case revision, verifies object update access, resolves the current acting assignment through "
|
||||||
|
"Access, and requires exactly one effective Mandate for cases.formal_decision and the selected Decision type. It then "
|
||||||
|
"records the outcome through the optional Decisions registry and links the exact Decision revision to a new Case revision "
|
||||||
|
"in the same database transaction. A replay uses a deterministic Decision identifier; changed replay payloads fail closed."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("case_manager", "decision_officer", "operator", "auditor"),
|
||||||
|
related_modules=("access", "mandates", "decisions", "records"),
|
||||||
|
order=14,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("cases", "access", "mandates", "decisions"),
|
||||||
|
required_scopes=("cases:case:update", "decisions:decision:write"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||||
|
DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"),
|
||||||
|
DocumentationLink(label="Decisions domain", href="govoplan-decisions/docs/DECISIONS_DOMAIN.md", kind="repository"),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": ["cases.action.decide", "cases.decision.editor"],
|
||||||
|
"prerequisites": [
|
||||||
|
"The Case has an exact responsible organization, function, evidence, and legal-basis version.",
|
||||||
|
"An active acting assignment and exactly one effective Mandate authorize the Decision type.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Open the Case and select the appropriate acting assignment in the title bar.",
|
||||||
|
"Record the operative result, reasoning, optional conditions, effective time, and change reason.",
|
||||||
|
"Confirm the exact Decision and Case revisions in the Case timeline.",
|
||||||
|
"When Records is enabled, file the exact Decision or Case revision into the destination eAkte.",
|
||||||
|
],
|
||||||
|
"outcome": "The formal outcome is Decisions-owned and exact-version linked from the Case.",
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="cases.workflow.file-exact-revision",
|
id="cases.workflow.file-exact-revision",
|
||||||
title="File an exact case revision into an eAkte",
|
title="File an exact case revision into an eAkte",
|
||||||
@@ -412,7 +483,10 @@ manifest = ModuleManifest(
|
|||||||
"intake retains the exact Service, Mandate, jurisdiction, legal basis, form, "
|
"intake retains the exact Service, Mandate, jurisdiction, legal basis, form, "
|
||||||
"workflow, and result bindings. Procedure parties come from an optional provider "
|
"workflow, and result bindings. Procedure parties come from an optional provider "
|
||||||
"or a limited Cases-only compatibility projection. Assignment, evidence, Decision, "
|
"or a limited Cases-only compatibility projection. Assignment, evidence, Decision, "
|
||||||
"and record links remain stable references owned by their source modules."
|
"and record links remain stable references owned by their source modules. When "
|
||||||
|
"Search is enabled, Cases contributes a rebuildable metadata-only projection. "
|
||||||
|
"Evidence and protected Decision content are excluded, and every candidate is "
|
||||||
|
"rechecked against current Case access before disclosure."
|
||||||
),
|
),
|
||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
@@ -428,6 +502,7 @@ manifest = ModuleManifest(
|
|||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"cases.list",
|
"cases.list",
|
||||||
"cases.detail",
|
"cases.detail",
|
||||||
|
"cases.search.result",
|
||||||
"cases.state.read-only",
|
"cases.state.read-only",
|
||||||
"cases.state.restricted",
|
"cases.state.restricted",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from govoplan_cases.backend.manifest import (
|
|||||||
UPDATE_SCOPE,
|
UPDATE_SCOPE,
|
||||||
)
|
)
|
||||||
from govoplan_cases.backend.schemas import (
|
from govoplan_cases.backend.schemas import (
|
||||||
|
CaseDecisionRequest,
|
||||||
CaseHistoryResponse,
|
CaseHistoryResponse,
|
||||||
CaseListResponse,
|
CaseListResponse,
|
||||||
CaseStatusWriteRequest,
|
CaseStatusWriteRequest,
|
||||||
@@ -40,6 +41,15 @@ from govoplan_cases.backend.schemas import (
|
|||||||
CaseUpdateRequest,
|
CaseUpdateRequest,
|
||||||
CaseWriteRequest,
|
CaseWriteRequest,
|
||||||
)
|
)
|
||||||
|
from govoplan_cases.backend.decision_path import (
|
||||||
|
CaseDecisionCommand,
|
||||||
|
CaseDecisionError,
|
||||||
|
CaseDecisionPath,
|
||||||
|
CaseDecisionUnavailable,
|
||||||
|
DECISION_READ_SCOPE,
|
||||||
|
DECISION_SENSITIVE_READ_SCOPE,
|
||||||
|
DECISION_WRITE_SCOPE,
|
||||||
|
)
|
||||||
from govoplan_cases.backend.service import (
|
from govoplan_cases.backend.service import (
|
||||||
CaseStoreError,
|
CaseStoreError,
|
||||||
can_access_case,
|
can_access_case,
|
||||||
@@ -66,7 +76,9 @@ def _require(principal: ApiPrincipal, scope: str) -> None:
|
|||||||
def _error(exc: Exception) -> HTTPException:
|
def _error(exc: Exception) -> HTTPException:
|
||||||
message = str(exc)
|
message = str(exc)
|
||||||
lowered = message.casefold()
|
lowered = message.casefold()
|
||||||
if isinstance(exc, LookupError):
|
if isinstance(exc, CaseDecisionUnavailable):
|
||||||
|
code = status.HTTP_424_FAILED_DEPENDENCY
|
||||||
|
elif isinstance(exc, LookupError):
|
||||||
code = 404
|
code = 404
|
||||||
elif isinstance(exc, PermissionError):
|
elif isinstance(exc, PermissionError):
|
||||||
code = 403
|
code = 403
|
||||||
@@ -206,6 +218,78 @@ def api_create_case(
|
|||||||
return item.to_dict()
|
return item.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{case_id}/decisions", response_model=dict[str, list[dict[str, Any]]])
|
||||||
|
def api_case_decisions(
|
||||||
|
case_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
_require(principal, DECISION_READ_SCOPE)
|
||||||
|
try:
|
||||||
|
items = CaseDecisionPath(get_registry()).linked(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
CaseDecisionError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
disclose = has_scope(principal, DECISION_SENSITIVE_READ_SCOPE)
|
||||||
|
return {
|
||||||
|
"decisions": [
|
||||||
|
item.to_dict(include_protected=disclose)
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{case_id}/decisions",
|
||||||
|
response_model=dict[str, Any],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_record_case_decision(
|
||||||
|
case_id: str,
|
||||||
|
payload: CaseDecisionRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_require(principal, UPDATE_SCOPE)
|
||||||
|
_require(principal, DECISION_WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
result = CaseDecisionPath(get_registry()).record(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
command=CaseDecisionCommand(
|
||||||
|
expected_case_revision=payload.expected_case_revision,
|
||||||
|
effective_at=payload.effective_at,
|
||||||
|
decision_type=payload.decision_type,
|
||||||
|
operative_result=payload.operative_result,
|
||||||
|
reasoning=payload.reasoning,
|
||||||
|
conditions=tuple(payload.conditions),
|
||||||
|
change_reason=payload.change_reason,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
CaseDecisionError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
ValueError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return result.to_dict()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{case_id}", response_model=dict[str, Any])
|
@router.get("/{case_id}", response_model=dict[str, Any])
|
||||||
def api_get_case(
|
def api_get_case(
|
||||||
case_id: str,
|
case_id: str,
|
||||||
|
|||||||
@@ -82,6 +82,19 @@ class CaseUpdateRequest(BaseModel):
|
|||||||
metadata: dict[str, Any] | None = None
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CaseDecisionRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_case_revision: int = Field(ge=1)
|
||||||
|
effective_at: datetime
|
||||||
|
decision_type: str = Field(min_length=1, max_length=120)
|
||||||
|
operative_result: str = Field(min_length=1, max_length=20_000)
|
||||||
|
reasoning: str = Field(min_length=1, max_length=50_000)
|
||||||
|
conditions: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
class CaseListResponse(BaseModel):
|
class CaseListResponse(BaseModel):
|
||||||
cases: list[dict[str, Any]]
|
cases: list[dict[str, Any]]
|
||||||
total: int
|
total: int
|
||||||
@@ -99,6 +112,7 @@ class CaseTimelineResponse(BaseModel):
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CaseGrantRequest",
|
"CaseGrantRequest",
|
||||||
|
"CaseDecisionRequest",
|
||||||
"CaseHistoryResponse",
|
"CaseHistoryResponse",
|
||||||
"CaseListResponse",
|
"CaseListResponse",
|
||||||
"CaseStatusWriteRequest",
|
"CaseStatusWriteRequest",
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.events import PlatformEvent
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.core.search import (
|
||||||
|
SearchAuthorizationRequest,
|
||||||
|
SearchBackfillPage,
|
||||||
|
SearchBackfillRequest,
|
||||||
|
SearchDocument,
|
||||||
|
SearchIndexChange,
|
||||||
|
SearchResourceReference,
|
||||||
|
SearchResourceType,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.db.models import (
|
||||||
|
CaseAccessGrant,
|
||||||
|
CaseIdentity,
|
||||||
|
CaseRecordRevision,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.service import can_access_case
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDER_ID = "cases.cases"
|
||||||
|
RESOURCE_TYPE = "case"
|
||||||
|
READ_SCOPE = "cases:case:read"
|
||||||
|
ADMIN_SCOPE = "cases:case:admin"
|
||||||
|
|
||||||
|
|
||||||
|
class CasesSearchSource:
|
||||||
|
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||||
|
return (
|
||||||
|
SearchResourceType(
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
module_id="cases",
|
||||||
|
resource_type=RESOURCE_TYPE,
|
||||||
|
label="Cases",
|
||||||
|
requires_authorization_recheck=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def backfill(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: SearchBackfillRequest,
|
||||||
|
) -> SearchBackfillPage:
|
||||||
|
_assert_source(request.provider_id, request.resource_type)
|
||||||
|
db = _session(session)
|
||||||
|
statement = (
|
||||||
|
select(CaseRecordRevision, CaseIdentity)
|
||||||
|
.join(CaseIdentity, CaseIdentity.id == CaseRecordRevision.identity_id)
|
||||||
|
.where(
|
||||||
|
CaseRecordRevision.tenant_id == request.tenant_id,
|
||||||
|
CaseRecordRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if request.cursor:
|
||||||
|
statement = statement.where(CaseRecordRevision.case_id > request.cursor)
|
||||||
|
rows = list(
|
||||||
|
db.execute(
|
||||||
|
statement.order_by(CaseRecordRevision.case_id).limit(request.limit + 1)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
has_more = len(rows) > request.limit
|
||||||
|
selected = rows[: request.limit]
|
||||||
|
grants = _grants_by_case(
|
||||||
|
db,
|
||||||
|
request.tenant_id,
|
||||||
|
[row.case_id for row, _identity in selected],
|
||||||
|
)
|
||||||
|
high_watermark = db.scalar(
|
||||||
|
select(func.max(CaseRecordRevision.recorded_at)).where(
|
||||||
|
CaseRecordRevision.tenant_id == request.tenant_id,
|
||||||
|
CaseRecordRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return SearchBackfillPage(
|
||||||
|
documents=tuple(
|
||||||
|
_document(
|
||||||
|
row,
|
||||||
|
identity=identity,
|
||||||
|
grants=grants.get(row.case_id, ()),
|
||||||
|
)
|
||||||
|
for row, identity in selected
|
||||||
|
),
|
||||||
|
next_cursor=selected[-1][0].case_id if has_more and selected else None,
|
||||||
|
complete=not has_more,
|
||||||
|
high_watermark=high_watermark.isoformat() if high_watermark else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
requests: Sequence[SearchAuthorizationRequest],
|
||||||
|
) -> Mapping[str, bool]:
|
||||||
|
decisions = {item.reference.key: False for item in requests}
|
||||||
|
if not isinstance(principal, ApiPrincipal) or not principal.has(READ_SCOPE):
|
||||||
|
return decisions
|
||||||
|
db = _session(session)
|
||||||
|
for request in requests:
|
||||||
|
reference = request.reference
|
||||||
|
if (
|
||||||
|
reference.tenant_id != principal.tenant_id
|
||||||
|
or reference.module_id != "cases"
|
||||||
|
or reference.resource_type != RESOURCE_TYPE
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
decisions[reference.key] = can_access_case(
|
||||||
|
db,
|
||||||
|
principal,
|
||||||
|
case_id=reference.resource_id,
|
||||||
|
permission="read",
|
||||||
|
)
|
||||||
|
return decisions
|
||||||
|
|
||||||
|
def index_changes_for_event(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
event: PlatformEvent,
|
||||||
|
delivery_key: str,
|
||||||
|
) -> Sequence[SearchIndexChange]:
|
||||||
|
if (
|
||||||
|
event.module_id != "cases"
|
||||||
|
or event.tenant is None
|
||||||
|
or event.resource is None
|
||||||
|
or event.resource.type != RESOURCE_TYPE
|
||||||
|
or event.resource.id is None
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
db = _session(session)
|
||||||
|
row = db.scalar(
|
||||||
|
select(CaseRecordRevision).where(
|
||||||
|
CaseRecordRevision.tenant_id == event.tenant.id,
|
||||||
|
CaseRecordRevision.case_id == event.resource.id,
|
||||||
|
CaseRecordRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
identity = db.scalar(
|
||||||
|
select(CaseIdentity).where(
|
||||||
|
CaseIdentity.tenant_id == event.tenant.id,
|
||||||
|
CaseIdentity.case_id == event.resource.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
deleted = row is None or identity is None
|
||||||
|
cursor = event.event_id
|
||||||
|
document = None
|
||||||
|
if not deleted:
|
||||||
|
document = _document(
|
||||||
|
row,
|
||||||
|
identity=identity,
|
||||||
|
grants=_grants_by_case(
|
||||||
|
db,
|
||||||
|
event.tenant.id,
|
||||||
|
[row.case_id],
|
||||||
|
).get(row.case_id, ()),
|
||||||
|
change_cursor=cursor,
|
||||||
|
)
|
||||||
|
reference = SearchResourceReference(
|
||||||
|
tenant_id=event.tenant.id,
|
||||||
|
module_id="cases",
|
||||||
|
resource_type=RESOURCE_TYPE,
|
||||||
|
resource_id=event.resource.id,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
SearchIndexChange(
|
||||||
|
change_id=f"{delivery_key}:{PROVIDER_ID}",
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
kind="delete" if deleted else "upsert",
|
||||||
|
reference=reference,
|
||||||
|
source_revision=(document.source_revision if document else cursor),
|
||||||
|
cursor=cursor,
|
||||||
|
document=document,
|
||||||
|
occurred_at=event.occurred_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_cases_search_source(_context: ModuleContext) -> CasesSearchSource:
|
||||||
|
return CasesSearchSource()
|
||||||
|
|
||||||
|
|
||||||
|
def _document(
|
||||||
|
row: CaseRecordRevision,
|
||||||
|
*,
|
||||||
|
identity: CaseIdentity,
|
||||||
|
grants: Sequence[CaseAccessGrant],
|
||||||
|
change_cursor: str | None = None,
|
||||||
|
) -> SearchDocument:
|
||||||
|
tokens = [f"scope:{READ_SCOPE}", f"scope:{ADMIN_SCOPE}"]
|
||||||
|
if identity.created_by:
|
||||||
|
tokens.extend(
|
||||||
|
(f"account:{identity.created_by}", f"membership:{identity.created_by}")
|
||||||
|
)
|
||||||
|
prefixes = {
|
||||||
|
"account": "account",
|
||||||
|
"identity": "identity",
|
||||||
|
"group": "group",
|
||||||
|
"role": "role",
|
||||||
|
"function": "function",
|
||||||
|
"function_assignment": "function",
|
||||||
|
}
|
||||||
|
for grant in grants:
|
||||||
|
prefix = prefixes.get(grant.subject_kind)
|
||||||
|
if prefix:
|
||||||
|
tokens.append(f"{prefix}:{grant.subject_id}")
|
||||||
|
return SearchDocument(
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
module_id="cases",
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
resource_type=RESOURCE_TYPE,
|
||||||
|
resource_id=row.case_id,
|
||||||
|
title=row.title,
|
||||||
|
url=f"/cases/{quote(row.case_id, safe='')}",
|
||||||
|
summary=f"{identity.case_number} - {row.status_key}",
|
||||||
|
body=row.search_text[:200_000],
|
||||||
|
keywords=(
|
||||||
|
identity.case_number[:200],
|
||||||
|
row.case_type_key[:200],
|
||||||
|
row.status_key[:200],
|
||||||
|
),
|
||||||
|
visibility="restricted",
|
||||||
|
acl_tokens=tuple(dict.fromkeys(tokens)),
|
||||||
|
metadata={
|
||||||
|
"case_number": identity.case_number,
|
||||||
|
"case_type_key": row.case_type_key,
|
||||||
|
"status_key": row.status_key,
|
||||||
|
"access_mode": row.access_mode,
|
||||||
|
},
|
||||||
|
source_revision=str(row.revision),
|
||||||
|
change_cursor=change_cursor,
|
||||||
|
source_updated_at=row.recorded_at,
|
||||||
|
requires_authorization_recheck=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _grants_by_case(
|
||||||
|
session: Session,
|
||||||
|
tenant_id: str,
|
||||||
|
case_ids: Sequence[str],
|
||||||
|
) -> dict[str, tuple[CaseAccessGrant, ...]]:
|
||||||
|
grouped: dict[str, list[CaseAccessGrant]] = {
|
||||||
|
case_id: [] for case_id in case_ids
|
||||||
|
}
|
||||||
|
if not case_ids:
|
||||||
|
return {}
|
||||||
|
rows = session.scalars(
|
||||||
|
select(CaseAccessGrant).where(
|
||||||
|
CaseAccessGrant.tenant_id == tenant_id,
|
||||||
|
CaseAccessGrant.case_id.in_(tuple(case_ids)),
|
||||||
|
CaseAccessGrant.active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
grouped.setdefault(row.case_id, []).append(row)
|
||||||
|
return {key: tuple(value) for key, value in grouped.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_source(provider_id: str, resource_type: str) -> None:
|
||||||
|
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
|
||||||
|
raise ValueError("Unsupported Cases search source.")
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Cases search requires a SQLAlchemy session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CasesSearchSource",
|
||||||
|
"PROVIDER_ID",
|
||||||
|
"RESOURCE_TYPE",
|
||||||
|
"create_cases_search_source",
|
||||||
|
]
|
||||||
@@ -331,6 +331,8 @@ def create_case_from_intake(
|
|||||||
"result_refs": list(plan.result_refs),
|
"result_refs": list(plan.result_refs),
|
||||||
"required_evidence_types": list(plan.required_evidence_types),
|
"required_evidence_types": list(plan.required_evidence_types),
|
||||||
"deadline_refs": list(plan.deadline_refs),
|
"deadline_refs": list(plan.deadline_refs),
|
||||||
|
"remedy_refs": list(plan.remedy_refs),
|
||||||
|
"service_level_refs": list(plan.service_level_refs),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return create_case(
|
return create_case(
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class CaseIntakePlan:
|
|||||||
result_refs: tuple[str, ...] = ()
|
result_refs: tuple[str, ...] = ()
|
||||||
required_evidence_types: tuple[str, ...] = ()
|
required_evidence_types: tuple[str, ...] = ()
|
||||||
deadline_refs: tuple[str, ...] = ()
|
deadline_refs: tuple[str, ...] = ()
|
||||||
|
remedy_refs: tuple[str, ...] = ()
|
||||||
|
service_level_refs: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
class CaseServiceIntake:
|
class CaseServiceIntake:
|
||||||
@@ -91,6 +93,8 @@ class CaseServiceIntake:
|
|||||||
result_refs=_binding_refs(definition, "result"),
|
result_refs=_binding_refs(definition, "result"),
|
||||||
required_evidence_types=definition.required_evidence_types,
|
required_evidence_types=definition.required_evidence_types,
|
||||||
deadline_refs=definition.deadline_refs,
|
deadline_refs=definition.deadline_refs,
|
||||||
|
remedy_refs=definition.remedy_refs,
|
||||||
|
service_level_refs=definition.service_level_refs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
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.access import (
|
||||||
|
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||||
|
FunctionAssignmentRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_DECISION_REGISTRY,
|
||||||
|
CAPABILITY_MANDATE_RESOLVER,
|
||||||
|
EvidenceReference,
|
||||||
|
FormalDecision,
|
||||||
|
GovernedContextEnvelope,
|
||||||
|
InstitutionalReference,
|
||||||
|
LegalBasisReference,
|
||||||
|
MandateDefinition,
|
||||||
|
MandateResolution,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.db.models import (
|
||||||
|
CaseAccessGrant,
|
||||||
|
CaseIdentity,
|
||||||
|
CaseRecordRevision,
|
||||||
|
CaseStatusDefinition,
|
||||||
|
CaseTimelineEntry,
|
||||||
|
CaseTypeDefinition,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.decision_path import (
|
||||||
|
CaseDecisionCommand,
|
||||||
|
CaseDecisionError,
|
||||||
|
CaseDecisionPath,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.domain import CaseRecord
|
||||||
|
from govoplan_cases.backend.service import (
|
||||||
|
create_case,
|
||||||
|
get_case,
|
||||||
|
upsert_case_status,
|
||||||
|
upsert_case_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 6, 9, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Principal:
|
||||||
|
tenant_id: str = "tenant-1"
|
||||||
|
account_id: str = "account-1"
|
||||||
|
identity_id: str = "identity-1"
|
||||||
|
acting_assignment_id: str | None = "assignment-1"
|
||||||
|
function_assignment_ids: tuple[str, ...] = ("assignment-1",)
|
||||||
|
scopes: frozenset[str] = frozenset(
|
||||||
|
{"cases:case:read", "cases:case:update", "decisions:decision:write"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Registry:
|
||||||
|
def __init__(self, providers: dict[str, object]) -> None:
|
||||||
|
self.providers = providers
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name in self.providers
|
||||||
|
|
||||||
|
def capability(self, name: str) -> object:
|
||||||
|
return self.providers[name]
|
||||||
|
|
||||||
|
|
||||||
|
class AccessDirectory:
|
||||||
|
def get_function_assignment(self, assignment_id: str):
|
||||||
|
if assignment_id != "assignment-1":
|
||||||
|
return None
|
||||||
|
return FunctionAssignmentRef(
|
||||||
|
id=assignment_id,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-1",
|
||||||
|
identity_id="identity-1",
|
||||||
|
function_id="permit-officer",
|
||||||
|
organization_unit_id="permits",
|
||||||
|
valid_from=NOW - timedelta(days=30),
|
||||||
|
valid_until=NOW + timedelta(days=30),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Mandates:
|
||||||
|
def __init__(self, mandate: MandateDefinition) -> None:
|
||||||
|
self.mandate = mandate
|
||||||
|
|
||||||
|
def resolve_mandate(self, session, principal, *, request):
|
||||||
|
del session, principal
|
||||||
|
return MandateResolution(competent=True, mandates=(self.mandate,))
|
||||||
|
|
||||||
|
|
||||||
|
class Decisions:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.items: dict[tuple[str, str], FormalDecision] = {}
|
||||||
|
|
||||||
|
def get_decision(self, session, principal, *, reference):
|
||||||
|
del session, principal
|
||||||
|
return self.items.get((reference.object_id, reference.version or ""))
|
||||||
|
|
||||||
|
def record_decision(self, session, principal, *, decision, expected_revision=None):
|
||||||
|
del session, principal, expected_revision
|
||||||
|
key = (decision.reference.object_id, decision.reference.version or "")
|
||||||
|
existing = self.items.get(key)
|
||||||
|
if existing is not None and existing != decision:
|
||||||
|
raise ValueError("A different Decision payload already uses this revision.")
|
||||||
|
self.items[key] = decision
|
||||||
|
return decision
|
||||||
|
|
||||||
|
|
||||||
|
def ref(kind: str, object_id: str, owner: str, *, version: str = "1") -> InstitutionalReference:
|
||||||
|
return InstitutionalReference(
|
||||||
|
kind=kind, # type: ignore[arg-type]
|
||||||
|
owner_module=owner,
|
||||||
|
object_id=object_id,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version=version,
|
||||||
|
valid_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def legal_basis() -> LegalBasisReference:
|
||||||
|
return LegalBasisReference(
|
||||||
|
kind="law",
|
||||||
|
authority="Example legislature",
|
||||||
|
reference="permit-law:3",
|
||||||
|
version="2026-01",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def evidence() -> EvidenceReference:
|
||||||
|
return EvidenceReference(
|
||||||
|
kind="document",
|
||||||
|
owner_module="files",
|
||||||
|
evidence_id="application-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="3",
|
||||||
|
captured_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def case_record() -> CaseRecord:
|
||||||
|
case_ref = ref("case", "case-1", "cases")
|
||||||
|
context = GovernedContextEnvelope(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="1",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
change_reason="Case intake.",
|
||||||
|
),
|
||||||
|
organization_unit_ref=ref("organization_unit", "permits", "organizations"),
|
||||||
|
function_ref=ref("function", "permit-officer", "organizations"),
|
||||||
|
mandate_ref=ref("mandate", "permit-mandate", "mandates", version="4"),
|
||||||
|
jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),),
|
||||||
|
service_ref=ref("service", "permit", "services", version="4"),
|
||||||
|
case_ref=case_ref,
|
||||||
|
legal_bases=(legal_basis(),),
|
||||||
|
)
|
||||||
|
return CaseRecord(
|
||||||
|
reference=case_ref,
|
||||||
|
case_number="PERMIT-2026-0001",
|
||||||
|
case_type_key="permit-application",
|
||||||
|
status_key="review",
|
||||||
|
title="Permit application",
|
||||||
|
context=context,
|
||||||
|
service_ref=context.service_ref,
|
||||||
|
party_refs=(ref("party", "applicant", "parties"),),
|
||||||
|
assignment_refs=(ref("function_assignment", "assignment-1", "access"),),
|
||||||
|
evidence_refs=(evidence(),),
|
||||||
|
opened_at=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
change_reason="Application received.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mandate() -> MandateDefinition:
|
||||||
|
return MandateDefinition(
|
||||||
|
reference=ref("mandate", "permit-mandate", "mandates", version="4"),
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="4",
|
||||||
|
valid_from=NOW - timedelta(days=30),
|
||||||
|
valid_to=NOW + timedelta(days=30),
|
||||||
|
recorded_at=NOW - timedelta(days=31),
|
||||||
|
change_reason="Permit authority delegated.",
|
||||||
|
),
|
||||||
|
task_types=("cases.formal_decision",),
|
||||||
|
authority_types=("permit",),
|
||||||
|
organization_unit_refs=(ref("organization_unit", "permits", "organizations"),),
|
||||||
|
function_refs=(ref("function", "permit-officer", "organizations"),),
|
||||||
|
jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),),
|
||||||
|
legal_bases=(legal_basis(),),
|
||||||
|
evidence=(evidence(),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def command(
|
||||||
|
*,
|
||||||
|
key: str = "decision-1",
|
||||||
|
result: str = "Permit granted.",
|
||||||
|
expected_case_revision: int = 1,
|
||||||
|
) -> CaseDecisionCommand:
|
||||||
|
return CaseDecisionCommand(
|
||||||
|
expected_case_revision=expected_case_revision,
|
||||||
|
effective_at=NOW + timedelta(minutes=5),
|
||||||
|
decision_type="permit",
|
||||||
|
operative_result=result,
|
||||||
|
reasoning="The submitted evidence satisfies the effective rule.",
|
||||||
|
conditions=("Display the permit visibly.",),
|
||||||
|
change_reason="Formal permit Decision recorded.",
|
||||||
|
idempotency_key=key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseDecisionPathTests(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="review",
|
||||||
|
label="Review",
|
||||||
|
)
|
||||||
|
upsert_case_type(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
type_key="permit-application",
|
||||||
|
label="Permit application",
|
||||||
|
initial_status_key="review",
|
||||||
|
allowed_status_keys=("review",),
|
||||||
|
)
|
||||||
|
create_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
record=case_record(),
|
||||||
|
idempotency_key="case-1",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.decisions = Decisions()
|
||||||
|
self.path = CaseDecisionPath(
|
||||||
|
Registry(
|
||||||
|
{
|
||||||
|
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY: AccessDirectory(),
|
||||||
|
CAPABILITY_MANDATE_RESOLVER: Mandates(mandate()),
|
||||||
|
CAPABILITY_DECISION_REGISTRY: self.decisions,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_records_and_links_exact_decision_in_one_case_revision(self) -> None:
|
||||||
|
result = self.path.record(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
command=command(),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(2, result.case.revision)
|
||||||
|
self.assertEqual("Permit granted.", result.decision.operative_result)
|
||||||
|
self.assertEqual("assignment-1", result.decision.authority_context.actor.function_assignment_ref.object_id)
|
||||||
|
self.assertEqual("permit-mandate", result.decision.authority_context.mandate_ref.object_id)
|
||||||
|
self.assertEqual(result.decision.reference, result.case.decision_refs[0])
|
||||||
|
self.assertEqual(
|
||||||
|
result.decision,
|
||||||
|
self.path.linked(self.session, self.principal, case_id="case-1")[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unchanged_retry_replays_but_changed_payload_fails_closed(self) -> None:
|
||||||
|
first = self.path.record(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
command=command(),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
replay = self.path.record(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
command=command(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(replay.replayed)
|
||||||
|
self.assertEqual(first.decision.reference, replay.decision.reference)
|
||||||
|
with self.assertRaisesRegex(ValueError, "different Decision payload"):
|
||||||
|
self.path.record(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
command=command(result="Permit denied."),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stale_case_revision_and_missing_acting_assignment_fail_closed(self) -> None:
|
||||||
|
self.path.record(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
command=command(),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(CaseDecisionError, "revision conflict"):
|
||||||
|
self.path.record(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
command=command(key="another-decision"),
|
||||||
|
)
|
||||||
|
without_assignment = Principal(
|
||||||
|
acting_assignment_id=None,
|
||||||
|
function_assignment_ids=(),
|
||||||
|
)
|
||||||
|
current = get_case(self.session, self.principal, case_id="case-1")
|
||||||
|
assert current is not None
|
||||||
|
with self.assertRaisesRegex(PermissionError, "No active acting assignment"):
|
||||||
|
self.path.record(
|
||||||
|
self.session,
|
||||||
|
without_assignment,
|
||||||
|
case_id="case-1",
|
||||||
|
command=command(
|
||||||
|
key="third-decision",
|
||||||
|
expected_case_revision=current.revision,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -26,6 +26,7 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
"cases.detail.timeline",
|
"cases.detail.timeline",
|
||||||
"cases.detail.history",
|
"cases.detail.history",
|
||||||
"cases.detail.access",
|
"cases.detail.access",
|
||||||
|
"cases.detail.decision",
|
||||||
},
|
},
|
||||||
set(surfaces),
|
set(surfaces),
|
||||||
)
|
)
|
||||||
@@ -37,6 +38,7 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
"cases.detail.timeline",
|
"cases.detail.timeline",
|
||||||
"cases.detail.history",
|
"cases.detail.history",
|
||||||
"cases.detail.access",
|
"cases.detail.access",
|
||||||
|
"cases.detail.decision",
|
||||||
):
|
):
|
||||||
self.assertEqual("cases.detail", surfaces[surface_id].parent_id)
|
self.assertEqual("cases.detail", surfaces[surface_id].parent_id)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
GovernedContextEnvelope,
|
||||||
|
InstitutionalReference,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.search import (
|
||||||
|
SearchAuthorizationRequest,
|
||||||
|
SearchBackfillRequest,
|
||||||
|
SearchResourceReference,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.db.models import (
|
||||||
|
CaseAccessGrant,
|
||||||
|
CaseIdentity,
|
||||||
|
CaseRecordRevision,
|
||||||
|
CaseStatusDefinition,
|
||||||
|
CaseTimelineEntry,
|
||||||
|
CaseTypeDefinition,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.domain import CaseRecord
|
||||||
|
from govoplan_cases.backend.search_source import CasesSearchSource, PROVIDER_ID
|
||||||
|
from govoplan_cases.backend.service import (
|
||||||
|
create_case,
|
||||||
|
upsert_case_status,
|
||||||
|
upsert_case_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 6, 10, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class CasesSearchSourceTests(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)
|
||||||
|
principal = _principal({"cases:case:read", "cases:case:create"})
|
||||||
|
upsert_case_status(
|
||||||
|
self.session,
|
||||||
|
principal,
|
||||||
|
status_key="review",
|
||||||
|
label="Review",
|
||||||
|
)
|
||||||
|
upsert_case_type(
|
||||||
|
self.session,
|
||||||
|
principal,
|
||||||
|
type_key="permit",
|
||||||
|
label="Permit",
|
||||||
|
initial_status_key="review",
|
||||||
|
)
|
||||||
|
case_ref = _ref("case", "case-1", "cases")
|
||||||
|
create_case(
|
||||||
|
self.session,
|
||||||
|
principal,
|
||||||
|
record=CaseRecord(
|
||||||
|
reference=case_ref,
|
||||||
|
case_number="PERMIT-1",
|
||||||
|
case_type_key="permit",
|
||||||
|
status_key="review",
|
||||||
|
title="Permit application",
|
||||||
|
context=GovernedContextEnvelope(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="1",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
change_reason="Created.",
|
||||||
|
),
|
||||||
|
case_ref=case_ref,
|
||||||
|
),
|
||||||
|
opened_at=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
change_reason="Created.",
|
||||||
|
),
|
||||||
|
idempotency_key="case-create",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.source = CasesSearchSource()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_backfill_authorization_and_event_change_are_bounded(self) -> None:
|
||||||
|
page = self.source.backfill(
|
||||||
|
self.session,
|
||||||
|
request=SearchBackfillRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
resource_type="case",
|
||||||
|
rebuild_id="rebuild-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(("case-1",), tuple(item.resource_id for item in page.documents))
|
||||||
|
self.assertEqual("PERMIT-1", page.documents[0].metadata["case_number"])
|
||||||
|
reference = SearchResourceReference(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
module_id="cases",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
)
|
||||||
|
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
|
||||||
|
self.assertTrue(
|
||||||
|
self.source.authorize(
|
||||||
|
self.session,
|
||||||
|
_principal({"cases:case:read"}),
|
||||||
|
requests=(request,),
|
||||||
|
)[reference.key]
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
self.source.authorize(
|
||||||
|
self.session,
|
||||||
|
_principal(set()),
|
||||||
|
requests=(request,),
|
||||||
|
)[reference.key]
|
||||||
|
)
|
||||||
|
changes = self.source.index_changes_for_event(
|
||||||
|
self.session,
|
||||||
|
event=PlatformEvent(
|
||||||
|
type="cases.case.updated",
|
||||||
|
module_id="cases",
|
||||||
|
tenant=EventTenantRef(id="tenant-1"),
|
||||||
|
resource=EventObjectRef(type="case", id="case-1"),
|
||||||
|
),
|
||||||
|
delivery_key="delivery-1",
|
||||||
|
)
|
||||||
|
self.assertEqual("upsert", changes[0].kind)
|
||||||
|
|
||||||
|
|
||||||
|
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 _principal(scopes: set[str]) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset(scopes),
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id="account-1"),
|
||||||
|
user=SimpleNamespace(id="membership-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -96,6 +96,33 @@ export type CaseTimelineEntry = {
|
|||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FormalDecision = {
|
||||||
|
reference: InstitutionalReference;
|
||||||
|
temporal: {
|
||||||
|
revision: string;
|
||||||
|
valid_from?: string | null;
|
||||||
|
valid_to?: string | null;
|
||||||
|
recorded_at?: string | null;
|
||||||
|
change_reason?: string | null;
|
||||||
|
};
|
||||||
|
decision_type: string;
|
||||||
|
state: string;
|
||||||
|
assurance_level: string;
|
||||||
|
operative_result?: string | null;
|
||||||
|
reasoning?: string | null;
|
||||||
|
conditions: string[];
|
||||||
|
authority_context: Record<string, unknown>;
|
||||||
|
delivery_refs: string[];
|
||||||
|
remedy_refs: string[];
|
||||||
|
review_refs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaseDecisionResult = {
|
||||||
|
case: CaseRecord;
|
||||||
|
decision: FormalDecision;
|
||||||
|
replayed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function listCases(
|
export function listCases(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
options: {
|
options: {
|
||||||
@@ -147,6 +174,42 @@ export function caseTimeline(
|
|||||||
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { signal });
|
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listCaseDecisions(
|
||||||
|
settings: ApiSettings,
|
||||||
|
caseId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ decisions: FormalDecision[] }> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`,
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordCaseDecision(
|
||||||
|
settings: ApiSettings,
|
||||||
|
caseId: string,
|
||||||
|
payload: {
|
||||||
|
expected_case_revision: number;
|
||||||
|
effective_at: string;
|
||||||
|
decision_type: string;
|
||||||
|
operative_result: string;
|
||||||
|
reasoning: string;
|
||||||
|
conditions: string[];
|
||||||
|
change_reason: string;
|
||||||
|
idempotency_key: string;
|
||||||
|
}
|
||||||
|
): Promise<CaseDecisionResult> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function updateCase(
|
export function updateCase(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
caseId: string,
|
caseId: string,
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { Scale } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
Dialog,
|
||||||
|
DismissibleAlert,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
FormField,
|
||||||
|
i18nMessage,
|
||||||
|
type ApiSettings
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
recordCaseDecision,
|
||||||
|
type CaseDecisionResult,
|
||||||
|
type CaseRecord
|
||||||
|
} from "../../api/cases";
|
||||||
|
import { CASES_FIELDS_DOCUMENTATION } from "./interfacePatterns";
|
||||||
|
|
||||||
|
|
||||||
|
export default function CaseDecisionDialog({
|
||||||
|
settings,
|
||||||
|
record,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSaved
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
record: CaseRecord;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (result: CaseDecisionResult) => void;
|
||||||
|
}) {
|
||||||
|
const [decisionType, setDecisionType] = useState("");
|
||||||
|
const [effectiveAt, setEffectiveAt] = useState("");
|
||||||
|
const [operativeResult, setOperativeResult] = useState("");
|
||||||
|
const [reasoning, setReasoning] = useState("");
|
||||||
|
const [conditions, setConditions] = useState("");
|
||||||
|
const [changeReason, setChangeReason] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const idempotencyKey = useRef(crypto.randomUUID());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setDecisionType(defaultDecisionType(record));
|
||||||
|
setEffectiveAt(dateTimeLocalValue(new Date()));
|
||||||
|
setOperativeResult("");
|
||||||
|
setReasoning("");
|
||||||
|
setConditions("");
|
||||||
|
setChangeReason("");
|
||||||
|
setError("");
|
||||||
|
setConfirmOpen(false);
|
||||||
|
idempotencyKey.current = crypto.randomUUID();
|
||||||
|
}, [open, record]);
|
||||||
|
|
||||||
|
const disabledReason = useMemo(() => {
|
||||||
|
if (busy) return "The formal Decision is being recorded.";
|
||||||
|
if (
|
||||||
|
!decisionType.trim()
|
||||||
|
|| !effectiveAt
|
||||||
|
|| !operativeResult.trim()
|
||||||
|
|| !reasoning.trim()
|
||||||
|
|| !changeReason.trim()
|
||||||
|
) {
|
||||||
|
return "Complete the Decision type, effective time, result, reasoning, and change reason.";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [busy, changeReason, decisionType, effectiveAt, operativeResult, reasoning]);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (disabledReason) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await recordCaseDecision(settings, record.reference.object_id, {
|
||||||
|
expected_case_revision: record.revision,
|
||||||
|
effective_at: new Date(effectiveAt).toISOString(),
|
||||||
|
decision_type: decisionType.trim(),
|
||||||
|
operative_result: operativeResult.trim(),
|
||||||
|
reasoning: reasoning.trim(),
|
||||||
|
conditions: conditions.split("\n").map((item) => item.trim()).filter(Boolean),
|
||||||
|
change_reason: changeReason.trim(),
|
||||||
|
idempotency_key: idempotencyKey.current
|
||||||
|
});
|
||||||
|
setConfirmOpen(false);
|
||||||
|
onSaved(result);
|
||||||
|
onClose();
|
||||||
|
} catch (reason) {
|
||||||
|
setConfirmOpen(false);
|
||||||
|
setError(reason instanceof Error ? reason.message : "The formal Decision could not be recorded.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title={i18nMessage("i18n:govoplan-cases.decision_title", { value0: record.case_number })}
|
||||||
|
onClose={onClose}
|
||||||
|
closeDisabled={busy}
|
||||||
|
portal
|
||||||
|
className="case-decision-dialog"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button disabled={busy} onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabledReason={disabledReason}
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
helpContextId="cases.action.decide"
|
||||||
|
>
|
||||||
|
<Scale size={16} aria-hidden="true" />
|
||||||
|
Record decision
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="case-decision-content" data-help-context-id="cases.decision.editor">
|
||||||
|
<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />
|
||||||
|
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||||
|
<p className="case-decision-explanation">
|
||||||
|
The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.
|
||||||
|
</p>
|
||||||
|
<div className="case-decision-grid">
|
||||||
|
<FormField label="Decision type" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<input
|
||||||
|
value={decisionType}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={120}
|
||||||
|
onChange={(event) => setDecisionType(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Effective at" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={effectiveAt}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => setEffectiveAt(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<FormField label="Operative result" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={operativeResult}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={20_000}
|
||||||
|
onChange={(event) => setOperativeResult(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Reasoning" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<textarea
|
||||||
|
rows={7}
|
||||||
|
value={reasoning}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={50_000}
|
||||||
|
onChange={(event) => setReasoning(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Conditions (one per line)" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={conditions}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => setConditions(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<input
|
||||||
|
value={changeReason}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={1_000}
|
||||||
|
onChange={(event) => setChangeReason(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmOpen}
|
||||||
|
title="Record formal Decision"
|
||||||
|
message="Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision."
|
||||||
|
confirmLabel="Record decision"
|
||||||
|
busy={busy}
|
||||||
|
onCancel={() => setConfirmOpen(false)}
|
||||||
|
onConfirm={() => void save()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function defaultDecisionType(record: CaseRecord): string {
|
||||||
|
const resultRefs = Array.isArray(record.metadata.result_refs)
|
||||||
|
? record.metadata.result_refs
|
||||||
|
: [];
|
||||||
|
const result = resultRefs.find((item): item is string => typeof item === "string");
|
||||||
|
if (result) return result.replace(/^decision:/, "");
|
||||||
|
return record.case_type_key.replace(/-application$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function dateTimeLocalValue(value: Date): string {
|
||||||
|
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
|
||||||
|
return local.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowLeft, Save, Share2 } from "lucide-react";
|
import { Archive, ArrowLeft, Save, Scale, Share2 } 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 {
|
import {
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
hasScope,
|
hasScope,
|
||||||
|
usePlatformModuleInstalled,
|
||||||
useGuardedNavigate,
|
useGuardedNavigate,
|
||||||
useUnsavedDraftGuard,
|
useUnsavedDraftGuard,
|
||||||
type PlatformRouteContext
|
type PlatformRouteContext
|
||||||
@@ -20,14 +21,17 @@ import {
|
|||||||
caseHistory,
|
caseHistory,
|
||||||
caseTimeline,
|
caseTimeline,
|
||||||
getCase,
|
getCase,
|
||||||
|
listCaseDecisions,
|
||||||
listCaseCatalog,
|
listCaseCatalog,
|
||||||
updateCase,
|
updateCase,
|
||||||
type CaseCatalog,
|
type CaseCatalog,
|
||||||
type CaseRecord,
|
type CaseRecord,
|
||||||
type CaseTimelineEntry,
|
type CaseTimelineEntry,
|
||||||
|
type FormalDecision,
|
||||||
type InstitutionalReference
|
type InstitutionalReference
|
||||||
} from "../../api/cases";
|
} from "../../api/cases";
|
||||||
import CaseShareDialog from "./CaseShareDialog";
|
import CaseShareDialog from "./CaseShareDialog";
|
||||||
|
import CaseDecisionDialog from "./CaseDecisionDialog";
|
||||||
import {
|
import {
|
||||||
CASES_DOCUMENTATION,
|
CASES_DOCUMENTATION,
|
||||||
CASES_FIELDS_DOCUMENTATION,
|
CASES_FIELDS_DOCUMENTATION,
|
||||||
@@ -42,6 +46,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
|
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
|
||||||
const [history, setHistory] = useState<CaseRecord[]>([]);
|
const [history, setHistory] = useState<CaseRecord[]>([]);
|
||||||
const [timeline, setTimeline] = useState<CaseTimelineEntry[]>([]);
|
const [timeline, setTimeline] = useState<CaseTimelineEntry[]>([]);
|
||||||
|
const [decisions, setDecisions] = useState<FormalDecision[]>([]);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [changeReason, setChangeReason] = useState("");
|
const [changeReason, setChangeReason] = useState("");
|
||||||
@@ -49,10 +54,26 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [shareOpen, setShareOpen] = useState(false);
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
|
const [decisionOpen, setDecisionOpen] = 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");
|
||||||
const canShare = hasScope(auth, "cases:case:share");
|
const canShare = hasScope(auth, "cases:case:share");
|
||||||
|
const decisionsAvailable = usePlatformModuleInstalled("decisions");
|
||||||
|
const mandatesAvailable = usePlatformModuleInstalled("mandates");
|
||||||
|
const accessAvailable = usePlatformModuleInstalled("access");
|
||||||
|
const recordsAvailable = usePlatformModuleInstalled("records");
|
||||||
|
const canReadDecisions = decisionsAvailable && hasScope(auth, "decisions:decision:read");
|
||||||
|
const decisionDisabledReason = !canUpdate
|
||||||
|
? "Your account may not update this Case."
|
||||||
|
: !hasScope(auth, "decisions:decision:write")
|
||||||
|
? "Your account may not record formal Decisions."
|
||||||
|
: !accessAvailable
|
||||||
|
? "Enable Access to verify the acting assignment."
|
||||||
|
: !mandatesAvailable
|
||||||
|
? "Enable Mandates to verify formal authority."
|
||||||
|
: undefined;
|
||||||
|
const canFileRecords = recordsAvailable && hasScope(auth, "records:workspace:write");
|
||||||
|
|
||||||
const load = useCallback((signal?: AbortSignal) => {
|
const load = useCallback((signal?: AbortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -61,19 +82,23 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
getCase(settings, caseId, signal),
|
getCase(settings, caseId, signal),
|
||||||
listCaseCatalog(settings, signal),
|
listCaseCatalog(settings, signal),
|
||||||
caseHistory(settings, caseId, signal),
|
caseHistory(settings, caseId, signal),
|
||||||
caseTimeline(settings, caseId, signal)
|
caseTimeline(settings, caseId, signal),
|
||||||
|
canReadDecisions
|
||||||
|
? listCaseDecisions(settings, caseId, signal)
|
||||||
|
: Promise.resolve({ decisions: [] as FormalDecision[] })
|
||||||
]).
|
]).
|
||||||
then(([nextRecord, nextCatalog, nextHistory, nextTimeline]) => {
|
then(([nextRecord, nextCatalog, nextHistory, nextTimeline, nextDecisions]) => {
|
||||||
setRecord(nextRecord);
|
setRecord(nextRecord);
|
||||||
setCatalog(nextCatalog);
|
setCatalog(nextCatalog);
|
||||||
setHistory(nextHistory.revisions);
|
setHistory(nextHistory.revisions);
|
||||||
setTimeline(nextTimeline.entries);
|
setTimeline(nextTimeline.entries);
|
||||||
|
setDecisions(nextDecisions.decisions);
|
||||||
setTitle(nextRecord.title);
|
setTitle(nextRecord.title);
|
||||||
setStatus(nextRecord.status_key);
|
setStatus(nextRecord.status_key);
|
||||||
setChangeReason("");
|
setChangeReason("");
|
||||||
}).
|
}).
|
||||||
finally(() => setLoading(false));
|
finally(() => setLoading(false));
|
||||||
}, [caseId, settings]);
|
}, [canReadDecisions, caseId, settings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -162,13 +187,33 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
Cases
|
Cases
|
||||||
</button>
|
</button>
|
||||||
{record && <span>{record.case_number}</span>}
|
{record && <span>{record.case_number}</span>}
|
||||||
{record ? <IconButton
|
{record ? (
|
||||||
|
<div className="case-detail-actions">
|
||||||
|
{decisionsAvailable ? (
|
||||||
|
<IconButton
|
||||||
|
label="Record formal decision"
|
||||||
|
icon={<Scale size={16} />}
|
||||||
|
disabledReason={decisionDisabledReason}
|
||||||
|
onClick={() => setDecisionOpen(true)}
|
||||||
|
helpContextId="cases.action.decide"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{canFileRecords ? (
|
||||||
|
<IconButton
|
||||||
|
label="File Case in eAkte"
|
||||||
|
icon={<Archive size={16} />}
|
||||||
|
onClick={() => navigate(recordFilingPath(record.reference, record.case_number, "cases", "case_revision"))}
|
||||||
|
helpContextId="records.action.file"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<IconButton
|
||||||
label="Manage case access"
|
label="Manage case access"
|
||||||
icon={<Share2 size={16} />}
|
icon={<Share2 size={16} />}
|
||||||
className="case-share-button"
|
|
||||||
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
|
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
|
||||||
onClick={() => setShareOpen(true)}
|
onClick={() => setShareOpen(true)}
|
||||||
/> : null}
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
|
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
|
||||||
</div>
|
</div>
|
||||||
<PageScrollViewport className="case-detail-viewport">
|
<PageScrollViewport className="case-detail-viewport">
|
||||||
@@ -250,7 +295,20 @@ 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} />
|
||||||
|
{canReadDecisions ? (
|
||||||
|
<DecisionSection
|
||||||
|
decisions={decisions}
|
||||||
|
canFile={canFileRecords}
|
||||||
|
onFile={(decision) => navigate(recordFilingPath(
|
||||||
|
decision.reference,
|
||||||
|
`${humanize(decision.decision_type)} Decision`,
|
||||||
|
"decisions",
|
||||||
|
"decision_revision"
|
||||||
|
))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<ReferenceSection title="Decisions" references={record.decision_refs} />
|
<ReferenceSection title="Decisions" references={record.decision_refs} />
|
||||||
|
)}
|
||||||
<ReferenceSection title="Records" references={record.record_refs} />
|
<ReferenceSection title="Records" references={record.record_refs} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -298,6 +356,24 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{record ? (
|
||||||
|
<CaseDecisionDialog
|
||||||
|
settings={settings}
|
||||||
|
record={record}
|
||||||
|
open={decisionOpen}
|
||||||
|
onClose={() => setDecisionOpen(false)}
|
||||||
|
onSaved={(result) => {
|
||||||
|
setRecord(result.case);
|
||||||
|
setDecisions((current) => [
|
||||||
|
result.decision,
|
||||||
|
...current.filter((item) => item.reference.object_id !== result.decision.reference.object_id)
|
||||||
|
]);
|
||||||
|
void load().catch((reason) => {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Case could not be reloaded.");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -323,6 +399,68 @@ function ReferenceSection({ title, references }: { title: string; references: In
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DecisionSection({
|
||||||
|
decisions,
|
||||||
|
canFile,
|
||||||
|
onFile
|
||||||
|
}: {
|
||||||
|
decisions: FormalDecision[];
|
||||||
|
canFile: boolean;
|
||||||
|
onFile: (decision: FormalDecision) => void;
|
||||||
|
}) {
|
||||||
|
if (decisions.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section className="case-reference-section">
|
||||||
|
<h2>Decisions</h2>
|
||||||
|
<div className="case-decision-list">
|
||||||
|
{decisions.map((decision) => (
|
||||||
|
<article key={`${decision.reference.object_id}:${decision.reference.version ?? "current"}`}>
|
||||||
|
<div className="case-decision-heading">
|
||||||
|
<div>
|
||||||
|
<strong>{humanize(decision.decision_type)}</strong>
|
||||||
|
<span>Revision {decision.reference.version ?? decision.temporal.revision}</span>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={decision.state === "effective" || decision.state === "decided" ? "active" : "inactive"} label={humanize(decision.state)} />
|
||||||
|
{canFile ? (
|
||||||
|
<IconButton
|
||||||
|
label="File Decision in eAkte"
|
||||||
|
icon={<Archive size={16} />}
|
||||||
|
onClick={() => onFile(decision)}
|
||||||
|
helpContextId="records.action.file"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{decision.operative_result ? <p><strong>Operative result</strong>{decision.operative_result}</p> : null}
|
||||||
|
{decision.reasoning ? <p><strong>Reasoning</strong>{decision.reasoning}</p> : null}
|
||||||
|
{!decision.operative_result && !decision.reasoning ? (
|
||||||
|
<p className="case-decision-protected">Protected Decision details require the sensitive-read permission.</p>
|
||||||
|
) : null}
|
||||||
|
{decision.conditions.length > 0 ? (
|
||||||
|
<ul>{decision.conditions.map((condition) => <li key={condition}>{condition}</li>)}</ul>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFilingPath(
|
||||||
|
reference: InstitutionalReference,
|
||||||
|
label: string,
|
||||||
|
sourceModule: string,
|
||||||
|
resourceType: string
|
||||||
|
): string {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
sourceModule,
|
||||||
|
resourceType,
|
||||||
|
resourceId: reference.object_id,
|
||||||
|
sourceRevision: reference.version ?? "",
|
||||||
|
sourceLabel: label
|
||||||
|
});
|
||||||
|
return `/records?${query.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value?: string | null): string {
|
function formatDateTime(value?: string | null): string {
|
||||||
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "-";
|
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "-";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const en = {
|
|||||||
"i18n:govoplan-cases.timeline": "Case timeline",
|
"i18n:govoplan-cases.timeline": "Case timeline",
|
||||||
"i18n:govoplan-cases.history": "Immutable case history",
|
"i18n:govoplan-cases.history": "Immutable case history",
|
||||||
"i18n:govoplan-cases.access": "Case access",
|
"i18n:govoplan-cases.access": "Case access",
|
||||||
|
"i18n:govoplan-cases.decision": "Record formal Decision",
|
||||||
|
"i18n:govoplan-cases.decision_title": "Record formal Decision - {value0}",
|
||||||
"i18n:govoplan-cases.loading_reason": "The case is still loading.",
|
"i18n:govoplan-cases.loading_reason": "The case is still loading.",
|
||||||
"i18n:govoplan-cases.saving_reason": "The case change is still being saved.",
|
"i18n:govoplan-cases.saving_reason": "The case change is still being saved.",
|
||||||
"i18n:govoplan-cases.update_reason": "Your account may inspect this case but may not change its title or lifecycle state.",
|
"i18n:govoplan-cases.update_reason": "Your account may inspect this case but may not change its title or lifecycle state.",
|
||||||
@@ -78,7 +80,27 @@ const en = {
|
|||||||
"Target": "Target",
|
"Target": "Target",
|
||||||
"Permission": "Permission",
|
"Permission": "Permission",
|
||||||
"Save access": "Save access",
|
"Save access": "Save access",
|
||||||
"No explicit access grants.": "No explicit access grants."
|
"No explicit access grants.": "No explicit access grants.",
|
||||||
|
"Record formal decision": "Record formal decision",
|
||||||
|
"File Case in eAkte": "File Case in eAkte",
|
||||||
|
"File Decision in eAkte": "File Decision in eAkte",
|
||||||
|
"Record decision": "Record decision",
|
||||||
|
"The formal Decision is being recorded.": "The formal Decision is being recorded.",
|
||||||
|
"Complete the Decision type, effective time, result, reasoning, and change reason.": "Complete the Decision type, effective time, result, reasoning, and change reason.",
|
||||||
|
"The formal Decision could not be recorded.": "The formal Decision could not be recorded.",
|
||||||
|
"The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.": "The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.",
|
||||||
|
"Decision type": "Decision type",
|
||||||
|
"Effective at": "Effective at",
|
||||||
|
"Operative result": "Operative result",
|
||||||
|
"Reasoning": "Reasoning",
|
||||||
|
"Conditions (one per line)": "Conditions (one per line)",
|
||||||
|
"Record formal Decision": "Record formal Decision",
|
||||||
|
"Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision.": "Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision.",
|
||||||
|
"Protected Decision details require the sensitive-read permission.": "Protected Decision details require the sensitive-read permission.",
|
||||||
|
"Your account may not update this Case.": "Your account may not update this Case.",
|
||||||
|
"Your account may not record formal Decisions.": "Your account may not record formal Decisions.",
|
||||||
|
"Enable Access to verify the acting assignment.": "Enable Access to verify the acting assignment.",
|
||||||
|
"Enable Mandates to verify formal authority.": "Enable Mandates to verify formal authority."
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const de: Record<keyof typeof en, string> = {
|
const de: Record<keyof typeof en, string> = {
|
||||||
@@ -93,6 +115,8 @@ const de: Record<keyof typeof en, string> = {
|
|||||||
"i18n:govoplan-cases.timeline": "Vorgangszeitachse",
|
"i18n:govoplan-cases.timeline": "Vorgangszeitachse",
|
||||||
"i18n:govoplan-cases.history": "Unveränderliche Vorgangshistorie",
|
"i18n:govoplan-cases.history": "Unveränderliche Vorgangshistorie",
|
||||||
"i18n:govoplan-cases.access": "Vorgangszugriff",
|
"i18n:govoplan-cases.access": "Vorgangszugriff",
|
||||||
|
"i18n:govoplan-cases.decision": "Formelle Entscheidung erfassen",
|
||||||
|
"i18n:govoplan-cases.decision_title": "Formelle Entscheidung erfassen - {value0}",
|
||||||
"i18n:govoplan-cases.loading_reason": "Der Vorgang wird noch geladen.",
|
"i18n:govoplan-cases.loading_reason": "Der Vorgang wird noch geladen.",
|
||||||
"i18n:govoplan-cases.saving_reason": "Die Vorgangsänderung wird noch gespeichert.",
|
"i18n:govoplan-cases.saving_reason": "Die Vorgangsänderung wird noch gespeichert.",
|
||||||
"i18n:govoplan-cases.update_reason": "Ihr Konto darf diesen Vorgang einsehen, aber Titel und Status nicht ändern.",
|
"i18n:govoplan-cases.update_reason": "Ihr Konto darf diesen Vorgang einsehen, aber Titel und Status nicht ändern.",
|
||||||
@@ -159,7 +183,27 @@ const de: Record<keyof typeof en, string> = {
|
|||||||
"Target": "Ziel",
|
"Target": "Ziel",
|
||||||
"Permission": "Berechtigung",
|
"Permission": "Berechtigung",
|
||||||
"Save access": "Zugriff speichern",
|
"Save access": "Zugriff speichern",
|
||||||
"No explicit access grants.": "Keine ausdrücklichen Zugriffsfreigaben."
|
"No explicit access grants.": "Keine ausdrücklichen Zugriffsfreigaben.",
|
||||||
|
"Record formal decision": "Formelle Entscheidung erfassen",
|
||||||
|
"File Case in eAkte": "Vorgang in eAkte verakten",
|
||||||
|
"File Decision in eAkte": "Entscheidung in eAkte verakten",
|
||||||
|
"Record decision": "Entscheidung erfassen",
|
||||||
|
"The formal Decision is being recorded.": "Die formelle Entscheidung wird erfasst.",
|
||||||
|
"Complete the Decision type, effective time, result, reasoning, and change reason.": "Vervollständigen Sie Entscheidungsart, Gültigkeitszeitpunkt, Entscheidungssatz, Begründung und Änderungsbegründung.",
|
||||||
|
"The formal Decision could not be recorded.": "Die formelle Entscheidung konnte nicht erfasst werden.",
|
||||||
|
"The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.": "Der Server prüft vor der Erfassung den aktuellen Funktionskontext, das wirksame Mandat, die exakte Vorgangsrevision, die Nachweise und die Rechtsgrundlage.",
|
||||||
|
"Decision type": "Entscheidungsart",
|
||||||
|
"Effective at": "Gültig ab",
|
||||||
|
"Operative result": "Entscheidungssatz",
|
||||||
|
"Reasoning": "Begründung",
|
||||||
|
"Conditions (one per line)": "Nebenbestimmungen (eine pro Zeile)",
|
||||||
|
"Record formal Decision": "Formelle Entscheidung erfassen",
|
||||||
|
"Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision.": "Diese formelle Entscheidung zur exakten Vorgangsrevision erfassen? Entscheidungssatz und Begründung werden als unveränderliche Entscheidungsrevision gespeichert.",
|
||||||
|
"Protected Decision details require the sensitive-read permission.": "Geschützte Entscheidungsinhalte erfordern die Berechtigung zum Lesen sensibler Entscheidungen.",
|
||||||
|
"Your account may not update this Case.": "Ihr Konto darf diesen Vorgang nicht ändern.",
|
||||||
|
"Your account may not record formal Decisions.": "Ihr Konto darf keine formellen Entscheidungen erfassen.",
|
||||||
|
"Enable Access to verify the acting assignment.": "Aktivieren Sie Access, damit der Funktionskontext geprüft werden kann.",
|
||||||
|
"Enable Mandates to verify formal authority.": "Aktivieren Sie Mandates, damit die formelle Zuständigkeit geprüft werden kann."
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generatedTranslations: PlatformTranslations = { en, de };
|
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||||
|
|||||||
+4
-2
@@ -19,7 +19,8 @@ export const casesModule: PlatformWebModule = {
|
|||||||
"mandates",
|
"mandates",
|
||||||
"decisions",
|
"decisions",
|
||||||
"forms_runtime",
|
"forms_runtime",
|
||||||
"workflow_engine"
|
"workflow_engine",
|
||||||
|
"records"
|
||||||
],
|
],
|
||||||
translations: generatedTranslations,
|
translations: generatedTranslations,
|
||||||
routes: [
|
routes: [
|
||||||
@@ -58,7 +59,8 @@ export const casesModule: PlatformWebModule = {
|
|||||||
{ id: "cases.detail.references", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.references", parentId: "cases.detail", order: 30 },
|
{ id: "cases.detail.references", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.references", parentId: "cases.detail", order: 30 },
|
||||||
{ id: "cases.detail.timeline", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.timeline", parentId: "cases.detail", order: 40 },
|
{ id: "cases.detail.timeline", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.timeline", parentId: "cases.detail", order: 40 },
|
||||||
{ id: "cases.detail.history", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.history", parentId: "cases.detail", order: 50 },
|
{ id: "cases.detail.history", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.history", parentId: "cases.detail", order: 50 },
|
||||||
{ id: "cases.detail.access", moduleId: "cases", kind: "action", label: "i18n:govoplan-cases.access", parentId: "cases.detail", order: 60 }
|
{ id: "cases.detail.access", moduleId: "cases", kind: "action", label: "i18n:govoplan-cases.access", parentId: "cases.detail", order: 60 },
|
||||||
|
{ id: "cases.detail.decision", moduleId: "cases", kind: "action", label: "i18n:govoplan-cases.decision", parentId: "cases.detail", order: 70 }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,10 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.case-share-button {
|
.case-detail-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +268,80 @@
|
|||||||
max-height: min(760px, calc(100vh - 32px));
|
max-height: min(760px, calc(100vh - 32px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.case-decision-dialog {
|
||||||
|
width: min(900px, calc(100vw - 32px));
|
||||||
|
max-height: min(820px, calc(100vh - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-content {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-explanation,
|
||||||
|
.case-decision-protected {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.7fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-content textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-list > article {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-heading > div:first-child {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-heading span {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-list p {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-list ul {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.case-share-content {
|
.case-share-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -352,6 +429,14 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.case-detail-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.case-share-add-row .icon-button {
|
.case-share-add-row .icon-button {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user