11 Commits
Author SHA1 Message Date
zemion d395b22301 build(webui): expose tagged Cases package
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 11:48:43 +02:00
zemion d3a70f87cd feat(cases): accept replay-safe ticket escalations 2026-08-22 11:41:45 +02:00
zemion f236034e88 feat(cases): link provider-owned evidence references
Module Package Release / publish-packages (push) Successful in 11s
2026-08-21 16:10:48 +02:00
zemion eab2f4b3b2 feat(cases): add governed DSAR coverage 2026-08-21 02:07:34 +02:00
zemion fd7f05ffc9 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:45 +02:00
zemion 2efe574f18 feat: publish case launch context and product area 2026-08-18 21:32:25 +02:00
zemion 83e69565fe Adopt shared WebUI structural primitives 2026-08-18 13:17:31 +02:00
zemion 86878ef7a7 Adopt shared WebUI layout primitives 2026-08-18 10:42:51 +02:00
zemion 23f5091a2b Integrate formal decisions with cases 2026-08-06 12:42:20 +02:00
zemion 2f030e56a9 feat(cases): expose exact revisions for records filing 2026-08-06 01:43:32 +02:00
zemion cfe2389e5a Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:44 +02:00
30 changed files with 5025 additions and 153 deletions
+27 -1
View File
@@ -26,7 +26,10 @@ The executable backend slice now exposes:
assignment/evidence/Decision/record references, and replay-safe lifecycle assignment/evidence/Decision/record references, and replay-safe lifecycle
events; and events; and
- `cases.service_launcher`, which opens exactly one deterministic case from an - `cases.service_launcher`, which opens exactly one deterministic case from an
exact published Service revision and safely replays the same Portal launch. exact published Service revision and safely replays the same Portal launch;
and
- `tickets.case_escalation`, which creates exactly one formal Case from an
authorized Ticket handoff while preserving a governed Ticket reference.
Cases does not own institutional Service, Party, representation, identity, Cases does not own institutional Service, Party, representation, identity,
address, Mandate, Decision, file, workflow, or task lifecycles. The `/cases` address, Mandate, Decision, file, workflow, or task lifecycles. The `/cases`
@@ -38,3 +41,26 @@ uses the shared reference selector to manage those grants; list, detail,
history, timeline, and update paths all apply the same fail-closed ACL. history, timeline, and update paths all apply the same fail-closed ACL.
See [docs/CONCEPT.md](docs/CONCEPT.md) for the current module concept. See [docs/CONCEPT.md](docs/CONCEPT.md) for the current module concept.
## Data-subject requests
Cases contributes `privacy.dsar.cases`. It reports exact-tenant access grants
and operator attribution, plus minimized case lifecycle data when an explicit
Cases reference is supplied and corroborated. Raw snapshots, metadata, search
text, free-text reasons, event payloads, evidence identifiers, request digests,
idempotency keys, audit internals, and unrelated access subjects are excluded.
Historical case evidence is retained; current open case and active access facts
require authorized manual review through the existing lifecycle. Applicant
identity correlation remains a Parties responsibility and is never guessed
from a case's party references.
## Ticket escalation
When Tickets requests escalation, Cases rechecks tenant membership and
`cases:case:create`, resolves the exact active Case type and its initial status,
and derives a deterministic Case ID from the Ticket and idempotency key. A retry
returns the same Case. The Case keeps only the stable Ticket reference, source
revision, number, and authorized handoff note in governed context; it does not
copy Ticket comments, attachments, participants, or lifecycle state. If Cases
is absent or the configured type is unavailable, Tickets remains usable and
shows the integration consequence to the operator.
+57 -2
View File
@@ -90,6 +90,7 @@ The persistent backend provides:
- tenant-level resource ACL and tenant summary providers - tenant-level resource ACL and tenant summary providers
- tenant-wide or restricted object access with explicit, revisioned grants - tenant-wide or restricted object access with explicit, revisioned grants
- deterministic, replay-safe case launch from an exact Service binding - deterministic, replay-safe case launch from an exact Service binding
- deterministic, replay-safe Case escalation from an authorized Ticket handoff
The `/cases` list and `/cases/:caseId` detail workspace provide server-side The `/cases` list and `/cases/:caseId` detail workspace provide server-side
search/status filtering, case facts, typed references, history, timeline, and search/status filtering, case facts, typed references, history, timeline, and
@@ -159,13 +160,44 @@ reference. Retrying the same launch returns the existing case; a changed request
cannot reuse the key. Portal therefore cannot create duplicate cases after an cannot reuse the key. Portal therefore cannot create duplicate cases after an
ambiguous network response and does not gain access to Cases tables. ambiguous network response and does not gain access to Cases tables.
`tickets.case_escalation` is the optional effect boundary used by Tickets. It
rechecks `cases:case:create`, tenant identity, and the requested active Case
type, then derives a stable Case identifier from the Ticket and idempotency key.
The Case stores a governed `work_item` reference plus bounded source and handoff
metadata. It never imports Tickets or copies Ticket messages, files,
participants, assignment, or lifecycle. Replaying the same handoff returns the
existing Case, while an unavailable Cases capability leaves the operational
Ticket intact.
Cases links formal Decision records and may retain a current outcome/status 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.
When Records is enabled, `records.source.cases` resolves one exact immutable
case revision for eAkte filing. Cases rechecks current tenant, scope, and
object-level access, then returns the case number, lifecycle state, represented
valid interval, recorded time, canonical snapshot digest, and launch link.
Records owns the filing decision and chronology; Cases remains authoritative
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:
@@ -173,8 +205,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.
@@ -192,6 +227,26 @@ The focused suite covers:
- explicit and assignment-derived case access, including non-disclosure - explicit and assignment-derived case access, including non-disclosure
- exact Service launch, deterministic replay, and conflict behavior - exact Service launch, deterministic replay, and conflict behavior
## Data-subject requests
Cases publishes `privacy.dsar.cases`. Canonical account, identity, and
membership selectors cover Cases-owned operator attribution; account and
identity selectors also cover explicit case-access grants. Exact
`cases.case`, `cases.revision`, `cases.access_grant`, and `cases.timeline`
references select lifecycle data. When a canonical and direct selector are
combined, a Cases-owned relationship must corroborate them and all supplied
direct references must identify the same case.
Direct case results are typed projections rather than stored snapshots. They
exclude opaque metadata, search text, free-text change reasons, timeline
payloads and summaries, evidence identifiers, request hashes, idempotency
keys, audit identifiers, and unrelated access subjects. Applicant-to-identity
correlation is owned by Parties; Cases does not infer it from opaque party
identifiers. Immutable case identities, revisions, timelines, and operator
attribution receive retention actions. Current open case facts and active
access grants receive non-executable manual-review actions and can only be
changed through the authorized case/access lifecycle.
## Open Decisions ## Open Decisions
- Whether comments belong in cases, tasks, or a collaboration module. - Whether comments belong in cases, tasks, or a collaboration module.
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@govoplan/cases-webui",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/cases.css": "./webui/src/styles/cases.css"
},
"files": [
"webui/src",
"README.md",
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.30",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-cases" name = "govoplan-cases"
version = "0.1.17" version = "0.1.20"
description = "GovOPlaN administrative case context module." description = "GovOPlaN administrative case context module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.17"] dependencies = ["govoplan-core>=0.1.30"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
+478
View File
@@ -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",
]
+729
View File
@@ -0,0 +1,729 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_cases.backend.db.models import (
CaseAccessGrant,
CaseIdentity,
CaseRecordRevision,
CaseTimelineEntry,
)
from govoplan_cases.backend.domain import CaseRecord
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
CASES_DSAR_CAPABILITY = dsar_capability_name("cases")
_MAX_RECORDS = 5_000
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
account_id: str | None
identity_id: str | None
membership_id: str | None
case_id: str | None
revision_id: str | None
access_grant_id: str | None
timeline_id: str | None
@property
def actor_ids(self) -> tuple[str, ...]:
return tuple(
value
for value in (self.account_id, self.identity_id, self.membership_id)
if value
)
@property
def has_canonical_selector(self) -> bool:
return bool(self.actor_ids)
@property
def has_direct_selector(self) -> bool:
return bool(
self.case_id or self.revision_id or self.access_grant_id or self.timeline_id
)
@property
def has_recognized_selector(self) -> bool:
return self.has_canonical_selector or self.has_direct_selector
class CasesDsarProvider:
provider_id = "cases"
module_id = "cases"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None or not selectors.has_recognized_selector:
return ()
identities = _matching_identities(db, tenant_id, selectors)
revisions = _matching_revisions(db, tenant_id, selectors)
grants = _matching_grants(db, tenant_id, selectors)
timeline = _matching_timeline(db, tenant_id, selectors)
if _direct_reference_conflicts(
selectors,
identities=identities,
revisions=revisions,
grants=grants,
timeline=timeline,
):
return ()
direct_case_ids = _direct_case_ids(
selectors,
identities=identities,
revisions=revisions,
grants=grants,
timeline=timeline,
)
records: list[DsarRecordRef] = []
seen: set[tuple[str, str]] = set()
def append(record: DsarRecordRef) -> None:
key = (record.resource_type, record.resource_id)
if key in seen:
return
if len(records) >= _MAX_RECORDS:
raise ValueError(
"Cases DSAR result limit exceeded; narrow the subject selectors."
)
seen.add(key)
records.append(record)
for identity in identities:
if identity.case_id in direct_case_ids:
append(_case_identity_record(identity))
if identity.created_by in selectors.actor_ids:
append(
_operator_record(
resource_id=f"identity:{identity.id}",
case_id=identity.case_id,
activity="created_case_identity",
observed_at=identity.created_at,
)
)
for revision in revisions:
if _revision_is_direct(revision, selectors, direct_case_ids):
record = CaseRecord.from_mapping(revision.snapshot)
append(_revision_record(revision, record))
if revision.superseded_at is None and revision.closed_at is None:
append(_current_fact_record(revision, record))
if revision.changed_by in selectors.actor_ids:
append(
_operator_record(
resource_id=f"revision:{revision.id}",
case_id=revision.case_id,
activity="changed_case_revision",
observed_at=revision.recorded_at,
case_revision=revision.revision,
)
)
for grant in grants:
if _grant_subject_matches(grant, selectors) or (
_grant_is_direct(grant, selectors)
and not selectors.has_canonical_selector
):
append(_access_grant_record(grant))
if grant.created_by in selectors.actor_ids:
append(
_operator_record(
resource_id=f"grant:{grant.id}",
case_id=grant.case_id,
activity="changed_case_access_grant",
observed_at=grant.updated_at,
)
)
for entry in timeline:
direct = _timeline_is_direct(entry, selectors, direct_case_ids)
actor_match = entry.actor_id in selectors.actor_ids
if direct or actor_match:
append(
_timeline_record(
entry,
expose_actor=actor_match,
match_fields=(
(["reference"] if direct else [])
+ (["actor_id"] if actor_match else [])
),
)
)
return tuple(records)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Cases DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
if record.immutable_evidence:
kind = "retain"
title = f"Retain {record.title}"
rationale = record.retention_reason or (
"Case history is retained as institutional evidence."
)
else:
kind = "manual_review"
title = f"Review {record.title}"
rationale = (
"An authorized case operator must amend, close, supersede, or "
"deactivate the current fact through the governed case lifecycle "
"after reviewing legal, procedural, access, and third-party effects."
)
actions.append(
DsarErasureActionRef(
action_id=f"cases:{kind}:{record.resource_type}:{record.resource_id}",
provider_id=self.provider_id,
module_id=self.module_id,
kind=kind,
resource_type=record.resource_type,
resource_id=record.resource_id,
title=title,
rationale=rationale,
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Cases DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if action.executable:
raise ValueError(
"Cases DSAR does not publish executable erasure actions."
)
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Use the governed case or access lifecycle after legal, "
"institutional-evidence, and third-party review."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _matching_identities(
session: Session,
tenant_id: str,
selectors: _SubjectSelectors,
) -> list[CaseIdentity]:
conditions = []
if selectors.actor_ids:
conditions.append(CaseIdentity.created_by.in_(selectors.actor_ids))
if selectors.case_id:
conditions.append(CaseIdentity.case_id == selectors.case_id)
return _query_conditions(session, CaseIdentity, tenant_id, conditions)
def _matching_revisions(
session: Session,
tenant_id: str,
selectors: _SubjectSelectors,
) -> list[CaseRecordRevision]:
conditions = []
if selectors.actor_ids:
conditions.append(CaseRecordRevision.changed_by.in_(selectors.actor_ids))
if selectors.case_id:
conditions.append(CaseRecordRevision.case_id == selectors.case_id)
if selectors.revision_id:
conditions.append(CaseRecordRevision.id == selectors.revision_id)
return _query_conditions(session, CaseRecordRevision, tenant_id, conditions)
def _matching_grants(
session: Session,
tenant_id: str,
selectors: _SubjectSelectors,
) -> list[CaseAccessGrant]:
conditions = []
for kind, value in (
("account", selectors.account_id),
("identity", selectors.identity_id),
):
if value:
conditions.append(
(CaseAccessGrant.subject_kind == kind)
& (CaseAccessGrant.subject_id == value)
)
if selectors.actor_ids:
conditions.append(CaseAccessGrant.created_by.in_(selectors.actor_ids))
if selectors.case_id:
conditions.append(CaseAccessGrant.case_id == selectors.case_id)
if selectors.access_grant_id:
conditions.append(CaseAccessGrant.id == selectors.access_grant_id)
return _query_conditions(session, CaseAccessGrant, tenant_id, conditions)
def _matching_timeline(
session: Session,
tenant_id: str,
selectors: _SubjectSelectors,
) -> list[CaseTimelineEntry]:
conditions = []
if selectors.actor_ids:
conditions.append(CaseTimelineEntry.actor_id.in_(selectors.actor_ids))
if selectors.case_id:
conditions.append(CaseTimelineEntry.case_id == selectors.case_id)
if selectors.timeline_id:
conditions.append(CaseTimelineEntry.id == selectors.timeline_id)
return _query_conditions(session, CaseTimelineEntry, tenant_id, conditions)
def _direct_reference_conflicts(
selectors: _SubjectSelectors,
*,
identities: Sequence[CaseIdentity],
revisions: Sequence[CaseRecordRevision],
grants: Sequence[CaseAccessGrant],
timeline: Sequence[CaseTimelineEntry],
) -> bool:
referenced_case_ids: list[str] = []
if selectors.case_id:
if not any(row.case_id == selectors.case_id for row in identities):
return True
referenced_case_ids.append(selectors.case_id)
if selectors.revision_id:
row = next(
(item for item in revisions if item.id == selectors.revision_id), None
)
if row is None:
return True
referenced_case_ids.append(row.case_id)
if selectors.access_grant_id:
row = next(
(item for item in grants if item.id == selectors.access_grant_id), None
)
if row is None:
return True
if selectors.has_canonical_selector and not _grant_subject_matches(
row, selectors
):
return True
referenced_case_ids.append(row.case_id)
if selectors.timeline_id:
row = next(
(item for item in timeline if item.id == selectors.timeline_id), None
)
if row is None:
return True
if selectors.has_canonical_selector and row.actor_id not in selectors.actor_ids:
return True
referenced_case_ids.append(row.case_id)
if len(set(referenced_case_ids)) > 1:
return True
if not selectors.has_canonical_selector or not referenced_case_ids:
return False
related_case_ids = {
row.case_id for row in identities if row.created_by in selectors.actor_ids
}
related_case_ids.update(
row.case_id for row in revisions if row.changed_by in selectors.actor_ids
)
related_case_ids.update(
row.case_id for row in grants if _grant_subject_matches(row, selectors)
)
related_case_ids.update(
row.case_id for row in timeline if row.actor_id in selectors.actor_ids
)
return referenced_case_ids[0] not in related_case_ids
def _direct_case_ids(
selectors: _SubjectSelectors,
*,
identities: Sequence[CaseIdentity],
revisions: Sequence[CaseRecordRevision],
grants: Sequence[CaseAccessGrant],
timeline: Sequence[CaseTimelineEntry],
) -> set[str]:
values = {selectors.case_id} if selectors.case_id else set()
values.update(row.case_id for row in revisions if row.id == selectors.revision_id)
values.update(row.case_id for row in grants if row.id == selectors.access_grant_id)
values.update(row.case_id for row in timeline if row.id == selectors.timeline_id)
values.update(
row.case_id
for row in identities
if selectors.case_id and row.case_id == selectors.case_id
)
return {value for value in values if value}
def _revision_is_direct(
row: CaseRecordRevision,
selectors: _SubjectSelectors,
direct_case_ids: set[str],
) -> bool:
if selectors.revision_id:
return row.id == selectors.revision_id
return bool(selectors.case_id and row.case_id in direct_case_ids)
def _grant_is_direct(
row: CaseAccessGrant,
selectors: _SubjectSelectors,
) -> bool:
return bool(selectors.access_grant_id and row.id == selectors.access_grant_id)
def _timeline_is_direct(
row: CaseTimelineEntry,
selectors: _SubjectSelectors,
direct_case_ids: set[str],
) -> bool:
if selectors.timeline_id:
return row.id == selectors.timeline_id
return bool(selectors.case_id and row.case_id in direct_case_ids)
def _grant_subject_matches(
row: CaseAccessGrant,
selectors: _SubjectSelectors,
) -> bool:
return bool(
(row.subject_kind == "account" and row.subject_id == selectors.account_id)
or (row.subject_kind == "identity" and row.subject_id == selectors.identity_id)
)
def _case_identity_record(row: CaseIdentity) -> DsarRecordRef:
return _record(
"cases_case_identity",
row.id,
"case_identity",
"Case identity",
{
"match_fields": ["reference"],
"case_id": row.case_id,
"case_number": _bounded_text(row.case_number, 255),
"created_at": _iso(row.created_at),
},
observed_at=row.created_at,
immutable=True,
retention_reason=(
"The stable case identifier and number are retained so immutable case and "
"record evidence remains reconstructable."
),
)
def _revision_record(
row: CaseRecordRevision,
record: CaseRecord,
) -> DsarRecordRef:
return _record(
"cases_case_revision",
row.id,
"case_history",
"Case revision",
{
"match_fields": ["reference"],
"case_id": row.case_id,
"case_number": _bounded_text(record.case_number, 255),
"revision": row.revision,
"previous_revision_id": row.previous_revision_id,
"case_type_key": row.case_type_key,
"status_key": row.status_key,
"title": _bounded_text(row.title, 500),
"access_mode": row.access_mode,
"opened_at": _iso(row.opened_at),
"deadline_at": _iso(row.deadline_at),
"closed_at": _iso(row.closed_at),
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
"service_ref": _institutional_reference(record.service_ref),
"party_reference_count": len(record.party_refs),
"assignment_reference_count": len(record.assignment_refs),
"evidence_reference_count": len(record.evidence_refs),
"decision_reference_count": len(record.decision_refs),
"record_reference_count": len(record.record_refs),
"access_grant_count": len(record.access_grants),
},
observed_at=row.recorded_at,
immutable=True,
retention_reason=(
"Case revisions are immutable procedure and accountability evidence; "
"corrections append a new governed revision."
),
)
def _current_fact_record(
row: CaseRecordRevision,
record: CaseRecord,
) -> DsarRecordRef:
return _record(
"cases_current_case_fact",
row.case_id,
"current_case_fact",
"Current case fact",
{
"match_fields": ["reference"],
"revision_record_id": row.id,
"revision": row.revision,
"case_number": _bounded_text(record.case_number, 255),
"status_key": row.status_key,
"title": _bounded_text(row.title, 500),
"deadline_at": _iso(row.deadline_at),
},
observed_at=row.recorded_at,
)
def _access_grant_record(row: CaseAccessGrant) -> DsarRecordRef:
immutable = not row.active
return _record(
"cases_access_grant",
row.id,
"case_access_fact",
"Case access grant",
{
"match_fields": ["subject"],
"case_id": row.case_id,
"subject_kind": row.subject_kind,
"subject_id": row.subject_id,
"permissions": [str(value)[:40] for value in row.permissions[:20]],
"source": _bounded_text(row.source, 30),
"active": row.active,
"source_revision": row.source_revision,
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
},
observed_at=row.updated_at,
immutable=immutable,
retention_reason=(
"Inactive access-grant state is retained to explain historical case access."
if immutable
else None
),
)
def _timeline_record(
row: CaseTimelineEntry,
*,
expose_actor: bool,
match_fields: Sequence[str],
) -> DsarRecordRef:
return _record(
"cases_timeline_event",
row.id,
"case_lifecycle_evidence",
"Case lifecycle event",
{
"match_fields": list(match_fields),
"case_id": row.case_id,
"event_type": _bounded_text(row.event_type, 120),
"case_revision": row.case_revision,
"occurred_at": _iso(row.occurred_at),
"actor_id": row.actor_id if expose_actor else None,
},
observed_at=row.occurred_at,
immutable=True,
retention_reason=(
"Case timeline events are immutable lifecycle and accountability evidence."
),
)
def _operator_record(
*,
resource_id: str,
case_id: str,
activity: str,
observed_at: datetime | None,
case_revision: int | None = None,
) -> DsarRecordRef:
return _record(
"cases_operator_attribution",
resource_id,
"operator_accountability_evidence",
"Case operator attribution",
{
"match_fields": ["actor_id"],
"case_id": case_id,
"activity": activity,
"case_revision": case_revision,
"observed_at": _iso(observed_at),
},
observed_at=observed_at,
immutable=True,
retention_reason=(
"Operator attribution is retained as accountability evidence; raw case, "
"party, evidence, change-reason, and event payload content is excluded."
),
)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
groups = {
"account_id": (
subject.account_id,
subject.external_references.get("cases.account"),
subject.external_references.get("access.account"),
),
"identity_id": (
subject.identity_id,
subject.external_references.get("cases.identity"),
subject.external_references.get("identity.id"),
),
"membership_id": (
subject.membership_id,
subject.external_references.get("cases.membership"),
subject.external_references.get("tenancy.membership"),
),
"case_id": (subject.external_references.get("cases.case"),),
"revision_id": (subject.external_references.get("cases.revision"),),
"access_grant_id": (subject.external_references.get("cases.access_grant"),),
"timeline_id": (subject.external_references.get("cases.timeline"),),
}
normalized: dict[str, str | None] = {}
for key, values in groups.items():
distinct = {value for item in values if (value := _normalized_id(item))}
if len(distinct) > 1:
return None
normalized[key] = next(iter(distinct), None)
return _SubjectSelectors(**normalized)
def _query_conditions(
session: Session,
model: type,
tenant_id: str,
conditions: Sequence[object],
) -> list[object]:
if not conditions:
return []
rows = (
session.query(model)
.filter(model.tenant_id == tenant_id, or_(*conditions))
.order_by(model.id.asc())
.limit(_MAX_RECORDS + 1)
.all()
)
if len(rows) > _MAX_RECORDS:
raise ValueError("Cases DSAR match limit exceeded; narrow the selectors.")
return rows
def _institutional_reference(value: object | None) -> dict[str, object] | None:
if value is None:
return None
return {
"kind": str(getattr(value, "kind", "")),
"owner_module": str(getattr(value, "owner_module", "")),
"object_id": str(getattr(value, "object_id", "")),
"version": _bounded_text(getattr(value, "version", None), 120),
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "cases" or record.module_id != "cases":
raise ValueError("Cases DSAR received a foreign provider record.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "cases" or action.module_id != "cases":
raise ValueError("Cases DSAR received a foreign provider action.")
def _record(
resource_type: str,
resource_id: str,
category: str,
title: str,
data: Mapping[str, object],
*,
observed_at: datetime | None,
immutable: bool = False,
retention_reason: str | None = None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="cases",
module_id="cases",
resource_type=resource_type,
resource_id=resource_id,
category=category,
title=title,
data=data,
observed_at=observed_at,
immutable_evidence=immutable,
retention_reason=retention_reason,
source_path="/cases",
)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Cases DSAR provider requires a SQLAlchemy session.")
return value
def _bounded_text(value: str | None, limit: int) -> str | None:
return value[:limit] if value else None
def _normalized_id(value: object) -> str | None:
if value is None:
return None
normalized = str(value).strip()
return normalized or None
def _iso(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.isoformat()
__all__ = ["CASES_DSAR_CAPABILITY", "CasesDsarProvider"]
@@ -0,0 +1,239 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime
import hashlib
import json
from urllib.parse import parse_qs, quote, urlsplit
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import EvidenceReference
from govoplan_cases.backend.domain import CaseRecord
from govoplan_cases.backend.service import CaseStoreError, get_case, update_case
_SOURCE_CONTRACT = "cases.quick-access-reference.v1"
_SUPPORTED_REFERENCES = {
("files", "file-version"): "document",
("mail", "message"): "event",
("campaigns", "campaign"): "event",
}
def link_case_evidence(
session: Session,
principal: object,
*,
case_id: str,
expected_revision: int,
reference: Mapping[str, object],
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
) -> CaseRecord:
base = _base_revision(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
)
evidence = _validated_evidence(reference, tenant_id=base.reference.tenant_id)
if any(item.evidence_id == evidence.evidence_id for item in base.evidence_refs):
raise CaseStoreError("The owner reference is already linked to this Case.")
return update_case(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
changes={"evidence_refs": (*base.evidence_refs, evidence)},
recorded_at=recorded_at,
change_reason=change_reason,
idempotency_key=idempotency_key,
)
def unlink_case_evidence(
session: Session,
principal: object,
*,
case_id: str,
evidence_id: str,
expected_revision: int,
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
) -> CaseRecord:
base = _base_revision(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
)
evidence = next(
(item for item in base.evidence_refs if item.evidence_id == evidence_id),
None,
)
if evidence is None or linked_source(evidence) is None:
raise CaseStoreError("The linked owner reference was not found.")
return update_case(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
changes={
"evidence_refs": tuple(
item for item in base.evidence_refs if item.evidence_id != evidence_id
)
},
recorded_at=recorded_at,
change_reason=change_reason,
idempotency_key=idempotency_key,
)
def linked_source(evidence: EvidenceReference) -> Mapping[str, str] | None:
if evidence.source_ref is None:
return None
try:
value = json.loads(evidence.source_ref)
except (TypeError, ValueError):
return None
if not isinstance(value, Mapping) or value.get("contract") != _SOURCE_CONTRACT:
return None
result = {
key: str(value.get(key) or "")
for key in ("owner_module", "kind", "object_id", "path")
}
if (
not all(result.values())
or (result["owner_module"], result["kind"]) not in _SUPPORTED_REFERENCES
):
return None
return result
def _base_revision(
session: Session,
principal: object,
*,
case_id: str,
expected_revision: int,
) -> CaseRecord:
base = get_case(
session,
principal,
case_id=case_id,
revision=expected_revision,
)
if base is None:
raise LookupError("Case revision not found.")
return base
def _validated_evidence(
value: Mapping[str, object],
*,
tenant_id: str,
) -> EvidenceReference:
owner_module = _bounded(value.get("owner_module"), "Owner module", 120)
reference_kind = _bounded(value.get("kind"), "Owner reference kind", 120)
object_id = _bounded(value.get("object_id"), "Owner object id", 1_000)
reference_tenant = _bounded(value.get("tenant_id"), "Owner tenant id", 255)
version = _bounded(value.get("version"), "Owner version", 255)
path = _bounded(value.get("path"), "Owner path", 2_000)
evidence_kind = _SUPPORTED_REFERENCES.get((owner_module, reference_kind))
if evidence_kind is None:
raise CaseStoreError("The owner reference kind is not supported by Cases.")
if reference_tenant != tenant_id:
raise CaseStoreError("Owner references cannot cross Case tenants.")
_validate_owner_path(
owner_module=owner_module,
reference_kind=reference_kind,
object_id=object_id,
version=version,
path=path,
)
canonical = {
"contract": _SOURCE_CONTRACT,
"owner_module": owner_module,
"kind": reference_kind,
"object_id": object_id,
"path": path,
}
encoded = json.dumps(
canonical,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
)
digest = hashlib.sha256(
f"{tenant_id}\x1f{encoded}\x1f{version}".encode()
).hexdigest()
return EvidenceReference(
kind=evidence_kind, # type: ignore[arg-type]
owner_module=owner_module,
evidence_id=f"linked-{digest[:40]}",
tenant_id=tenant_id,
version=version,
source_ref=encoded,
derived_from=(_SOURCE_CONTRACT,),
)
def _validate_owner_path(
*,
owner_module: str,
reference_kind: str,
object_id: str,
version: str,
path: str,
) -> None:
parsed = urlsplit(path)
if parsed.scheme or parsed.netloc or not path.startswith("/") or path.startswith("//"):
raise CaseStoreError("Owner paths must be local platform routes.")
if (owner_module, reference_kind) == ("files", "file-version"):
query = parse_qs(parsed.query, keep_blank_values=True)
if (
parsed.path != "/files"
or query.get("versionId") != [object_id]
or version != object_id
or not query.get("fileId", [""])[0]
):
raise CaseStoreError("Files links require an exact authorized version path.")
return
if (owner_module, reference_kind) == ("mail", "message"):
query = parse_qs(parsed.query, keep_blank_values=True)
profile = query.get("profile", [""])[0]
folder = query.get("folder", [""])[0]
message = query.get("message", [""])[0]
expected_id = f"{profile}:{folder}:{message}"
if (
parsed.path != "/mail"
or not profile
or not folder
or not message
or object_id != expected_id
or version != message
):
raise CaseStoreError("Mail links require an exact mailbox message path.")
return
expected_path = f"/campaigns/{quote(object_id, safe='')}"
if path != expected_path:
raise CaseStoreError("Campaign links require an exact Campaign path.")
def _bounded(value: object, label: str, maximum: int) -> str:
result = str(value or "").strip()
if not result:
raise CaseStoreError(f"{label} is required.")
if len(result) > maximum or any(ord(character) < 32 for character in result):
raise CaseStoreError(f"{label} is invalid.")
return result
__all__ = [
"link_case_evidence",
"linked_source",
"unlink_case_evidence",
]
+325 -4
View File
@@ -6,9 +6,15 @@ 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,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
@@ -20,9 +26,12 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
ProductAreaContribution,
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.tickets import CAPABILITY_TICKET_CASE_ESCALATION
from govoplan_core.core.provider_governance import ( from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration, ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation, ModuleArchitectureDocumentation,
@@ -34,6 +43,7 @@ from govoplan_cases.backend.party_context import (
) )
from govoplan_cases.backend.acl import CaseAclProvider from govoplan_cases.backend.acl import CaseAclProvider
from govoplan_cases.backend.db import models as case_models from govoplan_cases.backend.db import models as case_models
from govoplan_cases.backend.dsar_provider import CASES_DSAR_CAPABILITY, CasesDsarProvider
from govoplan_cases.backend.service_intake import ( from govoplan_cases.backend.service_intake import (
CAPABILITY_CASES_SERVICE_INTAKE, CAPABILITY_CASES_SERVICE_INTAKE,
CaseServiceIntake, CaseServiceIntake,
@@ -46,11 +56,17 @@ from govoplan_cases.backend.service import (
CAPABILITY_CASES_REGISTRY, CAPABILITY_CASES_REGISTRY,
SqlCaseRegistry, SqlCaseRegistry,
) )
from govoplan_cases.backend.record_source import (
CAPABILITY_RECORD_SOURCE_CASES,
create_cases_record_source,
)
from govoplan_cases.backend.search_source import create_cases_search_source
from govoplan_cases.backend.ticket_escalation import TicketCaseEscalationProvider
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
MODULE_ID = "cases" MODULE_ID = "cases"
MODULE_VERSION = "0.1.17" MODULE_VERSION = "0.1.20"
READ_SCOPE = "cases:case:read" READ_SCOPE = "cases:case:read"
CREATE_SCOPE = "cases:case:create" CREATE_SCOPE = "cases:case:create"
UPDATE_SCOPE = "cases:case:update" UPDATE_SCOPE = "cases:case:update"
@@ -79,6 +95,15 @@ def _service_launcher(context: ModuleContext) -> CaseServiceLauncher:
return CaseServiceLauncher() return CaseServiceLauncher()
def _dsar_provider(context: ModuleContext) -> CasesDsarProvider:
del context
return CasesDsarProvider()
def _ticket_escalation(_context: ModuleContext) -> TicketCaseEscalationProvider:
return TicketCaseEscalationProvider()
def _router(context: ModuleContext): def _router(context: ModuleContext):
del context del context
from govoplan_cases.backend.router import router from govoplan_cases.backend.router import router
@@ -131,8 +156,18 @@ manifest = ModuleManifest(
"decisions", "decisions",
"forms_runtime", "forms_runtime",
"workflow_engine", "workflow_engine",
"records",
"files",
"mail",
"campaigns",
"quick_access",
),
optional_capabilities=(
CAPABILITY_PARTY_RESOLVER,
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
CAPABILITY_MANDATE_RESOLVER,
CAPABILITY_DECISION_REGISTRY,
), ),
optional_capabilities=(CAPABILITY_PARTY_RESOLVER,),
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."),
@@ -198,6 +233,17 @@ manifest = ModuleManifest(
order=35, order=35,
), ),
), ),
product_areas=(
ProductAreaContribution(
id="services-cases",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.services_cases",
icon="landmark",
description="i18n:govoplan-core.product_area.services_cases_description",
surface_ids=("cases.nav.cases", "cases.route.cases", "cases.route.cases.caseid"),
order=20,
),
),
view_surfaces=( view_surfaces=(
ViewSurface( ViewSurface(
id="cases.navigation", id="cases.navigation",
@@ -276,6 +322,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=(
@@ -283,17 +337,25 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="cases.party_context", version="0.1.0"), ModuleInterfaceProvider(name="cases.party_context", version="0.1.0"),
ModuleInterfaceProvider(name="cases.registry", version="0.1.0"), ModuleInterfaceProvider(name="cases.registry", version="0.1.0"),
ModuleInterfaceProvider(name="cases.service_launcher", version="0.1.0"), ModuleInterfaceProvider(name="cases.service_launcher", version="0.1.0"),
ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_CASES, version="1.0.0"),
ModuleInterfaceProvider(name=CASES_DSAR_CAPABILITY, version="0.1.0"),
ModuleInterfaceProvider(name=CAPABILITY_TICKET_CASE_ESCALATION, version="1.0.0"),
), ),
requires_interfaces=( requires_interfaces=(
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,
CAPABILITY_CASES_PARTY_CONTEXT: _party_context, CAPABILITY_CASES_PARTY_CONTEXT: _party_context,
CAPABILITY_CASES_REGISTRY: _case_registry, CAPABILITY_CASES_REGISTRY: _case_registry,
CAPABILITY_CASES_SERVICE_LAUNCHER: _service_launcher, CAPABILITY_CASES_SERVICE_LAUNCHER: _service_launcher,
CAPABILITY_RECORD_SOURCE_CASES: create_cases_record_source,
CASES_DSAR_CAPABILITY: _dsar_provider,
CAPABILITY_TICKET_CASE_ESCALATION: _ticket_escalation,
}, },
capability_documentation={ capability_documentation={
CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation( CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation(
@@ -316,6 +378,21 @@ manifest = ModuleManifest(
summary="Starts a replay-safe case from an exact available Service revision.", summary="Starts a replay-safe case from an exact available Service revision.",
contract_version="0.1.0", contract_version="0.1.0",
), ),
CAPABILITY_RECORD_SOURCE_CASES: CapabilityDocumentation(
label="Cases record source",
summary="Resolves currently authorized immutable case revisions for Records filing.",
contract_version="1.0.0",
),
CASES_DSAR_CAPABILITY: CapabilityDocumentation(
label="Cases data-subject request provider",
summary="Finds minimized, tenant-scoped case access, attribution, and explicitly referenced lifecycle facts.",
contract_version="0.1.0",
),
CAPABILITY_TICKET_CASE_ESCALATION: CapabilityDocumentation(
label="Ticket-to-Case escalation",
summary="Creates one replay-safe formal Case from a Ticket handoff and returns only the stable Case reference.",
contract_version="1.0.0",
),
}, },
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id=MODULE_ID, module_id=MODULE_ID,
@@ -345,8 +422,248 @@ 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.ticket-escalation",
title="Create a formal Case from a Ticket escalation",
summary="Preserve the operational Ticket and start one replay-safe formal procedure with a stable cross-reference.",
body=(
"An authorized ticket triage actor selects an active Case type and submits a handoff note. "
"Cases rechecks the active tenant and cases:case:create permission, derives a stable Case identity from the Ticket and idempotency key, "
"uses the configured initial status, and records the exact Ticket work-item reference in the governed Case context. "
"The Ticket remains authoritative for intake, queue work, comments, and service resolution; the Case becomes authoritative for parties, evidence, decisions, deadlines, records, and retention. "
"Replaying the same request returns the existing Case. A changed request or missing/inactive Case type fails closed."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("case_manager", "ticket_manager", "operator", "auditor"),
related_modules=("tickets",),
order=11,
conditions=(
DocumentationCondition(
required_modules=("cases", "tickets"),
required_scopes=(CREATE_SCOPE, "tickets:ticket:triage"),
),
),
translations={
"de": {
"title": "Aus einer Ticket-Eskalation einen förmlichen Vorgang anlegen",
"summary": "Das operative Ticket erhalten und ein wiederholungssicheres förmliches Verfahren mit stabilem Querverweis starten.",
"body": "Eine berechtigte Triage wählt einen aktiven Vorgangstyp und übergibt einen Hinweis. Cases prüft den aktiven Mandanten und die Berechtigung cases:case:create erneut, leitet aus Ticket und Idempotenzschlüssel eine stabile Vorgangskennung ab, verwendet den konfigurierten Anfangsstatus und hält den genauen Ticket-Verweis im geregelten Vorgangskontext fest. Das Ticket bleibt für Aufnahme, Warteschlangenarbeit, Kommentare und Servicelösung maßgeblich; der Vorgang wird für Beteiligte, Nachweise, Entscheidungen, Fristen, Akten und Aufbewahrung maßgeblich. Dieselbe Anfrage liefert den vorhandenen Vorgang; geänderte Anfragen sowie fehlende oder inaktive Vorgangstypen werden abgewiesen.",
}
},
metadata={
"kind": "workflow",
"help_contexts": ["tickets.action.escalate", "cases.state.ticket-escalation"],
"consequence_classes": {
"create_case": "Creates one Cases-owned formal procedure and returns its stable reference.",
"preserve_ticket": "Does not convert, copy, or mutate the Ticket history.",
},
},
),
DocumentationTopic(
id="cases.workflow.link-owner-references",
title="Link exact Files, Mail, and Campaign references to a Case",
summary=(
"Append or remove an owner-authorized reference without copying "
"the provider's content into Cases."
),
body=(
"On an active Case, Files, Mail, and Campaigns may return an exact "
"tenant-bound selection through the versioned Quick Access result "
"contract. Cases accepts only the supported owner and reference-kind "
"pairs, a matching tenant, a pinned version, and the owner's canonical "
"local route. Linking and unlinking recheck Case update authority, use "
"expected-revision concurrency and idempotency, and append immutable "
"Case history. Cases stores only a typed owner identifier, exact version, "
"and owner route; it never copies file bytes, message headers or bodies, "
"recipients, campaign content, or a source access decision. Opening a "
"link always enters the owning module, which rechecks current access. "
"If the owner module is disabled or source access is later revoked, the "
"Case retains the historical reference but does not disclose source "
"content. Unlinking removes only the reference from the next Case revision "
"and never deletes the owner object. Unsaved Case edits must be saved or "
"discarded before a Quick Access result can be linked."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("case_manager", "operator", "module_admin", "auditor"),
related_modules=("quick_access", "files", "mail", "campaigns"),
order=12,
conditions=(
DocumentationCondition(
required_modules=("cases",),
required_scopes=(UPDATE_SCOPE,),
),
),
links=(
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
DocumentationLink(
label="Quick Access architecture",
href="govoplan/docs/architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md",
kind="repository",
),
),
metadata={
"kind": "workflow",
"help_contexts": ["cases.detail.references"],
"consequence_classes": {
"link_owner_reference": "Appends an immutable Case revision containing only the exact typed owner reference.",
"unlink_owner_reference": "Appends an immutable Case revision and leaves the owner object unchanged.",
},
},
),
DocumentationTopic(
id="cases.data-subject-requests",
title="Case data-subject requests",
summary="Report governed case facts without guessing applicant identity or exposing opaque case payloads.",
body=(
"The Cases DSAR provider matches exact-tenant account, identity, and membership attribution, account/identity access grants, and explicit Cases references. "
"A canonical selector combined with a direct reference must be corroborated by a Cases-owned relationship or the search fails closed. Direct case and revision references export a typed, minimized lifecycle projection; raw snapshots, metadata, search text, free-text change reasons, timeline payloads and summaries, evidence identifiers, request digests, idempotency keys, audit identifiers, and unrelated access subjects are excluded. "
"Immutable case identities, revisions, timelines, and attribution are retained. Current open case and active access facts require authorized manual review through the normal case/access lifecycle; no automatic erasure is published. Applicant-to-identity linkage remains owned by Parties, so Cases does not infer it from party identifiers."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("parties", "records", "forms_runtime", "portal"),
links=(
DocumentationLink(
label="Cases concept",
href="govoplan-cases/docs/CONCEPT.md",
kind="repository",
),
),
),
DocumentationTopic(
id="cases.workflow.quick-access-context",
title="Use task-local tools from an active Case",
summary="Launch an authorized full-page tool and return to the exact Case route and context.",
body=(
"After an exact Case revision loads, the Case detail page publishes a bounded active-object reference to Core. "
"Quick Access may carry that reference together with the current tenant, acting assignment, temporal selection, "
"View revision, and route when opening an owning module's full page. It never carries Case content or an access "
"decision. The destination reauthorizes its data and effects. Unsaved Case edits use the shared save/discard guard "
"before navigation, and the shell provides an explicit return action."
),
layer="available",
documentation_types=("admin", "user"),
audience=("case_manager", "operator", "module_admin"),
related_modules=("quick_access", "views", "access"),
order=13,
links=(
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
DocumentationLink(
label="Quick Access architecture",
href="govoplan/docs/architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md",
kind="repository",
),
),
translations={
"de": {
"title": "Aufgabenbezogene Werkzeuge aus einem aktiven Vorgang verwenden",
"summary": "Ein berechtigtes Werkzeug oeffnen und zum genauen Vorgangskontext zurueckkehren.",
"body": (
"Nach dem Laden einer genauen Vorgangsrevision veroeffentlicht die Detailseite einen begrenzten Objektverweis an Core. "
"Der Schnellzugriff darf diesen Verweis zusammen mit Mandant, handelnder Zuordnung, Zeitbezug, Ansichtsversion und Ruecksprungort uebergeben. "
"Vorgangsinhalte oder Zugriffsentscheidungen werden nicht uebertragen. Das Ziel prueft Daten und Wirkungen erneut; ungespeicherte Aenderungen "
"werden vor dem Wechsel durch den gemeinsamen Speichern-Verwerfen-Dialog geschuetzt."
),
}
},
metadata={"help_contexts": ["cases.detail.quick-access"]},
),
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(
id="cases.workflow.file-exact-revision",
title="File an exact case revision into an eAkte",
summary="Preserve a reconstructable case snapshot in Records without moving case ownership.",
body=(
"When Records is enabled, Cases resolves an exact immutable case revision only after "
"current case permission and object-level access are checked. The filed reference includes "
"the case number, lifecycle state, represented valid interval, recorded time, and a SHA-256 "
"digest of the canonical snapshot. Cases remains authoritative for the case lifecycle."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("case_manager", "records_manager", "operator", "auditor"),
related_modules=("records",),
order=15,
conditions=(
DocumentationCondition(
required_modules=("cases", "records"),
required_scopes=("cases:case:read", "records:workspace:write"),
),
),
links=(
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
DocumentationLink(label="Records", href="/records", kind="runtime"),
DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"),
),
metadata={
"kind": "workflow",
"help_contexts": ["cases.detail", "records.action.file"],
"prerequisites": [
"You currently may read the case and file items into the destination record.",
"The exact immutable case revision has been identified.",
],
"steps": [
"Open the case and identify the revision that represents the evidence state.",
"Choose the destination record and state the access purpose and filing reason.",
"Confirm filing; Cases rechecks current access and resolves the exact revision.",
"Verify the case revision and snapshot digest in the record chronology.",
],
"outcome": "The eAkte preserves an exact case snapshot reference while Cases retains authority.",
},
),
DocumentationTopic( DocumentationTopic(
id="cases.institutional-context", id="cases.institutional-context",
title="Case institutional context", title="Case institutional context",
@@ -357,7 +674,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"),
@@ -373,6 +693,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",
], ],
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
from collections.abc import Sequence
import hashlib
import json
from urllib.parse import quote
from sqlalchemy.orm import Session
from govoplan_core.core.records import (
RecordContractError,
RecordSourceLocator,
RecordSourceReference,
)
from govoplan_cases.backend.db.models import CaseIdentity, CaseRecordRevision
from govoplan_cases.backend.service import can_access_case
CAPABILITY_RECORD_SOURCE_CASES = "records.source.cases"
class CasesRecordSource:
provider_id = "cases"
def resource_types(self) -> Sequence[str]:
return ("case_revision",)
def resolve(
self,
session: object,
principal: object,
*,
locator: RecordSourceLocator,
purpose: str,
) -> RecordSourceReference:
if not isinstance(session, Session):
raise RecordContractError(
"Case record references require a database session."
)
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
if not tenant_id or locator.tenant_id != tenant_id:
raise RecordContractError("Case record references cannot cross tenants.")
if locator.source_module != "cases" or locator.resource_type != "case_revision":
raise RecordContractError("Unsupported Cases record source type.")
if not str(purpose or "").strip():
raise RecordContractError("Case record references require a purpose.")
if not hasattr(principal, "has") or not (
principal.has("cases:case:read") or principal.has("cases:case:admin")
):
raise RecordContractError("Current Cases read permission is required.")
if not can_access_case(
session, principal, case_id=locator.resource_id, permission="read"
):
raise RecordContractError("The current principal cannot read this case.")
try:
revision = int(locator.source_revision)
except ValueError as exc:
raise RecordContractError("Case source revisions must be numeric.") from exc
row = (
session.query(CaseRecordRevision)
.filter(
CaseRecordRevision.tenant_id == tenant_id,
CaseRecordRevision.case_id == locator.resource_id,
CaseRecordRevision.revision == revision,
)
.one_or_none()
)
if row is None:
raise RecordContractError("The exact case revision does not exist.")
identity = (
session.query(CaseIdentity)
.filter(
CaseIdentity.tenant_id == tenant_id,
CaseIdentity.case_id == locator.resource_id,
)
.one_or_none()
)
if identity is None:
raise RecordContractError("The case identity is unavailable.")
snapshot_json = json.dumps(
row.snapshot,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return RecordSourceReference(
locator=locator,
label=f"{identity.case_number} - {row.title}",
authority_mode="external_authoritative",
content_sha256=hashlib.sha256(snapshot_json).hexdigest(),
content_type="application/vnd.govoplan.case-revision+json",
size_bytes=len(snapshot_json),
valid_from=row.opened_at,
valid_to=row.closed_at,
recorded_at=row.recorded_at,
launch_url=f"/cases/{quote(row.case_id, safe='')}",
metadata={
"case_number": identity.case_number,
"case_type_key": row.case_type_key,
"status_key": row.status_key,
"access_mode": row.access_mode,
"snapshot_sha256": hashlib.sha256(snapshot_json).hexdigest(),
},
)
def create_cases_record_source(_context: object) -> CasesRecordSource:
return CasesRecordSource()
__all__ = [
"CAPABILITY_RECORD_SOURCE_CASES",
"CasesRecordSource",
"create_cases_record_source",
]
+157 -1
View File
@@ -32,6 +32,9 @@ from govoplan_cases.backend.manifest import (
UPDATE_SCOPE, UPDATE_SCOPE,
) )
from govoplan_cases.backend.schemas import ( from govoplan_cases.backend.schemas import (
CaseDecisionRequest,
CaseEvidenceLinkRequest,
CaseEvidenceUnlinkRequest,
CaseHistoryResponse, CaseHistoryResponse,
CaseListResponse, CaseListResponse,
CaseStatusWriteRequest, CaseStatusWriteRequest,
@@ -40,6 +43,19 @@ from govoplan_cases.backend.schemas import (
CaseUpdateRequest, CaseUpdateRequest,
CaseWriteRequest, CaseWriteRequest,
) )
from govoplan_cases.backend.evidence_links import (
link_case_evidence,
unlink_case_evidence,
)
from govoplan_cases.backend.decision_path import (
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 +82,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 +224,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,
@@ -265,6 +355,72 @@ def api_update_case(
return item.to_dict() return item.to_dict()
@router.post("/{case_id}/evidence-links", response_model=dict[str, Any])
def api_link_case_evidence(
case_id: str,
payload: CaseEvidenceLinkRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, UPDATE_SCOPE)
try:
item = link_case_evidence(
session,
principal,
case_id=case_id,
expected_revision=payload.expected_revision,
reference=payload.reference.model_dump(),
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
idempotency_key=payload.idempotency_key,
)
session.commit()
except (
CaseStoreError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.post(
"/{case_id}/evidence-links/{evidence_id}/unlink",
response_model=dict[str, Any],
)
def api_unlink_case_evidence(
case_id: str,
evidence_id: str,
payload: CaseEvidenceUnlinkRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, UPDATE_SCOPE)
try:
item = unlink_case_evidence(
session,
principal,
case_id=case_id,
evidence_id=evidence_id,
expected_revision=payload.expected_revision,
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
idempotency_key=payload.idempotency_key,
)
session.commit()
except (
CaseStoreError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.get( @router.get(
"/{case_id}/share-target-options", "/{case_id}/share-target-options",
response_model=ReferenceOptionListResponse, response_model=ReferenceOptionListResponse,
+46
View File
@@ -82,6 +82,49 @@ class CaseUpdateRequest(BaseModel):
metadata: dict[str, Any] | None = None metadata: dict[str, Any] | None = None
class CaseOwnerReferenceRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
owner_module: str = Field(min_length=1, max_length=120)
kind: str = Field(min_length=1, max_length=120)
object_id: str = Field(min_length=1, max_length=1_000)
tenant_id: str = Field(min_length=1, max_length=255)
version: str = Field(min_length=1, max_length=255)
path: str = Field(min_length=1, max_length=2_000)
class CaseEvidenceLinkRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
reference: CaseOwnerReferenceRequest
class CaseEvidenceUnlinkRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
class CaseDecisionRequest(BaseModel):
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 +142,9 @@ class CaseTimelineResponse(BaseModel):
__all__ = [ __all__ = [
"CaseGrantRequest", "CaseGrantRequest",
"CaseDecisionRequest",
"CaseEvidenceLinkRequest",
"CaseEvidenceUnlinkRequest",
"CaseHistoryResponse", "CaseHistoryResponse",
"CaseListResponse", "CaseListResponse",
"CaseStatusWriteRequest", "CaseStatusWriteRequest",
+283
View File
@@ -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",
]
+2
View File
@@ -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,161 @@
from __future__ import annotations
from urllib.parse import quote
import uuid
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
GovernedContextEnvelope,
InstitutionalReference,
TemporalRevision,
)
from govoplan_core.core.tickets import (
TicketCaseEscalationCommand,
TicketCaseEscalationResult,
)
from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_cases.backend.domain import CaseRecord
from govoplan_cases.backend.service import create_case, get_case, list_case_catalog
_CASE_NAMESPACE = uuid.uuid5(
uuid.NAMESPACE_URL,
"https://govoplan.add-ideas.de/contracts/tickets/case-escalation/v1",
)
class TicketCaseEscalationProvider:
"""Create one replay-safe formal Case while keeping Ticket history separate."""
def escalate_ticket(
self,
session: object,
principal: object,
*,
command: TicketCaseEscalationCommand,
) -> TicketCaseEscalationResult:
db = _session(session)
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
if command.tenant_id != tenant_id:
raise PermissionError("Ticket escalation cannot cross tenants.")
if not _has_scope(principal, "cases:case:create"):
raise PermissionError("Ticket escalation requires cases:case:create.")
case_id = str(
uuid.uuid5(
_CASE_NAMESPACE,
"\0".join((tenant_id, command.ticket_id, command.idempotency_key)),
)
)
existing = get_case(db, principal, case_id=case_id)
if existing is not None:
return _result(existing, replayed=True)
catalog = list_case_catalog(db, principal)
case_type = next(
(
item
for item in catalog["types"]
if item.get("type_key") == command.case_type_key and item.get("active")
),
None,
)
if case_type is None:
raise ValueError(
f"Case type {command.case_type_key!r} is not configured or active."
)
case_ref = InstitutionalReference(
kind="case",
owner_module="cases",
object_id=case_id,
tenant_id=tenant_id,
version="1",
valid_at=command.occurred_at,
)
ticket_ref = InstitutionalReference(
kind="work_item",
owner_module="tickets",
object_id=command.ticket_id,
tenant_id=tenant_id,
version=str(command.metadata.get("ticket_revision") or "1"),
valid_at=command.occurred_at,
label=command.ticket_number,
)
change_reason = f"Escalated from ticket {command.ticket_number}."
context = GovernedContextEnvelope(
tenant_id=tenant_id,
temporal=TemporalRevision(
revision="1",
valid_from=command.occurred_at,
recorded_at=command.occurred_at,
change_reason=change_reason,
),
case_ref=case_ref,
work_item_ref=ticket_ref,
)
record = CaseRecord(
reference=case_ref,
case_number=_case_number(command, case_id),
case_type_key=command.case_type_key,
status_key=str(case_type["initial_status_key"]),
title=command.title,
context=context,
opened_at=command.occurred_at,
recorded_at=command.occurred_at,
change_reason=change_reason,
metadata={
"source_module": "tickets",
"source_resource_type": "ticket",
"source_resource_id": command.ticket_id,
"source_resource_number": command.ticket_number,
"source_revision": str(command.metadata.get("ticket_revision") or "1"),
"handoff_note": command.handoff_note,
"integration_contract": "tickets.case_escalation/v1",
},
)
created = create_case(
db,
principal,
record=record,
idempotency_key=f"ticket-escalation:{command.idempotency_key}",
)
return _result(created, replayed=False)
def _result(record: CaseRecord, *, replayed: bool) -> TicketCaseEscalationResult:
return TicketCaseEscalationResult(
provider_id="cases",
case_id=record.reference.object_id,
case_number=record.case_number,
case_url=f"/cases/{quote(record.reference.object_id, safe='')}",
replayed=replayed,
metadata={
"case_revision": record.revision,
"case_type_key": record.case_type_key,
"status_key": record.status_key,
},
)
def _case_number(command: TicketCaseEscalationCommand, case_id: str) -> str:
prefix = f"CASE-{command.ticket_number}"[:230].rstrip("-")
return f"{prefix}-{case_id[:8].upper()}"
def _has_scope(principal: object, scope: str) -> bool:
method = getattr(principal, "has", None)
if callable(method):
return bool(method(scope))
return scopes_grant_compatible(
frozenset(getattr(principal, "scopes", ()) or ()),
scope,
)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Ticket Case escalation requires a SQLAlchemy session.")
return value
__all__ = ["TicketCaseEscalationProvider"]
+350
View File
@@ -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()
+471
View File
@@ -0,0 +1,471 @@
from __future__ import annotations
import json
import unittest
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_cases.backend.domain import CaseGrant, CaseRecord
from govoplan_cases.backend.dsar_provider import (
CASES_DSAR_CAPABILITY,
CasesDsarProvider,
)
from govoplan_cases.backend.manifest import manifest
from govoplan_cases.backend.service import (
create_case,
update_case,
upsert_case_status,
upsert_case_type,
)
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.core.institutional import (
EvidenceReference,
GovernedContextEnvelope,
InstitutionalReference,
TemporalRevision,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
NOW = datetime(2026, 8, 21, 12, 0, tzinfo=UTC)
@dataclass
class _Principal:
tenant_id: str
account_id: str
class _Registry:
def __init__(
self,
provider: CasesDsarProvider,
*,
cases_active: bool = True,
) -> None:
self.provider = provider
self.cases_active = cases_active
def capability_names(self):
return (CASES_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "cases"
def tenant_entitlement_resolver(self):
cases_active = self.cases_active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("cases",) if cases_active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "cases"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != CASES_DSAR_CAPABILITY:
raise KeyError(name)
def _ref(
kind: str,
object_id: str,
owner: str,
*,
tenant_id: str,
version: str | None = "1",
) -> InstitutionalReference:
return InstitutionalReference(
kind=kind, # type: ignore[arg-type]
owner_module=owner,
object_id=object_id,
tenant_id=tenant_id,
version=version,
valid_at=NOW,
)
def _record(
case_id: str,
*,
tenant_id: str = "tenant-1",
title: str = "Subject permit",
grants: tuple[CaseGrant, ...] = (),
) -> CaseRecord:
case_ref = _ref("case", case_id, "cases", tenant_id=tenant_id)
service_ref = _ref("service", "permit", "services", tenant_id=tenant_id)
return CaseRecord(
reference=case_ref,
case_number=f"PERMIT-{case_id}",
case_type_key="permit",
status_key="open",
title=title,
access_mode="restricted" if grants else "tenant",
access_grants=grants,
context=GovernedContextEnvelope(
tenant_id=tenant_id,
temporal=TemporalRevision(
revision="1",
valid_from=NOW,
recorded_at=NOW,
change_reason="private-context-reason-do-not-export",
),
service_ref=service_ref,
case_ref=case_ref,
),
service_ref=service_ref,
party_refs=(
_ref(
"party",
"private-party-reference-do-not-export",
"parties",
tenant_id=tenant_id,
),
),
evidence_refs=(
EvidenceReference(
kind="document",
owner_module="files",
evidence_id="private-evidence-id-do-not-export",
tenant_id=tenant_id,
version="1",
),
),
opened_at=NOW,
recorded_at=NOW,
deadline_at=NOW + timedelta(days=30),
change_reason="private-create-reason-do-not-export",
metadata={"secret": "private-metadata-do-not-export"},
)
class CasesDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = CasesDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
operator = _Principal("tenant-1", "operator-1")
other_operator = _Principal("tenant-1", "operator-other")
tenant_two_operator = _Principal("tenant-2", "operator-2")
for principal in (operator, tenant_two_operator):
upsert_case_status(
self.session,
principal,
status_key="open",
label="Open",
)
upsert_case_type(
self.session,
principal,
type_key="permit",
label="Permit",
initial_status_key="open",
allowed_status_keys=("open",),
)
create_case(
self.session,
operator,
record=_record(
"case-1",
grants=(
CaseGrant("account", "account-subject", ("read",)),
CaseGrant("identity", "identity-subject", ("read", "update")),
),
),
idempotency_key="private-create-key-do-not-export",
)
update_case(
self.session,
operator,
case_id="case-1",
expected_revision=1,
changes={
"title": "Subject permit revised",
"metadata": {"secret": "private-updated-metadata-do-not-export"},
},
recorded_at=NOW + timedelta(minutes=1),
change_reason="private-update-reason-do-not-export",
idempotency_key="private-update-key-do-not-export",
)
create_case(
self.session,
other_operator,
record=_record(
"case-unrelated",
title="private-unrelated-title-do-not-export",
grants=(CaseGrant("account", "account-other", ("read",)),),
),
idempotency_key="unrelated-key-do-not-export",
)
create_case(
self.session,
tenant_two_operator,
record=_record(
"case-other-tenant",
tenant_id="tenant-2",
title="private-other-tenant-title-do-not-export",
grants=(CaseGrant("account", "account-subject", ("read",)),),
),
idempotency_key="other-tenant-key-do-not-export",
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_subject_access_grants_are_exact_tenant_and_minimized(self) -> None:
account_records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="account-subject"),
)
self.assertEqual(1, len(account_records))
self.assertEqual("cases_access_grant", account_records[0].resource_type)
self.assertFalse(account_records[0].immutable_evidence)
self.assertEqual("account-subject", account_records[0].data["subject_id"])
identity_records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(identity_id="identity-subject"),
)
self.assertEqual(1, len(identity_records))
self.assertEqual("identity", identity_records[0].data["subject_kind"])
exported = json.dumps(
[item.to_dict() for item in account_records + identity_records],
sort_keys=True,
)
self.assertNotIn("private-unrelated-title-do-not-export", exported)
self.assertNotIn("private-other-tenant-title-do-not-export", exported)
self.assertNotIn("private-metadata-do-not-export", exported)
self.assertNotIn("private-evidence-id-do-not-export", exported)
def test_direct_case_exports_typed_history_without_opaque_content(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(external_references={"cases.case": "case-1"}),
)
types = [item.resource_type for item in records]
self.assertEqual(1, types.count("cases_case_identity"))
self.assertEqual(2, types.count("cases_case_revision"))
self.assertEqual(1, types.count("cases_current_case_fact"))
self.assertEqual(2, types.count("cases_timeline_event"))
exported = json.dumps([item.to_dict() for item in records], sort_keys=True)
self.assertIn("Subject permit revised", exported)
self.assertNotIn("account-subject", exported)
self.assertNotIn("identity-subject", exported)
self.assertNotIn("private-party-reference-do-not-export", exported)
self.assertNotIn("private-evidence-id-do-not-export", exported)
self.assertNotIn("private-create-reason-do-not-export", exported)
self.assertNotIn("private-update-reason-do-not-export", exported)
self.assertNotIn("private-create-key-do-not-export", exported)
self.assertNotIn("private-update-key-do-not-export", exported)
self.assertNotIn("private-metadata-do-not-export", exported)
self.assertNotIn("private-updated-metadata-do-not-export", exported)
def test_operator_attribution_excludes_raw_case_content(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="operator-1"),
)
self.assertGreaterEqual(len(records), 5)
self.assertTrue(
all(
item.resource_type
in {"cases_operator_attribution", "cases_timeline_event"}
for item in records
)
)
exported = json.dumps([item.to_dict() for item in records], sort_keys=True)
self.assertNotIn("Subject permit revised", exported)
self.assertNotIn("account-subject", exported)
self.assertNotIn("identity-subject", exported)
self.assertNotIn("private-update-reason-do-not-export", exported)
self.assertNotIn("private-updated-metadata-do-not-export", exported)
def test_direct_and_canonical_selector_conflicts_fail_closed(self) -> None:
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-subject",
external_references={"cases.case": "case-unrelated"},
),
)
self.assertEqual((), conflict)
alias_conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-subject",
external_references={"cases.account": "account-other"},
),
)
self.assertEqual((), alias_conflict)
def test_planning_retains_history_and_requires_current_fact_review(self) -> None:
subject = DsarSubjectRef(external_references={"cases.case": "case-1"})
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertEqual(5, sum(item.kind == "retain" for item in actions))
self.assertEqual(1, sum(item.kind == "manual_review" for item in actions))
self.assertTrue(all(not item.executable for item in actions))
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-cases-1",
)
self.assertTrue(all(item.status == "blocked" for item in results))
def test_foreign_records_and_actions_are_rejected(self) -> None:
subject = DsarSubjectRef(account_id="account-subject")
foreign_record = DsarRecordRef(
provider_id="foreign",
module_id="foreign",
resource_type="foreign",
resource_id="foreign-1",
category="foreign",
title="Foreign record",
)
with self.assertRaisesRegex(ValueError, "foreign provider record"):
self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(foreign_record,),
)
foreign_action = DsarErasureActionRef(
action_id="foreign:delete:1",
provider_id="foreign",
module_id="foreign",
kind="delete",
resource_type="foreign",
resource_id="foreign-1",
title="Delete foreign",
rationale="No",
executable=True,
)
with self.assertRaisesRegex(ValueError, "foreign provider action"):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(foreign_action,),
request_id="dsar-cases-1",
)
def test_workflow_discovers_only_the_active_tenant_capability(self) -> None:
active = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-CASES-1",
request_kind="access",
subject=DsarSubjectRef(account_id="account-subject"),
purpose="Subject access request",
legal_basis=None,
due_at=None,
requested_by_account_id="privacy-operator",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=active,
expected_revision=1,
)
self.assertEqual("searched", active.status)
self.assertEqual(
[CASES_DSAR_CAPABILITY], active.coverage["provider_capabilities"]
)
self.assertEqual(["cases"], active.coverage["covered_modules"])
inactive = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-CASES-2",
request_kind="access",
subject=DsarSubjectRef(account_id="account-subject"),
purpose="Inactive module coverage",
legal_basis=None,
due_at=None,
requested_by_account_id="privacy-operator",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider, cases_active=False),
row=inactive,
expected_revision=1,
)
self.assertEqual([], inactive.coverage["provider_capabilities"])
self.assertEqual(
[CASES_DSAR_CAPABILITY],
inactive.coverage["inactive_provider_capabilities"],
)
self.assertEqual(0, inactive.search_result["record_count"])
def test_manifest_registers_and_documents_the_capability(self) -> None:
self.assertIn(CASES_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(CASES_DSAR_CAPABILITY, manifest.capability_documentation)
self.assertIn(
CASES_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertTrue(
any(
topic.id == "cases.data-subject-requests"
and {"admin", "user"}.issubset(topic.documentation_types)
for topic in manifest.documentation
)
)
if __name__ == "__main__":
unittest.main()
+280
View File
@@ -0,0 +1,280 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
GovernedContextEnvelope,
InstitutionalReference,
TemporalRevision,
)
from govoplan_cases.backend.db.models import (
CaseAccessGrant,
CaseIdentity,
CaseRecordRevision,
CaseStatusDefinition,
CaseTimelineEntry,
CaseTypeDefinition,
)
from govoplan_cases.backend.domain import CaseRecord
from govoplan_cases.backend.evidence_links import (
link_case_evidence,
linked_source,
unlink_case_evidence,
)
from govoplan_cases.backend.service import (
CaseStoreError,
case_history,
case_timeline,
create_case,
update_case,
upsert_case_status,
upsert_case_type,
)
NOW = datetime(2026, 8, 21, 14, 0, tzinfo=UTC)
@dataclass
class Principal:
tenant_id: str = "tenant-1"
account_id: str = "account-1"
scopes: frozenset[str] = frozenset()
def ref(kind: str, object_id: str, owner: str) -> InstitutionalReference:
return InstitutionalReference(
kind=kind, # type: ignore[arg-type]
owner_module=owner,
object_id=object_id,
tenant_id="tenant-1",
version="1",
valid_at=NOW,
)
def case_record() -> CaseRecord:
case_ref = ref("case", "case-1", "cases")
return CaseRecord(
reference=case_ref,
case_number="PERMIT-2026-0001",
case_type_key="permit",
status_key="open",
title="Resident permit",
context=GovernedContextEnvelope(
tenant_id="tenant-1",
temporal=TemporalRevision(
revision="1",
valid_from=NOW,
recorded_at=NOW,
change_reason="Case intake.",
),
case_ref=case_ref,
),
opened_at=NOW,
recorded_at=NOW,
change_reason="Case created.",
)
def file_reference(**changes: str) -> dict[str, str]:
result = {
"owner_module": "files",
"kind": "file-version",
"object_id": "version-1",
"tenant_id": "tenant-1",
"version": "version-1",
"path": "/files?fileId=file-1&versionId=version-1",
}
result.update(changes)
return result
class CaseEvidenceLinkTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
for table in (
CaseStatusDefinition.__table__,
CaseTypeDefinition.__table__,
CaseIdentity.__table__,
CaseRecordRevision.__table__,
CaseAccessGrant.__table__,
CaseTimelineEntry.__table__,
):
table.create(self.engine)
self.session = Session(self.engine)
self.principal = Principal()
upsert_case_status(
self.session,
self.principal,
status_key="open",
label="Open",
)
upsert_case_type(
self.session,
self.principal,
type_key="permit",
label="Permit",
initial_status_key="open",
)
create_case(
self.session,
self.principal,
record=case_record(),
idempotency_key="create-case",
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def link(self, **reference_changes: str) -> CaseRecord:
return link_case_evidence(
self.session,
self.principal,
case_id="case-1",
expected_revision=1,
reference=file_reference(**reference_changes),
recorded_at=NOW + timedelta(minutes=1),
change_reason="Linked exact file version.",
idempotency_key="link-file-1",
)
def test_link_appends_minimized_immutable_revision_and_audit_event(self) -> None:
linked = self.link()
self.session.commit()
self.assertEqual(2, linked.revision)
self.assertEqual(1, len(linked.evidence_refs))
evidence = linked.evidence_refs[0]
self.assertEqual("files", evidence.owner_module)
self.assertEqual("version-1", evidence.version)
self.assertTrue(evidence.evidence_id.startswith("linked-"))
source = linked_source(evidence)
self.assertEqual("file-version", source["kind"])
self.assertEqual("/files?fileId=file-1&versionId=version-1", source["path"])
self.assertNotIn("label", evidence.source_ref or "")
self.assertNotIn("content", evidence.source_ref or "")
history = case_history(
self.session,
self.principal,
case_id="case-1",
)
timeline = case_timeline(
self.session,
self.principal,
case_id="case-1",
)
self.assertEqual([2, 1], [item.revision for item in history])
self.assertEqual("cases.case.updated", timeline[0]["event_type"])
self.assertNotIn(evidence.evidence_id, str(timeline[0]["payload"]))
def test_link_is_replay_safe_and_rejects_duplicate_or_stale_changes(self) -> None:
first = self.link()
replay = self.link()
self.assertEqual(first.reference, replay.reference)
self.session.commit()
with self.assertRaisesRegex(CaseStoreError, "already linked"):
link_case_evidence(
self.session,
self.principal,
case_id="case-1",
expected_revision=2,
reference=file_reference(),
recorded_at=NOW + timedelta(minutes=2),
change_reason="Duplicate link.",
idempotency_key="duplicate-link",
)
updated = update_case(
self.session,
self.principal,
case_id="case-1",
expected_revision=2,
changes={"title": "Updated permit"},
recorded_at=NOW + timedelta(minutes=2),
change_reason="Updated title.",
idempotency_key="update-title",
)
self.assertEqual(3, updated.revision)
with self.assertRaisesRegex(CaseStoreError, "stale"):
link_case_evidence(
self.session,
self.principal,
case_id="case-1",
expected_revision=2,
reference={
"owner_module": "campaigns",
"kind": "campaign",
"object_id": "campaign-1",
"tenant_id": "tenant-1",
"version": "campaign-version-1",
"path": "/campaigns/campaign-1",
},
recorded_at=NOW + timedelta(minutes=3),
change_reason="Stale link.",
idempotency_key="stale-link",
)
def test_rejects_cross_tenant_unsupported_and_malformed_owner_paths(self) -> None:
for changes, message in (
({"tenant_id": "tenant-2"}, "cross Case tenants"),
({"owner_module": "unknown"}, "not supported"),
({"path": "/files?versionId=version-1"}, "exact authorized"),
({"path": "https://example.invalid/file"}, "local platform"),
):
with self.subTest(changes=changes):
with self.assertRaisesRegex(CaseStoreError, message):
self.link(**changes)
def test_unlink_only_removes_cases_owned_links_and_is_replay_safe(self) -> None:
linked = self.link()
self.session.commit()
evidence_id = linked.evidence_refs[0].evidence_id
unlinked = unlink_case_evidence(
self.session,
self.principal,
case_id="case-1",
evidence_id=evidence_id,
expected_revision=2,
recorded_at=NOW + timedelta(minutes=2),
change_reason="Removed obsolete link.",
idempotency_key="unlink-file-1",
)
replay = unlink_case_evidence(
self.session,
self.principal,
case_id="case-1",
evidence_id=evidence_id,
expected_revision=2,
recorded_at=NOW + timedelta(minutes=2),
change_reason="Removed obsolete link.",
idempotency_key="unlink-file-1",
)
self.assertEqual(3, unlinked.revision)
self.assertEqual(unlinked.reference, replay.reference)
self.assertEqual((), unlinked.evidence_refs)
with self.assertRaisesRegex(CaseStoreError, "not found"):
unlink_case_evidence(
self.session,
self.principal,
case_id="case-1",
evidence_id="ordinary-evidence",
expected_revision=3,
recorded_at=NOW + timedelta(minutes=3),
change_reason="Invalid unlink.",
idempotency_key="invalid-unlink",
)
if __name__ == "__main__":
unittest.main()
@@ -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)
@@ -44,12 +46,17 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
topics = {topic.id: topic for topic in get_manifest().documentation} topics = {topic.id: topic for topic in get_manifest().documentation}
context = topics["cases.institutional-context"] context = topics["cases.institutional-context"]
reference = topics["cases.reference.lifecycle-access-and-evidence"] reference = topics["cases.reference.lifecycle-access-and-evidence"]
owner_links = topics["cases.workflow.link-owner-references"]
self.assertIn("cases.state.read-only", context.metadata["help_contexts"]) self.assertIn("cases.state.read-only", context.metadata["help_contexts"])
self.assertIn("cases.field.access-grant", reference.metadata["help_contexts"]) self.assertIn("cases.field.access-grant", reference.metadata["help_contexts"])
self.assertIn("update_case", reference.metadata["consequence_classes"]) self.assertIn("update_case", reference.metadata["consequence_classes"])
self.assertIn("close_case", reference.metadata["consequence_classes"]) self.assertIn("close_case", reference.metadata["consequence_classes"])
self.assertIn("change_access", reference.metadata["consequence_classes"]) self.assertIn("change_access", reference.metadata["consequence_classes"])
self.assertIn(
"link_owner_reference",
owner_links.metadata["consequence_classes"],
)
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None: def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
list_page = ( list_page = (
@@ -68,6 +75,9 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
"DocumentationHelpLink", "DocumentationHelpLink",
"FormField", "FormField",
"useUnsavedDraftGuard", "useUnsavedDraftGuard",
"QUICK_ACCESS_RESULT_EVENT",
"linkCaseEvidence",
"unlinkCaseEvidence",
): ):
self.assertIn(component, detail_page) self.assertIn(component, detail_page)
for component in ( for component in (
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.records import RecordContractError, RecordSourceLocator
from govoplan_cases.backend.db.models import CaseIdentity, CaseRecordRevision
from govoplan_cases.backend.record_source import CasesRecordSource
NOW = datetime(2026, 1, 6, 9, 0, tzinfo=UTC)
@dataclass
class Principal:
tenant_id: str = "tenant-1"
def has(self, scope: str) -> bool:
return scope == "cases:case:read"
class CasesRecordSourceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
CaseIdentity.__table__.create(self.engine)
CaseRecordRevision.__table__.create(self.engine)
self.session = Session(self.engine)
identity = CaseIdentity(
id="identity-1",
tenant_id="tenant-1",
case_id="case-1",
case_number="2026/C-1",
created_by="account-1",
)
revision = CaseRecordRevision(
id="revision-1",
tenant_id="tenant-1",
case_id="case-1",
identity_id="identity-1",
revision=3,
case_type_key="permit",
status_key="review",
title="Permit application",
access_mode="restricted",
search_text="permit application",
opened_at=NOW,
recorded_at=NOW,
snapshot={
"case_id": "case-1",
"revision": 3,
"title": "Permit application",
},
)
self.session.add_all((identity, revision))
self.session.flush()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def locator(self) -> RecordSourceLocator:
return RecordSourceLocator(
tenant_id="tenant-1",
source_module="cases",
resource_type="case_revision",
resource_id="case-1",
source_revision="3",
)
def test_resolves_exact_currently_authorized_case_revision(self) -> None:
with patch(
"govoplan_cases.backend.record_source.can_access_case", return_value=True
):
result = CasesRecordSource().resolve(
self.session,
Principal(),
locator=self.locator(),
purpose="preserve decision basis",
)
self.assertEqual("2026/C-1 - Permit application", result.label)
self.assertEqual(64, len(result.content_sha256 or ""))
self.assertEqual("review", result.metadata["status_key"])
def test_object_access_is_rechecked_and_fails_closed(self) -> None:
with (
patch(
"govoplan_cases.backend.record_source.can_access_case",
return_value=False,
),
self.assertRaisesRegex(RecordContractError, "cannot read"),
):
CasesRecordSource().resolve(
self.session,
Principal(),
locator=self.locator(),
purpose="preserve decision basis",
)
if __name__ == "__main__":
unittest.main()
+174
View File
@@ -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()
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.tickets import TicketCaseEscalationCommand
from govoplan_core.db.base import Base
from govoplan_cases.backend.db import models as _case_models
from govoplan_cases.backend.service import upsert_case_status, upsert_case_type
from govoplan_cases.backend.ticket_escalation import TicketCaseEscalationProvider
NOW = datetime(2026, 8, 22, 10, 0, tzinfo=UTC)
class _Principal:
def __init__(self, tenant_id: str = "tenant-1", *, can_create: bool = True) -> None:
self.tenant_id = tenant_id
self.account_id = "case-manager-1"
self.identity_id = None
self.membership_id = "membership-1"
self.group_ids = frozenset()
self.role_ids = frozenset()
self.function_assignment_ids = frozenset()
self.acting_assignment_id = None
self.scopes = frozenset({"cases:case:create", "cases:case:read"} if can_create else {"cases:case:read"})
self.user = SimpleNamespace(id=self.account_id)
def has(self, scope: str) -> bool:
return scope in self.scopes
class TicketEscalationTests(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.Session = sessionmaker(bind=engine, expire_on_commit=False)
self.principal = _Principal()
def test_escalation_creates_one_linked_case_and_replays(self) -> None:
with self.Session() as session:
upsert_case_status(
session,
self.principal,
status_key="open",
label="Open",
)
upsert_case_type(
session,
self.principal,
type_key="service-request",
label="Service request",
initial_status_key="open",
)
command = TicketCaseEscalationCommand(
tenant_id="tenant-1",
ticket_id="ticket-1",
ticket_number="TKT-2026-0001",
title="Broken streetlight",
case_type_key="service-request",
occurred_at=NOW,
idempotency_key="escalation-1",
handoff_note="Formal procedure required.",
metadata={"ticket_revision": 3},
)
provider = TicketCaseEscalationProvider()
created = provider.escalate_ticket(session, self.principal, command=command)
session.commit()
replay = provider.escalate_ticket(session, self.principal, command=command)
self.assertFalse(created.replayed)
self.assertTrue(replay.replayed)
self.assertEqual(created.case_id, replay.case_id)
self.assertEqual("service-request", created.metadata["case_type_key"])
self.assertTrue(created.case_url.startswith("/cases/"))
self.assertEqual(1, session.query(_case_models.CaseIdentity).count())
current = session.query(_case_models.CaseRecordRevision).filter(
_case_models.CaseRecordRevision.case_id == created.case_id,
_case_models.CaseRecordRevision.superseded_at.is_(None),
).one()
self.assertEqual("tickets", current.snapshot["context"]["work_item_ref"]["owner_module"])
self.assertEqual("ticket-1", current.snapshot["metadata"]["source_resource_id"])
def test_escalation_fails_closed_for_cross_tenant_or_missing_permission(self) -> None:
command = TicketCaseEscalationCommand(
tenant_id="tenant-1",
ticket_id="ticket-1",
ticket_number="TKT-1",
title="Request",
case_type_key="service-request",
occurred_at=NOW,
idempotency_key="escalation-2",
)
provider = TicketCaseEscalationProvider()
with self.Session() as session:
with self.assertRaises(PermissionError):
provider.escalate_ticket(session, _Principal("tenant-2"), command=command)
with self.assertRaises(PermissionError):
provider.escalate_ticket(session, _Principal(can_create=False), command=command)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/cases-webui", "name": "@govoplan/cases-webui",
"version": "0.1.17", "version": "0.1.20",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/cases.css": "./src/styles/cases.css" "./styles/cases.css": "./src/styles/cases.css"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.17", "@govoplan/core-webui": "^0.1.30",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+117
View File
@@ -21,6 +21,17 @@ export type EvidenceReference = {
evidence_id: string; evidence_id: string;
tenant_id: string; tenant_id: string;
version?: string | null; version?: string | null;
source_ref?: string | null;
derived_from?: string[];
};
export type OwnerReference = {
ownerModule: string;
kind: string;
objectId: string;
tenantId: string;
version?: string | null;
path?: string | null;
}; };
export type CaseRecord = { export type CaseRecord = {
@@ -96,6 +107,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 +185,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,
@@ -167,6 +241,49 @@ export function updateCase(
}); });
} }
export function linkCaseEvidence(
settings: ApiSettings,
caseId: string,
payload: {
expected_revision: number;
recorded_at: string;
change_reason: string;
idempotency_key: string;
reference: {
owner_module: string;
kind: string;
object_id: string;
tenant_id: string;
version: string;
path: string;
};
}
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/evidence-links`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export function unlinkCaseEvidence(
settings: ApiSettings,
caseId: string,
evidenceId: string,
payload: {
expected_revision: number;
recorded_at: string;
change_reason: string;
idempotency_key: string;
}
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/evidence-links/${encodeURIComponent(evidenceId)}/unlink`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export function caseShareTargetProvider( export function caseShareTargetProvider(
settings: ApiSettings, settings: ApiSettings,
caseId: string, caseId: string,
@@ -0,0 +1,209 @@
import { Scale } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { FormGrid,
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>
<FormGrid columns={2} gap="small" collapseAt="workspace" 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>
</FormGrid>
<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);
}
+356 -11
View File
@@ -1,7 +1,7 @@
import { ArrowLeft, Save, Share2 } from "lucide-react"; import { Archive, ArrowLeft, ExternalLink, Save, Scale, Share2, Unlink } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { import { ActionToolbar,
ActionBlockerHint, ActionBlockerHint,
Button, Button,
DocumentationHelpLink, DocumentationHelpLink,
@@ -11,23 +11,34 @@ import {
LoadingIndicator, LoadingIndicator,
PageScrollViewport, PageScrollViewport,
StatusBadge, StatusBadge,
QUICK_ACCESS_RESULT_EVENT,
hasScope, hasScope,
usePlatformModuleInstalled,
useGuardedNavigate, useGuardedNavigate,
useRegisterActiveObject,
useUnsavedDraftGuard, useUnsavedDraftGuard,
type PlatformRouteContext type PlatformRouteContext,
type QuickAccessLaunchContext,
type QuickAccessResult
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
caseHistory, caseHistory,
caseTimeline, caseTimeline,
getCase, getCase,
linkCaseEvidence,
listCaseDecisions,
listCaseCatalog, listCaseCatalog,
updateCase, updateCase,
unlinkCaseEvidence,
type CaseCatalog, type CaseCatalog,
type CaseRecord, type CaseRecord,
type CaseTimelineEntry, type CaseTimelineEntry,
type EvidenceReference,
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 +53,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 +61,40 @@ 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 [linkingEvidence, setLinkingEvidence] = useState(false);
const idempotencyKey = useRef(crypto.randomUUID()); const idempotencyKey = useRef(crypto.randomUUID());
const canUpdate = hasScope(auth, "cases:case:update"); const canUpdate = hasScope(auth, "cases:case:update");
const canClose = hasScope(auth, "cases:case:close"); const canClose = hasScope(auth, "cases:case:close");
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 filesAvailable = usePlatformModuleInstalled("files");
const mailAvailable = usePlatformModuleInstalled("mail");
const campaignsAvailable = usePlatformModuleInstalled("campaigns");
const canReadDecisions = decisionsAvailable && hasScope(auth, "decisions:decision:read");
const decisionDisabledReason = !canUpdate
? "Your account may not update this Case."
: !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 activeCase = useMemo(() => record ? {
ownerModule: record.reference.owner_module,
kind: record.reference.kind,
objectId: record.reference.object_id,
tenantId: record.reference.tenant_id,
label: `${record.case_number} · ${record.title}`,
version: record.reference.version ?? String(record.revision),
path: `/cases/${encodeURIComponent(record.reference.object_id)}`
} : null, [record]);
useRegisterActiveObject(activeCase);
const load = useCallback((signal?: AbortSignal) => { const load = useCallback((signal?: AbortSignal) => {
setLoading(true); setLoading(true);
@@ -61,19 +103,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();
@@ -96,6 +142,69 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
const changed = Boolean(record && (title.trim() !== record.title || status !== record.status_key)); const changed = Boolean(record && (title.trim() !== record.title || status !== record.status_key));
const draftDirty = Boolean(record && canUpdate && (changed || changeReason.trim())); const draftDirty = Boolean(record && canUpdate && (changed || changeReason.trim()));
useEffect(() => {
if (!record || !canUpdate) return;
const receiveReference = (event: Event) => {
const detail = (event as CustomEvent<{
launchContext?: QuickAccessLaunchContext;
result?: QuickAccessResult;
}>).detail;
const sourceCase = detail?.launchContext?.activeObject;
const result = detail?.result;
if (
!sourceCase
|| sourceCase.ownerModule !== "cases"
|| sourceCase.kind !== "case"
|| sourceCase.objectId !== record.reference.object_id
|| sourceCase.tenantId !== record.reference.tenant_id
|| result?.outcome !== "completed"
|| result.action !== "selected"
|| !result.reference
) return;
if (draftDirty) {
setError(
"Save or discard the current Case edits before linking the selected reference."
);
return;
}
const reference = result.reference;
if (!reference.version || !reference.path) {
setError("The selected owner reference is not an exact versioned link.");
return;
}
setLinkingEvidence(true);
setError("");
void linkCaseEvidence(settings, caseId, {
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: `Linked ${reference.ownerModule}.${reference.kind} through Quick Access.`,
idempotency_key: crypto.randomUUID(),
reference: {
owner_module: reference.ownerModule,
kind: reference.kind,
object_id: reference.objectId,
tenant_id: reference.tenantId,
version: reference.version,
path: reference.path
}
}).then((saved) => {
setRecord(saved);
setTitle(saved.title);
setStatus(saved.status_key);
return load();
}).catch((reason) => {
setError(reason instanceof Error
? reason.message
: "The selected reference could not be linked to this Case.");
}).finally(() => setLinkingEvidence(false));
};
window.addEventListener(QUICK_ACCESS_RESULT_EVENT, receiveReference);
return () => window.removeEventListener(
QUICK_ACCESS_RESULT_EVENT,
receiveReference
);
}, [canUpdate, caseId, draftDirty, load, record, settings]);
function discardDraft() { function discardDraft() {
if (!record) return; if (!record) return;
setTitle(record.title); setTitle(record.title);
@@ -137,6 +246,36 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
} }
} }
async function unlinkEvidence(evidence: EvidenceReference): Promise<void> {
if (!record || draftDirty || linkingEvidence) return;
if (!window.confirm("Remove this owner reference from the Case? The owner data is not deleted.")) return;
setLinkingEvidence(true);
setError("");
try {
const saved = await unlinkCaseEvidence(
settings,
caseId,
evidence.evidence_id,
{
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: `Unlinked ${evidence.owner_module} reference from the Case.`,
idempotency_key: crypto.randomUUID()
}
);
setRecord(saved);
setTitle(saved.title);
setStatus(saved.status_key);
await load();
} catch (reason) {
setError(reason instanceof Error
? reason.message
: "The owner reference could not be removed from this Case.");
} finally {
setLinkingEvidence(false);
}
}
useUnsavedDraftGuard({ useUnsavedDraftGuard({
dirty: draftDirty, dirty: draftDirty,
title: "i18n:govoplan-cases.unsaved_title", title: "i18n:govoplan-cases.unsaved_title",
@@ -156,21 +295,41 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
return ( return (
<main className="cases-page"> <main className="cases-page">
<div className="case-detail-shell"> <div className="case-detail-shell">
<div className="case-detail-toolbar"> <ActionToolbar className="case-detail-toolbar">
<button type="button" className="btn btn-ghost" onClick={() => navigate("/cases")}> <button type="button" className="btn btn-ghost" onClick={() => navigate("/cases")}>
<ArrowLeft size={16} aria-hidden="true" /> <ArrowLeft size={16} aria-hidden="true" />
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} />
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
</div> </div>
) : null}
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
</ActionToolbar>
<PageScrollViewport className="case-detail-viewport"> <PageScrollViewport className="case-detail-viewport">
{error && {error &&
<DismissibleAlert tone="danger" resetKey={error}> <DismissibleAlert tone="danger" resetKey={error}>
@@ -250,7 +409,30 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<ReferenceSection title="Parties" references={record.party_refs} /> <ReferenceSection title="Parties" references={record.party_refs} />
<ReferenceSection title="Assignments" references={record.assignment_refs} /> <ReferenceSection title="Assignments" references={record.assignment_refs} />
<EvidenceSection
references={record.evidence_refs}
providers={{
files: filesAvailable,
mail: mailAvailable,
campaigns: campaignsAvailable
}}
canUnlink={canUpdate && !draftDirty && !linkingEvidence}
onUnlink={(evidence) => void unlinkEvidence(evidence)}
/>
{canReadDecisions ? (
<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 +480,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 +523,151 @@ function ReferenceSection({ title, references }: { title: string; references: In
); );
} }
function EvidenceSection({
references,
providers,
canUnlink,
onUnlink
}: {
references: EvidenceReference[];
providers: Record<string, boolean>;
canUnlink: boolean;
onUnlink: (reference: EvidenceReference) => void;
}) {
return (
<section className="case-reference-section case-evidence-section">
<h2>Linked files, mail, and campaigns</h2>
<p className="muted small-note">
Select an authorized exact item in Quick Access to link it. Cases stores
only the owner reference; the owning module checks access again when opened.
</p>
{references.length ? (
<div className="case-reference-list">
{references.map((reference) => {
const source = linkedEvidenceSource(reference);
const available = source
? providers[source.owner_module] !== false
: providers[reference.owner_module] !== false;
return (
<span key={`${reference.owner_module}:${reference.evidence_id}:${reference.version ?? "current"}`}>
<strong>{humanize(source?.kind ?? reference.kind)}</strong>
<small>{reference.owner_module} · version {reference.version ?? "not pinned"}</small>
{source && available ? (
<a href={source.path} className="btn btn-secondary">
<ExternalLink size={15} aria-hidden="true" /> Open in owner
</a>
) : source ? (
<small>Owner module unavailable; the reference remains in Case history.</small>
) : null}
{source ? (
<Button
variant="danger"
disabled={!canUnlink}
disabledReason={!canUnlink
? "Save or discard Case edits before removing a link."
: undefined}
onClick={() => onUnlink(reference)}
>
<Unlink size={15} aria-hidden="true" /> Unlink
</Button>
) : null}
</span>
);
})}
</div>
) : (
<p className="muted">No owner references are linked.</p>
)}
</section>
);
}
function linkedEvidenceSource(reference: EvidenceReference): {
owner_module: string;
kind: string;
path: string;
} | null {
if (!reference.source_ref) return null;
try {
const value = JSON.parse(reference.source_ref) as Record<string, unknown>;
const owner = String(value.owner_module ?? "");
const kind = String(value.kind ?? "");
const path = String(value.path ?? "");
if (
value.contract !== "cases.quick-access-reference.v1"
|| !owner
|| !kind
|| !path.startsWith("/")
|| path.startsWith("//")
) return null;
return { owner_module: owner, kind, path };
} catch {
return null;
}
}
function DecisionSection({
decisions,
canFile,
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)) : "-";
} }
+31 -24
View File
@@ -1,14 +1,20 @@
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react"; import { useEffect, useMemo, useState, type FormEvent } from "react";
import { import { Button,
Button,
DocumentationHelpLink, DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
FilterBar,
LoadingIndicator, LoadingIndicator,
PageScrollViewport, PageScrollViewport,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge, StatusBadge,
i18nMessage, i18nMessage,
useGuardedNavigate, useGuardedNavigate,
WorkspaceActionBar,
WorkspaceFrame,
type PlatformRouteContext type PlatformRouteContext
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
@@ -30,6 +36,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -55,7 +62,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
}). }).
finally(() => setLoading(false)); finally(() => setLoading(false));
return () => controller.abort(); return () => controller.abort();
}, [settings, status, submittedQuery]); }, [reloadKey, settings, status, submittedQuery]);
const statusLabels = useMemo( const statusLabels = useMemo(
() => new Map(catalog.statuses.map((item) => [item.status_key, item])), () => new Map(catalog.statuses.map((item) => [item.status_key, item])),
@@ -73,9 +80,15 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
return ( return (
<main className="cases-page"> <main className="cases-page">
<div className="cases-shell"> <WorkspaceFrame className="cases-shell" label="Cases" interfaceId="cases.catalogue" helpContextId="cases.page.catalogue" helpModuleId="cases">
<div className="cases-toolbar"> <WorkspaceActionBar
<form className="cases-search" onSubmit={submit}> scope="collection-pane"
variant="collection"
refreshable
reloadAction={{ onReload: () => setReloadKey((value) => value + 1), loading }}
className="cases-toolbar"
contextActions={<>
<FilterBar as="form" surface="control" wrap="never" width="default" className="cases-search" onSubmit={submit}>
<Search size={17} aria-hidden="true" /> <Search size={17} aria-hidden="true" />
<input <input
value={query} value={query}
@@ -90,7 +103,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
> >
Search Search
</Button> </Button>
</form> </FilterBar>
<label className="cases-status-filter"> <label className="cases-status-filter">
<span>Status</span> <span>Status</span>
<select value={status} onChange={(event) => setStatus(event.target.value)}> <select value={status} onChange={(event) => setStatus(event.target.value)}>
@@ -101,8 +114,9 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
</select> </select>
</label> </label>
<span className="cases-count">{i18nMessage("i18n:govoplan-cases.case_count", { value0: total })}</span> <span className="cases-count">{i18nMessage("i18n:govoplan-cases.case_count", { value0: total })}</span>
<DocumentationHelpLink reference={CASES_DOCUMENTATION} /> </>}
</div> helpAction={<DocumentationHelpLink reference={CASES_DOCUMENTATION} />}
/>
<PageScrollViewport className="cases-list-viewport"> <PageScrollViewport className="cases-list-viewport">
{error && {error &&
<DismissibleAlert tone="error" onDismiss={() => setError("")}> <DismissibleAlert tone="error" onDismiss={() => setError("")}>
@@ -111,36 +125,29 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
} }
{loading && <LoadingIndicator label="Loading cases" />} {loading && <LoadingIndicator label="Loading cases" />}
{!loading && !error && cases.length === 0 && {!loading && !error && cases.length === 0 &&
<div className="cases-empty">No matching cases.</div> <StatePanel size="compact" description="No matching cases." />
} }
{!loading && cases.length > 0 && {!loading && cases.length > 0 &&
<div className="cases-list" role="list"> <SelectionList variant="navigation" label="Cases">
{cases.map((item) => { {cases.map((item) => {
const statusDefinition = statusLabels.get(item.status_key); const statusDefinition = statusLabels.get(item.status_key);
return ( return (
<button <SelectionListItem
type="button" selected={false}
className="case-list-row"
role="listitem"
key={item.reference.object_id} key={item.reference.object_id}
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}`)}> onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}`)}>
<div className="case-list-primary"> <SelectionListItemContent title={item.title} description={`${item.case_number} · ${typeLabels.get(item.case_type_key) ?? humanize(item.case_type_key)} · ${formatDate(item.deadline_at)}`} />
<strong>{item.title}</strong>
<span>{item.case_number}</span>
</div>
<span>{typeLabels.get(item.case_type_key) ?? humanize(item.case_type_key)}</span>
<span>{formatDate(item.deadline_at)}</span>
<StatusBadge <StatusBadge
status={statusDefinition?.terminal ? "inactive" : "active"} status={statusDefinition?.terminal ? "inactive" : "active"}
label={statusDefinition?.label ?? humanize(item.status_key)} label={statusDefinition?.label ?? humanize(item.status_key)}
/> />
</button> </SelectionListItem>
); );
})} })}
</div> </SelectionList>
} }
</PageScrollViewport> </PageScrollViewport>
</div> </WorkspaceFrame>
</main> </main>
); );
} }
+46 -2
View File
@@ -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 };
+5 -3
View File
@@ -10,7 +10,7 @@ const CaseDetailPage = lazy(() => import("./features/cases/CaseDetailPage"));
export const casesModule: PlatformWebModule = { export const casesModule: PlatformWebModule = {
id: "cases", id: "cases",
label: "i18n:govoplan-cases.cases", label: "i18n:govoplan-cases.cases",
version: "0.1.8", version: "0.1.20",
optionalDependencies: [ optionalDependencies: [
"access", "access",
"addresses", "addresses",
@@ -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 }
] ]
}; };
+93 -93
View File
@@ -1,19 +1,16 @@
.cases-page, .cases-page,
.cases-shell,
.case-detail-shell { .case-detail-shell {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.cases-shell,
.case-detail-shell { .case-detail-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--surface); background: var(--surface);
} }
.cases-toolbar,
.case-detail-toolbar { .case-detail-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -24,17 +21,7 @@
background: var(--surface-raised); background: var(--surface-raised);
} }
.cases-search { .cases-search { flex: 1 1 560px; }
display: flex;
align-items: center;
gap: 8px;
width: min(560px, 100%);
}
.cases-search input {
min-width: 120px;
flex: 1;
}
.cases-status-filter { .cases-status-filter {
display: flex; display: flex;
@@ -52,7 +39,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;
} }
@@ -63,58 +53,6 @@
padding: 16px 18px 24px; padding: 16px 18px 24px;
} }
.cases-list {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-raised);
}
.case-list-row {
display: grid;
grid-template-columns: minmax(260px, 2fr) minmax(150px, 1fr) minmax(150px, 0.8fr) auto;
align-items: center;
gap: 16px;
width: 100%;
min-height: 66px;
padding: 10px 14px;
border: 0;
border-bottom: 1px solid var(--border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.case-list-row:last-child {
border-bottom: 0;
}
.case-list-row:hover,
.case-list-row:focus-visible {
background: var(--hover-bg);
}
.case-list-primary {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.case-list-primary strong,
.case-list-primary span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.case-list-primary span,
.case-list-row > span:not(.status-badge) {
color: var(--text-soft);
font-size: 0.84rem;
}
.case-detail-content { .case-detail-content {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
@@ -159,7 +97,7 @@
margin-top: 16px; margin-top: 16px;
padding: 14px; padding: 14px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: var(--radius-compact);
background: var(--surface-raised); background: var(--surface-raised);
} }
@@ -190,7 +128,7 @@
margin-top: 18px; margin-top: 18px;
background: var(--border); background: var(--border);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: var(--radius-compact);
overflow: hidden; overflow: hidden;
} }
@@ -238,6 +176,15 @@
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.case-evidence-section .case-reference-list > span {
align-items: center;
flex-wrap: wrap;
}
.case-evidence-section .case-reference-list small {
color: var(--text-soft);
}
.case-timeline { .case-timeline {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -254,17 +201,83 @@
border-left: 2px solid var(--accent); border-left: 2px solid var(--accent);
} }
.cases-empty {
padding: 36px 0;
color: var(--text-soft);
text-align: center;
}
.case-share-dialog { .case-share-dialog {
width: min(860px, calc(100vw - 32px)); width: min(860px, calc(100vw - 32px));
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 {
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.7fr);
}
.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: var(--radius-compact);
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;
@@ -322,28 +335,11 @@
font-size: 0.76rem; font-size: 0.76rem;
} }
@media (max-width: 860px) { @media (max-width: 900px) {
.cases-toolbar {
align-items: stretch;
flex-direction: column;
}
.cases-search {
width: 100%;
}
.cases-count { .cases-count {
margin-left: 0; margin-left: 0;
} }
.case-list-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.case-list-row > span:not(.status-badge) {
display: none;
}
.case-detail-content { .case-detail-content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -352,6 +348,10 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.case-detail-toolbar {
flex-wrap: wrap;
}
.case-share-add-row .icon-button { .case-share-add-row .icon-button {
justify-self: end; justify-self: end;
} }