16 Commits
Author SHA1 Message Date
zemion afda3a5ac5 Release govoplan-cases v0.1.24: align published package metadata
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 01:32:27 +02:00
zemion 17415d8831 feat(cases): enforce purpose-bound case governance
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 20:05:31 +02:00
zemion 9435116299 docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 10s
2026-08-24 01:15:32 +02:00
zemion e1cffd3e53 docs(cases): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 20:19:14 +02:00
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
zemion 0265e135f5 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:33:58 +02:00
37 changed files with 6100 additions and 279 deletions
+36 -6
View File
@@ -26,15 +26,45 @@ The executable backend slice now exposes:
assignment/evidence/Decision/record references, and replay-safe lifecycle
events; and
- `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,
address, Mandate, Decision, file, workflow, or task lifecycles. The `/cases`
workspace now supplies list/detail, status/title revision, history, and timeline
surfaces. A case can remain tenant-visible or become restricted to its creator,
case administrators, explicit user/group grants, and assignment-derived
function, function-assignment, or organization-unit grants. The detail surface
uses the shared reference selector to manage those grants; list, detail,
history, timeline, and update paths all apply the same fail-closed ACL.
surfaces. A case can remain tenant-visible or become restricted. Restricted
access requires a current custodian, explicit subject, or assignment-derived
grant whose permission and exact allowed purpose both match the operation.
`cases.casework` is the interactive default and `cases.search` is reserved for
Search rechecks. The detail surface uses the shared reference selector to
manage grants and purposes; list, detail, history, timeline, update, evidence,
Decision, Search, and Records-source paths apply the same fail-closed decision.
Current grants also govern historical revision reads.
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,
including their bounded allowed purposes,
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.
+92 -12
View File
@@ -88,18 +88,21 @@ The persistent backend provides:
- replay-safe case timeline events carrying the institutional context and a
stable event/audit reference
- 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 purpose-bound, revisioned grant snapshots
- 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
search/status filtering, case facts, typed references, history, timeline, and
OCC-guarded title/status/access revisions with an explicit change reason.
Restricted cases are visible to their creator, case administrators, explicit
user/group grants, and matching function, function-assignment, or
organization-unit assignments. The same decision filters list, detail,
history, timeline, and update operations so an inaccessible identifier does
not disclose case existence. API reads and writes are tenant-bound, and
create/update/assign/close/share/catalog operations have separate permissions.
Restricted cases require a current custodian, explicit subject, or matching
function, function-assignment, or organization-unit grant. The grant must imply
the requested permission and contain the exact declared purpose. The same
decision filters list, detail, history, timeline, update, evidence, Decision,
Search, and Records-source operations so an inaccessible identifier does not
disclose case existence. Current grants govern historical revisions. API reads
and writes are tenant-bound, and create/update/assign/close/share/catalog
operations have separate permissions.
## Permissions
@@ -113,9 +116,14 @@ Implemented scopes:
- `cases:case:share`
- `cases:case:admin`
Access decisions combine tenant permissions, creator/administrative authority,
case assignments, and explicit case shares. Restricted access is versioned
with the case record so its history remains reconstructable.
Access decisions combine tenant permissions with current purpose-bound
custodian, assignment-derived, and explicit subject grants. Creating or newly
restricting a case establishes a custodian grant for the attributable actor.
`cases.casework` is the stable interactive default; Search rechecks use only
`cases.search`; Records filing supplies its declared purpose. Assignment-derived
grants admit `cases.casework`, `cases.search`, and `cases.records`. Each accepted
timeline event stores the evaluated purpose. Restricted access snapshots remain
versioned with the case record while current grant state governs disclosure.
## Data Model Sketch
@@ -159,13 +167,62 @@ reference. Retrying the same launch returns the existing case; a changed request
cannot reuse the key. Portal therefore cannot create duplicate cases after an
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
projection. It does not own decision authority, rule versions, reasoning,
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
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 for the caller-declared purpose, 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 using the fixed `cases.search`
purpose before disclosure. Search links preserve that purpose in the Case URL.
## Information governance adoption
Cases enforces temporal browsing for immutable case revisions while always
using current authorization for historical reads. It enforces exact-purpose
access for restricted cases and records the evaluated purpose in lifecycle
evidence. Governed context validates and persists tenant, service, party,
organization, function, acting assignment/delegation, mandate, jurisdiction,
legal-basis, evidence, Decision, and record references at their owning
boundaries.
Retention remains contract-only. Cases preserves immutable revisions and
timeline evidence and publishes DSAR retention/manual-review consequences, but
does not yet apply configurable schedules, legal holds, appraisal, or
disposition gates. Those controls must be implemented through the Records
boundary or a future explicit Cases retention policy before retention can be
claimed as enforced.
## WebUI
Initial route contributions:
@@ -173,8 +230,11 @@ Initial route contributions:
- `/cases`
- `/cases/:caseId`
The initial detail view renders the module-owned record, stable references,
history, and timeline. Future linked forms, files, tasks, workflow state,
The detail view renders the module-owned record, stable references, history,
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
panels must arrive through declarative extension points, without direct UI
imports from sibling modules.
@@ -192,6 +252,26 @@ The focused suite covers:
- explicit and assignment-derived case access, including non-disclosure
- 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 and matching
account, identity, or membership 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
- Whether comments belong in cases, tasks, or a collaboration module.
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@govoplan/cases-webui",
"version": "0.1.24",
"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]
name = "govoplan-cases"
version = "0.1.16"
version = "0.1.24"
description = "GovOPlaN administrative case context module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.16"]
dependencies = ["govoplan-core>=0.1.30"]
[tool.setuptools.packages.find]
where = ["src"]
+6
View File
@@ -140,6 +140,9 @@ class CaseAccessGrant(Base, TimestampMixin):
subject_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
allowed_purposes: Mapped[list[str]] = mapped_column(
JSON, default=list, nullable=False
)
source: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
source_revision: Mapped[int] = mapped_column(Integer, nullable=False)
@@ -163,6 +166,9 @@ class CaseTimelineEntry(Base, TimestampMixin):
summary: Mapped[str] = mapped_column(String(500), nullable=False)
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
purpose: Mapped[str] = mapped_column(
String(255), nullable=False, default="cases.casework", index=True
)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
audit_event_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
+494
View File
@@ -0,0 +1,494 @@
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 DEFAULT_CASE_ACCESS_PURPOSE, 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
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE
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,
purpose=command.purpose,
)
current = get_case(
session,
principal,
case_id=case_id,
purpose=command.purpose,
)
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",
purpose=command.purpose,
):
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}",
purpose=command.purpose,
)
return CaseDecisionResult(case=linked, decision=persisted)
def linked(
self,
session: Session,
principal: object,
*,
case_id: str,
purpose: str | None = None,
) -> tuple[FormalDecision, ...]:
case = get_case(
session,
principal,
case_id=case_id,
purpose=purpose,
)
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"),
(command.purpose, "Case access purpose"),
):
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",
]
+30
View File
@@ -17,15 +17,19 @@ CASE_ACCESS_SUBJECT_KINDS = frozenset(
{
"account",
"identity",
"membership",
"group",
"role",
"function",
"function_assignment",
"organization_unit",
"service_account",
"delegation",
}
)
CASE_ACCESS_PERMISSIONS = frozenset({"read", "update", "share", "admin"})
DEFAULT_CASE_ACCESS_PURPOSE = "cases.casework"
MAX_CASE_ACCESS_PURPOSES = 100
@dataclass(frozen=True, slots=True)
@@ -33,6 +37,7 @@ class CaseGrant:
subject_kind: str
subject_id: str
permissions: tuple[str, ...] = ("read",)
allowed_purposes: tuple[str, ...] = (DEFAULT_CASE_ACCESS_PURPOSE,)
def __post_init__(self) -> None:
if self.subject_kind not in CASE_ACCESS_SUBJECT_KINDS:
@@ -43,12 +48,29 @@ class CaseGrant:
if not normalized or set(normalized) - CASE_ACCESS_PERMISSIONS:
raise InstitutionalContextError("Case access permissions are invalid.")
object.__setattr__(self, "permissions", normalized)
purposes = tuple(
dict.fromkeys(
str(value).strip() for value in self.allowed_purposes if str(value).strip()
)
)
if not purposes:
raise InstitutionalContextError(
"Case access grants require at least one allowed purpose."
)
if len(purposes) > MAX_CASE_ACCESS_PURPOSES or any(
len(value) > 255 for value in purposes
):
raise InstitutionalContextError(
"Case access grant purposes exceed the supported bounds."
)
object.__setattr__(self, "allowed_purposes", purposes)
def to_dict(self) -> dict[str, object]:
return {
"subject_kind": self.subject_kind,
"subject_id": self.subject_id,
"permissions": list(self.permissions),
"allowed_purposes": list(self.allowed_purposes),
}
@classmethod
@@ -58,10 +80,18 @@ class CaseGrant:
raise InstitutionalContextError(
"Case access permissions must be a list."
)
raw_purposes = value.get(
"allowed_purposes", (DEFAULT_CASE_ACCESS_PURPOSE,)
)
if not isinstance(raw_purposes, (list, tuple)):
raise InstitutionalContextError(
"Case access purposes must be a list."
)
return cls(
subject_kind=_text(value, "subject_kind"),
subject_id=_text(value, "subject_id"),
permissions=tuple(str(item) for item in raw_permissions),
allowed_purposes=tuple(str(item) for item in raw_purposes),
)
+738
View File
@@ -0,0 +1,738 @@
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),
("membership", selectors.membership_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)
or (
row.subject_kind == "membership"
and row.subject_id == selectors.membership_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]],
"allowed_purposes": [
str(value)[:255] for value in row.allowed_purposes[:100]
],
"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),
"purpose": _bounded_text(row.purpose, 255),
"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,247 @@
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 DEFAULT_CASE_ACCESS_PURPOSE, 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,
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
) -> CaseRecord:
base = _base_revision(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
purpose=purpose,
)
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,
purpose=purpose,
)
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,
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
) -> CaseRecord:
base = _base_revision(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
purpose=purpose,
)
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,
purpose=purpose,
)
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,
purpose: str,
) -> CaseRecord:
base = get_case(
session,
principal,
case_id=case_id,
revision=expected_revision,
purpose=purpose,
)
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",
]
@@ -0,0 +1,100 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'cases.reference.lifecycle-access-and-evidence': {'consequence_classes': {'change_access': 'Beifügen '
'eines '
'bestätigten '
'Ereignisses '
'hinsichtlich '
'der '
'Sichtbarkeit/der '
'Überarbeitung '
'der '
'Finanzhilfe '
'und '
'des '
'Zeitplans',
'close_case': 'Anfügen '
'einer '
'Terminalrevision '
'nach '
'Close-Scope-Autorisierung',
'update_case': 'Anfügen '
'eines '
'OCC-geschützten '
'unveränderlichen '
'Revisions- '
'und '
'Zeitleistenereignisses'}},
'cases.workflow.file-exact-revision': {'outcome': 'Der eAkte behält eine genaue '
'Fall-Snapshot-Referenz, während Cases die '
'Autorität behält.',
'prerequisites': ['Sie können derzeit den Fall lesen und '
'Elemente in den Zieldatensatz eingeben.',
'Die genaue Revision des '
'unveränderlichen Falles wurde '
'ermittelt.'],
'steps': ['Öffnen Sie den Fall und identifizieren Sie die '
'Revision, die den Beweiszustand darstellt.',
'Wählen Sie den Zieldatensatz und geben Sie den '
'Zugangszweck und den Anmeldegrund an.',
'Bestätigen Sie die Einreichung; Cases überprüft '
'den aktuellen Zugriff erneut und löst die '
'genaue Überarbeitung.',
'Überprüfen Sie die Fallrevision und Snapshot '
'Digest in der Datensatzchronologie.']},
'cases.workflow.link-owner-references': {'consequence_classes': {'link_owner_reference': 'Eine '
'unveränderliche '
'Fallrevision, '
'die nur '
'die '
'genaue '
'typisierte '
'Eigentümerreferenz '
'enthält.',
'unlink_owner_reference': 'Hängt '
'eine '
'unveränderliche '
'Case-Revision '
'an '
'und '
'lässt '
'das '
'Owner-Objekt '
'unverändert.'}},
'cases.workflow.record-formal-decision': {'outcome': 'Das formale Ergebnis ist '
'Entscheidungsbesitz und exakte Version aus '
'dem Fall verknüpft.',
'prerequisites': ['Der Fall hat eine genaue '
'verantwortliche Organisation, '
'Funktion, Nachweise und rechtliche '
'Grundlage Version.',
'Eine aktive Handlungszuweisung und '
'genau ein wirksames Mandat '
'autorisieren den Entscheidungstyp.'],
'steps': ['Öffnen Sie den Fall und wählen Sie die '
'entsprechende Handlungszuweisung in der '
'Titelleiste aus.',
'Notieren Sie das operative Ergebnis, die '
'Begründung, die optionalen Bedingungen, die '
'effektive Zeit und den Änderungsgrund.',
'Bestätigen Sie die genauen Entscheidungs- '
'und Fallrevisionen in der Fallzeitleiste.',
'Wenn Records aktiviert ist, archivieren Sie '
'die genaue Entscheidungs- oder Fallrevision '
'im Zielort eAkte.']},
'cases.workflow.ticket-escalation': {'consequence_classes': {'create_case': 'Erstellt ein '
'Fall-eigenes '
'formales Verfahren '
'und gibt seine '
'stabile Referenz '
'zurück.',
'preserve_ticket': 'Konvertiert, '
'kopiert oder '
'verändert den '
'Ticketverlauf '
'nicht.'}}}
+619 -18
View File
@@ -1,14 +1,27 @@
from __future__ import annotations
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_cases.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
from pathlib import Path
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
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.information_governance import (
InformationGovernanceDimension,
ModuleInformationGovernance,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
@@ -20,9 +33,12 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
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 (
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
@@ -34,6 +50,10 @@ from govoplan_cases.backend.party_context import (
)
from govoplan_cases.backend.acl import CaseAclProvider
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 (
CAPABILITY_CASES_SERVICE_INTAKE,
CaseServiceIntake,
@@ -46,11 +66,17 @@ from govoplan_cases.backend.service import (
CAPABILITY_CASES_REGISTRY,
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
MODULE_ID = "cases"
MODULE_VERSION = "0.1.16"
MODULE_VERSION = "0.1.24"
READ_SCOPE = "cases:case:read"
CREATE_SCOPE = "cases:case:create"
UPDATE_SCOPE = "cases:case:update"
@@ -79,6 +105,15 @@ def _service_launcher(context: ModuleContext) -> 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):
del context
from govoplan_cases.backend.router import router
@@ -131,23 +166,60 @@ manifest = ModuleManifest(
"decisions",
"forms_runtime",
"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=(
_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(UPDATE_SCOPE, "Update cases", "Create a guarded immutable case revision."),
_permission(ASSIGN_SCOPE, "Assign cases", "Change stable function, assignment, or work-item references on a case."),
_permission(CLOSE_SCOPE, "Close cases", "Move a case to a configured terminal status."),
_permission(SHARE_SCOPE, "Share cases", "Restrict a case and grant object-level access to selected principals."),
_permission(ADMIN_SCOPE, "Administer cases", "Configure tenant case types and statuses."),
_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(
UPDATE_SCOPE, "Update cases", "Create a guarded immutable case revision."
),
_permission(
ASSIGN_SCOPE,
"Assign cases",
"Change stable function, assignment, or work-item references on a case.",
),
_permission(
CLOSE_SCOPE, "Close cases", "Move a case to a configured terminal status."
),
_permission(
SHARE_SCOPE,
"Share cases",
"Restrict a case and grant object-level access to selected principals.",
),
_permission(
ADMIN_SCOPE, "Administer cases", "Configure tenant case types and statuses."
),
),
role_templates=(
RoleTemplate(
slug="case_manager",
name="Case manager",
description="Create, assign, update, and close tenant cases.",
permissions=(READ_SCOPE, CREATE_SCOPE, UPDATE_SCOPE, ASSIGN_SCOPE, CLOSE_SCOPE, SHARE_SCOPE),
permissions=(
READ_SCOPE,
CREATE_SCOPE,
UPDATE_SCOPE,
ASSIGN_SCOPE,
CLOSE_SCOPE,
SHARE_SCOPE,
),
),
RoleTemplate(
slug="case_reader",
@@ -159,7 +231,15 @@ manifest = ModuleManifest(
slug="case_administrator",
name="Case administrator",
description="Configure case types and statuses and manage cases.",
permissions=(READ_SCOPE, CREATE_SCOPE, UPDATE_SCOPE, ASSIGN_SCOPE, CLOSE_SCOPE, SHARE_SCOPE, ADMIN_SCOPE),
permissions=(
READ_SCOPE,
CREATE_SCOPE,
UPDATE_SCOPE,
ASSIGN_SCOPE,
CLOSE_SCOPE,
SHARE_SCOPE,
ADMIN_SCOPE,
),
),
),
route_factory=_router,
@@ -198,6 +278,21 @@ manifest = ModuleManifest(
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=(
ViewSurface(
id="cases.navigation",
@@ -276,6 +371,14 @@ manifest = ModuleManifest(
parent_id="cases.detail",
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=(
@@ -283,17 +386,52 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="cases.party_context", version="0.1.0"),
ModuleInterfaceProvider(name="cases.registry", 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=(
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.representation", 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.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_CASES_SERVICE_INTAKE: _service_intake,
CAPABILITY_CASES_PARTY_CONTEXT: _party_context,
CAPABILITY_CASES_REGISTRY: _case_registry,
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_CASES_SERVICE_INTAKE: CapabilityDocumentation(
@@ -316,6 +454,21 @@ manifest = ModuleManifest(
summary="Starts a replay-safe case from an exact available Service revision.",
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(
module_id=MODULE_ID,
@@ -345,8 +498,370 @@ manifest = ModuleManifest(
),
),
resource_acl_providers=(CaseAclProvider(),),
search_sources=(
SearchSourceProviderRegistration(
id="cases.cases",
factory=create_cases_search_source,
),
),
tenant_summary_providers=(_tenant_summary,),
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",
),
),
translations={
"de": {
"title": "Exakte Files-, Mail- und Campaign-Verweise mit einem Vorgang verknüpfen",
"summary": (
"Einen vom zuständigen Modul autorisierten Verweis hinzufügen oder entfernen, ohne dessen Inhalt nach Cases zu kopieren."
),
"body": (
"Für einen aktiven Vorgang können Files, Mail und Campaigns über den versionierten Schnellzugriff-Ergebnisvertrag eine "
"exakte mandantengebundene Auswahl zurückgeben. Cases akzeptiert nur unterstützte Kombinationen aus zuständigem Modul "
"und Verweisart, den passenden Mandanten, eine festgelegte Version und die kanonische lokale Route des zuständigen Moduls. "
"Verknüpfen und Entfernen prüfen die Änderungsberechtigung für den Vorgang erneut, verwenden erwartete Revision und "
"Idempotenz und ergänzen eine unveränderliche Vorgangshistorie. Cases speichert nur typisierte Modulkennung, exakte "
"Version und Modulroute; Dateibytes, Nachrichtenkopf oder -inhalt, Empfangende, Campaign-Inhalt oder eine frühere "
"Zugriffsentscheidung werden nie kopiert. Das Öffnen wechselt stets in das zuständige Modul, das den aktuellen Zugriff "
"erneut prüft. Wird dieses Modul deaktiviert oder der Quellzugriff später entzogen, behält der Vorgang den historischen "
"Verweis, legt aber keinen Quellinhalt offen. Das Entfernen nimmt nur den Verweis aus der nächsten Vorgangsrevision und "
"löscht niemals das Quellobjekt. Ungespeicherte Vorgangsänderungen müssen vor dem Verknüpfen eines Schnellzugriff-Ergebnisses "
"gespeichert oder verworfen werden."
),
}
},
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",
),
),
translations={
"de": {
"title": "Betroffenenanfragen für Vorgänge",
"summary": (
"Gesteuerte Vorgangstatsachen ausgeben, ohne die antragstellende Identität zu erraten oder undurchsichtige "
"Vorgangsdaten offenzulegen."
),
"body": (
"Der DSAR-Provider von Cases gleicht im exakten Mandanten Konten-, Identitäts- und Mitgliedschaftszuordnungen, "
"Zugriffsfreigaben für Konten oder Identitäten und ausdrückliche Cases-Verweise ab. Wird ein kanonischer Selektor mit "
"einem direkten Verweis kombiniert, muss eine Cases-eigene Beziehung beide bestätigen; andernfalls schließt die Suche "
"sicher. Direkte Vorgangs- und Revisionsverweise exportieren eine typisierte, minimierte Lebenszyklusprojektion. Rohe "
"Snapshots, Metadaten, Suchtext, Freitext-Änderungsgründe, Timeline-Nutzdaten und -Zusammenfassungen, Nachweiskennungen, "
"Anfrage-Digests, Idempotenzschlüssel, Auditkennungen und unbeteiligte Zugriffsbetroffene sind ausgeschlossen. "
"Unveränderliche Vorgangskennungen, Revisionen, Timelines und Zuordnungen werden aufbewahrt. Aktuelle offene Vorgänge und "
"aktive Zugriffsmerkmale erfordern eine berechtigte manuelle Prüfung im normalen Vorgangs-/Zugriffslebenszyklus; eine "
"automatische Löschung wird nicht angeboten. Die Verbindung zwischen antragstellender Person und Identität gehört Parties; "
"Cases leitet sie daher nicht aus Beteiligtenkennungen ab."
),
}
},
),
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",
),
),
translations={
"de": {
"title": "Förmliche Entscheidung aus einem Vorgang festhalten",
"summary": "Handlungsbefugnis erneut prüfen und eine exakte Decision-Revision in der Vorgangshistorie bewahren.",
"body": (
"Die Aktion in der Vorgangsdetailansicht akzeptiert nur Tenor, Begründung, Bedingungen, Wirksamkeitszeitpunkt und "
"Änderungsgrund. Cases löst die exakte Vorgangsrevision auf, prüft den objektbezogenen Änderungszugriff, ermittelt über "
"Access die aktuelle Handlungszuweisung und verlangt genau ein wirksames Mandat für cases.formal_decision und den gewählten "
"Decision-Typ. Anschließend wird das Ergebnis über das optionale Decisions-Register aufgezeichnet und die exakte "
"Decision-Revision in derselben Datenbanktransaktion mit einer neuen Vorgangsrevision verknüpft. Eine Wiederholung "
"verwendet eine deterministische Decision-Kennung; abweichende Nutzdaten schließen sicher."
),
}
},
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",
),
),
translations={
"de": {
"title": "Exakte Vorgangsrevision in eine eAkte ablegen",
"summary": "Einen rekonstruierbaren Vorgangs-Snapshot in Records bewahren, ohne die Zuständigkeit von Cases zu verlagern.",
"body": (
"Ist Records aktiviert, löst Cases eine exakte unveränderliche Vorgangsrevision erst auf, nachdem aktuelle "
"Vorgangsberechtigung und objektbezogener Zugriff geprüft wurden. Der abgelegte Verweis enthält Vorgangsnummer, "
"Lebenszyklusstatus, dargestelltes Gültigkeitsintervall, Aufzeichnungszeitpunkt und einen SHA-256-Digest des kanonischen "
"Snapshots. Cases bleibt für den Vorgangslebenszyklus maßgeblich."
),
}
},
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(
id="cases.governance.purpose-bound-access",
title="Purpose-bound access to restricted cases",
summary="Require a current subject grant, sufficient action, and one exact declared purpose before disclosing or changing a restricted case.",
body=(
"Tenant-visible cases continue to follow the Cases read permission. For a restricted case, every list, detail, history, timeline, update, share, evidence, Decision, Search, and Records-source path evaluates current grants against the acting account, membership, group, role, function, assignment, organization unit, service account, or delegation. The requested action and exact purpose must both match; missing or different purposes fail closed. cases.casework is the stable interactive default, cases.search is used only by Search authorization rechecks, and a Records filing request supplies its own declared purpose. Creating or newly restricting a case writes a purpose-bound custodian grant for the attributable actor. Assignment-derived grants permit cases.casework, cases.search, and cases.records. Explicit grants list their purposes separately and can be changed through the confirmed access dialog. Current grants always govern historical revision reads, so removed access cannot be recovered by selecting an older revision. Every accepted lifecycle event records the evaluated purpose. Retention schedules, legal holds, and disposition gates are not yet Cases-owned and remain a documented limitation."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "case_manager", "module_admin", "auditor"),
related_modules=("search", "records", "access"),
order=16,
links=(
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
DocumentationLink(
label="Cases concept",
href="govoplan-cases/docs/CONCEPT.md",
kind="repository",
),
),
translations={
"de": {
"title": "Zweckgebundener Zugriff auf eingeschränkte Vorgänge",
"summary": "Vor Offenlegung oder Änderung eines eingeschränkten Vorgangs eine aktuelle Subjektfreigabe, ausreichende Aktion und einen exakt angegebenen Zweck verlangen.",
"body": (
"Mandantenweit sichtbare Vorgänge folgen weiterhin der Leseberechtigung von Cases. Bei einem eingeschränkten Vorgang prüfen Listen-, Detail-, Historien-, Timeline-, Änderungs-, Freigabe-, Nachweis-, Decision-, Search- und Records-Quellpfade aktuelle Freigaben gegen das handelnde Konto, die Mitgliedschaft, Gruppe, Rolle, Funktion, Funktionszuweisung, Organisationseinheit, das Dienstkonto oder die Delegation. Angeforderte Aktion und exakter Zweck müssen übereinstimmen; fehlende oder abweichende Zwecke schließen sicher. cases.casework ist der stabile interaktive Standard, cases.search wird ausschließlich für erneute Search-Autorisierungen verwendet und eine Records-Ablage übergibt ihren eigenen angegebenen Zweck. Beim Anlegen oder erstmaligen Einschränken entsteht eine zweckgebundene Custodian-Freigabe für die zurechenbare handelnde Person. Aus Zuweisungen abgeleitete Freigaben erlauben cases.casework, cases.search und cases.records. Ausdrückliche Freigaben führen ihre Zwecke getrennt und werden im bestätigten Zugriffsdialog geändert. Aktuelle Freigaben gelten stets auch für historische Revisionsabrufe; entzogener Zugriff kann daher nicht über eine ältere Revision wiederhergestellt werden. Jedes akzeptierte Lebenszyklusereignis hält den ausgewerteten Zweck fest. Aufbewahrungspläne, Legal Holds und Aussonderungssperren gehören noch nicht Cases und bleiben als Einschränkung dokumentiert."
),
}
},
metadata={
"kind": "reference",
"help_contexts": [
"cases.page.catalogue",
"cases.detail.access",
"cases.field.access-purpose",
"cases.field.allowed-purposes",
],
},
),
DocumentationTopic(
id="cases.institutional-context",
title="Case institutional context",
@@ -357,7 +872,10 @@ manifest = ModuleManifest(
"intake retains the exact Service, Mandate, jurisdiction, legal basis, form, "
"workflow, and result bindings. Procedure parties come from an optional provider "
"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",
documentation_types=("admin", "user"),
@@ -369,10 +887,28 @@ manifest = ModuleManifest(
kind="repository",
),
),
translations={
"de": {
"title": "Institutioneller Kontext eines Vorgangs",
"summary": (
"Cases bewahrt Service- und Beteiligtenverweise, ohne institutionelle Definitionen oder Betroffenenstammdaten zu übernehmen."
),
"body": (
"Vorgangstypen und -status sind Mandantenkonfiguration. Jede Anlage oder Änderung schreibt eine unveränderliche, durch "
"optimistische Nebenläufigkeitskontrolle geschützte Revision und ein wiederholungssicheres Timeline-Ereignis. Die "
"Service-Aufnahme hält exakte Bindungen an Service, Mandat, Zuständigkeit, Rechtsgrundlage, Formular, Workflow und Ergebnis "
"fest. Verfahrensbeteiligte stammen von einem optionalen Provider oder einer begrenzten Cases-Kompatibilitätsprojektion. "
"Zuweisungs-, Nachweis-, Decision- und Aktenverweise bleiben stabile Referenzen im Eigentum ihrer Quellmodule. Ist Search "
"aktiviert, liefert Cases eine neu aufbaubare reine Metadatenprojektion. Nachweise und geschützte Decision-Inhalte sind "
"ausgeschlossen; jeder Treffer wird vor der Offenlegung erneut gegen den aktuellen Vorgangszugriff geprüft."
),
}
},
metadata={
"help_contexts": [
"cases.list",
"cases.detail",
"cases.search.result",
"cases.state.read-only",
"cases.state.restricted",
],
@@ -388,8 +924,8 @@ manifest = ModuleManifest(
"appends a timeline entry with actor, time, and change reason; a terminal "
"status additionally requires the case-close permission. Case visibility is "
"tenant-wide or restricted. Restricted cases remain visible only through "
"administrative authority, assignment or unit context, creator authority, or "
"an explicit account/group grant. Access changes append another immutable "
"a current purpose-bound custodian, assignment/unit context, or explicit "
"subject grant. Permission and exact allowed purpose must both match. Access changes append another immutable "
"revision and require confirmation. Service, party, assignment, Decision, and "
"record references identify provider-owned objects; Cases preserves their "
"stable identifiers and versions without copying or silently changing them."
@@ -404,7 +940,26 @@ manifest = ModuleManifest(
kind="repository",
),
),
translations={
"de": {
"title": "Referenz für Vorgangslebenszyklus, Zugriff und Nachweise",
"summary": (
"Revisions-, Status-, Zugriffs- und Verweisfelder gemeinsam mit ihren dauerhaften Folgen erläutern."
),
"body": (
"Änderungen an Titel und Status ergänzen eine unveränderliche Vorgangsrevision, geschützt durch erwartete Revision und "
"stabilen Idempotenzschlüssel. Jede akzeptierte Änderung ergänzt außerdem einen Timeline-Eintrag mit handelnder Person, "
"Zeitpunkt und Änderungsgrund; ein abschließender Status verlangt zusätzlich die Berechtigung zum Schließen. Die Sichtbarkeit "
"eines Vorgangs ist mandantenweit oder eingeschränkt. Eingeschränkte Vorgänge bleiben nur über eine aktuelle zweckgebundene "
"Custodian-, Zuweisungs-/Einheiten- oder ausdrückliche Subjektfreigabe sichtbar; Berechtigung und exakter erlaubter Zweck müssen übereinstimmen. "
"Zugriffsänderungen ergänzen eine weitere unveränderliche Revision und verlangen Bestätigung. Service-, Beteiligten-, "
"Zuweisungs-, Decision- und Aktenverweise bezeichnen Objekte anderer Provider; Cases bewahrt ihre stabilen Kennungen und "
"Versionen, ohne sie zu kopieren oder stillschweigend zu verändern."
),
}
},
metadata={
"kind": "reference",
"help_contexts": [
"cases.field.title",
"cases.field.status",
@@ -421,6 +976,47 @@ manifest = ModuleManifest(
},
),
),
information_governance=ModuleInformationGovernance(
temporal_browsing=InformationGovernanceDimension(
adoption="enforced",
object_types=("case", "case_revision", "case_timeline_event"),
evidence=(
"src/govoplan_cases/backend/service.py",
"tests/test_case_lifecycle.py",
),
),
purpose_aware_access=InformationGovernanceDimension(
adoption="enforced",
object_types=("case", "case_access_grant", "case_timeline_event"),
evidence=(
"src/govoplan_cases/backend/service.py",
"src/govoplan_cases/backend/search_source.py",
"src/govoplan_cases/backend/record_source.py",
"tests/test_case_lifecycle.py",
"tests/test_search_source.py",
"tests/test_record_source.py",
),
),
retention=InformationGovernanceDimension(
adoption="contract_only",
object_types=("case", "case_revision", "case_timeline_event"),
limitation=(
"Cases preserves immutable history and DSAR retention evidence but does not yet apply configurable schedules, legal holds, appraisal, or disposition gates."
),
),
institutional_context=InformationGovernanceDimension(
adoption="enforced",
object_types=("case", "case_revision", "case_timeline_event"),
evidence=(
"src/govoplan_cases/backend/domain.py",
"src/govoplan_cases/backend/service.py",
"src/govoplan_cases/backend/decision_path.py",
"tests/test_case_lifecycle.py",
"tests/test_institutional_consumers.py",
"tests/test_decision_path.py",
),
),
),
architecture=ModuleArchitectureDeclaration(
layer="human_work_procedure",
kind="domain",
@@ -459,5 +1055,10 @@ manifest = ModuleManifest(
)
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -0,0 +1,51 @@
"""v0.1.23 purpose-bound case access.
Revision ID: a7c4e2f9b1d6
Revises: f6d3a8b1c4e7
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a7c4e2f9b1d6"
down_revision = "f6d3a8b1c4e7"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"case_access_grants",
sa.Column(
"allowed_purposes",
sa.JSON(),
nullable=False,
server_default='["cases.casework"]',
),
)
op.add_column(
"case_timeline_entries",
sa.Column(
"purpose",
sa.String(length=255),
nullable=False,
server_default="cases.casework",
),
)
op.create_index(
op.f("ix_case_timeline_entries_purpose"),
"case_timeline_entries",
["purpose"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f("ix_case_timeline_entries_purpose"),
table_name="case_timeline_entries",
)
op.drop_column("case_timeline_entries", "purpose")
op.drop_column("case_access_grants", "allowed_purposes")
+122
View File
@@ -0,0 +1,122 @@
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",
purpose=purpose,
):
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='')}"
f"?purpose={quote(purpose, 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",
]
+181 -2
View File
@@ -32,6 +32,9 @@ from govoplan_cases.backend.manifest import (
UPDATE_SCOPE,
)
from govoplan_cases.backend.schemas import (
CaseDecisionRequest,
CaseEvidenceLinkRequest,
CaseEvidenceUnlinkRequest,
CaseHistoryResponse,
CaseListResponse,
CaseStatusWriteRequest,
@@ -40,6 +43,19 @@ from govoplan_cases.backend.schemas import (
CaseUpdateRequest,
CaseWriteRequest,
)
from govoplan_cases.backend.evidence_links import (
link_case_evidence,
unlink_case_evidence,
)
from govoplan_cases.backend.decision_path import (
CaseDecisionCommand,
CaseDecisionError,
CaseDecisionPath,
CaseDecisionUnavailable,
DECISION_READ_SCOPE,
DECISION_SENSITIVE_READ_SCOPE,
DECISION_WRITE_SCOPE,
)
from govoplan_cases.backend.service import (
CaseStoreError,
can_access_case,
@@ -66,7 +82,9 @@ def _require(principal: ApiPrincipal, scope: str) -> None:
def _error(exc: Exception) -> HTTPException:
message = str(exc)
lowered = message.casefold()
if isinstance(exc, LookupError):
if isinstance(exc, CaseDecisionUnavailable):
code = status.HTTP_424_FAILED_DEPENDENCY
elif isinstance(exc, LookupError):
code = 404
elif isinstance(exc, PermissionError):
code = 403
@@ -156,6 +174,7 @@ def api_list_cases(
query: str = "",
status_key: list[str] | None = Query(default=None),
case_type_key: list[str] | None = Query(default=None),
purpose: str | None = Query(default=None, max_length=255),
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
@@ -169,6 +188,7 @@ def api_list_cases(
query=query,
status_keys=status_key,
case_type_keys=case_type_key,
purpose=purpose,
offset=offset,
limit=limit,
)
@@ -198,6 +218,7 @@ def api_create_case(
principal,
record=record,
idempotency_key=payload.idempotency_key,
purpose=payload.purpose,
)
session.commit()
except (CaseStoreError, InstitutionalContextError) as exc:
@@ -206,15 +227,97 @@ def api_create_case(
return item.to_dict()
@router.get("/{case_id}/decisions", response_model=dict[str, list[dict[str, Any]]])
def api_case_decisions(
case_id: str,
purpose: str | None = Query(default=None, max_length=255),
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,
purpose=purpose,
)
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,
purpose=payload.purpose,
),
)
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])
def api_get_case(
case_id: str,
revision: int | None = Query(default=None, ge=1),
purpose: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, READ_SCOPE)
item = get_case(session, principal, case_id=case_id, revision=revision)
item = get_case(
session,
principal,
case_id=case_id,
revision=revision,
purpose=purpose,
)
if item is None:
raise HTTPException(status_code=404, detail="Case not found")
return item.to_dict()
@@ -252,6 +355,75 @@ def api_update_case(
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
idempotency_key=payload.idempotency_key,
purpose=payload.purpose,
)
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", 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,
purpose=payload.purpose,
)
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,
purpose=payload.purpose,
)
session.commit()
except (
@@ -276,6 +448,7 @@ def api_case_share_target_options(
selected: list[str] = Query(default=[]),
limit: int = Query(default=50, ge=1, le=200),
cursor: str | None = None,
purpose: str = Query(default="cases.casework", min_length=1, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ReferenceOptionListResponse:
@@ -285,6 +458,7 @@ def api_case_share_target_options(
principal,
case_id=case_id,
permission="share",
purpose=purpose,
):
raise HTTPException(status_code=403, detail="Case share access is denied")
try:
@@ -314,6 +488,7 @@ def api_case_share_target_options(
def api_case_history(
case_id: str,
limit: int = Query(default=100, ge=1, le=200),
purpose: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> CaseHistoryResponse:
@@ -326,6 +501,7 @@ def api_case_history(
principal,
case_id=case_id,
limit=limit,
purpose=purpose,
)
]
)
@@ -335,6 +511,7 @@ def api_case_history(
def api_case_timeline(
case_id: str,
limit: int = Query(default=200, ge=1, le=500),
purpose: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> CaseTimelineResponse:
@@ -346,6 +523,7 @@ def api_case_timeline(
principal,
case_id=case_id,
limit=limit,
purpose=purpose,
)
)
)
@@ -357,6 +535,7 @@ def _update_changes(payload: CaseUpdateRequest) -> dict[str, object]:
"recorded_at",
"change_reason",
"idempotency_key",
"purpose",
}
raw = payload.model_dump(exclude_unset=True, exclude=excluded)
for key in ("party_refs", "assignment_refs", "decision_refs", "record_refs"):
+58
View File
@@ -34,6 +34,7 @@ class CaseWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
record: dict[str, Any]
purpose: str = Field(default="cases.casework", min_length=1, max_length=255)
idempotency_key: str = Field(min_length=1, max_length=255)
@@ -43,12 +44,14 @@ class CaseGrantRequest(BaseModel):
subject_kind: Literal[
"account",
"identity",
"membership",
"group",
"role",
"function",
"function_assignment",
"organization_unit",
"service_account",
"delegation",
]
subject_id: str = Field(min_length=1, max_length=255)
permissions: list[Literal["read", "update", "share", "admin"]] = Field(
@@ -56,6 +59,11 @@ class CaseGrantRequest(BaseModel):
min_length=1,
max_length=4,
)
allowed_purposes: list[str] = Field(
default_factory=lambda: ["cases.casework"],
min_length=1,
max_length=100,
)
class CaseUpdateRequest(BaseModel):
@@ -65,6 +73,7 @@ class CaseUpdateRequest(BaseModel):
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
purpose: str = Field(default="cases.casework", min_length=1, max_length=255)
status_key: str | None = Field(default=None, min_length=1, max_length=120)
title: str | None = Field(default=None, min_length=1, max_length=500)
access_mode: Literal["tenant", "restricted"] | None = None
@@ -82,6 +91,52 @@ class CaseUpdateRequest(BaseModel):
metadata: dict[str, Any] | None = None
class CaseOwnerReferenceRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
owner_module: str = Field(min_length=1, max_length=120)
kind: str = Field(min_length=1, max_length=120)
object_id: str = Field(min_length=1, max_length=1_000)
tenant_id: str = Field(min_length=1, max_length=255)
version: str = Field(min_length=1, max_length=255)
path: str = Field(min_length=1, max_length=2_000)
class CaseEvidenceLinkRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
purpose: str = Field(default="cases.casework", 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)
purpose: str = Field(default="cases.casework", 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)
purpose: str = Field(default="cases.casework", min_length=1, max_length=255)
class CaseListResponse(BaseModel):
cases: list[dict[str, Any]]
total: int
@@ -99,6 +154,9 @@ class CaseTimelineResponse(BaseModel):
__all__ = [
"CaseGrantRequest",
"CaseDecisionRequest",
"CaseEvidenceLinkRequest",
"CaseEvidenceUnlinkRequest",
"CaseHistoryResponse",
"CaseListResponse",
"CaseStatusWriteRequest",
+289
View File
@@ -0,0 +1,289 @@
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"
SEARCH_ACCESS_PURPOSE = "cases.search"
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",
purpose=SEARCH_ACCESS_PURPOSE,
)
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='')}"
f"?purpose={quote(SEARCH_ACCESS_PURPOSE, 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",
"SEARCH_ACCESS_PURPOSE",
"create_cases_search_source",
]
+178 -65
View File
@@ -8,7 +8,7 @@ import json
from typing import Any
import uuid
from sqlalchemy import and_, exists, func, or_
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.events import (
@@ -32,7 +32,11 @@ from govoplan_cases.backend.db.models import (
CaseTimelineEntry,
CaseTypeDefinition,
)
from govoplan_cases.backend.domain import CaseGrant, CaseRecord
from govoplan_cases.backend.domain import (
DEFAULT_CASE_ACCESS_PURPOSE,
CaseGrant,
CaseRecord,
)
from govoplan_cases.backend.service_intake import CaseIntakePlan
@@ -222,8 +226,10 @@ def create_case(
*,
record: CaseRecord,
idempotency_key: str,
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
) -> CaseRecord:
tenant_id = _principal_tenant(principal)
declared_purpose = _access_purpose(purpose)
if record.reference.tenant_id != tenant_id:
raise CaseStoreError("Case records cannot cross tenants.")
if record.revision != 1:
@@ -241,6 +247,7 @@ def create_case(
principal,
case_id=replay.reference.object_id,
permission="read",
purpose=declared_purpose,
):
raise PermissionError("Case replay access is denied.")
return replay
@@ -274,6 +281,7 @@ def create_case(
idempotency_key=idempotency_key,
request_sha256=request_sha256,
operation="created",
purpose=declared_purpose,
)
return persisted
@@ -331,6 +339,8 @@ def create_case_from_intake(
"result_refs": list(plan.result_refs),
"required_evidence_types": list(plan.required_evidence_types),
"deadline_refs": list(plan.deadline_refs),
"remedy_refs": list(plan.remedy_refs),
"service_level_refs": list(plan.service_level_refs),
},
)
return create_case(
@@ -351,8 +361,10 @@ def update_case(
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
) -> CaseRecord:
tenant_id = _principal_tenant(principal)
declared_purpose = _access_purpose(purpose)
current_row = _current_row(
session,
tenant_id=tenant_id,
@@ -361,7 +373,13 @@ def update_case(
)
if current_row is None:
raise LookupError("Case not found.")
if not can_access_case(session, principal, case_id=case_id, permission="update"):
if not can_access_case(
session,
principal,
case_id=case_id,
permission="update",
purpose=declared_purpose,
):
raise PermissionError("Case update access is denied.")
current = _record_from_row(current_row)
normalized = _normalized_changes(changes)
@@ -381,6 +399,14 @@ def update_case(
request_sha256=request_sha256,
)
if replay is not None:
if not can_access_case(
session,
principal,
case_id=case_id,
permission="read",
purpose=declared_purpose,
):
raise PermissionError("Case replay access is denied.")
return replay
if current.revision != expected_revision:
raise CaseStoreError(
@@ -459,6 +485,7 @@ def update_case(
idempotency_key=idempotency_key,
request_sha256=request_sha256,
operation="closed" if terminal and not current.closed_at else "updated",
purpose=declared_purpose,
)
@@ -468,6 +495,7 @@ def get_case(
*,
case_id: str,
revision: int | None = None,
purpose: str | None = None,
) -> CaseRecord | None:
tenant_id = _principal_tenant(principal)
query = session.query(CaseRecordRevision).filter(
@@ -484,6 +512,7 @@ def get_case(
principal,
case_id=case_id,
permission="read",
purpose=purpose,
):
return None
return _record_from_row(row) if row is not None else None
@@ -496,6 +525,7 @@ def list_cases(
query: str = "",
status_keys: Sequence[str] | None = None,
case_type_keys: Sequence[str] | None = None,
purpose: str | None = None,
offset: int = 0,
limit: int = 100,
) -> tuple[tuple[CaseRecord, ...], int]:
@@ -508,7 +538,13 @@ def list_cases(
CaseRecordRevision.tenant_id == tenant_id,
CaseRecordRevision.superseded_at.is_(None),
)
statement = _filter_accessible_cases(statement, principal, permission="read")
eligible = _eligible_case_ids(
session,
principal,
permission="read",
purpose=purpose,
)
statement = statement.filter(CaseRecordRevision.case_id.in_(eligible))
if status_keys:
statement = statement.filter(
CaseRecordRevision.status_key.in_(tuple(status_keys))
@@ -541,8 +577,15 @@ def case_history(
*,
case_id: str,
limit: int = 100,
purpose: str | None = None,
) -> tuple[CaseRecord, ...]:
if not can_access_case(session, principal, case_id=case_id, permission="read"):
if not can_access_case(
session,
principal,
case_id=case_id,
permission="read",
purpose=purpose,
):
return ()
if not 1 <= limit <= 200:
raise CaseStoreError("Case history limit must be between 1 and 200.")
@@ -565,8 +608,15 @@ def case_timeline(
*,
case_id: str,
limit: int = 200,
purpose: str | None = None,
) -> tuple[dict[str, object], ...]:
if not can_access_case(session, principal, case_id=case_id, permission="read"):
if not can_access_case(
session,
principal,
case_id=case_id,
permission="read",
purpose=purpose,
):
return ()
if not 1 <= limit <= 500:
raise CaseStoreError("Case timeline limit must be between 1 and 500.")
@@ -593,10 +643,21 @@ class SqlCaseRegistry:
if reference.tenant_id != _principal_tenant(principal):
raise InstitutionalContextError("Case lookup cannot cross tenants.")
revision = int(reference.version) if reference.version else None
return get_case(_session(session), principal, case_id=reference.object_id, revision=revision)
return get_case(
_session(session),
principal,
case_id=reference.object_id,
revision=revision,
purpose=DEFAULT_CASE_ACCESS_PURPOSE,
)
def list_cases(self, session: object, principal: object, *, limit: int = 100) -> Sequence[CaseRecord]:
items, _total = list_cases(_session(session), principal, limit=limit)
items, _total = list_cases(
_session(session),
principal,
limit=limit,
purpose=DEFAULT_CASE_ACCESS_PURPOSE,
)
return items
@@ -606,6 +667,7 @@ def can_access_case(
*,
case_id: str,
permission: str = "read",
purpose: str | None = None,
) -> bool:
if permission not in {"read", "update", "share", "admin"}:
raise CaseStoreError("Unsupported case access permission.")
@@ -618,87 +680,86 @@ def can_access_case(
)
if current is None:
return False
if _has_scope(principal, "cases:case:admin"):
return True
identity = _identity(session, tenant_id=tenant_id, case_id=case_id)
if identity is not None and identity.created_by in _principal_actor_ids(principal):
return True
if current.access_mode == "tenant":
return True
if current.access_mode != "restricted":
return False
declared_purpose = str(purpose or "").strip()
if not declared_purpose:
return False
subjects = _principal_subjects(principal)
if not subjects:
return False
clauses = [
and_(
CaseAccessGrant.subject_kind == kind,
CaseAccessGrant.subject_id == subject_id,
)
for kind, subject_id in subjects
]
grants = (
session.query(CaseAccessGrant)
.filter(
CaseAccessGrant.tenant_id == tenant_id,
CaseAccessGrant.case_id == case_id,
CaseAccessGrant.active.is_(True),
or_(*clauses),
)
.all()
)
return any(
_access_permissions_allow(tuple(item.permissions or ()), permission)
(item.subject_kind, item.subject_id) in subjects
and _access_permissions_allow(tuple(item.permissions or ()), permission)
and declared_purpose in tuple(item.allowed_purposes or ())
for item in grants
)
def _filter_accessible_cases(statement, principal: object, *, permission: str):
if _has_scope(principal, "cases:case:admin"):
return statement
conditions = [CaseRecordRevision.access_mode == "tenant"]
actor_ids = _principal_actor_ids(principal)
if actor_ids:
conditions.append(
exists().where(
and_(
CaseIdentity.tenant_id == CaseRecordRevision.tenant_id,
CaseIdentity.case_id == CaseRecordRevision.case_id,
CaseIdentity.created_by.in_(actor_ids),
)
)
)
def _eligible_case_ids(
session: Session,
principal: object,
*,
permission: str,
purpose: str | None,
) -> tuple[str, ...]:
tenant_id = _principal_tenant(principal)
current = session.query(
CaseRecordRevision.case_id,
CaseRecordRevision.access_mode,
).filter(
CaseRecordRevision.tenant_id == tenant_id,
CaseRecordRevision.superseded_at.is_(None),
).all()
eligible = {
case_id for case_id, access_mode in current if access_mode == "tenant"
}
declared_purpose = str(purpose or "").strip()
if not declared_purpose:
return tuple(eligible)
subjects = _principal_subjects(principal)
if subjects:
subject_clause = or_(
*(
and_(
CaseAccessGrant.subject_kind == kind,
CaseAccessGrant.subject_id == subject_id,
)
for kind, subject_id in subjects
)
)
conditions.append(
exists().where(
and_(
CaseAccessGrant.tenant_id == CaseRecordRevision.tenant_id,
CaseAccessGrant.case_id == CaseRecordRevision.case_id,
if not subjects:
return tuple(eligible)
grants = session.query(CaseAccessGrant).filter(
CaseAccessGrant.tenant_id == tenant_id,
CaseAccessGrant.active.is_(True),
subject_clause,
).all()
eligible.update(
grant.case_id
for grant in grants
if (grant.subject_kind, grant.subject_id) in subjects
and _access_permissions_allow(tuple(grant.permissions or ()), permission)
and declared_purpose in tuple(grant.allowed_purposes or ())
)
)
)
if permission != "read":
raise CaseStoreError("Case list filtering supports read access only.")
return statement.filter(or_(*conditions))
return tuple(eligible)
def _sync_access_grants(
session: Session,
principal: object,
record: CaseRecord,
*,
purpose: str,
ensure_custodian: bool,
) -> None:
desired: dict[tuple[str, str, str], tuple[str, ...]] = {
(item.subject_kind, item.subject_id, "explicit"): item.permissions
desired: dict[
tuple[str, str, str], tuple[tuple[str, ...], tuple[str, ...]]
] = {
(item.subject_kind, item.subject_id, "explicit"): (
item.permissions,
item.allowed_purposes,
)
for item in record.access_grants
}
for reference in record.assignment_refs:
@@ -709,8 +770,8 @@ def _sync_access_grants(
}:
continue
desired[(reference.kind, reference.object_id, "assignment")] = (
"read",
"update",
("read", "update"),
("cases.casework", "cases.search", "cases.records"),
)
rows = (
session.query(CaseAccessGrant)
@@ -723,11 +784,21 @@ def _sync_access_grants(
existing = {
(item.subject_kind, item.subject_id, item.source): item for item in rows
}
if ensure_custodian and record.access_mode == "restricted":
subject = _custodian_subject(principal)
if subject is None:
raise CaseStoreError(
"Restricted case creation requires an attributable custodian."
)
desired[(subject[0], subject[1], "custodian")] = (
("read", "update", "share", "admin"),
(purpose,),
)
for key, row in existing.items():
if key not in desired:
if key not in desired and row.source != "custodian":
row.active = False
row.source_revision = record.revision
for key, permissions in desired.items():
for key, (permissions, allowed_purposes) in desired.items():
row = existing.get(key)
if row is None:
session.add(
@@ -737,6 +808,7 @@ def _sync_access_grants(
subject_kind=key[0],
subject_id=key[1],
permissions=list(permissions),
allowed_purposes=list(allowed_purposes),
source=key[2],
active=True,
source_revision=record.revision,
@@ -745,6 +817,7 @@ def _sync_access_grants(
)
else:
row.permissions = list(permissions)
row.allowed_purposes = list(allowed_purposes)
row.active = True
row.source_revision = record.revision
session.flush()
@@ -778,6 +851,7 @@ def _principal_subjects(principal: object) -> tuple[tuple[str, str], ...]:
for kind, name in (
("account", "account_id"),
("identity", "identity_id"),
("membership", "membership_id"),
("service_account", "service_account_id"),
):
value = str(getattr(principal, name, None) or "").strip()
@@ -789,15 +863,41 @@ def _principal_subjects(principal: object) -> tuple[tuple[str, str], ...]:
("function", "function_ids"),
("function_assignment", "function_assignment_ids"),
("organization_unit", "organization_unit_ids"),
("delegation", "delegation_ids"),
):
subjects.extend(
(kind, str(item))
for item in (getattr(principal, name, ()) or ())
if str(item).strip()
)
for kind, name in (
("function_assignment", "acting_assignment_id"),
("delegation", "acting_delegation_id"),
("delegation", "delegation_id"),
):
value = str(getattr(principal, name, None) or "").strip()
if value:
subjects.append((kind, value))
return tuple(dict.fromkeys(subjects))
def _custodian_subject(principal: object) -> tuple[str, str] | None:
for kind, name in (
("account", "account_id"),
("identity", "identity_id"),
("membership", "membership_id"),
("service_account", "service_account_id"),
):
value = str(getattr(principal, name, None) or "").strip()
if value:
return kind, value
return None
def _access_purpose(value: object) -> str:
return _required_text(value, "Case access purpose", maximum=255)
def _has_scope(principal: object, scope: str) -> bool:
method = getattr(principal, "has", None)
if callable(method):
@@ -818,6 +918,7 @@ def _record_revision(
idempotency_key: str,
request_sha256: str,
operation: str,
purpose: str,
) -> CaseRecord:
clean_key = _required_text(
idempotency_key,
@@ -858,6 +959,7 @@ def _record_revision(
summary=f"Case {identity.case_number} {operation}.",
occurred_at=record.recorded_at,
actor_id=_principal_actor(principal),
purpose=purpose,
idempotency_key=clean_key,
request_sha256=request_sha256,
audit_event_id=event_id,
@@ -868,12 +970,22 @@ def _record_revision(
"revision": record.revision,
"access_mode": record.access_mode,
"access_grant_count": len(record.access_grants),
"purpose": purpose,
"change_reason": record.change_reason,
},
)
session.add_all((row, timeline))
session.flush()
_sync_access_grants(session, principal, record)
_sync_access_grants(
session,
principal,
record,
purpose=purpose,
ensure_custodian=(
record.access_mode == "restricted"
and (current is None or current.access_mode != "restricted")
),
)
emit_platform_event(
session,
PlatformEvent(
@@ -1106,6 +1218,7 @@ def _timeline_payload(row: CaseTimelineEntry) -> dict[str, object]:
"summary": row.summary,
"occurred_at": _datetime_text(row.occurred_at),
"actor_id": row.actor_id,
"purpose": row.purpose,
"audit_event_id": row.audit_event_id,
"payload": dict(row.payload or {}),
}
@@ -26,6 +26,8 @@ class CaseIntakePlan:
result_refs: tuple[str, ...] = ()
required_evidence_types: tuple[str, ...] = ()
deadline_refs: tuple[str, ...] = ()
remedy_refs: tuple[str, ...] = ()
service_level_refs: tuple[str, ...] = ()
class CaseServiceIntake:
@@ -91,6 +93,8 @@ class CaseServiceIntake:
result_refs=_binding_refs(definition, "result"),
required_evidence_types=definition.required_evidence_types,
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"]
+105 -5
View File
@@ -324,13 +324,66 @@ class CaseLifecycleTests(unittest.TestCase):
outsider = Principal(account_id="account-2")
self.assertIsNone(get_case(self.session, outsider, case_id="case-1"))
self.assertEqual(0, list_cases(self.session, outsider)[1])
self.assertEqual(
0,
list_cases(
self.session,
outsider,
purpose="cases.casework",
)[1],
)
self.assertEqual((), case_history(self.session, outsider, case_id="case-1"))
self.assertEqual((), case_timeline(self.session, outsider, case_id="case-1"))
reader = Principal(account_id="account-3", group_ids=("reviewers",))
self.assertIsNotNone(get_case(self.session, reader, case_id="case-1"))
self.assertEqual(1, list_cases(self.session, reader)[1])
self.assertIsNone(get_case(self.session, reader, case_id="case-1"))
self.assertIsNone(
get_case(
self.session,
reader,
case_id="case-1",
purpose="cases.unrelated",
)
)
self.assertIsNotNone(
get_case(
self.session,
reader,
case_id="case-1",
purpose="cases.casework",
)
)
administrator = Principal(
account_id="account-6",
scopes=frozenset({"cases:case:admin"}),
)
self.assertIsNone(
get_case(
self.session,
administrator,
case_id="case-1",
purpose="cases.casework",
)
)
self.assertEqual(
1,
list_cases(
self.session,
reader,
purpose="cases.casework",
)[1],
)
self.assertEqual(
1,
len(
case_history(
self.session,
reader,
case_id="case-1",
purpose="cases.casework",
)
),
)
with self.assertRaises(PermissionError):
update_case(
self.session,
@@ -396,9 +449,56 @@ class CaseLifecycleTests(unittest.TestCase):
self.session.commit()
self.assertEqual(3, shared.revision)
self.assertIsNotNone(
get_case(self.session, Principal(account_id="account-5"), case_id="case-1")
get_case(
self.session,
Principal(account_id="account-5"),
case_id="case-1",
purpose="cases.casework",
)
)
self.assertEqual(
3,
len(
case_history(
self.session,
self.principal,
case_id="case-1",
purpose="cases.casework",
)
),
)
revoked = update_case(
self.session,
self.principal,
case_id="case-1",
expected_revision=3,
changes={"access_grants": []},
recorded_at=NOW + timedelta(minutes=3),
change_reason="Removed direct collaboration access.",
idempotency_key="case-share-revoke",
purpose="cases.casework",
)
self.session.commit()
self.assertEqual(4, revoked.revision)
self.assertIsNone(
get_case(
self.session,
Principal(account_id="account-5"),
case_id="case-1",
revision=2,
purpose="cases.casework",
)
)
self.assertEqual(
"cases.casework",
case_timeline(
self.session,
self.principal,
case_id="case-1",
purpose="cases.casework",
)[0]["purpose"],
)
self.assertEqual(3, len(case_history(self.session, self.principal, case_id="case-1")))
def test_malformed_access_grant_is_rejected(self) -> None:
create_case(
+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()
+479
View File
@@ -0,0 +1,479 @@
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"])
self.assertEqual(
["cases.casework"],
account_records[0].data["allowed_purposes"],
)
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_access_grant",
"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()
+36 -3
View File
@@ -10,6 +10,14 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
class CasesInterfaceDocumentationContractTests(unittest.TestCase):
def test_all_static_topics_have_complete_german_content(self) -> None:
for topic in get_manifest().documentation:
german = (topic.translations or {}).get("de", {})
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
self.assertTrue(
all(str(value).strip() for value in german.values()), topic.id
)
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
frontend = get_manifest().frontend
self.assertIsNotNone(frontend)
@@ -26,6 +34,7 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
"cases.detail.timeline",
"cases.detail.history",
"cases.detail.access",
"cases.detail.decision",
},
set(surfaces),
)
@@ -37,6 +46,7 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
"cases.detail.timeline",
"cases.detail.history",
"cases.detail.access",
"cases.detail.decision",
):
self.assertEqual("cases.detail", surfaces[surface_id].parent_id)
@@ -44,17 +54,37 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
topics = {topic.id: topic for topic in get_manifest().documentation}
context = topics["cases.institutional-context"]
reference = topics["cases.reference.lifecycle-access-and-evidence"]
owner_links = topics["cases.workflow.link-owner-references"]
self.assertIn("cases.state.read-only", context.metadata["help_contexts"])
self.assertIn("cases.field.access-grant", reference.metadata["help_contexts"])
self.assertIn("update_case", reference.metadata["consequence_classes"])
self.assertIn("close_case", reference.metadata["consequence_classes"])
self.assertIn("change_access", reference.metadata["consequence_classes"])
self.assertEqual("reference", reference.metadata["kind"])
self.assertIn(
"link_owner_reference",
owner_links.metadata["consequence_classes"],
)
def test_information_governance_claims_are_bounded_and_documented(self) -> None:
manifest = get_manifest()
governance = manifest.information_governance
self.assertEqual("enforced", governance.temporal_browsing.adoption)
self.assertEqual("enforced", governance.purpose_aware_access.adoption)
self.assertEqual("enforced", governance.institutional_context.adoption)
self.assertEqual("contract_only", governance.retention.adoption)
self.assertIn("legal holds", governance.retention.limitation or "")
self.assertTrue(governance.current_authorization_for_historical_reads)
topics = {topic.id: topic for topic in manifest.documentation}
purpose = topics["cases.governance.purpose-bound-access"]
self.assertIn("cases.field.access-purpose", purpose.metadata["help_contexts"])
self.assertIn("cases.field.allowed-purposes", purpose.metadata["help_contexts"])
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
list_page = (
REPO_ROOT / "webui/src/features/cases/CasesPage.tsx"
).read_text(encoding="utf-8")
list_page = (REPO_ROOT / "webui/src/features/cases/CasesPage.tsx").read_text(
encoding="utf-8"
)
detail_page = (
REPO_ROOT / "webui/src/features/cases/CaseDetailPage.tsx"
).read_text(encoding="utf-8")
@@ -68,6 +98,9 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
"DocumentationHelpLink",
"FormField",
"useUnsavedDraftGuard",
"QUICK_ACCESS_RESULT_EVENT",
"linkCaseEvidence",
"unlinkCaseEvidence",
):
self.assertIn(component, detail_page)
for component in (
+15 -1
View File
@@ -40,9 +40,23 @@ class CasesMigrationTests(unittest.TestCase):
for item in inspector.get_columns("case_record_revisions")
},
)
self.assertIn(
"allowed_purposes",
{
item["name"]
for item in inspector.get_columns("case_access_grants")
},
)
self.assertIn(
"purpose",
{
item["name"]
for item in inspector.get_columns("case_timeline_entries")
},
)
with engine.connect() as connection:
self.assertIn(
"f6d3a8b1c4e7",
"a7c4e2f9b1d6",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
+118
View File
@@ -0,0 +1,118 @@
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
) as can_access:
result = CasesRecordSource().resolve(
self.session,
Principal(),
locator=self.locator(),
purpose="preserve decision basis",
)
can_access.assert_called_once_with(
self.session,
unittest.mock.ANY,
case_id="case-1",
permission="read",
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"])
self.assertEqual(
"/cases/case-1?purpose=preserve%20decision%20basis",
result.launch_url,
)
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()
+188
View File
@@ -0,0 +1,188 @@
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 CaseGrant, CaseRecord
from govoplan_cases.backend.search_source import (
SEARCH_ACCESS_PURPOSE,
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",
access_mode="restricted",
access_grants=(
CaseGrant(
"account",
"account-1",
("read",),
(SEARCH_ACCESS_PURPOSE,),
),
),
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"])
self.assertIn("purpose=cases.search", page.documents[0].url)
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",
"version": "0.1.16",
"version": "0.1.24",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/cases.css": "./src/styles/cases.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.16",
"@govoplan/core-webui": "^0.1.30",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+138 -6
View File
@@ -21,6 +21,17 @@ export type EvidenceReference = {
evidence_id: string;
tenant_id: string;
version?: string | null;
source_ref?: string | null;
derived_from?: string[];
};
export type OwnerReference = {
ownerModule: string;
kind: string;
objectId: string;
tenantId: string;
version?: string | null;
path?: string | null;
};
export type CaseRecord = {
@@ -48,11 +59,14 @@ export type CaseRecord = {
};
export type CaseGrant = {
subject_kind: "account" | "identity" | "group" | "role" | "function" | "function_assignment" | "organization_unit" | "service_account";
subject_kind: "account" | "identity" | "membership" | "group" | "role" | "function" | "function_assignment" | "organization_unit" | "service_account" | "delegation";
subject_id: string;
permissions: Array<"read" | "update" | "share" | "admin">;
allowed_purposes: string[];
};
export const DEFAULT_CASE_ACCESS_PURPOSE = "cases.casework";
export type CaseStatusDefinition = {
status_key: string;
label: string;
@@ -90,18 +104,47 @@ export type CaseTimelineEntry = {
event_type: string;
case_revision: number;
summary: string;
purpose: string;
actor_id?: string | null;
occurred_at: string;
audit_event_id?: string | null;
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(
settings: ApiSettings,
options: {
query?: string;
statuses?: string[];
caseTypes?: string[];
purpose?: string;
offset?: number;
limit?: number;
},
@@ -111,6 +154,7 @@ export function listCases(
query: options.query,
status_key: options.statuses,
case_type_key: options.caseTypes,
purpose: options.purpose,
offset: options.offset,
limit: options.limit
}), { signal });
@@ -119,9 +163,10 @@ export function listCases(
export function getCase(
settings: ApiSettings,
caseId: string,
purpose: string,
signal?: AbortSignal
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(settings, `/api/v1/cases/${encodeURIComponent(caseId)}`, { signal });
return apiFetch<CaseRecord>(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}`, { purpose }), { signal });
}
export function listCaseCatalog(
@@ -134,17 +179,57 @@ export function listCaseCatalog(
export function caseHistory(
settings: ApiSettings,
caseId: string,
purpose: string,
signal?: AbortSignal
): Promise<{ revisions: CaseRecord[] }> {
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/history`, { signal });
return apiFetch(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/history`, { purpose }), { signal });
}
export function caseTimeline(
settings: ApiSettings,
caseId: string,
purpose: string,
signal?: AbortSignal
): Promise<{ entries: CaseTimelineEntry[] }> {
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { signal });
return apiFetch(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { purpose }), { signal });
}
export function listCaseDecisions(
settings: ApiSettings,
caseId: string,
purpose: string,
signal?: AbortSignal
): Promise<{ decisions: FormalDecision[] }> {
return apiFetch(
settings,
apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`, { purpose }),
{ 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;
purpose: string;
}
): Promise<CaseDecisionResult> {
return apiFetch(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`,
{
method: "POST",
body: JSON.stringify(payload)
}
);
}
export function updateCase(
@@ -155,6 +240,7 @@ export function updateCase(
recorded_at: string;
change_reason: string;
idempotency_key: string;
purpose: string;
title?: string;
status_key?: string;
access_mode?: "tenant" | "restricted";
@@ -167,14 +253,60 @@ export function updateCase(
});
}
export function linkCaseEvidence(
settings: ApiSettings,
caseId: string,
payload: {
expected_revision: number;
recorded_at: string;
change_reason: string;
idempotency_key: string;
purpose: 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;
purpose: string;
}
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/evidence-links/${encodeURIComponent(evidenceId)}/unlink`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export function caseShareTargetProvider(
settings: ApiSettings,
caseId: string,
targetType: "user" | "group"
targetType: "user" | "group",
purpose: string
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/share-target-options`,
{ target_type: targetType }
{ target_type: targetType, purpose }
);
}
@@ -0,0 +1,212 @@
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,
purpose,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CaseRecord;
purpose: string;
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,
purpose
});
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);
}
+372 -15
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 { useParams } from "react-router";
import {
import { useParams, useSearchParams } from "react-router";
import { ActionToolbar,
ActionBlockerHint,
Button,
DocumentationHelpLink,
@@ -11,23 +11,35 @@ import {
LoadingIndicator,
PageScrollViewport,
StatusBadge,
QUICK_ACCESS_RESULT_EVENT,
hasScope,
usePlatformModuleInstalled,
useGuardedNavigate,
useRegisterActiveObject,
useUnsavedDraftGuard,
type PlatformRouteContext
type PlatformRouteContext,
type QuickAccessLaunchContext,
type QuickAccessResult
} from "@govoplan/core-webui";
import {
DEFAULT_CASE_ACCESS_PURPOSE,
caseHistory,
caseTimeline,
getCase,
linkCaseEvidence,
listCaseDecisions,
listCaseCatalog,
updateCase,
unlinkCaseEvidence,
type CaseCatalog,
type CaseRecord,
type CaseTimelineEntry,
type EvidenceReference,
type FormalDecision,
type InstitutionalReference
} from "../../api/cases";
import CaseShareDialog from "./CaseShareDialog";
import CaseDecisionDialog from "./CaseDecisionDialog";
import {
CASES_DOCUMENTATION,
CASES_FIELDS_DOCUMENTATION,
@@ -37,11 +49,14 @@ import {
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
const { caseId = "" } = useParams();
const [searchParams] = useSearchParams();
const accessPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
const navigate = useGuardedNavigate();
const [record, setRecord] = useState<CaseRecord | null>(null);
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
const [history, setHistory] = useState<CaseRecord[]>([]);
const [timeline, setTimeline] = useState<CaseTimelineEntry[]>([]);
const [decisions, setDecisions] = useState<FormalDecision[]>([]);
const [title, setTitle] = useState("");
const [status, setStatus] = useState("");
const [changeReason, setChangeReason] = useState("");
@@ -49,31 +64,65 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [shareOpen, setShareOpen] = useState(false);
const [decisionOpen, setDecisionOpen] = useState(false);
const [linkingEvidence, setLinkingEvidence] = useState(false);
const idempotencyKey = useRef(crypto.randomUUID());
const canUpdate = hasScope(auth, "cases:case:update");
const canClose = hasScope(auth, "cases:case:close");
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)}?purpose=${encodeURIComponent(accessPurpose)}`
} : null, [accessPurpose, record]);
useRegisterActiveObject(activeCase);
const load = useCallback((signal?: AbortSignal) => {
setLoading(true);
setError("");
return Promise.all([
getCase(settings, caseId, signal),
getCase(settings, caseId, accessPurpose, signal),
listCaseCatalog(settings, signal),
caseHistory(settings, caseId, signal),
caseTimeline(settings, caseId, signal)
caseHistory(settings, caseId, accessPurpose, signal),
caseTimeline(settings, caseId, accessPurpose, signal),
canReadDecisions
? listCaseDecisions(settings, caseId, accessPurpose, signal)
: Promise.resolve({ decisions: [] as FormalDecision[] })
]).
then(([nextRecord, nextCatalog, nextHistory, nextTimeline]) => {
then(([nextRecord, nextCatalog, nextHistory, nextTimeline, nextDecisions]) => {
setRecord(nextRecord);
setCatalog(nextCatalog);
setHistory(nextHistory.revisions);
setTimeline(nextTimeline.entries);
setDecisions(nextDecisions.decisions);
setTitle(nextRecord.title);
setStatus(nextRecord.status_key);
setChangeReason("");
}).
finally(() => setLoading(false));
}, [caseId, settings]);
}, [accessPurpose, canReadDecisions, caseId, settings]);
useEffect(() => {
const controller = new AbortController();
@@ -96,6 +145,70 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
const changed = Boolean(record && (title.trim() !== record.title || status !== record.status_key));
const draftDirty = Boolean(record && canUpdate && (changed || changeReason.trim()));
useEffect(() => {
if (!record || !canUpdate) return;
const receiveReference = (event: Event) => {
const detail = (event as CustomEvent<{
launchContext?: QuickAccessLaunchContext;
result?: QuickAccessResult;
}>).detail;
const sourceCase = detail?.launchContext?.activeObject;
const result = detail?.result;
if (
!sourceCase
|| sourceCase.ownerModule !== "cases"
|| sourceCase.kind !== "case"
|| sourceCase.objectId !== record.reference.object_id
|| sourceCase.tenantId !== record.reference.tenant_id
|| result?.outcome !== "completed"
|| result.action !== "selected"
|| !result.reference
) return;
if (draftDirty) {
setError(
"Save or discard the current Case edits before linking the selected reference."
);
return;
}
const reference = result.reference;
if (!reference.version || !reference.path) {
setError("The selected owner reference is not an exact versioned link.");
return;
}
setLinkingEvidence(true);
setError("");
void linkCaseEvidence(settings, caseId, {
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: `Linked ${reference.ownerModule}.${reference.kind} through Quick Access.`,
idempotency_key: crypto.randomUUID(),
purpose: accessPurpose,
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
);
}, [accessPurpose, canUpdate, caseId, draftDirty, load, record, settings]);
function discardDraft() {
if (!record) return;
setTitle(record.title);
@@ -113,6 +226,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: idempotencyKey.current,
purpose: accessPurpose,
...(title.trim() !== record.title ? { title: title.trim() } : {}),
...(status !== record.status_key ? { status_key: status } : {})
});
@@ -137,6 +251,37 @@ 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(),
purpose: accessPurpose
}
);
setRecord(saved);
setTitle(saved.title);
setStatus(saved.status_key);
await load();
} catch (reason) {
setError(reason instanceof Error
? reason.message
: "The owner reference could not be removed from this Case.");
} finally {
setLinkingEvidence(false);
}
}
useUnsavedDraftGuard({
dirty: draftDirty,
title: "i18n:govoplan-cases.unsaved_title",
@@ -156,21 +301,42 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
return (
<main className="cases-page">
<div className="case-detail-shell">
<div className="case-detail-toolbar">
<button type="button" className="btn btn-ghost" onClick={() => navigate("/cases")}>
<ActionToolbar className="case-detail-toolbar">
<button type="button" className="btn btn-ghost" onClick={() => navigate(`/cases?purpose=${encodeURIComponent(accessPurpose)}`)}>
<ArrowLeft size={16} aria-hidden="true" />
Cases
</button>
{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"
icon={<Share2 size={16} />}
className="case-share-button"
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
onClick={() => setShareOpen(true)}
/> : null}
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
helpContextId="cases.detail.access"
/>
</div>
) : null}
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
</ActionToolbar>
<PageScrollViewport className="case-detail-viewport">
{error &&
<DismissibleAlert tone="danger" resetKey={error}>
@@ -245,12 +411,37 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<Fact label="Opened" value={formatDateTime(record.opened_at)} />
<Fact label="Deadline" value={formatDateTime(record.deadline_at)} />
<Fact label="Revision" value={String(record.revision)} />
<Fact label="Access purpose" value={accessPurpose} />
<Fact label="Access mode" value={humanize(record.access_mode)} />
<Fact label="Last change" value={record.change_reason} />
</div>
<ReferenceSection title="Parties" references={record.party_refs} />
<ReferenceSection title="Assignments" references={record.assignment_refs} />
<EvidenceSection
references={record.evidence_refs}
providers={{
files: filesAvailable,
mail: mailAvailable,
campaigns: campaignsAvailable
}}
canUnlink={canUpdate && !draftDirty && !linkingEvidence}
onUnlink={(evidence) => void unlinkEvidence(evidence)}
/>
{canReadDecisions ? (
<DecisionSection
decisions={decisions}
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="Records" references={record.record_refs} />
</section>
@@ -262,6 +453,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<li key={entry.event_id}>
<strong>{humanize(entry.event_type)}</strong>
<span>{entry.summary}</span>
<span>{entry.purpose}</span>
<time>{formatDateTime(entry.occurred_at)}</time>
</li>
)}
@@ -288,6 +480,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<CaseShareDialog
settings={settings}
record={record}
purpose={accessPurpose}
open={shareOpen}
onClose={() => setShareOpen(false)}
onSaved={(saved) => {
@@ -298,6 +491,25 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
}}
/>
) : null}
{record ? (
<CaseDecisionDialog
settings={settings}
record={record}
purpose={accessPurpose}
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>
);
}
@@ -323,6 +535,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 {
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "-";
}
+42 -9
View File
@@ -33,12 +33,14 @@ type Permission = CaseGrant["permissions"][number];
export default function CaseShareDialog({
settings,
record,
purpose,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CaseRecord;
purpose: string;
open: boolean;
onClose: () => void;
onSaved: (record: CaseRecord) => void;
@@ -48,6 +50,7 @@ export default function CaseShareDialog({
const [targetType, setTargetType] = useState<TargetType>("user");
const [targetId, setTargetId] = useState("");
const [permission, setPermission] = useState<Permission>("read");
const [allowedPurposes, setAllowedPurposes] = useState(purpose);
const [changeReason, setChangeReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -55,8 +58,8 @@ export default function CaseShareDialog({
const idempotencyKey = useRef(crypto.randomUUID());
const { requestDiscard } = useUnsavedChanges();
const targetProvider = useMemo(
() => caseShareTargetProvider(settings, record.reference.object_id, targetType),
[record.reference.object_id, settings, targetType]
() => caseShareTargetProvider(settings, record.reference.object_id, targetType, purpose),
[purpose, record.reference.object_id, settings, targetType]
);
useEffect(() => {
@@ -66,11 +69,12 @@ export default function CaseShareDialog({
setTargetType("user");
setTargetId("");
setPermission("read");
setAllowedPurposes(purpose);
setChangeReason("");
setError("");
setConfirmOpen(false);
idempotencyKey.current = crypto.randomUUID();
}, [open, record]);
}, [open, purpose, record]);
const changed = restricted !== (record.access_mode === "restricted")
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
@@ -82,13 +86,15 @@ export default function CaseShareDialog({
setTargetType("user");
setTargetId("");
setPermission("read");
setAllowedPurposes(purpose);
setChangeReason("");
setError("");
}
function addGrant() {
const subjectId = targetId.trim();
if (!subjectId) return;
const purposes = splitPurposes(allowedPurposes);
if (!subjectId || purposes.length === 0) return;
const subjectKind = targetType === "user" ? "account" : "group";
setGrants((current) => [
...current.filter(
@@ -97,7 +103,8 @@ export default function CaseShareDialog({
{
subject_kind: subjectKind,
subject_id: subjectId,
permissions: [permission]
permissions: [permission],
allowed_purposes: purposes
}
]);
setTargetId("");
@@ -113,6 +120,7 @@ export default function CaseShareDialog({
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: idempotencyKey.current,
purpose,
access_mode: restricted ? "restricted" : "tenant",
access_grants: grants
});
@@ -169,6 +177,7 @@ export default function CaseShareDialog({
closeDisabled={busy}
portal
className="case-share-dialog"
helpContextId="cases.detail.access"
footer={
<>
<Button disabled={busy} onClick={close}>Cancel</Button>
@@ -176,6 +185,7 @@ export default function CaseShareDialog({
variant="primary"
disabledReason={saveDisabledReason}
onClick={() => setConfirmOpen(true)}
helpContextId="cases.detail.access"
>
{busy ? "Saving" : "Save access"}
</Button>
@@ -196,9 +206,9 @@ export default function CaseShareDialog({
onChange={setRestricted}
/>
<p className="case-share-explanation">
Tenant cases follow the Cases read permission. Restricted cases are visible only to
their creator, case administrators, assigned functions or units, and the explicit
grants below.
Tenant cases follow the Cases read permission. Restricted cases require a current
grant whose permission and exact allowed purpose both match the attempted action.
The creating account receives a purpose-bound custodian grant.
</p>
<div className="case-share-add-row">
@@ -237,11 +247,19 @@ export default function CaseShareDialog({
<option value="admin">Administer</option>
</select>
</FormField>
<FormField label="Allowed purposes" helpContextId="cases.field.allowed-purposes" documentation={CASES_FIELDS_DOCUMENTATION}>
<input
value={allowedPurposes}
disabled={busy}
onChange={(event) => setAllowedPurposes(event.target.value)}
placeholder="cases.casework, cases.search"
/>
</FormField>
<IconButton
label="Add access grant"
icon={<Plus size={16} />}
variant="primary"
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : undefined}
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : splitPurposes(allowedPurposes).length === 0 ? "Declare at least one allowed purpose." : undefined}
onClick={addGrant}
/>
</div>
@@ -273,11 +291,22 @@ export default function CaseShareDialog({
<option value="share">Share</option>
<option value="admin">Administer</option>
</select>
<input
aria-label={`Allowed purposes for ${grant.subject_id}`}
value={grant.allowed_purposes.join(", ")}
disabled={busy}
onChange={(event) => setGrants((current) => current.map((item) =>
item === grant
? { ...item, allowed_purposes: splitPurposes(event.target.value) }
: item
))}
/>
<IconButton
label={`Remove access for ${grant.subject_id}`}
icon={<Trash2 size={16} />}
variant="danger"
disabled={busy}
helpContextId="cases.detail.access"
onClick={() => setGrants((current) => current.filter((item) => item !== grant))}
/>
</div>
@@ -317,3 +346,7 @@ export default function CaseShareDialog({
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function splitPurposes(value: string): string[] {
return Array.from(new Set(value.split(/[\n,]+/).map((item) => item.trim()).filter(Boolean)));
}
+48 -25
View File
@@ -1,17 +1,25 @@
import { Search } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
Button,
import { useSearchParams } from "react-router";
import { Button,
DocumentationHelpLink,
DismissibleAlert,
FilterBar,
LoadingIndicator,
PageScrollViewport,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
i18nMessage,
useGuardedNavigate,
WorkspaceActionBar,
WorkspaceFrame,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
DEFAULT_CASE_ACCESS_PURPOSE,
listCaseCatalog,
listCases,
type CaseCatalog,
@@ -22,14 +30,19 @@ import { CASES_DOCUMENTATION, CASES_I18N } from "./interfacePatterns";
export default function CasesPage({ settings }: PlatformRouteContext) {
const navigate = useGuardedNavigate();
const [searchParams] = useSearchParams();
const initialPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [purpose, setPurpose] = useState(initialPurpose);
const [submittedPurpose, setSubmittedPurpose] = useState(initialPurpose);
const [status, setStatus] = useState("");
const [cases, setCases] = useState<CaseRecord[]>([]);
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
const controller = new AbortController();
@@ -39,6 +52,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
listCases(settings, {
query: submittedQuery,
statuses: status ? [status] : undefined,
purpose: submittedPurpose,
limit: 200
}, controller.signal),
listCaseCatalog(settings, controller.signal)
@@ -55,7 +69,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
}).
finally(() => setLoading(false));
return () => controller.abort();
}, [settings, status, submittedQuery]);
}, [reloadKey, settings, status, submittedPurpose, submittedQuery]);
const statusLabels = useMemo(
() => new Map(catalog.statuses.map((item) => [item.status_key, item])),
@@ -69,13 +83,20 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
function submit(event: FormEvent) {
event.preventDefault();
setSubmittedQuery(query.trim());
setSubmittedPurpose(purpose.trim());
}
return (
<main className="cases-page">
<div className="cases-shell">
<div className="cases-toolbar">
<form className="cases-search" onSubmit={submit}>
<WorkspaceFrame className="cases-shell" label="Cases" interfaceId="cases.catalogue" helpContextId="cases.page.catalogue" helpModuleId="cases">
<WorkspaceActionBar
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" />
<input
value={query}
@@ -83,6 +104,14 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
aria-label="Search cases"
placeholder="Search cases"
/>
<input
value={purpose}
onChange={(event) => setPurpose(event.target.value)}
aria-label="Case access purpose"
placeholder="Case access purpose"
data-help-context-id="cases.field.access-purpose"
required
/>
<Button
type="submit"
variant="primary"
@@ -90,7 +119,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
>
Search
</Button>
</form>
</FilterBar>
<label className="cases-status-filter">
<span>Status</span>
<select value={status} onChange={(event) => setStatus(event.target.value)}>
@@ -101,8 +130,9 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
</select>
</label>
<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">
{error &&
<DismissibleAlert tone="error" onDismiss={() => setError("")}>
@@ -111,36 +141,29 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
}
{loading && <LoadingIndicator label="Loading cases" />}
{!loading && !error && cases.length === 0 &&
<div className="cases-empty">No matching cases.</div>
<StatePanel size="compact" description="No matching cases." />
}
{!loading && cases.length > 0 &&
<div className="cases-list" role="list">
<SelectionList variant="navigation" label="Cases">
{cases.map((item) => {
const statusDefinition = statusLabels.get(item.status_key);
return (
<button
type="button"
className="case-list-row"
role="listitem"
<SelectionListItem
selected={false}
key={item.reference.object_id}
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}`)}>
<div className="case-list-primary">
<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>
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}?purpose=${encodeURIComponent(submittedPurpose)}`)}>
<SelectionListItemContent title={item.title} description={`${item.case_number} · ${typeLabels.get(item.case_type_key) ?? humanize(item.case_type_key)} · ${formatDate(item.deadline_at)}`} />
<StatusBadge
status={statusDefinition?.terminal ? "inactive" : "active"}
label={statusDefinition?.label ?? humanize(item.status_key)}
/>
</button>
</SelectionListItem>
);
})}
</div>
</SelectionList>
}
</PageScrollViewport>
</div>
</WorkspaceFrame>
</main>
);
}
+58 -2
View File
@@ -12,6 +12,8 @@ const en = {
"i18n:govoplan-cases.timeline": "Case timeline",
"i18n:govoplan-cases.history": "Immutable case history",
"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.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.",
@@ -78,7 +80,33 @@ const en = {
"Target": "Target",
"Permission": "Permission",
"Save access": "Save access",
"No explicit access grants.": "No explicit access grants."
"No explicit access grants.": "No explicit access grants.",
"Case access purpose": "Case access purpose",
"Access purpose": "Access purpose",
"Access mode": "Access mode",
"Allowed purposes": "Allowed purposes",
"Declare at least one allowed purpose.": "Declare at least one allowed purpose.",
"Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.": "Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.",
"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;
const de: Record<keyof typeof en, string> = {
@@ -93,6 +121,8 @@ const de: Record<keyof typeof en, string> = {
"i18n:govoplan-cases.timeline": "Vorgangszeitachse",
"i18n:govoplan-cases.history": "Unveränderliche Vorgangshistorie",
"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.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.",
@@ -159,7 +189,33 @@ const de: Record<keyof typeof en, string> = {
"Target": "Ziel",
"Permission": "Berechtigung",
"Save access": "Zugriff speichern",
"No explicit access grants.": "Keine ausdrücklichen Zugriffsfreigaben."
"No explicit access grants.": "Keine ausdrücklichen Zugriffsfreigaben.",
"Case access purpose": "Zugriffszweck für Vorgänge",
"Access purpose": "Zugriffszweck",
"Access mode": "Zugriffsmodus",
"Allowed purposes": "Erlaubte Zwecke",
"Declare at least one allowed purpose.": "Geben Sie mindestens einen erlaubten Zweck an.",
"Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.": "Mandantenweit sichtbare Vorgänge folgen der Cases-Leseberechtigung. Eingeschränkte Vorgänge verlangen eine aktuelle Freigabe, deren Berechtigung und exakter erlaubter Zweck beide zur Aktion passen. Das anlegende Konto erhält eine zweckgebundene Custodian-Freigabe.",
"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 };
+5 -3
View File
@@ -10,7 +10,7 @@ const CaseDetailPage = lazy(() => import("./features/cases/CaseDetailPage"));
export const casesModule: PlatformWebModule = {
id: "cases",
label: "i18n:govoplan-cases.cases",
version: "0.1.8",
version: "0.1.21",
optionalDependencies: [
"access",
"addresses",
@@ -19,7 +19,8 @@ export const casesModule: PlatformWebModule = {
"mandates",
"decisions",
"forms_runtime",
"workflow_engine"
"workflow_engine",
"records"
],
translations: generatedTranslations,
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.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.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-shell,
.case-detail-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.cases-shell,
.case-detail-shell {
display: flex;
flex-direction: column;
background: var(--surface);
}
.cases-toolbar,
.case-detail-toolbar {
display: flex;
align-items: center;
@@ -24,17 +21,7 @@
background: var(--surface-raised);
}
.cases-search {
display: flex;
align-items: center;
gap: 8px;
width: min(560px, 100%);
}
.cases-search input {
min-width: 120px;
flex: 1;
}
.cases-search { flex: 1 1 560px; }
.cases-status-filter {
display: flex;
@@ -52,7 +39,10 @@
margin-left: auto;
}
.case-share-button {
.case-detail-actions {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
@@ -63,58 +53,6 @@
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 {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
@@ -159,7 +97,7 @@
margin-top: 16px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius-compact);
background: var(--surface-raised);
}
@@ -190,7 +128,7 @@
margin-top: 18px;
background: var(--border);
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius-compact);
overflow: hidden;
}
@@ -238,6 +176,15 @@
border-bottom: 1px solid var(--border);
}
.case-evidence-section .case-reference-list > span {
align-items: center;
flex-wrap: wrap;
}
.case-evidence-section .case-reference-list small {
color: var(--text-soft);
}
.case-timeline {
margin: 0;
padding: 0;
@@ -254,17 +201,83 @@
border-left: 2px solid var(--accent);
}
.cases-empty {
padding: 36px 0;
color: var(--text-soft);
text-align: center;
}
.case-share-dialog {
width: min(860px, calc(100vw - 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 {
display: flex;
min-height: 0;
@@ -322,28 +335,11 @@
font-size: 0.76rem;
}
@media (max-width: 860px) {
.cases-toolbar {
align-items: stretch;
flex-direction: column;
}
.cases-search {
width: 100%;
}
@media (max-width: 900px) {
.cases-count {
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 {
grid-template-columns: 1fr;
}
@@ -352,6 +348,10 @@
grid-template-columns: 1fr;
}
.case-detail-toolbar {
flex-wrap: wrap;
}
.case-share-add-row .icon-button {
justify-self: end;
}