feat: implement governed cases workspace
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""GovOPlaN Cases module."""
|
||||
|
||||
from govoplan_cases.backend.manifest import get_manifest
|
||||
|
||||
__all__ = ["get_manifest"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Cases backend contracts."""
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, casesModule } from "./module";
|
||||
export * from "./api/cases";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user