feat: implement governed cases workspace

This commit is contained in:
2026-08-01 17:48:24 +02:00
parent 122159de16
commit c7821a5cb0
32 changed files with 5699 additions and 27 deletions
+26 -1
View File
@@ -4,7 +4,7 @@
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
`govoplan-cases` will own formal administrative case records for GovOPlaN.
`govoplan-cases` owns formal administrative case context for GovOPlaN.
The module should stay focused on case identity, lifecycle state, assignments,
deadlines, participants, evidence links, and case-level audit context. It should
@@ -12,4 +12,29 @@ not own forms, files, workflow execution, task queues, templates, payments, or
mail delivery; those are integrated through GovOPlaN capabilities, events,
commands, and DTOs.
The executable backend slice now exposes:
- `cases.service_intake`, which converts one published, effective Service
definition into a case intake plan while retaining the exact service,
mandate, jurisdiction, legal-basis, form, workflow, result, evidence, and
deadline references; and
- `cases.party_context`, which consumes an optional procedure-party provider
or a bounded Cases-only compatibility projection and emits policy-filtered,
frozen contact snapshot references for downstream delivery; and
- `cases.registry` plus `/api/v1/cases`, which persist tenant-local case/status
catalogs, stable case identities, immutable OCC-guarded revisions, stable
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.
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.
See [docs/CONCEPT.md](docs/CONCEPT.md) for the current module concept.
+82 -26
View File
@@ -15,7 +15,8 @@ evidence, decisions, communications, audit events, and retention references.
The module owns:
- case identifiers, references, titles, types, and status
- case parties and role labels such as applicant, assignee, reviewer, and owner
- case-local party references and role labels such as applicant, respondent,
beneficiary, and representative, using a shared Parties contract when present
- case metadata and tags
- due dates, service-level targets, and milestone dates
- links to evidence provided by other modules
@@ -32,6 +33,8 @@ The module does not own:
- work queues and task assignment semantics, owned by tasks
- generated documents, owned by templates/DMS
- appointments, mail, notifications, postbox, payments, or ledger postings
- identity/contact/organization masters, representation powers, formal
institutional decision semantics, mandates, or jurisdiction
## Core Contracts
@@ -40,6 +43,8 @@ The module should integrate through:
- module manifest metadata, route factories, permissions, and migrations
- a `cases.access` or similar case access capability for resource checks
- a `cases.summary` capability for dashboards and cross-module previews
- shared party, mandate, service, and decision reference DTOs when those
providers are available
- events such as `case.created`, `case.updated`, `case.status_changed`,
`case.assigned`, and `case.closed`
- commands such as `cases.open`, `cases.update_status`, `cases.link_evidence`,
@@ -69,33 +74,48 @@ Permit-to-payment MVP:
7. Payments links payment evidence.
8. Audit and records retain the case history.
## MVP Slice
## Implemented MVP Backend
The first implementation should provide:
The persistent backend provides:
- case type registry with a minimal tenant-local configuration
- create/list/read/update case APIs
- status values with a simple configurable catalog
- parties and assignments stored as access subject references
- tenant-local case type and status catalogs with guarded revisions
- create/list/read/update/history/timeline APIs
- stable case identities and immutable OCC-guarded record revisions
- parties stored as stable procedure-party references with a compatibility
path for direct identity/organization references; assignments remain
responsibility references
- evidence links as module/resource references
- case timeline from local events plus linked audit event IDs
- basic WebUI list/detail route
- resource ACL provider for case read/update
- tenant summary provider for dashboard counts
- 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
- deterministic, replay-safe case launch from an exact Service binding
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.
## Permissions
Candidate scopes:
Implemented scopes:
- `cases:case:read`
- `cases:case:create`
- `cases:case:update`
- `cases:case:assign`
- `cases:case:close`
- `cases:case:share`
- `cases:case:admin`
Access decisions should combine tenant permissions, case ownership/assignment,
and explicit case shares when those are introduced.
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.
## Data Model Sketch
@@ -108,6 +128,40 @@ Candidate tables:
- `case_timeline_entries`
- `case_type_definitions`
- `case_status_definitions`
- `case_access_grants`
`case_parties` is a compatibility seed for a Cases-only composition. It must
not grow a second identity/contact master or duplicate effective-dated powers
of representation. Once a shared Parties provider exists, Cases should retain
the provider reference plus the minimal immutable role/evidence snapshot needed
to reconstruct the case.
The implemented headless compatibility path therefore accepts only a stable
subject reference, case-local role, effective interval, permitted/preferred
channels, delivery flag, evidence, and immutable contact snapshot references.
It cannot model representation powers. When `parties.resolver` is available,
Cases uses its effective, tenant-scoped party and representation records and
rejects mismatched procedures or conflicting active role assignments.
Shared Core revision helpers keep corrections, expiry, supersession, and
revocation immutable and OCC-guarded. Representation revocation has its own
effective timestamp so a historical delivery decision can be reconstructed.
`cases.service_intake` consumes a published, effective shared Service
definition. It requires one case binding and preserves the exact Service
version plus its responsible unit/function, Mandate, jurisdictions, legal
bases, required evidence, deadlines, forms, workflows, and result bindings in
a governed intake plan. It does not copy or persist the Service definition.
`cases.service_launcher` is the effect boundary used by Portal. It derives a
stable case ID from tenant, exact Service revision, and idempotency key, creates
the case through the normal registry, and returns that exact case and Service
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.
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.
Evidence links should store only stable module/resource references and display
metadata snapshots. The owning module remains responsible for the real object.
@@ -119,25 +173,27 @@ Initial route contributions:
- `/cases`
- `/cases/:caseId`
The case detail view should expose extension points for linked forms, files,
tasks, workflow state, appointments, documents, communication, payment evidence,
and audit timeline. Extension points must be declarative; no direct UI imports
from sibling modules.
The initial detail view renders the module-owned record, stable references,
history, and timeline. 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.
## Tests
Minimum tests:
The focused suite covers:
- core can start with cases present and sibling modules absent
- creating a case emits a case event and audit event
- case ACL blocks unauthorized read/update
- evidence links accept only module/resource references
- optional modules can contribute detail panels without direct imports
- migration metadata registers through the module manifest
- provider-neutral Service intake and Party resolution
- committed-only lifecycle events with institutional context
- replay conflict detection, stale revision rejection, and immutable history
- tenant isolation, terminal status handling, and server-side filtering
- stable typed assignment/evidence/Decision/record references
- migration, uninstall-guard, ACL, summary, and capability registration
- explicit and assignment-derived case access, including non-disclosure
- exact Service launch, deterministic replay, and conflict behavior
## Open Decisions
- Whether comments belong in cases, tasks, or a collaboration module.
- Whether case type/status catalogs are fully configurable in MVP or seeded.
- How records/legal-hold integration should own retention of closed cases.
- Whether case shares are local to cases or use a generic resource ACL module.
+21
View File
@@ -0,0 +1,21 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-cases"
version = "0.1.8"
description = "GovOPlaN administrative case context module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.8"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_cases = ["py.typed"]
[project.entry-points."govoplan.modules"]
"cases" = "govoplan_cases.backend.manifest:get_manifest"
+5
View File
@@ -0,0 +1,5 @@
"""GovOPlaN Cases module."""
from govoplan_cases.backend.manifest import get_manifest
__all__ = ["get_manifest"]
+1
View File
@@ -0,0 +1 @@
"""Cases backend contracts."""
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from govoplan_core.auth import has_scope
from govoplan_core.core.modules import AccessDecision
class CaseAclProvider:
"""Expose the tenant-level Case permission boundary to generic consumers."""
resource_type = "case"
def can_read(self, principal: object, resource_id: str) -> bool:
del resource_id
return has_scope(principal, "cases:case:read")
def can_write(self, principal: object, resource_id: str) -> bool:
del resource_id
return has_scope(principal, "cases:case:update")
def explain(self, principal: object, resource_id: str) -> AccessDecision:
del resource_id
allowed = self.can_read(principal, "")
return AccessDecision(
allowed=allowed,
reason=None if allowed else "Missing scope: cases:case:read",
requirements=("cases:case:read",),
)
__all__ = ["CaseAclProvider"]
+19
View File
@@ -0,0 +1,19 @@
"""Cases database models."""
from govoplan_cases.backend.db.models import (
CaseAccessGrant,
CaseIdentity,
CaseRecordRevision,
CaseStatusDefinition,
CaseTimelineEntry,
CaseTypeDefinition,
)
__all__ = [
"CaseAccessGrant",
"CaseIdentity",
"CaseRecordRevision",
"CaseStatusDefinition",
"CaseTimelineEntry",
"CaseTypeDefinition",
]
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class CaseTypeDefinition(Base, TimestampMixin):
__tablename__ = "case_type_definitions"
__table_args__ = (
UniqueConstraint("tenant_id", "type_key", name="uq_case_type_tenant_key"),
Index("ix_case_type_catalog", "tenant_id", "active", "label"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
type_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
initial_status_key: Mapped[str] = mapped_column(String(120), nullable=False)
allowed_status_keys: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
class CaseStatusDefinition(Base, TimestampMixin):
__tablename__ = "case_status_definitions"
__table_args__ = (
UniqueConstraint("tenant_id", "status_key", name="uq_case_status_tenant_key"),
Index("ix_case_status_catalog", "tenant_id", "active", "sort_order"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
status_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(255), nullable=False)
category: Mapped[str] = mapped_column(String(30), nullable=False, default="open")
terminal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
class CaseIdentity(Base, TimestampMixin):
__tablename__ = "case_identities"
__table_args__ = (
UniqueConstraint("tenant_id", "case_id", name="uq_case_identity_tenant_id"),
UniqueConstraint("tenant_id", "case_number", name="uq_case_identity_tenant_number"),
Index("ix_case_identity_catalog", "tenant_id", "case_number"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
case_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
case_number: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
class CaseRecordRevision(Base, TimestampMixin):
__tablename__ = "case_record_revisions"
__table_args__ = (
UniqueConstraint("tenant_id", "case_id", "revision", name="uq_case_record_revision"),
Index("ix_case_record_current", "tenant_id", "case_id", "superseded_at"),
Index("ix_case_record_list", "tenant_id", "status_key", "case_type_key", "recorded_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
case_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
identity_id: Mapped[str] = mapped_column(
ForeignKey("case_identities.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("case_record_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
case_type_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
status_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(500), nullable=False)
access_mode: Mapped[str] = mapped_column(
String(30), nullable=False, default="tenant", index=True
)
search_text: Mapped[str] = mapped_column(Text, nullable=False)
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
deadline_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
superseded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
changed_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
class CaseAccessGrant(Base, TimestampMixin):
__tablename__ = "case_access_grants"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"case_id",
"subject_kind",
"subject_id",
"source",
name="uq_case_access_grant_subject",
),
Index(
"ix_case_access_grant_lookup",
"tenant_id",
"case_id",
"active",
"subject_kind",
"subject_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
case_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
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)
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)
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
class CaseTimelineEntry(Base, TimestampMixin):
__tablename__ = "case_timeline_entries"
__table_args__ = (
UniqueConstraint("tenant_id", "event_id", name="uq_case_timeline_event"),
UniqueConstraint("tenant_id", "idempotency_key", name="uq_case_timeline_idempotency"),
Index("ix_case_timeline_case", "tenant_id", "case_id", "occurred_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
case_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
case_revision: Mapped[int] = mapped_column(Integer, nullable=False)
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)
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)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
__all__ = [
"CaseAccessGrant",
"CaseIdentity",
"CaseRecordRevision",
"CaseStatusDefinition",
"CaseTimelineEntry",
"CaseTypeDefinition",
]
+364
View File
@@ -0,0 +1,364 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from govoplan_core.core.institutional import (
EvidenceReference,
GovernedContextEnvelope,
InstitutionalContextError,
InstitutionalReference,
)
CASE_ACCESS_SUBJECT_KINDS = frozenset(
{
"account",
"identity",
"group",
"role",
"function",
"function_assignment",
"organization_unit",
"service_account",
}
)
CASE_ACCESS_PERMISSIONS = frozenset({"read", "update", "share", "admin"})
@dataclass(frozen=True, slots=True)
class CaseGrant:
subject_kind: str
subject_id: str
permissions: tuple[str, ...] = ("read",)
def __post_init__(self) -> None:
if self.subject_kind not in CASE_ACCESS_SUBJECT_KINDS:
raise InstitutionalContextError("Case access subject kind is invalid.")
if not self.subject_id.strip():
raise InstitutionalContextError("Case access subject id is required.")
normalized = tuple(dict.fromkeys(self.permissions))
if not normalized or set(normalized) - CASE_ACCESS_PERMISSIONS:
raise InstitutionalContextError("Case access permissions are invalid.")
object.__setattr__(self, "permissions", normalized)
def to_dict(self) -> dict[str, object]:
return {
"subject_kind": self.subject_kind,
"subject_id": self.subject_id,
"permissions": list(self.permissions),
}
@classmethod
def from_mapping(cls, value: Mapping[str, object]) -> "CaseGrant":
raw_permissions = value.get("permissions", ["read"])
if not isinstance(raw_permissions, (list, tuple)):
raise InstitutionalContextError(
"Case access permissions 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),
)
@dataclass(frozen=True, slots=True)
class CaseRecord:
reference: InstitutionalReference
case_number: str
case_type_key: str
status_key: str
title: str
context: GovernedContextEnvelope
opened_at: datetime
recorded_at: datetime
change_reason: str
access_mode: str = "tenant"
access_grants: tuple[CaseGrant, ...] = ()
service_ref: InstitutionalReference | None = None
party_refs: tuple[InstitutionalReference, ...] = ()
assignment_refs: tuple[InstitutionalReference, ...] = ()
evidence_refs: tuple[EvidenceReference, ...] = ()
decision_refs: tuple[InstitutionalReference, ...] = ()
record_refs: tuple[InstitutionalReference, ...] = ()
deadline_at: datetime | None = None
closed_at: datetime | None = None
metadata: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.reference.kind != "case" or self.reference.owner_module != "cases":
raise InstitutionalContextError(
"Case records require a Cases-owned case reference."
)
if not self.reference.version or not self.reference.version.isdigit():
raise InstitutionalContextError(
"Case references require a positive integer revision."
)
if int(self.reference.version) < 1:
raise InstitutionalContextError(
"Case references require a positive integer revision."
)
for value, label in (
(self.case_number, "Case number"),
(self.case_type_key, "Case type"),
(self.status_key, "Case status"),
(self.title, "Case title"),
(self.change_reason, "Case change reason"),
):
if not value.strip():
raise InstitutionalContextError(f"{label} is required.")
if self.access_mode not in {"tenant", "restricted"}:
raise InstitutionalContextError("Unsupported case access mode.")
grant_keys = {
(item.subject_kind, item.subject_id) for item in self.access_grants
}
if len(grant_keys) != len(self.access_grants):
raise InstitutionalContextError("Case access grants must be unique.")
for value, label in (
(self.opened_at, "Case opened_at"),
(self.recorded_at, "Case recorded_at"),
(self.deadline_at, "Case deadline_at"),
(self.closed_at, "Case closed_at"),
):
_require_aware(value, label)
if self.closed_at is not None and self.closed_at < self.opened_at:
raise InstitutionalContextError(
"Case closed_at cannot precede opened_at."
)
tenant_id = self.reference.tenant_id
if self.context.tenant_id != tenant_id:
raise InstitutionalContextError(
"Case institutional context belongs to another tenant."
)
if self.context.case_ref is not None and not _same_object(
self.context.case_ref,
self.reference,
):
raise InstitutionalContextError(
"Case institutional context references another case."
)
if self.service_ref is not None and self.service_ref.kind != "service":
raise InstitutionalContextError(
"Case service_ref must identify an institutional Service."
)
_validate_references(
tenant_id,
self.party_refs,
expected_kinds={"party"},
label="Case parties",
)
_validate_references(
tenant_id,
self.assignment_refs,
expected_kinds={
"function",
"function_assignment",
"organization_unit",
"work_item",
},
label="Case assignments",
)
_validate_references(
tenant_id,
self.decision_refs,
expected_kinds={"decision"},
label="Case decisions",
)
_validate_references(
tenant_id,
self.record_refs,
expected_kinds={"record"},
label="Case records",
)
references = (
(self.service_ref,) if self.service_ref is not None else ()
)
if any(item.tenant_id != tenant_id for item in references):
raise InstitutionalContextError(
"Case references cannot cross tenants."
)
if any(item.tenant_id != tenant_id for item in self.evidence_refs):
raise InstitutionalContextError(
"Case evidence cannot cross tenants."
)
if len(self.metadata) > 100:
raise InstitutionalContextError(
"Case metadata is limited to 100 entries."
)
@property
def revision(self) -> int:
return int(self.reference.version or "0")
def to_dict(self) -> dict[str, Any]:
return {
"reference": self.reference.to_dict(),
"revision": self.revision,
"case_number": self.case_number,
"case_type_key": self.case_type_key,
"status_key": self.status_key,
"title": self.title,
"access_mode": self.access_mode,
"access_grants": [item.to_dict() for item in self.access_grants],
"context": self.context.to_dict(),
"service_ref": self.service_ref.to_dict() if self.service_ref else None,
"party_refs": [item.to_dict() for item in self.party_refs],
"assignment_refs": [item.to_dict() for item in self.assignment_refs],
"evidence_refs": [item.to_dict() for item in self.evidence_refs],
"decision_refs": [item.to_dict() for item in self.decision_refs],
"record_refs": [item.to_dict() for item in self.record_refs],
"opened_at": self.opened_at.isoformat(),
"recorded_at": self.recorded_at.isoformat(),
"deadline_at": self.deadline_at.isoformat() if self.deadline_at else None,
"closed_at": self.closed_at.isoformat() if self.closed_at else None,
"change_reason": self.change_reason,
"metadata": dict(self.metadata),
}
@classmethod
def from_mapping(cls, value: Mapping[str, object]) -> "CaseRecord":
reference = _mapping(value, "reference")
context = _mapping(value, "context")
service_ref = value.get("service_ref")
metadata = value.get("metadata")
if metadata is None:
metadata = {}
if not isinstance(metadata, Mapping):
raise InstitutionalContextError("Case metadata must be an object.")
return cls(
reference=InstitutionalReference.from_mapping(reference),
case_number=_text(value, "case_number"),
case_type_key=_text(value, "case_type_key"),
status_key=_text(value, "status_key"),
title=_text(value, "title"),
access_mode=str(value.get("access_mode") or "tenant"),
access_grants=tuple(
CaseGrant.from_mapping(item)
for item in _items(value.get("access_grants"), "Case access grants")
),
context=GovernedContextEnvelope.from_mapping(context),
service_ref=(
InstitutionalReference.from_mapping(service_ref)
if isinstance(service_ref, Mapping)
else None
),
party_refs=_institutional_references(value.get("party_refs")),
assignment_refs=_institutional_references(
value.get("assignment_refs")
),
evidence_refs=_evidence_references(value.get("evidence_refs")),
decision_refs=_institutional_references(value.get("decision_refs")),
record_refs=_institutional_references(value.get("record_refs")),
opened_at=_datetime(value, "opened_at"),
recorded_at=_datetime(value, "recorded_at"),
deadline_at=_optional_datetime(value.get("deadline_at")),
closed_at=_optional_datetime(value.get("closed_at")),
change_reason=_text(value, "change_reason"),
metadata=dict(metadata),
)
def _validate_references(
tenant_id: str,
references: tuple[InstitutionalReference, ...],
*,
expected_kinds: set[str],
label: str,
) -> None:
if any(item.tenant_id != tenant_id for item in references):
raise InstitutionalContextError(f"{label} cannot cross tenants.")
invalid = {item.kind for item in references} - expected_kinds
if invalid:
raise InstitutionalContextError(
f"{label} contain unsupported reference kinds: "
+ ", ".join(sorted(invalid))
)
def _same_object(
left: InstitutionalReference,
right: InstitutionalReference,
) -> bool:
return (
left.kind,
left.owner_module,
left.object_id,
left.tenant_id,
) == (
right.kind,
right.owner_module,
right.object_id,
right.tenant_id,
)
def _require_aware(value: datetime | None, label: str) -> None:
if value is not None and (value.tzinfo is None or value.utcoffset() is None):
raise InstitutionalContextError(f"{label} must include a timezone.")
def _mapping(value: Mapping[str, object], key: str) -> Mapping[str, object]:
item = value.get(key)
if not isinstance(item, Mapping):
raise InstitutionalContextError(f"Case {key} must be an object.")
return item
def _text(value: Mapping[str, object], key: str) -> str:
item = str(value.get(key) or "").strip()
if not item:
raise InstitutionalContextError(f"Case {key} is required.")
return item
def _datetime(value: Mapping[str, object], key: str) -> datetime:
item = _optional_datetime(value.get(key))
if item is None:
raise InstitutionalContextError(f"Case {key} is required.")
return item
def _optional_datetime(value: object) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
result = value
else:
try:
result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError as exc:
raise InstitutionalContextError("Case timestamp is invalid.") from exc
_require_aware(result, "Case timestamp")
return result
def _items(value: object, label: str) -> tuple[Mapping[str, object], ...]:
if value is None:
return ()
if not isinstance(value, (list, tuple)) or any(
not isinstance(item, Mapping) for item in value
):
raise InstitutionalContextError(f"{label} must be a list of objects.")
return tuple(value) # type: ignore[return-value]
def _institutional_references(
value: object,
) -> tuple[InstitutionalReference, ...]:
return tuple(
InstitutionalReference.from_mapping(item)
for item in _items(value, "Case references")
)
def _evidence_references(value: object) -> tuple[EvidenceReference, ...]:
return tuple(
EvidenceReference.from_mapping(item)
for item in _items(value, "Case evidence")
)
__all__ = ["CaseGrant", "CaseRecord"]
+357
View File
@@ -0,0 +1,357 @@
from __future__ import annotations
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.modules import (
CapabilityDocumentation,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
ModuleMaturityEvidence,
)
from govoplan_cases.backend.party_context import (
CAPABILITY_CASES_PARTY_CONTEXT,
CasePartyContext,
)
from govoplan_cases.backend.acl import CaseAclProvider
from govoplan_cases.backend.db import models as case_models
from govoplan_cases.backend.service_intake import (
CAPABILITY_CASES_SERVICE_INTAKE,
CaseServiceIntake,
)
from govoplan_cases.backend.service_launcher import (
CAPABILITY_CASES_SERVICE_LAUNCHER,
CaseServiceLauncher,
)
from govoplan_cases.backend.service import (
CAPABILITY_CASES_REGISTRY,
SqlCaseRegistry,
)
from govoplan_core.db.base import Base
MODULE_ID = "cases"
MODULE_VERSION = "0.1.8"
READ_SCOPE = "cases:case:read"
CREATE_SCOPE = "cases:case:create"
UPDATE_SCOPE = "cases:case:update"
ASSIGN_SCOPE = "cases:case:assign"
CLOSE_SCOPE = "cases:case:close"
SHARE_SCOPE = "cases:case:share"
ADMIN_SCOPE = "cases:case:admin"
def _party_context(context: ModuleContext) -> CasePartyContext:
return CasePartyContext(context.registry)
def _service_intake(context: ModuleContext) -> CaseServiceIntake:
del context
return CaseServiceIntake()
def _case_registry(context: ModuleContext) -> SqlCaseRegistry:
del context
return SqlCaseRegistry()
def _service_launcher(context: ModuleContext) -> CaseServiceLauncher:
del context
return CaseServiceLauncher()
def _router(context: ModuleContext):
del context
from govoplan_cases.backend.router import router
return router
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Cases",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
total = (
session.query(case_models.CaseIdentity)
.filter(case_models.CaseIdentity.tenant_id == tenant_id)
.count()
)
open_cases = (
session.query(case_models.CaseRecordRevision)
.filter(
case_models.CaseRecordRevision.tenant_id == tenant_id,
case_models.CaseRecordRevision.superseded_at.is_(None),
case_models.CaseRecordRevision.closed_at.is_(None),
)
.count()
)
return {"cases": total, "open_cases": open_cases}
manifest = ModuleManifest(
id=MODULE_ID,
name="Cases",
version=MODULE_VERSION,
optional_dependencies=(
"access",
"addresses",
"services",
"parties",
"mandates",
"decisions",
"forms_runtime",
"workflow_engine",
),
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."),
),
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),
),
RoleTemplate(
slug="case_reader",
name="Case reader",
description="Read cases and their governed history.",
permissions=(READ_SCOPE,),
),
RoleTemplate(
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),
),
),
route_factory=_router,
nav_items=(
NavItem(
path="/cases",
label="Cases",
icon="briefcase-business",
required_any=(READ_SCOPE,),
order=35,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/cases-webui",
routes=(
FrontendRoute(
path="/cases",
component="CasesPage",
required_any=(READ_SCOPE,),
order=35,
),
FrontendRoute(
path="/cases/:caseId",
component="CaseDetailPage",
required_any=(READ_SCOPE,),
order=36,
),
),
nav_items=(
NavItem(
path="/cases",
label="Cases",
icon="briefcase-business",
required_any=(READ_SCOPE,),
order=35,
),
),
view_surfaces=(
ViewSurface(
id="cases.navigation",
module_id=MODULE_ID,
kind="navigation",
label="Cases navigation",
order=10,
),
ViewSurface(
id="cases.list",
module_id=MODULE_ID,
kind="route",
label="Case list",
order=20,
),
ViewSurface(
id="cases.detail",
module_id=MODULE_ID,
kind="route",
label="Case details",
order=30,
),
),
),
provides_interfaces=(
ModuleInterfaceProvider(name="cases.service_intake", version="0.1.0"),
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"),
),
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),
),
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_documentation={
CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation(
label="Case service intake",
summary="Preserves a governed Service version in a case intake plan.",
contract_version="0.1.0",
),
CAPABILITY_CASES_PARTY_CONTEXT: CapabilityDocumentation(
label="Case party context",
summary="Resolves provider-owned procedure parties or a bounded local compatibility projection.",
contract_version="0.1.0",
),
CAPABILITY_CASES_REGISTRY: CapabilityDocumentation(
label="Case registry",
summary="Persists tenant-scoped case identities, immutable revisions, and lifecycle events.",
contract_version="0.1.0",
),
CAPABILITY_CASES_SERVICE_LAUNCHER: CapabilityDocumentation(
label="Case service launcher",
summary="Starts a replay-safe case from an exact available Service revision.",
contract_version="0.1.0",
),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
case_models.CaseTimelineEntry,
case_models.CaseAccessGrant,
case_models.CaseRecordRevision,
case_models.CaseIdentity,
case_models.CaseTypeDefinition,
case_models.CaseStatusDefinition,
label="Cases",
),
retirement_notes="Destructive retirement requires a database snapshot and removes case identities, immutable revisions, catalogs, and timeline evidence.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
case_models.CaseIdentity,
case_models.CaseRecordRevision,
case_models.CaseTimelineEntry,
case_models.CaseAccessGrant,
case_models.CaseTypeDefinition,
case_models.CaseStatusDefinition,
label="Cases",
),
),
resource_acl_providers=(CaseAclProvider(),),
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
id="cases.institutional-context",
title="Case institutional context",
summary="Cases retain service and party references without taking ownership of institutional definitions or subject masters.",
body=(
"Case types and statuses are tenant configuration. Every create or update writes "
"an immutable OCC-guarded revision and a replay-safe timeline event. Service "
"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."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin"),
links=(
DocumentationLink(
label="Cases concept",
href="govoplan-cases/docs/CONCEPT.md",
kind="repository",
),
),
),
),
architecture=ModuleArchitectureDeclaration(
layer="human_work_procedure",
kind="domain",
maturity="vertical_slice",
evidence=(
ModuleMaturityEvidence(
kind="test",
reference="tests/test_institutional_consumers.py",
summary="Proves versioned service intake and optional procedure-party resolution.",
),
ModuleMaturityEvidence(
kind="documentation",
reference="docs/CONCEPT.md",
summary="Defines Cases ownership and optional institutional providers.",
),
),
known_limits=(
"The first Cases workspace covers list, detail, status/title revision, history, and timeline; richer procedure-specific panels remain module contributions.",
"The compatibility party path deliberately excludes representation lifecycle ownership.",
),
owned_concepts=("case identity", "case lifecycle", "case-local links"),
non_owned_concepts=(
"institutional service definition",
"party subject master",
"representation power lifecycle",
"formal decision lifecycle",
),
reference_packages=("product.service-to-decision",),
documentation=ModuleArchitectureDocumentation(
migration=("docs/CONCEPT.md",),
recovery=("docs/CONCEPT.md",),
security=("docs/CONCEPT.md",),
operations=("docs/CONCEPT.md",),
),
),
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -0,0 +1 @@
"""Cases Alembic revisions."""
@@ -0,0 +1 @@
"""Cases migration revisions."""
@@ -0,0 +1,139 @@
"""v0.1.8 Cases persistent lifecycle baseline.
Revision ID: c7a8e9f0b1d2
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c7a8e9f0b1d2"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"case_type_definitions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("type_key", sa.String(length=120), nullable=False),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("initial_status_key", sa.String(length=120), nullable=False),
sa.Column("allowed_status_keys", sa.JSON(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_type_definitions")),
sa.UniqueConstraint("tenant_id", "type_key", name="uq_case_type_tenant_key"),
)
op.create_index(op.f("ix_case_type_definitions_tenant_id"), "case_type_definitions", ["tenant_id"], unique=False)
op.create_index(op.f("ix_case_type_definitions_type_key"), "case_type_definitions", ["type_key"], unique=False)
op.create_index(op.f("ix_case_type_definitions_active"), "case_type_definitions", ["active"], unique=False)
op.create_index("ix_case_type_catalog", "case_type_definitions", ["tenant_id", "active", "label"], unique=False)
op.create_table(
"case_status_definitions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("status_key", sa.String(length=120), nullable=False),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("category", sa.String(length=30), nullable=False),
sa.Column("terminal", sa.Boolean(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_status_definitions")),
sa.UniqueConstraint("tenant_id", "status_key", name="uq_case_status_tenant_key"),
)
for column in ("tenant_id", "status_key", "terminal", "active"):
op.create_index(op.f(f"ix_case_status_definitions_{column}"), "case_status_definitions", [column], unique=False)
op.create_index("ix_case_status_catalog", "case_status_definitions", ["tenant_id", "active", "sort_order"], unique=False)
op.create_table(
"case_identities",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("case_id", sa.String(length=255), nullable=False),
sa.Column("case_number", sa.String(length=255), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_identities")),
sa.UniqueConstraint("tenant_id", "case_id", name="uq_case_identity_tenant_id"),
sa.UniqueConstraint("tenant_id", "case_number", name="uq_case_identity_tenant_number"),
)
for column in ("tenant_id", "case_id", "case_number", "created_by"):
op.create_index(op.f(f"ix_case_identities_{column}"), "case_identities", [column], unique=False)
op.create_index("ix_case_identity_catalog", "case_identities", ["tenant_id", "case_number"], unique=False)
op.create_table(
"case_record_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("case_id", sa.String(length=255), nullable=False),
sa.Column("identity_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("case_type_key", sa.String(length=120), nullable=False),
sa.Column("status_key", sa.String(length=120), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("search_text", sa.Text(), nullable=False),
sa.Column("opened_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deadline_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("snapshot", sa.JSON(), nullable=False),
sa.Column("changed_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["identity_id"], ["case_identities.id"], name=op.f("fk_case_record_revisions_identity_id_case_identities"), ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["previous_revision_id"], ["case_record_revisions.id"], name=op.f("fk_case_record_revisions_previous_revision_id_case_record_revisions"), ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_record_revisions")),
sa.UniqueConstraint("tenant_id", "case_id", "revision", name="uq_case_record_revision"),
)
for column in ("tenant_id", "case_id", "identity_id", "previous_revision_id", "case_type_key", "status_key", "opened_at", "deadline_at", "closed_at", "recorded_at", "superseded_at", "changed_by"):
op.create_index(op.f(f"ix_case_record_revisions_{column}"), "case_record_revisions", [column], unique=False)
op.create_index("ix_case_record_current", "case_record_revisions", ["tenant_id", "case_id", "superseded_at"], unique=False)
op.create_index("ix_case_record_list", "case_record_revisions", ["tenant_id", "status_key", "case_type_key", "recorded_at"], unique=False)
op.create_table(
"case_timeline_entries",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("case_id", sa.String(length=255), nullable=False),
sa.Column("event_id", sa.String(length=36), nullable=False),
sa.Column("event_type", sa.String(length=120), nullable=False),
sa.Column("case_revision", sa.Integer(), nullable=False),
sa.Column("summary", sa.String(length=500), nullable=False),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("audit_event_id", sa.String(length=255), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_timeline_entries")),
sa.UniqueConstraint("tenant_id", "event_id", name="uq_case_timeline_event"),
sa.UniqueConstraint("tenant_id", "idempotency_key", name="uq_case_timeline_idempotency"),
)
for column in ("tenant_id", "case_id", "event_id", "event_type", "occurred_at", "actor_id", "audit_event_id"):
op.create_index(op.f(f"ix_case_timeline_entries_{column}"), "case_timeline_entries", [column], unique=False)
op.create_index("ix_case_timeline_case", "case_timeline_entries", ["tenant_id", "case_id", "occurred_at"], unique=False)
def downgrade() -> None:
op.drop_table("case_timeline_entries")
op.drop_table("case_record_revisions")
op.drop_table("case_identities")
op.drop_table("case_status_definitions")
op.drop_table("case_type_definitions")
@@ -0,0 +1,87 @@
"""v0.1.14 case object sharing.
Revision ID: f6d3a8b1c4e7
Revises: c7a8e9f0b1d2
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f6d3a8b1c4e7"
down_revision = "c7a8e9f0b1d2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"case_record_revisions",
sa.Column(
"access_mode",
sa.String(length=30),
nullable=False,
server_default="tenant",
),
)
op.create_index(
op.f("ix_case_record_revisions_access_mode"),
"case_record_revisions",
["access_mode"],
unique=False,
)
op.create_table(
"case_access_grants",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("case_id", sa.String(length=255), nullable=False),
sa.Column("subject_kind", sa.String(length=40), nullable=False),
sa.Column("subject_id", sa.String(length=255), nullable=False),
sa.Column("permissions", sa.JSON(), nullable=False),
sa.Column("source", sa.String(length=30), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("source_revision", sa.Integer(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_access_grants")),
sa.UniqueConstraint(
"tenant_id",
"case_id",
"subject_kind",
"subject_id",
"source",
name="uq_case_access_grant_subject",
),
)
for column in (
"tenant_id",
"case_id",
"subject_kind",
"subject_id",
"source",
"active",
"created_by",
):
op.create_index(
op.f(f"ix_case_access_grants_{column}"),
"case_access_grants",
[column],
unique=False,
)
op.create_index(
"ix_case_access_grant_lookup",
"case_access_grants",
["tenant_id", "case_id", "active", "subject_kind", "subject_id"],
unique=False,
)
def downgrade() -> None:
op.drop_table("case_access_grants")
op.drop_index(
op.f("ix_case_record_revisions_access_mode"),
table_name="case_record_revisions",
)
op.drop_column("case_record_revisions", "access_mode")
+255
View File
@@ -0,0 +1,255 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Literal
from govoplan_core.core.institutional import (
CAPABILITY_PARTY_RESOLVER,
EvidenceReference,
InstitutionalContextError,
InstitutionalReference,
PartyRepresentation,
PartyResolver,
PartySubjectReference,
ProcedureParty,
TemporalRevision,
)
CAPABILITY_CASES_PARTY_CONTEXT = "cases.party_context"
CasePartySource = Literal["provider", "compatibility"]
@dataclass(frozen=True, slots=True)
class CasePartyCompatibilityRecord:
party_id: str
role: str
subject: PartySubjectReference
valid_from: datetime
valid_to: datetime | None = None
preferred_channels: tuple[str, ...] = ()
permitted_channels: tuple[str, ...] = ()
delivery_recipient: bool = False
contact_snapshot_refs: tuple[str, ...] = ()
evidence: tuple[EvidenceReference, ...] = ()
@dataclass(frozen=True, slots=True)
class CasePartySet:
case_ref: InstitutionalReference
effective_at: datetime
source: CasePartySource
parties: tuple[ProcedureParty, ...]
@dataclass(frozen=True, slots=True)
class CasePartyDeliveryTarget:
party_ref: InstitutionalReference
subject: PartySubjectReference
role: str
channel: str
contact_snapshot_refs: tuple[str, ...]
represented_party_refs: tuple[InstitutionalReference, ...] = ()
evidence: tuple[EvidenceReference, ...] = ()
class CasePartyContext:
"""Resolve case-local participation without copying subject master data."""
def __init__(self, registry: object | None = None) -> None:
self._registry = registry
def resolve(
self,
session: object,
principal: object,
*,
case_ref: InstitutionalReference,
effective_at: datetime,
compatibility: tuple[CasePartyCompatibilityRecord, ...] = (),
) -> CasePartySet:
if case_ref.kind != "case":
raise InstitutionalContextError(
"Case party resolution requires a case reference."
)
_require_aware(effective_at)
provider = _capability(self._registry, CAPABILITY_PARTY_RESOLVER)
if isinstance(provider, PartyResolver):
candidates = tuple(
provider.list_procedure_parties(
session,
principal,
procedure_ref=case_ref,
effective_at=effective_at,
)
)
source: CasePartySource = "provider"
else:
candidates = tuple(
_compatibility_party(case_ref, item) for item in compatibility
)
source = "compatibility"
effective = tuple(
item
for item in candidates
if item.status == "active" and item.temporal.effective_at(effective_at)
)
for item in effective:
if not _same_object(item.procedure_ref, case_ref):
raise InstitutionalContextError(
"Party provider returned a party for another procedure."
)
keys = tuple(
(
item.role,
item.subject.kind,
item.subject.provider,
item.subject.subject_id,
)
for item in effective
)
if len(keys) != len(set(keys)):
raise InstitutionalContextError(
"Party resolution returned conflicting active role assignments."
)
return CasePartySet(
case_ref=case_ref,
effective_at=effective_at,
source=source,
parties=effective,
)
def delivery_targets(
self,
party_set: CasePartySet,
*,
channel: str,
) -> tuple[CasePartyDeliveryTarget, ...]:
if not channel.strip():
raise InstitutionalContextError("Delivery channel is required.")
targets: list[CasePartyDeliveryTarget] = []
for party in party_set.parties:
if not party.delivery_recipient or channel not in party.permitted_channels:
continue
if not party.contact_snapshot_refs:
raise InstitutionalContextError(
"A case delivery target requires frozen contact snapshots."
)
represented = tuple(
item.represented_party_ref
for item in party.representations
if _representation_effective(
item,
effective_at=party_set.effective_at,
action="receive",
)
)
targets.append(
CasePartyDeliveryTarget(
party_ref=party.reference,
subject=party.subject,
role=party.role,
channel=channel,
contact_snapshot_refs=party.contact_snapshot_refs,
represented_party_refs=represented,
evidence=party.evidence,
)
)
return tuple(targets)
def _compatibility_party(
case_ref: InstitutionalReference,
item: CasePartyCompatibilityRecord,
) -> ProcedureParty:
if item.subject.tenant_id != case_ref.tenant_id:
raise InstitutionalContextError(
"Compatibility party subject belongs to another tenant."
)
return ProcedureParty(
reference=InstitutionalReference(
kind="party",
owner_module="cases",
object_id=item.party_id,
tenant_id=case_ref.tenant_id,
version="compatibility-1",
valid_at=item.valid_from,
),
procedure_ref=case_ref,
role=item.role,
subject=item.subject,
temporal=TemporalRevision(
revision="compatibility-1",
valid_from=item.valid_from,
valid_to=item.valid_to,
recorded_at=item.valid_from,
change_reason="Cases compatibility party projection.",
),
preferred_channels=item.preferred_channels,
permitted_channels=item.permitted_channels,
delivery_recipient=item.delivery_recipient,
contact_snapshot_refs=item.contact_snapshot_refs,
evidence=item.evidence,
)
def _representation_effective(
representation: PartyRepresentation,
*,
effective_at: datetime,
action: str,
) -> bool:
return (
action in representation.permitted_actions
and representation.temporal.effective_at(effective_at)
and (
representation.revoked_at is None
or representation.revoked_at > effective_at
)
)
def _same_object(
left: InstitutionalReference,
right: InstitutionalReference,
) -> bool:
return (
left.kind,
left.owner_module,
left.object_id,
left.tenant_id,
) == (
right.kind,
right.owner_module,
right.object_id,
right.tenant_id,
)
def _require_aware(value: datetime) -> None:
if value.tzinfo is None or value.utcoffset() is None:
raise InstitutionalContextError(
"Case party effective time 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)
__all__ = [
"CAPABILITY_CASES_PARTY_CONTEXT",
"CasePartyCompatibilityRecord",
"CasePartyContext",
"CasePartyDeliveryTarget",
"CasePartySet",
]
+382
View File
@@ -0,0 +1,382 @@
from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.api.v1.schemas import (
ReferenceOptionListResponse,
ReferenceOptionResponse,
)
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.institutional import (
EvidenceReference,
InstitutionalContextError,
InstitutionalReference,
)
from govoplan_core.db.session import get_session
from govoplan_core.core.references import (
access_scope_reference_page,
access_scope_reference_provider_available,
)
from govoplan_core.core.runtime import get_registry
from govoplan_cases.backend.domain import CaseGrant, CaseRecord
from govoplan_cases.backend.manifest import (
ADMIN_SCOPE,
ASSIGN_SCOPE,
CLOSE_SCOPE,
CREATE_SCOPE,
READ_SCOPE,
SHARE_SCOPE,
UPDATE_SCOPE,
)
from govoplan_cases.backend.schemas import (
CaseHistoryResponse,
CaseListResponse,
CaseStatusWriteRequest,
CaseTimelineResponse,
CaseTypeWriteRequest,
CaseUpdateRequest,
CaseWriteRequest,
)
from govoplan_cases.backend.service import (
CaseStoreError,
can_access_case,
case_history,
case_timeline,
create_case,
get_case,
list_case_catalog,
list_cases,
update_case,
upsert_case_status,
upsert_case_type,
)
router = APIRouter(prefix="/cases", tags=["cases"])
def _require(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
def _error(exc: Exception) -> HTTPException:
message = str(exc)
lowered = message.casefold()
if isinstance(exc, LookupError):
code = 404
elif isinstance(exc, PermissionError):
code = 403
elif any(word in lowered for word in ("conflict", "already", "stale")):
code = 409
else:
code = 400
return HTTPException(status_code=code, detail=message)
@router.get("/catalog", response_model=dict[str, list[dict[str, Any]]])
def api_case_catalog(
include_inactive: bool = False,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, list[dict[str, Any]]]:
_require(principal, ADMIN_SCOPE if include_inactive else READ_SCOPE)
return list_case_catalog(
session,
principal,
include_inactive=include_inactive,
)
@router.put("/catalog/statuses/{status_key}", response_model=dict[str, Any])
def api_upsert_case_status(
status_key: str,
payload: CaseStatusWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, ADMIN_SCOPE)
if status_key != payload.status_key:
raise HTTPException(
status_code=400,
detail="Case status path and payload keys must match.",
)
try:
row = upsert_case_status(session, principal, **payload.model_dump())
session.commit()
except (CaseStoreError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return {
"status_key": row.status_key,
"label": row.label,
"category": row.category,
"terminal": row.terminal,
"sort_order": row.sort_order,
"active": row.active,
"revision": row.revision,
}
@router.put("/catalog/types/{type_key}", response_model=dict[str, Any])
def api_upsert_case_type(
type_key: str,
payload: CaseTypeWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, ADMIN_SCOPE)
if type_key != payload.type_key:
raise HTTPException(
status_code=400,
detail="Case type path and payload keys must match.",
)
try:
row = upsert_case_type(session, principal, **payload.model_dump())
session.commit()
except (CaseStoreError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return {
"type_key": row.type_key,
"label": row.label,
"description": row.description,
"initial_status_key": row.initial_status_key,
"allowed_status_keys": list(row.allowed_status_keys or ()),
"active": row.active,
"revision": row.revision,
}
@router.get("", response_model=CaseListResponse)
def api_list_cases(
query: str = "",
status_key: list[str] | None = Query(default=None),
case_type_key: list[str] | None = Query(default=None),
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> CaseListResponse:
_require(principal, READ_SCOPE)
try:
items, total = list_cases(
session,
principal,
query=query,
status_keys=status_key,
case_type_keys=case_type_key,
offset=offset,
limit=limit,
)
except CaseStoreError as exc:
raise _error(exc) from exc
return CaseListResponse(
cases=[item.to_dict() for item in items],
total=total,
offset=offset,
limit=limit,
)
@router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED)
def api_create_case(
payload: CaseWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, CREATE_SCOPE)
try:
record = CaseRecord.from_mapping(payload.record)
if record.access_mode == "restricted" or record.access_grants:
_require(principal, SHARE_SCOPE)
item = create_case(
session,
principal,
record=record,
idempotency_key=payload.idempotency_key,
)
session.commit()
except (CaseStoreError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return item.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),
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)
if item is None:
raise HTTPException(status_code=404, detail="Case not found")
return item.to_dict()
@router.patch("/{case_id}", response_model=dict[str, Any])
def api_update_case(
case_id: str,
payload: CaseUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, UPDATE_SCOPE)
fields = payload.model_fields_set
if "assignment_refs" in fields:
_require(principal, ASSIGN_SCOPE)
if fields & {"access_mode", "access_grants"}:
_require(principal, SHARE_SCOPE)
changes = _update_changes(payload)
if "status_key" in changes:
catalog = list_case_catalog(session, principal)
terminal = {
str(item["status_key"]): bool(item["terminal"])
for item in catalog["statuses"]
}
if terminal.get(str(changes["status_key"]), False):
_require(principal, CLOSE_SCOPE)
try:
item = update_case(
session,
principal,
case_id=case_id,
expected_revision=payload.expected_revision,
changes=changes,
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
idempotency_key=payload.idempotency_key,
)
session.commit()
except (
CaseStoreError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.get(
"/{case_id}/share-target-options",
response_model=ReferenceOptionListResponse,
)
def api_case_share_target_options(
case_id: str,
target_type: Literal["user", "group"],
q: str = "",
selected: list[str] = Query(default=[]),
limit: int = Query(default=50, ge=1, le=200),
cursor: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ReferenceOptionListResponse:
_require(principal, SHARE_SCOPE)
if not can_access_case(
session,
principal,
case_id=case_id,
permission="share",
):
raise HTTPException(status_code=403, detail="Case share access is denied")
try:
page = access_scope_reference_page(
get_registry(),
principal,
scope_type=target_type,
reference_kind="user" if target_type == "user" else "group",
query=q,
selected_values=selected,
limit=limit,
cursor=cursor,
administrative=True,
session=session,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return ReferenceOptionListResponse(
options=[ReferenceOptionResponse(**item.to_dict()) for item in page.options],
provider_available=access_scope_reference_provider_available(get_registry()),
next_cursor=page.next_cursor,
has_more=page.has_more,
)
@router.get("/{case_id}/history", response_model=CaseHistoryResponse)
def api_case_history(
case_id: str,
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> CaseHistoryResponse:
_require(principal, READ_SCOPE)
return CaseHistoryResponse(
revisions=[
item.to_dict()
for item in case_history(
session,
principal,
case_id=case_id,
limit=limit,
)
]
)
@router.get("/{case_id}/timeline", response_model=CaseTimelineResponse)
def api_case_timeline(
case_id: str,
limit: int = Query(default=200, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> CaseTimelineResponse:
_require(principal, READ_SCOPE)
return CaseTimelineResponse(
entries=list(
case_timeline(
session,
principal,
case_id=case_id,
limit=limit,
)
)
)
def _update_changes(payload: CaseUpdateRequest) -> dict[str, object]:
excluded = {
"expected_revision",
"recorded_at",
"change_reason",
"idempotency_key",
}
raw = payload.model_dump(exclude_unset=True, exclude=excluded)
for key in ("party_refs", "assignment_refs", "decision_refs", "record_refs"):
if key in raw:
raw[key] = tuple(
InstitutionalReference.from_mapping(item)
for item in (raw[key] or ())
)
if "access_grants" in raw:
raw["access_grants"] = tuple(
CaseGrant.from_mapping(item) for item in (raw["access_grants"] or ())
)
if "evidence_refs" in raw:
raw["evidence_refs"] = tuple(
EvidenceReference.from_mapping(item)
for item in (raw["evidence_refs"] or ())
)
if "metadata" in raw and raw["metadata"] is None:
raw["metadata"] = {}
return raw
__all__ = ["router"]
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class CaseStatusWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
status_key: str = Field(min_length=1, max_length=120)
label: str = Field(min_length=1, max_length=255)
category: str = Field(default="open", max_length=30)
terminal: bool = False
sort_order: int = Field(default=100, ge=-10_000, le=10_000)
active: bool = True
expected_revision: int | None = Field(default=None, ge=1)
class CaseTypeWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
type_key: str = Field(min_length=1, max_length=120)
label: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4_000)
initial_status_key: str = Field(min_length=1, max_length=120)
allowed_status_keys: list[str] = Field(default_factory=list, max_length=100)
active: bool = True
expected_revision: int | None = Field(default=None, ge=1)
class CaseWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
record: dict[str, Any]
idempotency_key: str = Field(min_length=1, max_length=255)
class CaseGrantRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
subject_kind: Literal[
"account",
"identity",
"group",
"role",
"function",
"function_assignment",
"organization_unit",
"service_account",
]
subject_id: str = Field(min_length=1, max_length=255)
permissions: list[Literal["read", "update", "share", "admin"]] = Field(
default_factory=lambda: ["read"],
min_length=1,
max_length=4,
)
class CaseUpdateRequest(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)
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
access_grants: list[CaseGrantRequest] | None = Field(
default=None,
max_length=500,
)
party_refs: list[dict[str, Any]] | None = Field(default=None, max_length=500)
assignment_refs: list[dict[str, Any]] | None = Field(default=None, max_length=500)
evidence_refs: list[dict[str, Any]] | None = Field(default=None, max_length=1_000)
decision_refs: list[dict[str, Any]] | None = Field(default=None, max_length=500)
record_refs: list[dict[str, Any]] | None = Field(default=None, max_length=500)
deadline_at: datetime | None = None
closed_at: datetime | None = None
metadata: dict[str, Any] | None = None
class CaseListResponse(BaseModel):
cases: list[dict[str, Any]]
total: int
offset: int
limit: int
class CaseHistoryResponse(BaseModel):
revisions: list[dict[str, Any]]
class CaseTimelineResponse(BaseModel):
entries: list[dict[str, Any]]
__all__ = [
"CaseGrantRequest",
"CaseHistoryResponse",
"CaseListResponse",
"CaseStatusWriteRequest",
"CaseTimelineResponse",
"CaseTypeWriteRequest",
"CaseUpdateRequest",
"CaseWriteRequest",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from govoplan_core.core.institutional import (
GovernedContextEnvelope,
InstitutionalContextError,
InstitutionalReference,
ServiceDefinition,
TemporalRevision,
)
CAPABILITY_CASES_SERVICE_INTAKE = "cases.service_intake"
@dataclass(frozen=True, slots=True)
class CaseIntakePlan:
case_ref: InstitutionalReference
service_ref: InstitutionalReference
case_type_ref: str
context: GovernedContextEnvelope
form_refs: tuple[str, ...] = ()
workflow_refs: tuple[str, ...] = ()
result_refs: tuple[str, ...] = ()
required_evidence_types: tuple[str, ...] = ()
deadline_refs: tuple[str, ...] = ()
class CaseServiceIntake:
"""Translate a provider-owned Service definition into a case intake plan."""
def plan(
self,
definition: ServiceDefinition,
*,
case_id: str,
effective_at: datetime,
) -> CaseIntakePlan:
if definition.publication_state != "published":
raise InstitutionalContextError(
"Only a published institutional service can start a case."
)
if not definition.temporal.effective_at(effective_at):
raise InstitutionalContextError(
"The institutional service is not effective at case intake time."
)
case_bindings = tuple(
item for item in definition.bindings if item.kind == "case"
)
if len(case_bindings) != 1:
raise InstitutionalContextError(
"Case intake requires exactly one case binding in the service definition."
)
case_ref = InstitutionalReference(
kind="case",
owner_module="cases",
object_id=case_id,
tenant_id=definition.reference.tenant_id,
version="1",
valid_at=effective_at,
)
temporal = TemporalRevision(
revision="1",
valid_from=effective_at,
recorded_at=effective_at,
change_reason=(
f"Case intake from service {definition.key}@"
f"{definition.temporal.revision}."
),
)
context = GovernedContextEnvelope(
tenant_id=definition.reference.tenant_id,
temporal=temporal,
organization_unit_ref=definition.responsible_organization_ref,
function_ref=definition.responsible_function_ref,
mandate_ref=definition.mandate_ref,
jurisdiction_refs=definition.jurisdiction_refs,
service_ref=definition.reference,
case_ref=case_ref,
legal_bases=definition.legal_bases,
)
return CaseIntakePlan(
case_ref=case_ref,
service_ref=definition.reference,
case_type_ref=case_bindings[0].reference,
context=context,
form_refs=_binding_refs(definition, "form"),
workflow_refs=_binding_refs(definition, "workflow"),
result_refs=_binding_refs(definition, "result"),
required_evidence_types=definition.required_evidence_types,
deadline_refs=definition.deadline_refs,
)
def _binding_refs(
definition: ServiceDefinition,
kind: str,
) -> tuple[str, ...]:
return tuple(item.reference for item in definition.bindings if item.kind == kind)
__all__ = [
"CAPABILITY_CASES_SERVICE_INTAKE",
"CaseIntakePlan",
"CaseServiceIntake",
]
@@ -0,0 +1,156 @@
from __future__ import annotations
from collections.abc import Mapping
import re
from urllib.parse import quote
import uuid
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
InstitutionalContextError,
ServiceDefinition,
ServiceLaunchRequest,
ServiceLaunchResult,
)
from govoplan_cases.backend.service import create_case_from_intake, get_case
from govoplan_cases.backend.service_intake import CaseServiceIntake
CAPABILITY_CASES_SERVICE_LAUNCHER = "cases.service_launcher"
_CASE_LAUNCH_NAMESPACE = uuid.uuid5(
uuid.NAMESPACE_URL,
"https://govoplan.add-ideas.de/contracts/cases/service-launch/v1",
)
class CaseServiceLauncher:
"""Start a replay-safe case from an exact institutional Service revision."""
def launch_service(
self,
session: object,
principal: object,
*,
definition: ServiceDefinition,
request: ServiceLaunchRequest,
) -> ServiceLaunchResult:
if not isinstance(session, Session):
raise InstitutionalContextError(
"Case service launch requires a SQLAlchemy session."
)
if request.service_ref != definition.reference:
raise InstitutionalContextError(
"Case service launch must use the requested exact Service revision."
)
if request.binding.kind != "case" or request.binding not in definition.bindings:
raise InstitutionalContextError(
"Case service launch requires a case binding from the Service definition."
)
case_id = str(
uuid.uuid5(
_CASE_LAUNCH_NAMESPACE,
":".join(
(
definition.reference.tenant_id,
definition.reference.object_id,
definition.reference.version or "",
request.idempotency_key,
)
),
)
)
replayed = get_case(session, principal, case_id=case_id) is not None
plan = CaseServiceIntake().plan(
definition,
case_id=case_id,
effective_at=request.requested_at,
)
title = _parameter_text(
request.parameters,
"title",
default=definition.title,
maximum=500,
)
case_number = _parameter_text(
request.parameters,
"case_number",
default=_default_case_number(definition, case_id),
maximum=255,
)
status_key = _optional_parameter_text(
request.parameters,
"status_key",
maximum=120,
)
item = create_case_from_intake(
session,
principal,
plan=plan,
case_number=case_number,
title=title,
status_key=status_key,
opened_at=request.requested_at,
recorded_at=request.requested_at,
change_reason=(
f"Started from service {definition.key}@"
f"{definition.reference.version}."
),
idempotency_key=request.idempotency_key,
metadata={
"service_launch_binding": request.binding.reference,
"service_launch_capability": CAPABILITY_CASES_SERVICE_LAUNCHER,
},
)
return ServiceLaunchResult(
service_ref=definition.reference,
binding=request.binding,
state="started",
target_ref=item.reference,
href=f"/cases/{quote(item.reference.object_id, safe='')}",
replayed=replayed,
metadata={
"case_number": item.case_number,
"case_revision": item.revision,
},
)
def _default_case_number(definition: ServiceDefinition, case_id: str) -> str:
prefix = re.sub(r"[^A-Za-z0-9]+", "-", definition.key).strip("-")
return f"{(prefix or 'CASE')[:40].upper()}-{case_id[:8].upper()}"
def _parameter_text(
parameters: Mapping[str, object],
key: str,
*,
default: str,
maximum: int,
) -> str:
value = str(parameters.get(key) or default).strip()
if not value or len(value) > maximum:
raise InstitutionalContextError(
f"Case launch parameter {key!r} must contain at most {maximum} characters."
)
return value
def _optional_parameter_text(
parameters: Mapping[str, object],
key: str,
*,
maximum: int,
) -> str | None:
raw = parameters.get(key)
if raw is None:
return None
value = str(raw).strip()
if not value or len(value) > maximum:
raise InstitutionalContextError(
f"Case launch parameter {key!r} must contain at most {maximum} characters."
)
return value
__all__ = ["CAPABILITY_CASES_SERVICE_LAUNCHER", "CaseServiceLauncher"]
+1
View File
@@ -0,0 +1 @@
+473
View File
@@ -0,0 +1,473 @@
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.events import EventBus, event_bus_context
from govoplan_core.core.institutional import (
EvidenceReference,
GovernedContextEnvelope,
InstitutionalReference,
ServiceBinding,
ServiceDefinition,
ServiceLaunchRequest,
TemporalRevision,
)
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.service import (
CaseStoreError,
case_history,
case_timeline,
create_case,
get_case,
list_cases,
update_case,
upsert_case_status,
upsert_case_type,
)
from govoplan_cases.backend.service_launcher import CaseServiceLauncher
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
@dataclass
class Principal:
tenant_id: str = "tenant-1"
account_id: str = "account-1"
scopes: frozenset[str] = frozenset()
group_ids: tuple[str, ...] = ()
function_assignment_ids: tuple[str, ...] = ()
def ref(
kind: str,
object_id: str,
owner: str,
*,
version: str | None = "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 record(*, title: str = "Permit application") -> 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.",
),
service_ref=ref("service", "permit", "services", version="4"),
case_ref=case_ref,
organization_unit_ref=ref(
"organization_unit",
"permits",
"organizations",
),
function_ref=ref("function", "case-worker", "organizations"),
)
return CaseRecord(
reference=case_ref,
case_number="PERMIT-2026-0001",
case_type_key="permit-application",
status_key="intake",
title=title,
context=context,
service_ref=context.service_ref,
party_refs=(ref("party", "applicant", "parties"),),
assignment_refs=(
ref("function_assignment", "assignment-1", "idm"),
),
evidence_refs=(
EvidenceReference(
kind="document",
owner_module="files",
evidence_id="file-1",
tenant_id="tenant-1",
version="3",
captured_at=NOW,
),
),
opened_at=NOW,
recorded_at=NOW,
deadline_at=NOW + timedelta(days=30),
change_reason="Application received.",
)
class CaseLifecycleTests(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="intake",
label="Intake",
)
upsert_case_status(
self.session,
self.principal,
status_key="review",
label="Review",
category="waiting",
)
upsert_case_status(
self.session,
self.principal,
status_key="closed",
label="Closed",
category="closed",
terminal=True,
)
upsert_case_type(
self.session,
self.principal,
type_key="permit-application",
label="Permit application",
initial_status_key="intake",
allowed_status_keys=("intake", "review", "closed"),
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_case_revisions_are_replay_safe_occ_guarded_and_event_backed(self) -> None:
events = []
bus = EventBus()
bus.subscribe("*", events.append)
with event_bus_context(bus):
created = create_case(
self.session,
self.principal,
record=record(),
idempotency_key="case-create-1",
)
self.assertEqual([], events)
self.session.commit()
replay = create_case(
self.session,
self.principal,
record=record(),
idempotency_key="case-create-1",
)
self.assertEqual(1, replay.revision)
with self.assertRaisesRegex(CaseStoreError, "idempotency conflict"):
create_case(
self.session,
self.principal,
record=record(title="Another application"),
idempotency_key="case-create-1",
)
revised = update_case(
self.session,
self.principal,
case_id="case-1",
expected_revision=1,
changes={"status_key": "review", "title": "Reviewed permit"},
recorded_at=NOW + timedelta(minutes=1),
change_reason="Review started.",
idempotency_key="case-update-1",
)
self.session.commit()
self.assertEqual(1, created.revision)
self.assertEqual(2, revised.revision)
self.assertEqual("review", revised.status_key)
self.assertEqual(
["cases.case.created", "cases.case.updated"],
[item.type for item in events],
)
self.assertEqual("case-1", events[-1].institutional_context.case_ref.object_id)
self.assertEqual(2, len(case_history(self.session, self.principal, case_id="case-1")))
timeline = case_timeline(self.session, self.principal, case_id="case-1")
self.assertEqual(2, len(timeline))
self.assertEqual(timeline[0]["event_id"], timeline[0]["audit_event_id"])
with self.assertRaisesRegex(CaseStoreError, "stale"):
update_case(
self.session,
self.principal,
case_id="case-1",
expected_revision=1,
changes={"title": "Stale title"},
recorded_at=NOW + timedelta(minutes=2),
change_reason="Stale update.",
idempotency_key="case-update-stale",
)
def test_terminal_state_sets_closed_time_and_list_is_server_filtered(self) -> None:
create_case(
self.session,
self.principal,
record=record(),
idempotency_key="case-create-2",
)
self.session.commit()
closed = update_case(
self.session,
self.principal,
case_id="case-1",
expected_revision=1,
changes={"status_key": "closed"},
recorded_at=NOW + timedelta(hours=1),
change_reason="Decision became final.",
idempotency_key="case-close-1",
)
self.session.commit()
self.assertEqual(NOW + timedelta(hours=1), closed.closed_at)
items, total = list_cases(
self.session,
self.principal,
status_keys=("closed",),
query="permit-2026",
)
self.assertEqual(1, total)
self.assertEqual("case-1", items[0].reference.object_id)
def test_tenant_boundary_and_catalog_revision_conflicts_fail_closed(self) -> None:
create_case(
self.session,
self.principal,
record=record(),
idempotency_key="case-create-3",
)
self.session.commit()
self.assertIsNone(
get_case(
self.session,
Principal(tenant_id="tenant-2"),
case_id="case-1",
)
)
with self.assertRaisesRegex(CaseStoreError, "stale"):
upsert_case_status(
self.session,
self.principal,
status_key="review",
label="In review",
expected_revision=99,
)
with self.assertRaisesRegex(CaseStoreError, "cannot be deactivated"):
upsert_case_type(
self.session,
self.principal,
type_key="permit-application",
label="Permit application",
initial_status_key="intake",
allowed_status_keys=("intake", "review", "closed"),
active=False,
expected_revision=1,
)
def test_restricted_cases_filter_reads_and_honor_explicit_permissions(self) -> None:
restricted = record()
restricted = CaseRecord.from_mapping(
{
**restricted.to_dict(),
"access_mode": "restricted",
"access_grants": [
CaseGrant(
subject_kind="group",
subject_id="reviewers",
permissions=("read",),
).to_dict()
],
}
)
create_case(
self.session,
self.principal,
record=restricted,
idempotency_key="case-restricted-create",
)
self.session.commit()
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((), 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])
with self.assertRaises(PermissionError):
update_case(
self.session,
reader,
case_id="case-1",
expected_revision=1,
changes={"title": "Unauthorized title"},
recorded_at=NOW + timedelta(minutes=1),
change_reason="Attempted update.",
idempotency_key="case-reader-update",
)
def test_assignment_grants_and_access_revision_are_effective(self) -> None:
restricted = CaseRecord.from_mapping(
{
**record().to_dict(),
"access_mode": "restricted",
}
)
create_case(
self.session,
self.principal,
record=restricted,
idempotency_key="case-assignment-create",
)
self.session.commit()
assignee = Principal(
account_id="account-4",
function_assignment_ids=("assignment-1",),
)
revised = update_case(
self.session,
assignee,
case_id="case-1",
expected_revision=1,
changes={"title": "Assigned case"},
recorded_at=NOW + timedelta(minutes=1),
change_reason="Assigned worker updated the case.",
idempotency_key="case-assignee-update",
)
self.session.commit()
self.assertEqual(2, revised.revision)
shared = update_case(
self.session,
self.principal,
case_id="case-1",
expected_revision=2,
changes={
"access_grants": [
CaseGrant(
subject_kind="account",
subject_id="account-5",
permissions=("read", "update"),
)
]
},
recorded_at=NOW + timedelta(minutes=2),
change_reason="Granted direct collaboration access.",
idempotency_key="case-share-update",
)
self.session.commit()
self.assertEqual(3, shared.revision)
self.assertIsNotNone(
get_case(self.session, Principal(account_id="account-5"), case_id="case-1")
)
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(
self.session,
self.principal,
record=record(),
idempotency_key="case-malformed-create",
)
self.session.commit()
with self.assertRaisesRegex(CaseStoreError, "must be objects"):
update_case(
self.session,
self.principal,
case_id="case-1",
expected_revision=1,
changes={"access_grants": ["account-2"]},
recorded_at=NOW + timedelta(minutes=1),
change_reason="Invalid share.",
idempotency_key="case-malformed-share",
)
def test_exact_service_launch_creates_and_replays_one_case(self) -> None:
definition = ServiceDefinition(
reference=ref("service", "permit", "services", version="4"),
key="permit.apply",
temporal=TemporalRevision(
revision="4",
valid_from=NOW - timedelta(days=1),
recorded_at=NOW - timedelta(days=2),
change_reason="Published permit service.",
),
title="Apply for a permit",
audience=("authenticated",),
bindings=(ServiceBinding("case", "permit-application"),),
publication_state="published",
)
request = ServiceLaunchRequest(
service_ref=definition.reference,
binding=definition.bindings[0],
idempotency_key="portal-launch-1",
requested_at=NOW,
parameters={},
)
first = CaseServiceLauncher().launch_service(
self.session,
self.principal,
definition=definition,
request=request,
)
self.session.commit()
second = CaseServiceLauncher().launch_service(
self.session,
self.principal,
definition=definition,
request=request,
)
self.session.commit()
self.assertEqual(first.target_ref, second.target_ref)
self.assertFalse(first.replayed)
self.assertTrue(second.replayed)
self.assertEqual(1, list_cases(self.session, self.principal)[1])
self.assertEqual("4", get_case(
self.session,
self.principal,
case_id=first.target_ref.object_id,
).service_ref.version)
if __name__ == "__main__":
unittest.main()
+290
View File
@@ -0,0 +1,290 @@
from __future__ import annotations
from dataclasses import replace
from datetime import UTC, datetime, timedelta
import unittest
from govoplan_core.core.institutional import (
CAPABILITY_PARTY_RESOLVER,
EvidenceReference,
InstitutionalContextError,
InstitutionalReference,
LegalBasisReference,
PartyRepresentation,
PartySubjectReference,
ProcedureParty,
ServiceBinding,
ServiceDefinition,
TemporalRevision,
)
from govoplan_cases.backend.manifest import get_manifest
from govoplan_cases.backend.party_context import (
CasePartyCompatibilityRecord,
CasePartyContext,
)
from govoplan_cases.backend.service_intake import CaseServiceIntake
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
def reference(kind: str, object_id: str) -> InstitutionalReference:
owners = {
"organization_unit": "organizations",
"function": "organizations",
"mandate": "committee",
"jurisdiction": "organizations",
"service": "portal",
"case": "cases",
"party": "cases",
}
return InstitutionalReference(
kind=kind, # type: ignore[arg-type]
owner_module=owners[kind],
object_id=object_id,
tenant_id="tenant-1",
version="1",
valid_at=NOW,
)
def evidence() -> EvidenceReference:
return EvidenceReference(
kind="snapshot",
owner_module="addresses",
evidence_id="contact-snapshot-1",
tenant_id="tenant-1",
version="1",
captured_at=NOW,
)
def service() -> ServiceDefinition:
return ServiceDefinition(
reference=replace(reference("service", "permit"), version="4"),
key="permit.apply",
temporal=TemporalRevision(
revision="4",
valid_from=NOW - timedelta(days=1),
valid_to=NOW + timedelta(days=1),
recorded_at=NOW - timedelta(days=2),
),
title="Apply for a permit",
audience=("resident",),
legal_bases=(
LegalBasisReference(
kind="law",
authority="Example legislature",
reference="permit-law:3",
version="2026-01",
),
),
required_evidence_types=("identity", "application"),
deadline_refs=("deadline:permit:90d",),
responsible_organization_ref=reference("organization_unit", "unit-1"),
responsible_function_ref=reference("function", "function-1"),
mandate_ref=reference("mandate", "mandate-1"),
jurisdiction_refs=(reference("jurisdiction", "city-1"),),
bindings=(
ServiceBinding("case", "permit-application"),
ServiceBinding("form", "forms:permit-application"),
ServiceBinding("workflow", "workflow:permit-review"),
ServiceBinding("result", "decision:permit"),
),
publication_state="published",
)
def procedure_party(*, revoked_at: datetime | None = None) -> ProcedureParty:
represented = reference("party", "applicant")
representative = reference("party", "representative")
return ProcedureParty(
reference=representative,
procedure_ref=reference("case", "case-1"),
role="representative",
subject=PartySubjectReference(
kind="identity",
provider="identity",
subject_id="identity-2",
tenant_id="tenant-1",
version="3",
),
temporal=TemporalRevision(
revision="2",
valid_from=NOW - timedelta(days=2),
recorded_at=NOW - timedelta(days=2),
),
preferred_channels=("postbox",),
permitted_channels=("postbox", "mail"),
delivery_recipient=True,
representations=(
PartyRepresentation(
representative_party_ref=representative,
represented_party_ref=represented,
power_ref="power-1",
permitted_actions=("submit", "receive"),
temporal=TemporalRevision(
revision="1",
valid_from=NOW - timedelta(days=1),
recorded_at=NOW - timedelta(days=1),
),
evidence=(evidence(),),
revoked_at=revoked_at,
),
),
contact_snapshot_refs=("addresses:snapshot-1",),
evidence=(evidence(),),
)
class PartyProvider:
def __init__(self, party: ProcedureParty) -> None:
self.party = party
def list_procedure_parties(self, session, principal, *, procedure_ref, effective_at=None):
return (self.party,)
class DenyingPartyProvider:
def list_procedure_parties(self, session, principal, *, procedure_ref, effective_at=None):
raise InstitutionalContextError("Party provider access denied.")
class Registry:
def __init__(self, provider: object | None = None) -> None:
self.provider = provider
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_PARTY_RESOLVER and self.provider is not None
def capability(self, name: str) -> object:
return self.provider
class InstitutionalConsumerTests(unittest.TestCase):
def test_service_definition_drives_versioned_case_intake(self) -> None:
plan = CaseServiceIntake().plan(service(), case_id="case-1", effective_at=NOW)
self.assertEqual("permit-application", plan.case_type_ref)
self.assertEqual("4", plan.service_ref.version)
self.assertEqual("permit", plan.context.service_ref.object_id)
self.assertEqual("mandate-1", plan.context.mandate_ref.object_id)
self.assertEqual("city-1", plan.context.jurisdiction_refs[0].object_id)
self.assertEqual(("workflow:permit-review",), plan.workflow_refs)
def test_case_party_provider_drives_frozen_delivery_authority(self) -> None:
context = CasePartyContext(Registry(PartyProvider(procedure_party())))
party_set = context.resolve(
None,
None,
case_ref=reference("case", "case-1"),
effective_at=NOW,
)
targets = context.delivery_targets(party_set, channel="postbox")
self.assertEqual("provider", party_set.source)
self.assertEqual(("addresses:snapshot-1",), targets[0].contact_snapshot_refs)
self.assertEqual("applicant", targets[0].represented_party_refs[0].object_id)
def test_revoked_representation_is_not_delivery_authority(self) -> None:
context = CasePartyContext(
Registry(PartyProvider(procedure_party(revoked_at=NOW)))
)
party_set = context.resolve(
None,
None,
case_ref=reference("case", "case-1"),
effective_at=NOW,
)
self.assertEqual(
(),
context.delivery_targets(party_set, channel="postbox")[0].represented_party_refs,
)
def test_cases_only_compatibility_path_is_bounded(self) -> None:
subject = PartySubjectReference(
kind="organization",
provider="organizations",
subject_id="organization-1",
tenant_id="tenant-1",
)
party_set = CasePartyContext().resolve(
None,
None,
case_ref=reference("case", "case-1"),
effective_at=NOW,
compatibility=(
CasePartyCompatibilityRecord(
party_id="party-1",
role="applicant",
subject=subject,
valid_from=NOW - timedelta(days=1),
permitted_channels=("mail",),
delivery_recipient=True,
contact_snapshot_refs=("addresses:snapshot-2",),
),
),
)
self.assertEqual("compatibility", party_set.source)
self.assertEqual("organization-1", party_set.parties[0].subject.subject_id)
self.assertEqual((), party_set.parties[0].representations)
def test_cross_tenant_compatibility_subject_fails_closed(self) -> None:
with self.assertRaisesRegex(InstitutionalContextError, "another tenant"):
CasePartyContext().resolve(
None,
None,
case_ref=reference("case", "case-1"),
effective_at=NOW,
compatibility=(
CasePartyCompatibilityRecord(
party_id="party-1",
role="applicant",
subject=PartySubjectReference(
kind="identity",
provider="identity",
subject_id="identity-9",
tenant_id="tenant-2",
),
valid_from=NOW,
),
),
)
def test_provider_access_denial_never_falls_back_to_local_rows(self) -> None:
compatibility = CasePartyCompatibilityRecord(
party_id="party-1",
role="applicant",
subject=PartySubjectReference(
kind="identity",
provider="identity",
subject_id="identity-1",
tenant_id="tenant-1",
),
valid_from=NOW,
)
with self.assertRaisesRegex(InstitutionalContextError, "access denied"):
CasePartyContext(Registry(DenyingPartyProvider())).resolve(
None,
None,
case_ref=reference("case", "case-1"),
effective_at=NOW,
compatibility=(compatibility,),
)
def test_manifest_exposes_optional_party_provider(self) -> None:
manifest = get_manifest()
self.assertEqual("cases", manifest.id)
self.assertEqual((), manifest.dependencies)
self.assertIn(CAPABILITY_PARTY_RESOLVER, manifest.optional_capabilities)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertIn("cases.registry", manifest.capability_factories)
if __name__ == "__main__":
unittest.main()
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_cases.backend.manifest import get_manifest
from govoplan_core.db.migrations import migrate_database
class CasesMigrationTests(unittest.TestCase):
def test_fresh_migration_creates_case_sharing_schema_and_head(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-cases-migration-") as directory:
url = f"sqlite:///{Path(directory) / 'cases.db'}"
migrate_database(
database_url=url,
enabled_modules=("cases",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
inspector = inspect(engine)
self.assertTrue(
{
"case_access_grants",
"case_identities",
"case_record_revisions",
"case_status_definitions",
"case_timeline_entries",
"case_type_definitions",
}.issubset(inspector.get_table_names())
)
self.assertIn(
"access_mode",
{
item["name"]
for item in inspector.get_columns("case_record_revisions")
},
)
with engine.connect() as connection:
self.assertIn(
"f6d3a8b1c4e7",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@govoplan/cases-webui",
"version": "0.1.8",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/cases.css": "./src/styles/cases.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"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
}
}
}
+180
View File
@@ -0,0 +1,180 @@
import {
apiFetch,
apiPath,
apiReferenceOptionProvider,
type ApiSettings,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
export type InstitutionalReference = {
kind: string;
owner_module: string;
object_id: string;
tenant_id: string;
version?: string | null;
};
export type EvidenceReference = {
kind: string;
owner_module: string;
evidence_id: string;
tenant_id: string;
version?: string | null;
};
export type CaseRecord = {
reference: InstitutionalReference;
revision: number;
case_number: string;
case_type_key: string;
status_key: string;
title: string;
access_mode: "tenant" | "restricted";
access_grants: CaseGrant[];
context: Record<string, unknown>;
service_ref?: InstitutionalReference | null;
party_refs: InstitutionalReference[];
assignment_refs: InstitutionalReference[];
evidence_refs: EvidenceReference[];
decision_refs: InstitutionalReference[];
record_refs: InstitutionalReference[];
opened_at: string;
recorded_at: string;
deadline_at?: string | null;
closed_at?: string | null;
change_reason: string;
metadata: Record<string, unknown>;
};
export type CaseGrant = {
subject_kind: "account" | "identity" | "group" | "role" | "function" | "function_assignment" | "organization_unit" | "service_account";
subject_id: string;
permissions: Array<"read" | "update" | "share" | "admin">;
};
export type CaseStatusDefinition = {
status_key: string;
label: string;
category: string;
terminal: boolean;
sort_order: number;
active: boolean;
revision: number;
};
export type CaseTypeDefinition = {
type_key: string;
label: string;
description?: string | null;
initial_status_key: string;
allowed_status_keys: string[];
active: boolean;
revision: number;
};
export type CaseCatalog = {
statuses: CaseStatusDefinition[];
types: CaseTypeDefinition[];
};
export type CaseListResponse = {
cases: CaseRecord[];
total: number;
offset: number;
limit: number;
};
export type CaseTimelineEntry = {
event_id: string;
event_type: string;
case_revision: number;
summary: string;
actor_id?: string | null;
occurred_at: string;
audit_event_id?: string | null;
payload: Record<string, unknown>;
};
export function listCases(
settings: ApiSettings,
options: {
query?: string;
statuses?: string[];
caseTypes?: string[];
offset?: number;
limit?: number;
},
signal?: AbortSignal
): Promise<CaseListResponse> {
return apiFetch<CaseListResponse>(settings, apiPath("/api/v1/cases", {
query: options.query,
status_key: options.statuses,
case_type_key: options.caseTypes,
offset: options.offset,
limit: options.limit
}), { signal });
}
export function getCase(
settings: ApiSettings,
caseId: string,
signal?: AbortSignal
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(settings, `/api/v1/cases/${encodeURIComponent(caseId)}`, { signal });
}
export function listCaseCatalog(
settings: ApiSettings,
signal?: AbortSignal
): Promise<CaseCatalog> {
return apiFetch<CaseCatalog>(settings, "/api/v1/cases/catalog", { signal });
}
export function caseHistory(
settings: ApiSettings,
caseId: string,
signal?: AbortSignal
): Promise<{ revisions: CaseRecord[] }> {
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/history`, { signal });
}
export function caseTimeline(
settings: ApiSettings,
caseId: string,
signal?: AbortSignal
): Promise<{ entries: CaseTimelineEntry[] }> {
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { signal });
}
export function updateCase(
settings: ApiSettings,
caseId: string,
payload: {
expected_revision: number;
recorded_at: string;
change_reason: string;
idempotency_key: string;
title?: string;
status_key?: string;
access_mode?: "tenant" | "restricted";
access_grants?: CaseGrant[];
}
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(settings, `/api/v1/cases/${encodeURIComponent(caseId)}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function caseShareTargetProvider(
settings: ApiSettings,
caseId: string,
targetType: "user" | "group"
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/share-target-options`,
{ target_type: targetType }
);
}
+259
View File
@@ -0,0 +1,259 @@
import { ArrowLeft, Save, Share2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import {
Button,
DismissibleAlert,
IconButton,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
hasScope,
useGuardedNavigate,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
caseHistory,
caseTimeline,
getCase,
listCaseCatalog,
updateCase,
type CaseCatalog,
type CaseRecord,
type CaseTimelineEntry,
type InstitutionalReference
} from "../../api/cases";
import CaseShareDialog from "./CaseShareDialog";
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
const { caseId = "" } = useParams();
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 [title, setTitle] = useState("");
const [status, setStatus] = useState("");
const [changeReason, setChangeReason] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [shareOpen, setShareOpen] = useState(false);
const canUpdate = hasScope(auth, "cases:case:update");
const canClose = hasScope(auth, "cases:case:close");
const canShare = hasScope(auth, "cases:case:share");
const load = useCallback((signal?: AbortSignal) => {
setLoading(true);
setError("");
return Promise.all([
getCase(settings, caseId, signal),
listCaseCatalog(settings, signal),
caseHistory(settings, caseId, signal),
caseTimeline(settings, caseId, signal)
]).
then(([nextRecord, nextCatalog, nextHistory, nextTimeline]) => {
setRecord(nextRecord);
setCatalog(nextCatalog);
setHistory(nextHistory.revisions);
setTimeline(nextTimeline.entries);
setTitle(nextRecord.title);
setStatus(nextRecord.status_key);
setChangeReason("");
}).
finally(() => setLoading(false));
}, [caseId, settings]);
useEffect(() => {
const controller = new AbortController();
load(controller.signal).catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Case could not be loaded.");
}
});
return () => controller.abort();
}, [load]);
const statuses = useMemo(() => {
const type = catalog.types.find((item) => item.type_key === record?.case_type_key);
const allowed = new Set(type?.allowed_status_keys ?? []);
return catalog.statuses.filter((item) =>
(allowed.size === 0 || allowed.has(item.status_key))
&& (canClose || !item.terminal || item.status_key === record?.status_key)
);
}, [canClose, catalog, record]);
const changed = Boolean(record && (title.trim() !== record.title || status !== record.status_key));
async function save() {
if (!record || !changed || !changeReason.trim()) return;
setSaving(true);
setError("");
try {
await updateCase(settings, caseId, {
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: crypto.randomUUID(),
...(title.trim() !== record.title ? { title: title.trim() } : {}),
...(status !== record.status_key ? { status_key: status } : {})
});
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Case could not be saved.");
} finally {
setSaving(false);
}
}
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")}>
<ArrowLeft size={16} aria-hidden="true" />
Cases
</button>
{record && <span>{record.case_number}</span>}
{record && canShare ? (
<IconButton
label="Manage case access"
icon={<Share2 size={16} />}
className="case-share-button"
onClick={() => setShareOpen(true)}
/>
) : null}
</div>
<PageScrollViewport className="case-detail-viewport">
{error &&
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
}
{loading && <LoadingIndicator label="Loading case" />}
{!loading && record &&
<div className="case-detail-content">
<section className="case-detail-main">
<div className="case-detail-title-row">
<div>
<span className="case-detail-eyebrow">{humanize(record.case_type_key)}</span>
<h1>{record.title}</h1>
</div>
<StatusBadge status={record.closed_at ? "inactive" : "active"} label={humanize(record.status_key)} />
</div>
{canUpdate &&
<div className="case-edit-panel">
<label>
<span>Title</span>
<input value={title} onChange={(event) => setTitle(event.target.value)} />
</label>
<label>
<span>Status</span>
<select value={status} onChange={(event) => setStatus(event.target.value)}>
{statuses.map((item) => <option key={item.status_key} value={item.status_key}>{item.label}</option>)}
</select>
</label>
<label className="case-change-reason">
<span>Change reason</span>
<input value={changeReason} onChange={(event) => setChangeReason(event.target.value)} />
</label>
<Button
variant="primary"
disabled={!changed || !title.trim() || !changeReason.trim() || saving}
onClick={save}>
<Save size={16} aria-hidden="true" />
{saving ? "Saving" : "Save"}
</Button>
</div>
}
<div className="case-facts">
<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="Last change" value={record.change_reason} />
</div>
<ReferenceSection title="Parties" references={record.party_refs} />
<ReferenceSection title="Assignments" references={record.assignment_refs} />
<ReferenceSection title="Decisions" references={record.decision_refs} />
<ReferenceSection title="Records" references={record.record_refs} />
</section>
<aside className="case-detail-aside">
<section>
<h2>Timeline</h2>
<ol className="case-timeline">
{timeline.map((entry) =>
<li key={entry.event_id}>
<strong>{humanize(entry.event_type)}</strong>
<span>{entry.summary}</span>
<time>{formatDateTime(entry.occurred_at)}</time>
</li>
)}
</ol>
</section>
<section>
<h2>History</h2>
<div className="case-history-list">
{history.map((revision) =>
<div key={revision.revision}>
<strong>Revision {revision.revision}</strong>
<span>{revision.change_reason}</span>
<time>{formatDateTime(revision.recorded_at)}</time>
</div>
)}
</div>
</section>
</aside>
</div>
}
</PageScrollViewport>
</div>
{record ? (
<CaseShareDialog
settings={settings}
record={record}
open={shareOpen}
onClose={() => setShareOpen(false)}
onSaved={(saved) => {
setRecord(saved);
void load().catch((reason) => {
setError(reason instanceof Error ? reason.message : "Case could not be reloaded.");
});
}}
/>
) : null}
</main>
);
}
function Fact({ label, value }: { label: string; value: string }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
function ReferenceSection({ title, references }: { title: string; references: InstitutionalReference[] }) {
if (references.length === 0) return null;
return (
<section className="case-reference-section">
<h2>{title}</h2>
<div className="case-reference-list">
{references.map((reference) =>
<span key={`${reference.owner_module}:${reference.object_id}:${reference.version ?? "current"}`}>
<strong>{humanize(reference.kind)}</strong>
{reference.object_id}
</span>
)}
</div>
</section>
);
}
function formatDateTime(value?: string | null): string {
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "-";
}
function humanize(value: string): string {
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
@@ -0,0 +1,240 @@
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DismissibleAlert,
FormField,
IconButton,
ReferenceSelect,
ToggleSwitch,
type ApiSettings
} from "@govoplan/core-webui";
import {
caseShareTargetProvider,
updateCase,
type CaseGrant,
type CaseRecord
} from "../../api/cases";
type TargetType = "user" | "group";
type Permission = CaseGrant["permissions"][number];
export default function CaseShareDialog({
settings,
record,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CaseRecord;
open: boolean;
onClose: () => void;
onSaved: (record: CaseRecord) => void;
}) {
const [restricted, setRestricted] = useState(record.access_mode === "restricted");
const [grants, setGrants] = useState<CaseGrant[]>(record.access_grants);
const [targetType, setTargetType] = useState<TargetType>("user");
const [targetId, setTargetId] = useState("");
const [permission, setPermission] = useState<Permission>("read");
const [changeReason, setChangeReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const targetProvider = useMemo(
() => caseShareTargetProvider(settings, record.reference.object_id, targetType),
[record.reference.object_id, settings, targetType]
);
useEffect(() => {
if (!open) return;
setRestricted(record.access_mode === "restricted");
setGrants(record.access_grants);
setTargetId("");
setPermission("read");
setChangeReason("");
setError("");
}, [open, record]);
const changed = restricted !== (record.access_mode === "restricted")
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
function addGrant() {
const subjectId = targetId.trim();
if (!subjectId) return;
const subjectKind = targetType === "user" ? "account" : "group";
setGrants((current) => [
...current.filter(
(item) => !(item.subject_kind === subjectKind && item.subject_id === subjectId)
),
{
subject_kind: subjectKind,
subject_id: subjectId,
permissions: [permission]
}
]);
setTargetId("");
}
async function save() {
if (!changed || !changeReason.trim()) return;
setBusy(true);
setError("");
try {
const saved = await updateCase(settings, record.reference.object_id, {
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: crypto.randomUUID(),
access_mode: restricted ? "restricted" : "tenant",
access_grants: grants
});
onSaved(saved);
onClose();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Case access could not be saved.");
} finally {
setBusy(false);
}
}
return (
<Dialog
open={open}
title={`Case access - ${record.case_number}`}
onClose={onClose}
closeDisabled={busy}
portal
className="case-share-dialog"
footer={
<>
<Button disabled={busy} onClick={onClose}>Cancel</Button>
<Button
variant="primary"
disabled={busy || !changed || !changeReason.trim()}
onClick={() => void save()}
>
{busy ? "Saving" : "Save access"}
</Button>
</>
}
>
<div className="case-share-content">
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>
) : null}
<ToggleSwitch
label="Case visibility"
inactiveLabel="Tenant"
activeLabel="Restricted"
checked={restricted}
disabled={busy}
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.
</p>
<div className="case-share-add-row">
<FormField label="Target type">
<select
value={targetType}
disabled={busy}
onChange={(event) => {
setTargetType(event.target.value as TargetType);
setTargetId("");
}}
>
<option value="user">User</option>
<option value="group">Group</option>
</select>
</FormField>
<FormField label="Target">
<ReferenceSelect
value={targetId}
onChange={setTargetId}
provider={targetProvider}
disabled={busy}
placeholder={`Select a ${targetType}`}
aria-label={`Case access ${targetType}`}
/>
</FormField>
<FormField label="Permission">
<select
value={permission}
disabled={busy}
onChange={(event) => setPermission(event.target.value as Permission)}
>
<option value="read">Read</option>
<option value="update">Update</option>
<option value="share">Share</option>
<option value="admin">Administer</option>
</select>
</FormField>
<IconButton
label="Add access grant"
icon={<Plus size={16} />}
variant="primary"
disabled={busy || !targetId.trim()}
onClick={addGrant}
/>
</div>
<div className="case-share-grants" aria-label="Explicit access grants">
{grants.length === 0 ? (
<p>No explicit access grants.</p>
) : grants.map((grant) => (
<div
key={`${grant.subject_kind}:${grant.subject_id}`}
className="case-share-grant"
>
<span>
<strong>{humanize(grant.subject_kind)}</strong>
{grant.subject_id}
</span>
<select
aria-label={`Permission for ${grant.subject_id}`}
value={grant.permissions[0] ?? "read"}
disabled={busy}
onChange={(event) => setGrants((current) => current.map((item) =>
item === grant
? { ...item, permissions: [event.target.value as Permission] }
: item
))}
>
<option value="read">Read</option>
<option value="update">Update</option>
<option value="share">Share</option>
<option value="admin">Administer</option>
</select>
<IconButton
label={`Remove access for ${grant.subject_id}`}
icon={<Trash2 size={16} />}
variant="danger"
disabled={busy}
onClick={() => setGrants((current) => current.filter((item) => item !== grant))}
/>
</div>
))}
</div>
<FormField label="Change reason">
<input
value={changeReason}
disabled={busy}
maxLength={1000}
onChange={(event) => setChangeReason(event.target.value)}
placeholder="Why is case access changing?"
/>
</FormField>
</div>
</Dialog>
);
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
+143
View File
@@ -0,0 +1,143 @@
import { Search } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
DismissibleAlert,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
useGuardedNavigate,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
listCaseCatalog,
listCases,
type CaseCatalog,
type CaseRecord
} from "../../api/cases";
export default function CasesPage({ settings }: PlatformRouteContext) {
const navigate = useGuardedNavigate();
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
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("");
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError("");
Promise.all([
listCases(settings, {
query: submittedQuery,
statuses: status ? [status] : undefined,
limit: 200
}, controller.signal),
listCaseCatalog(settings, controller.signal)
]).
then(([result, nextCatalog]) => {
setCases(result.cases);
setTotal(result.total);
setCatalog(nextCatalog);
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Cases could not be loaded.");
}
}).
finally(() => setLoading(false));
return () => controller.abort();
}, [settings, status, submittedQuery]);
const statusLabels = useMemo(
() => new Map(catalog.statuses.map((item) => [item.status_key, item])),
[catalog.statuses]
);
const typeLabels = useMemo(
() => new Map(catalog.types.map((item) => [item.type_key, item.label])),
[catalog.types]
);
function submit(event: FormEvent) {
event.preventDefault();
setSubmittedQuery(query.trim());
}
return (
<main className="cases-page">
<div className="cases-shell">
<div className="cases-toolbar">
<form className="cases-search" onSubmit={submit}>
<Search size={17} aria-hidden="true" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label="Search cases"
placeholder="Search cases"
/>
<button type="submit" className="btn btn-primary">Search</button>
</form>
<label className="cases-status-filter">
<span>Status</span>
<select value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="">All statuses</option>
{catalog.statuses.map((item) =>
<option key={item.status_key} value={item.status_key}>{item.label}</option>
)}
</select>
</label>
<span className="cases-count">{total} cases</span>
</div>
<PageScrollViewport className="cases-list-viewport">
{error &&
<DismissibleAlert tone="error" onDismiss={() => setError("")}>
{error}
</DismissibleAlert>
}
{loading && <LoadingIndicator label="Loading cases" />}
{!loading && !error && cases.length === 0 &&
<div className="cases-empty">No matching cases.</div>
}
{!loading && cases.length > 0 &&
<div className="cases-list" role="list">
{cases.map((item) => {
const statusDefinition = statusLabels.get(item.status_key);
return (
<button
type="button"
className="case-list-row"
role="listitem"
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>
<StatusBadge
status={statusDefinition?.terminal ? "inactive" : "active"}
label={statusDefinition?.label ?? humanize(item.status_key)}
/>
</button>
);
})}
</div>
}
</PageScrollViewport>
</div>
</main>
);
}
function formatDate(value?: string | null): string {
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(new Date(value)) : "No deadline";
}
function humanize(value: string): string {
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
+2
View File
@@ -0,0 +1,2 @@
export { default, casesModule } from "./module";
export * from "./api/cases";
+56
View File
@@ -0,0 +1,56 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/cases.css";
const CasesPage = lazy(() => import("./features/cases/CasesPage"));
const CaseDetailPage = lazy(() => import("./features/cases/CaseDetailPage"));
export const casesModule: PlatformWebModule = {
id: "cases",
label: "Cases",
version: "0.1.8",
optionalDependencies: [
"access",
"addresses",
"services",
"parties",
"mandates",
"decisions",
"forms_runtime",
"workflow_engine"
],
routes: [
{
path: "/cases",
anyOf: ["cases:case:read"],
order: 35,
surfaceId: "cases.list",
render: (context) => createElement(CasesPage, context)
},
{
path: "/cases/:caseId",
anyOf: ["cases:case:read"],
order: 36,
surfaceId: "cases.detail",
render: (context) => createElement(CaseDetailPage, context)
}
],
navItems: [
{
to: "/cases",
label: "Cases",
iconName: "briefcase-business",
anyOf: ["cases:case:read"],
order: 35,
surfaceId: "cases.navigation"
}
],
viewSurfaces: [
{ id: "cases.navigation", moduleId: "cases", kind: "navigation", label: "Cases navigation", order: 10 },
{ id: "cases.list", moduleId: "cases", kind: "route", label: "Case list", order: 20 },
{ id: "cases.detail", moduleId: "cases", kind: "route", label: "Case details", order: 30 }
]
};
export default casesModule;
+354
View File
@@ -0,0 +1,354 @@
.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;
gap: 14px;
min-height: 58px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
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-status-filter {
display: flex;
align-items: center;
gap: 7px;
}
.cases-status-filter > span,
.cases-count {
color: var(--text-soft);
font-size: 0.82rem;
}
.cases-count {
margin-left: auto;
}
.case-share-button {
margin-left: auto;
}
.cases-list-viewport,
.case-detail-viewport {
flex: 1;
min-height: 0;
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);
gap: 24px;
max-width: 1380px;
margin: 0 auto;
}
.case-detail-main,
.case-detail-aside {
min-width: 0;
}
.case-detail-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.case-detail-title-row h1 {
margin: 4px 0 0;
font-size: 1.45rem;
letter-spacing: 0;
}
.case-detail-eyebrow {
color: var(--text-soft);
font-size: 0.8rem;
}
.case-edit-panel {
display: grid;
grid-template-columns: minmax(220px, 2fr) minmax(150px, 1fr);
gap: 10px;
margin-top: 16px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-raised);
}
.case-edit-panel label {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
}
.case-edit-panel label > span {
color: var(--text-soft);
font-size: 0.8rem;
}
.case-change-reason {
grid-column: 1 / -1;
}
.case-edit-panel .btn {
justify-self: end;
}
.case-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
margin-top: 18px;
background: var(--border);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.case-facts > div {
display: flex;
min-height: 66px;
flex-direction: column;
gap: 4px;
padding: 12px;
background: var(--surface-raised);
}
.case-facts span,
.case-history-list time,
.case-timeline time {
color: var(--text-soft);
font-size: 0.78rem;
}
.case-reference-section,
.case-detail-aside section {
margin-top: 22px;
}
.case-reference-section h2,
.case-detail-aside h2 {
margin: 0 0 10px;
font-size: 0.95rem;
letter-spacing: 0;
}
.case-reference-list,
.case-history-list {
display: flex;
flex-direction: column;
border-top: 1px solid var(--border);
}
.case-reference-list > span,
.case-history-list > div {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 9px 0;
border-bottom: 1px solid var(--border);
}
.case-timeline {
margin: 0;
padding: 0;
list-style: none;
border-top: 1px solid var(--border);
}
.case-timeline li {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 0 10px 14px;
border-bottom: 1px solid var(--border);
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-share-content {
display: flex;
min-height: 0;
flex-direction: column;
gap: 14px;
}
.case-share-explanation,
.case-share-grants > p {
margin: 0;
color: var(--text-soft);
font-size: 0.84rem;
}
.case-share-add-row {
display: grid;
grid-template-columns: minmax(120px, 0.7fr) minmax(220px, 1.7fr) minmax(130px, 0.8fr) auto;
align-items: end;
gap: 10px;
}
.case-share-add-row .icon-button {
width: 38px;
height: 38px;
}
.case-share-grants {
overflow: auto;
max-height: 280px;
border-top: 1px solid var(--border);
}
.case-share-grant {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(130px, 180px) auto;
align-items: center;
gap: 10px;
min-height: 50px;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.case-share-grant > span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.case-share-grant > span strong {
color: var(--text-soft);
font-size: 0.76rem;
}
@media (max-width: 860px) {
.cases-toolbar {
align-items: stretch;
flex-direction: column;
}
.cases-search {
width: 100%;
}
.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;
}
.case-share-add-row {
grid-template-columns: 1fr;
}
.case-share-add-row .icon-button {
justify-self: end;
}
}