diff --git a/README.md b/README.md index d7d118a..1c70ba2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **Repository type:** module (domain). -`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. diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 83212b5..b53e7ba 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3ebb546 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/src/govoplan_cases/__init__.py b/src/govoplan_cases/__init__.py new file mode 100644 index 0000000..8438070 --- /dev/null +++ b/src/govoplan_cases/__init__.py @@ -0,0 +1,5 @@ +"""GovOPlaN Cases module.""" + +from govoplan_cases.backend.manifest import get_manifest + +__all__ = ["get_manifest"] diff --git a/src/govoplan_cases/backend/__init__.py b/src/govoplan_cases/backend/__init__.py new file mode 100644 index 0000000..04d1f4d --- /dev/null +++ b/src/govoplan_cases/backend/__init__.py @@ -0,0 +1 @@ +"""Cases backend contracts.""" diff --git a/src/govoplan_cases/backend/acl.py b/src/govoplan_cases/backend/acl.py new file mode 100644 index 0000000..d79b24c --- /dev/null +++ b/src/govoplan_cases/backend/acl.py @@ -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"] diff --git a/src/govoplan_cases/backend/db/__init__.py b/src/govoplan_cases/backend/db/__init__.py new file mode 100644 index 0000000..e955a07 --- /dev/null +++ b/src/govoplan_cases/backend/db/__init__.py @@ -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", +] diff --git a/src/govoplan_cases/backend/db/models.py b/src/govoplan_cases/backend/db/models.py new file mode 100644 index 0000000..0a0f8e1 --- /dev/null +++ b/src/govoplan_cases/backend/db/models.py @@ -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", +] diff --git a/src/govoplan_cases/backend/domain.py b/src/govoplan_cases/backend/domain.py new file mode 100644 index 0000000..d1e2560 --- /dev/null +++ b/src/govoplan_cases/backend/domain.py @@ -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"] diff --git a/src/govoplan_cases/backend/manifest.py b/src/govoplan_cases/backend/manifest.py new file mode 100644 index 0000000..6bd2803 --- /dev/null +++ b/src/govoplan_cases/backend/manifest.py @@ -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 diff --git a/src/govoplan_cases/backend/migrations/__init__.py b/src/govoplan_cases/backend/migrations/__init__.py new file mode 100644 index 0000000..1ffbc23 --- /dev/null +++ b/src/govoplan_cases/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Cases Alembic revisions.""" diff --git a/src/govoplan_cases/backend/migrations/versions/__init__.py b/src/govoplan_cases/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..ace220f --- /dev/null +++ b/src/govoplan_cases/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Cases migration revisions.""" diff --git a/src/govoplan_cases/backend/migrations/versions/c7a8e9f0b1d2_v018_cases_mvp.py b/src/govoplan_cases/backend/migrations/versions/c7a8e9f0b1d2_v018_cases_mvp.py new file mode 100644 index 0000000..937adf5 --- /dev/null +++ b/src/govoplan_cases/backend/migrations/versions/c7a8e9f0b1d2_v018_cases_mvp.py @@ -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") diff --git a/src/govoplan_cases/backend/migrations/versions/f6d3a8b1c4e7_v0114_case_sharing.py b/src/govoplan_cases/backend/migrations/versions/f6d3a8b1c4e7_v0114_case_sharing.py new file mode 100644 index 0000000..8e1d1da --- /dev/null +++ b/src/govoplan_cases/backend/migrations/versions/f6d3a8b1c4e7_v0114_case_sharing.py @@ -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") diff --git a/src/govoplan_cases/backend/party_context.py b/src/govoplan_cases/backend/party_context.py new file mode 100644 index 0000000..f0562ed --- /dev/null +++ b/src/govoplan_cases/backend/party_context.py @@ -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", +] diff --git a/src/govoplan_cases/backend/router.py b/src/govoplan_cases/backend/router.py new file mode 100644 index 0000000..f0bb00a --- /dev/null +++ b/src/govoplan_cases/backend/router.py @@ -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"] diff --git a/src/govoplan_cases/backend/schemas.py b/src/govoplan_cases/backend/schemas.py new file mode 100644 index 0000000..444337f --- /dev/null +++ b/src/govoplan_cases/backend/schemas.py @@ -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", +] diff --git a/src/govoplan_cases/backend/service.py b/src/govoplan_cases/backend/service.py new file mode 100644 index 0000000..b11a81f --- /dev/null +++ b/src/govoplan_cases/backend/service.py @@ -0,0 +1,1298 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import replace +from datetime import UTC, datetime +import hashlib +import json +from typing import Any +import uuid + +from sqlalchemy import and_, exists, func, or_ +from sqlalchemy.orm import Session + +from govoplan_core.core.events import ( + EventActorRef, + EventObjectRef, + EventTenantRef, + PlatformEvent, + emit_platform_event, +) +from govoplan_core.core.institutional import ( + EvidenceReference, + InstitutionalContextError, + InstitutionalReference, +) +from govoplan_core.security.module_permissions import scopes_grant_compatible +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_intake import CaseIntakePlan + + +CAPABILITY_CASES_REGISTRY = "cases.registry" + + +class CaseStoreError(ValueError): + pass + + +def upsert_case_status( + session: Session, + principal: object, + *, + status_key: str, + label: str, + category: str = "open", + terminal: bool = False, + sort_order: int = 100, + active: bool = True, + expected_revision: int | None = None, +) -> CaseStatusDefinition: + tenant_id = _principal_tenant(principal) + key = _catalog_key(status_key, "Case status key") + clean_label = _required_text(label, "Case status label", maximum=255) + if category not in {"open", "waiting", "decided", "closed", "cancelled"}: + raise CaseStoreError("Unsupported case status category.") + row = ( + session.query(CaseStatusDefinition) + .filter( + CaseStatusDefinition.tenant_id == tenant_id, + CaseStatusDefinition.status_key == key, + ) + .with_for_update() + .one_or_none() + ) + if row is None: + if expected_revision is not None: + raise CaseStoreError( + "Case status revision conflict: no definition exists." + ) + row = CaseStatusDefinition( + tenant_id=tenant_id, + status_key=key, + label=clean_label, + category=category, + terminal=terminal, + sort_order=sort_order, + active=active, + revision=1, + ) + else: + if expected_revision != row.revision: + raise CaseStoreError( + "Case status revision conflict: the expected revision is stale." + ) + if not active and _status_is_in_use(session, tenant_id=tenant_id, key=key): + raise CaseStoreError( + "An active case status cannot be deactivated while cases use it." + ) + row.label = clean_label + row.category = category + row.terminal = terminal + row.sort_order = sort_order + row.active = active + row.revision += 1 + session.add(row) + session.flush() + return row + + +def upsert_case_type( + session: Session, + principal: object, + *, + type_key: str, + label: str, + initial_status_key: str, + allowed_status_keys: Sequence[str] = (), + description: str | None = None, + active: bool = True, + expected_revision: int | None = None, +) -> CaseTypeDefinition: + tenant_id = _principal_tenant(principal) + key = _catalog_key(type_key, "Case type key") + initial = _catalog_key(initial_status_key, "Initial case status key") + allowed = tuple( + dict.fromkeys( + _catalog_key(item, "Allowed case status key") + for item in allowed_status_keys + ) + ) + if allowed and initial not in allowed: + raise CaseStoreError( + "The initial case status must be part of allowed_status_keys." + ) + required_statuses = set(allowed or (initial,)) | {initial} + existing_statuses = { + item.status_key + for item in session.query(CaseStatusDefinition) + .filter( + CaseStatusDefinition.tenant_id == tenant_id, + CaseStatusDefinition.status_key.in_(tuple(required_statuses)), + CaseStatusDefinition.active.is_(True), + ) + .all() + } + if required_statuses != existing_statuses: + raise CaseStoreError( + "Case type references missing or inactive statuses: " + + ", ".join(sorted(required_statuses - existing_statuses)) + ) + row = ( + session.query(CaseTypeDefinition) + .filter( + CaseTypeDefinition.tenant_id == tenant_id, + CaseTypeDefinition.type_key == key, + ) + .with_for_update() + .one_or_none() + ) + if row is None: + if expected_revision is not None: + raise CaseStoreError( + "Case type revision conflict: no definition exists." + ) + row = CaseTypeDefinition( + tenant_id=tenant_id, + type_key=key, + label=_required_text(label, "Case type label", maximum=255), + description=_optional_text(description, maximum=4_000), + initial_status_key=initial, + allowed_status_keys=list(allowed), + active=active, + revision=1, + ) + else: + if expected_revision != row.revision: + raise CaseStoreError( + "Case type revision conflict: the expected revision is stale." + ) + if not active and _type_is_in_use(session, tenant_id=tenant_id, key=key): + raise CaseStoreError( + "An active case type cannot be deactivated while cases use it." + ) + row.label = _required_text(label, "Case type label", maximum=255) + row.description = _optional_text(description, maximum=4_000) + row.initial_status_key = initial + row.allowed_status_keys = list(allowed) + row.active = active + row.revision += 1 + session.add(row) + session.flush() + return row + + +def list_case_catalog( + session: Session, + principal: object, + *, + include_inactive: bool = False, +) -> dict[str, list[dict[str, object]]]: + tenant_id = _principal_tenant(principal) + status_query = session.query(CaseStatusDefinition).filter( + CaseStatusDefinition.tenant_id == tenant_id + ) + type_query = session.query(CaseTypeDefinition).filter( + CaseTypeDefinition.tenant_id == tenant_id + ) + if not include_inactive: + status_query = status_query.filter(CaseStatusDefinition.active.is_(True)) + type_query = type_query.filter(CaseTypeDefinition.active.is_(True)) + statuses = status_query.order_by( + CaseStatusDefinition.sort_order.asc(), + CaseStatusDefinition.label.asc(), + ).all() + types = type_query.order_by(CaseTypeDefinition.label.asc()).all() + return { + "statuses": [_status_payload(item) for item in statuses], + "types": [_type_payload(item) for item in types], + } + + +def create_case( + session: Session, + principal: object, + *, + record: CaseRecord, + idempotency_key: str, +) -> CaseRecord: + tenant_id = _principal_tenant(principal) + if record.reference.tenant_id != tenant_id: + raise CaseStoreError("Case records cannot cross tenants.") + if record.revision != 1: + raise CaseStoreError("A new case must start at revision 1.") + request_sha256 = _request_sha256(record.to_dict()) + replay = _replay( + session, + tenant_id=tenant_id, + idempotency_key=idempotency_key, + request_sha256=request_sha256, + ) + if replay is not None: + if not can_access_case( + session, + principal, + case_id=replay.reference.object_id, + permission="read", + ): + raise PermissionError("Case replay access is denied.") + return replay + _validate_catalog_for_record(session, record) + if _identity(session, tenant_id=tenant_id, case_id=record.reference.object_id): + raise CaseStoreError("A case with this identifier already exists.") + if ( + session.query(CaseIdentity.id) + .filter( + CaseIdentity.tenant_id == tenant_id, + CaseIdentity.case_number == record.case_number, + ) + .first() + is not None + ): + raise CaseStoreError("A case with this case number already exists.") + identity = CaseIdentity( + tenant_id=tenant_id, + case_id=record.reference.object_id, + case_number=record.case_number, + created_by=_principal_actor(principal), + ) + session.add(identity) + session.flush() + persisted = _record_revision( + session, + principal, + identity=identity, + record=record, + current=None, + idempotency_key=idempotency_key, + request_sha256=request_sha256, + operation="created", + ) + return persisted + + +def create_case_from_intake( + session: Session, + principal: object, + *, + plan: CaseIntakePlan, + case_number: str, + title: str, + status_key: str | None, + opened_at: datetime, + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + party_refs: tuple[InstitutionalReference, ...] = (), + assignment_refs: tuple[InstitutionalReference, ...] = (), + evidence_refs: tuple[EvidenceReference, ...] = (), + deadline_at: datetime | None = None, + metadata: Mapping[str, Any] | None = None, +) -> CaseRecord: + case_type = _case_type( + session, + tenant_id=_principal_tenant(principal), + key=plan.case_type_ref, + ) + if case_type is None or not case_type.active: + raise CaseStoreError( + f"Case type {plan.case_type_ref!r} is not configured or active." + ) + record = CaseRecord( + reference=replace( + plan.case_ref, + version="1", + valid_at=opened_at, + ), + case_number=case_number, + case_type_key=plan.case_type_ref, + status_key=status_key or case_type.initial_status_key, + title=title, + context=plan.context, + service_ref=plan.service_ref, + party_refs=party_refs, + assignment_refs=assignment_refs, + evidence_refs=evidence_refs, + opened_at=opened_at, + recorded_at=recorded_at, + deadline_at=deadline_at, + change_reason=change_reason, + metadata={ + **dict(metadata or {}), + "form_refs": list(plan.form_refs), + "workflow_refs": list(plan.workflow_refs), + "result_refs": list(plan.result_refs), + "required_evidence_types": list(plan.required_evidence_types), + "deadline_refs": list(plan.deadline_refs), + }, + ) + return create_case( + session, + principal, + record=record, + idempotency_key=idempotency_key, + ) + + +def update_case( + session: Session, + principal: object, + *, + case_id: str, + expected_revision: int, + changes: Mapping[str, object], + recorded_at: datetime, + change_reason: str, + idempotency_key: str, +) -> CaseRecord: + tenant_id = _principal_tenant(principal) + current_row = _current_row( + session, + tenant_id=tenant_id, + case_id=case_id, + lock=True, + ) + if current_row is None: + raise LookupError("Case not found.") + if not can_access_case(session, principal, case_id=case_id, permission="update"): + raise PermissionError("Case update access is denied.") + current = _record_from_row(current_row) + normalized = _normalized_changes(changes) + request_sha256 = _request_sha256( + { + "case_id": case_id, + "expected_revision": expected_revision, + "changes": normalized, + "recorded_at": recorded_at, + "change_reason": change_reason, + } + ) + replay = _replay( + session, + tenant_id=tenant_id, + idempotency_key=idempotency_key, + request_sha256=request_sha256, + ) + if replay is not None: + return replay + if current.revision != expected_revision: + raise CaseStoreError( + "Case revision conflict: the expected revision is stale." + ) + _require_aware(recorded_at, "Case recorded_at") + next_status = str(changes.get("status_key", current.status_key)).strip() + terminal = _validate_status_transition( + session, + record=current, + next_status=next_status, + ) + closed_at = changes.get("closed_at", current.closed_at) + if terminal and closed_at is None: + closed_at = recorded_at + if not terminal: + closed_at = None + next_record = replace( + current, + reference=replace( + current.reference, + version=str(current.revision + 1), + ), + status_key=next_status, + title=str(changes.get("title", current.title)).strip(), + access_mode=str(changes.get("access_mode", current.access_mode)), + access_grants=_change_grants( + changes, + "access_grants", + current.access_grants, + ), + party_refs=_change_tuple(changes, "party_refs", current.party_refs), + assignment_refs=_change_tuple( + changes, + "assignment_refs", + current.assignment_refs, + ), + evidence_refs=_change_tuple( + changes, + "evidence_refs", + current.evidence_refs, + ), + decision_refs=_change_tuple( + changes, + "decision_refs", + current.decision_refs, + ), + record_refs=_change_tuple( + changes, + "record_refs", + current.record_refs, + ), + deadline_at=changes.get("deadline_at", current.deadline_at), + closed_at=closed_at, + metadata=( + dict(changes["metadata"]) + if "metadata" in changes and isinstance(changes["metadata"], Mapping) + else current.metadata + ), + recorded_at=recorded_at, + change_reason=_required_text( + change_reason, + "Case change reason", + maximum=1_000, + ), + ) + identity = _identity(session, tenant_id=tenant_id, case_id=case_id) + if identity is None: + raise CaseStoreError("Case identity is missing.") + return _record_revision( + session, + principal, + identity=identity, + record=next_record, + current=current_row, + idempotency_key=idempotency_key, + request_sha256=request_sha256, + operation="closed" if terminal and not current.closed_at else "updated", + ) + + +def get_case( + session: Session, + principal: object, + *, + case_id: str, + revision: int | None = None, +) -> CaseRecord | None: + tenant_id = _principal_tenant(principal) + query = session.query(CaseRecordRevision).filter( + CaseRecordRevision.tenant_id == tenant_id, + CaseRecordRevision.case_id == case_id, + ) + if revision is None: + query = query.filter(CaseRecordRevision.superseded_at.is_(None)) + else: + query = query.filter(CaseRecordRevision.revision == revision) + row = query.order_by(CaseRecordRevision.revision.desc()).first() + if row is not None and not can_access_case( + session, + principal, + case_id=case_id, + permission="read", + ): + return None + return _record_from_row(row) if row is not None else None + + +def list_cases( + session: Session, + principal: object, + *, + query: str = "", + status_keys: Sequence[str] | None = None, + case_type_keys: Sequence[str] | None = None, + offset: int = 0, + limit: int = 100, +) -> tuple[tuple[CaseRecord, ...], int]: + tenant_id = _principal_tenant(principal) + if offset < 0 or not 1 <= limit <= 200: + raise CaseStoreError( + "Case list offset must be non-negative and limit between 1 and 200." + ) + statement = session.query(CaseRecordRevision).filter( + CaseRecordRevision.tenant_id == tenant_id, + CaseRecordRevision.superseded_at.is_(None), + ) + statement = _filter_accessible_cases(statement, principal, permission="read") + if status_keys: + statement = statement.filter( + CaseRecordRevision.status_key.in_(tuple(status_keys)) + ) + if case_type_keys: + statement = statement.filter( + CaseRecordRevision.case_type_key.in_(tuple(case_type_keys)) + ) + clean_query = query.strip().casefold() + if clean_query: + statement = statement.filter( + CaseRecordRevision.search_text.contains(clean_query) + ) + total = int(statement.with_entities(func.count()).scalar() or 0) + rows = ( + statement.order_by( + CaseRecordRevision.recorded_at.desc(), + CaseRecordRevision.case_id.asc(), + ) + .offset(offset) + .limit(limit) + .all() + ) + return tuple(_record_from_row(row) for row in rows), total + + +def case_history( + session: Session, + principal: object, + *, + case_id: str, + limit: int = 100, +) -> tuple[CaseRecord, ...]: + if not can_access_case(session, principal, case_id=case_id, permission="read"): + return () + if not 1 <= limit <= 200: + raise CaseStoreError("Case history limit must be between 1 and 200.") + rows = ( + session.query(CaseRecordRevision) + .filter( + CaseRecordRevision.tenant_id == _principal_tenant(principal), + CaseRecordRevision.case_id == case_id, + ) + .order_by(CaseRecordRevision.revision.desc()) + .limit(limit) + .all() + ) + return tuple(_record_from_row(row) for row in rows) + + +def case_timeline( + session: Session, + principal: object, + *, + case_id: str, + limit: int = 200, +) -> tuple[dict[str, object], ...]: + if not can_access_case(session, principal, case_id=case_id, permission="read"): + return () + if not 1 <= limit <= 500: + raise CaseStoreError("Case timeline limit must be between 1 and 500.") + rows = ( + session.query(CaseTimelineEntry) + .filter( + CaseTimelineEntry.tenant_id == _principal_tenant(principal), + CaseTimelineEntry.case_id == case_id, + ) + .order_by(CaseTimelineEntry.occurred_at.desc(), CaseTimelineEntry.id.desc()) + .limit(limit) + .all() + ) + return tuple(_timeline_payload(row) for row in rows) + + +class SqlCaseRegistry: + def create_case(self, session: object, principal: object, *, record: CaseRecord, idempotency_key: str) -> CaseRecord: + return create_case(_session(session), principal, record=record, idempotency_key=idempotency_key) + + def get_case(self, session: object, principal: object, *, reference: InstitutionalReference) -> CaseRecord | None: + if reference.kind != "case": + raise InstitutionalContextError("Case lookup requires a case reference.") + if reference.tenant_id != _principal_tenant(principal): + raise InstitutionalContextError("Case lookup cannot cross tenants.") + revision = int(reference.version) if reference.version else None + return get_case(_session(session), principal, case_id=reference.object_id, revision=revision) + + def list_cases(self, session: object, principal: object, *, limit: int = 100) -> Sequence[CaseRecord]: + items, _total = list_cases(_session(session), principal, limit=limit) + return items + + +def can_access_case( + session: Session, + principal: object, + *, + case_id: str, + permission: str = "read", +) -> bool: + if permission not in {"read", "update", "share", "admin"}: + raise CaseStoreError("Unsupported case access permission.") + tenant_id = _principal_tenant(principal) + current = _current_row( + session, + tenant_id=tenant_id, + case_id=case_id, + lock=False, + ) + if current is None: + return False + if _has_scope(principal, "cases:case:admin"): + return True + identity = _identity(session, tenant_id=tenant_id, case_id=case_id) + if identity is not None and identity.created_by in _principal_actor_ids(principal): + return True + if current.access_mode == "tenant": + return True + subjects = _principal_subjects(principal) + if not subjects: + return False + clauses = [ + and_( + CaseAccessGrant.subject_kind == kind, + CaseAccessGrant.subject_id == subject_id, + ) + for kind, subject_id in subjects + ] + grants = ( + session.query(CaseAccessGrant) + .filter( + CaseAccessGrant.tenant_id == tenant_id, + CaseAccessGrant.case_id == case_id, + CaseAccessGrant.active.is_(True), + or_(*clauses), + ) + .all() + ) + return any( + _access_permissions_allow(tuple(item.permissions or ()), permission) + for item in grants + ) + + +def _filter_accessible_cases(statement, principal: object, *, permission: str): + if _has_scope(principal, "cases:case:admin"): + return statement + conditions = [CaseRecordRevision.access_mode == "tenant"] + actor_ids = _principal_actor_ids(principal) + if actor_ids: + conditions.append( + exists().where( + and_( + CaseIdentity.tenant_id == CaseRecordRevision.tenant_id, + CaseIdentity.case_id == CaseRecordRevision.case_id, + CaseIdentity.created_by.in_(actor_ids), + ) + ) + ) + subjects = _principal_subjects(principal) + if subjects: + subject_clause = or_( + *( + and_( + CaseAccessGrant.subject_kind == kind, + CaseAccessGrant.subject_id == subject_id, + ) + for kind, subject_id in subjects + ) + ) + conditions.append( + exists().where( + and_( + CaseAccessGrant.tenant_id == CaseRecordRevision.tenant_id, + CaseAccessGrant.case_id == CaseRecordRevision.case_id, + CaseAccessGrant.active.is_(True), + subject_clause, + ) + ) + ) + if permission != "read": + raise CaseStoreError("Case list filtering supports read access only.") + return statement.filter(or_(*conditions)) + + +def _sync_access_grants( + session: Session, + principal: object, + record: CaseRecord, +) -> None: + desired: dict[tuple[str, str, str], tuple[str, ...]] = { + (item.subject_kind, item.subject_id, "explicit"): item.permissions + for item in record.access_grants + } + for reference in record.assignment_refs: + if reference.kind not in { + "function", + "function_assignment", + "organization_unit", + }: + continue + desired[(reference.kind, reference.object_id, "assignment")] = ( + "read", + "update", + ) + rows = ( + session.query(CaseAccessGrant) + .filter( + CaseAccessGrant.tenant_id == record.reference.tenant_id, + CaseAccessGrant.case_id == record.reference.object_id, + ) + .all() + ) + existing = { + (item.subject_kind, item.subject_id, item.source): item for item in rows + } + for key, row in existing.items(): + if key not in desired: + row.active = False + row.source_revision = record.revision + for key, permissions in desired.items(): + row = existing.get(key) + if row is None: + session.add( + CaseAccessGrant( + tenant_id=record.reference.tenant_id, + case_id=record.reference.object_id, + subject_kind=key[0], + subject_id=key[1], + permissions=list(permissions), + source=key[2], + active=True, + source_revision=record.revision, + created_by=_principal_actor(principal), + ) + ) + else: + row.permissions = list(permissions) + row.active = True + row.source_revision = record.revision + session.flush() + + +def _access_permissions_allow( + permissions: tuple[str, ...], + requested: str, +) -> bool: + values = set(permissions) + if "admin" in values: + return True + if requested == "read": + return bool(values & {"read", "update", "share"}) + return requested in values + + +def _principal_actor_ids(principal: object) -> tuple[str, ...]: + values = ( + getattr(principal, "account_id", None), + getattr(principal, "identity_id", None), + getattr(principal, "membership_id", None), + getattr(principal, "service_account_id", None), + getattr(getattr(principal, "user", None), "id", None), + ) + return tuple(dict.fromkeys(str(item) for item in values if str(item or "").strip())) + + +def _principal_subjects(principal: object) -> tuple[tuple[str, str], ...]: + subjects: list[tuple[str, str]] = [] + for kind, name in ( + ("account", "account_id"), + ("identity", "identity_id"), + ("service_account", "service_account_id"), + ): + value = str(getattr(principal, name, None) or "").strip() + if value: + subjects.append((kind, value)) + for kind, name in ( + ("group", "group_ids"), + ("role", "role_ids"), + ("function", "function_ids"), + ("function_assignment", "function_assignment_ids"), + ("organization_unit", "organization_unit_ids"), + ): + subjects.extend( + (kind, str(item)) + for item in (getattr(principal, name, ()) or ()) + if str(item).strip() + ) + return tuple(dict.fromkeys(subjects)) + + +def _has_scope(principal: object, scope: str) -> bool: + method = getattr(principal, "has", None) + if callable(method): + return bool(method(scope)) + return scopes_grant_compatible( + frozenset(getattr(principal, "scopes", ()) or ()), + scope, + ) + + +def _record_revision( + session: Session, + principal: object, + *, + identity: CaseIdentity, + record: CaseRecord, + current: CaseRecordRevision | None, + idempotency_key: str, + request_sha256: str, + operation: str, +) -> CaseRecord: + clean_key = _required_text( + idempotency_key, + "Case idempotency key", + maximum=255, + ) + if current is not None: + current.superseded_at = record.recorded_at + snapshot = record.to_dict() + row = CaseRecordRevision( + tenant_id=identity.tenant_id, + case_id=identity.case_id, + identity_id=identity.id, + revision=record.revision, + previous_revision_id=current.id if current is not None else None, + case_type_key=record.case_type_key, + status_key=record.status_key, + title=record.title, + access_mode=record.access_mode, + search_text=( + f"{identity.case_number} {record.title} {record.case_type_key} " + f"{record.status_key}" + ).casefold(), + opened_at=record.opened_at, + deadline_at=record.deadline_at, + closed_at=record.closed_at, + recorded_at=record.recorded_at, + snapshot=snapshot, + changed_by=_principal_actor(principal), + ) + event_id = str(uuid.uuid4()) + timeline = CaseTimelineEntry( + tenant_id=identity.tenant_id, + case_id=identity.case_id, + event_id=event_id, + event_type=f"cases.case.{operation}", + case_revision=record.revision, + summary=f"Case {identity.case_number} {operation}.", + occurred_at=record.recorded_at, + actor_id=_principal_actor(principal), + idempotency_key=clean_key, + request_sha256=request_sha256, + audit_event_id=event_id, + payload={ + "case_number": identity.case_number, + "status_key": record.status_key, + "case_type_key": record.case_type_key, + "revision": record.revision, + "access_mode": record.access_mode, + "access_grant_count": len(record.access_grants), + "change_reason": record.change_reason, + }, + ) + session.add_all((row, timeline)) + session.flush() + _sync_access_grants(session, principal, record) + emit_platform_event( + session, + PlatformEvent( + event_id=event_id, + type=timeline.event_type, + module_id="cases", + payload=dict(timeline.payload), + occurred_at=record.recorded_at, + actor=EventActorRef( + type="account", + id=_principal_actor(principal), + ), + tenant=EventTenantRef(id=identity.tenant_id), + resource=EventObjectRef( + type="case", + id=identity.case_id, + label=identity.case_number, + ), + classification=( + "restricted" if record.access_mode == "restricted" else "internal" + ), + institutional_context=record.context, + ), + ) + return _record_from_row(row) + + +def _replay( + session: Session, + *, + tenant_id: str, + idempotency_key: str, + request_sha256: str, +) -> CaseRecord | None: + clean_key = _required_text( + idempotency_key, + "Case idempotency key", + maximum=255, + ) + row = ( + session.query(CaseTimelineEntry) + .filter( + CaseTimelineEntry.tenant_id == tenant_id, + CaseTimelineEntry.idempotency_key == clean_key, + ) + .one_or_none() + ) + if row is None: + return None + if row.request_sha256 != request_sha256: + raise CaseStoreError( + "Case idempotency conflict: the key was used for another request." + ) + revision = ( + session.query(CaseRecordRevision) + .filter( + CaseRecordRevision.tenant_id == tenant_id, + CaseRecordRevision.case_id == row.case_id, + CaseRecordRevision.revision == row.case_revision, + ) + .one() + ) + return _record_from_row(revision) + + +def _validate_catalog_for_record(session: Session, record: CaseRecord) -> None: + case_type = _case_type( + session, + tenant_id=record.reference.tenant_id, + key=record.case_type_key, + ) + if case_type is None or not case_type.active: + raise CaseStoreError("Case type is missing or inactive.") + _validate_status(session, record=record, case_type=case_type) + + +def _validate_status_transition( + session: Session, + *, + record: CaseRecord, + next_status: str, +) -> bool: + case_type = _case_type( + session, + tenant_id=record.reference.tenant_id, + key=record.case_type_key, + ) + if case_type is None or not case_type.active: + raise CaseStoreError("Case type is missing or inactive.") + status = ( + session.query(CaseStatusDefinition) + .filter( + CaseStatusDefinition.tenant_id == record.reference.tenant_id, + CaseStatusDefinition.status_key == next_status, + CaseStatusDefinition.active.is_(True), + ) + .one_or_none() + ) + if status is None: + raise CaseStoreError("Case status is missing or inactive.") + allowed = tuple(case_type.allowed_status_keys or ()) + if allowed and status.status_key not in allowed: + raise CaseStoreError( + "Case status is not allowed for this case type." + ) + return status.terminal + + +def _validate_status( + session: Session, + *, + record: CaseRecord, + case_type: CaseTypeDefinition, +) -> CaseStatusDefinition: + status = ( + session.query(CaseStatusDefinition) + .filter( + CaseStatusDefinition.tenant_id == record.reference.tenant_id, + CaseStatusDefinition.status_key == record.status_key, + CaseStatusDefinition.active.is_(True), + ) + .one_or_none() + ) + if status is None: + raise CaseStoreError("Case status is missing or inactive.") + allowed = tuple(case_type.allowed_status_keys or ()) + if allowed and status.status_key not in allowed: + raise CaseStoreError( + "Case status is not allowed for this case type." + ) + if status.terminal and record.closed_at is None: + raise CaseStoreError("A terminal case status requires closed_at.") + if not status.terminal and record.closed_at is not None: + raise CaseStoreError("A non-terminal case status cannot set closed_at.") + return status + + +def _case_type( + session: Session, + *, + tenant_id: str, + key: str, +) -> CaseTypeDefinition | None: + return ( + session.query(CaseTypeDefinition) + .filter( + CaseTypeDefinition.tenant_id == tenant_id, + CaseTypeDefinition.type_key == key, + ) + .one_or_none() + ) + + +def _current_row( + session: Session, + *, + tenant_id: str, + case_id: str, + lock: bool, +) -> CaseRecordRevision | None: + query = session.query(CaseRecordRevision).filter( + CaseRecordRevision.tenant_id == tenant_id, + CaseRecordRevision.case_id == case_id, + CaseRecordRevision.superseded_at.is_(None), + ) + if lock: + query = query.with_for_update() + return query.one_or_none() + + +def _identity( + session: Session, + *, + tenant_id: str, + case_id: str, +) -> CaseIdentity | None: + return ( + session.query(CaseIdentity) + .filter( + CaseIdentity.tenant_id == tenant_id, + CaseIdentity.case_id == case_id, + ) + .one_or_none() + ) + + +def _record_from_row(row: CaseRecordRevision) -> CaseRecord: + payload: dict[str, object] = dict(row.snapshot) + payload["revision"] = row.revision + reference = dict(payload.get("reference") or {}) + reference["version"] = str(row.revision) + payload["reference"] = reference + payload["status_key"] = row.status_key + payload["access_mode"] = row.access_mode + payload["recorded_at"] = _datetime_text(row.recorded_at) + payload["deadline_at"] = _datetime_text(row.deadline_at) + payload["closed_at"] = _datetime_text(row.closed_at) + return CaseRecord.from_mapping(payload) + + +def _status_payload(row: CaseStatusDefinition) -> dict[str, object]: + 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, + } + + +def _type_payload(row: CaseTypeDefinition) -> dict[str, object]: + 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, + } + + +def _timeline_payload(row: CaseTimelineEntry) -> dict[str, object]: + return { + "event_id": row.event_id, + "event_type": row.event_type, + "case_revision": row.case_revision, + "summary": row.summary, + "occurred_at": _datetime_text(row.occurred_at), + "actor_id": row.actor_id, + "audit_event_id": row.audit_event_id, + "payload": dict(row.payload or {}), + } + + +def _change_tuple( + changes: Mapping[str, object], + key: str, + current: tuple[Any, ...], +) -> tuple[Any, ...]: + if key not in changes: + return current + value = changes[key] + if not isinstance(value, (list, tuple)): + raise CaseStoreError(f"Case {key} must be a list.") + return tuple(value) + + +def _change_grants( + changes: Mapping[str, object], + key: str, + current: tuple[CaseGrant, ...], +) -> tuple[CaseGrant, ...]: + values = _change_tuple(changes, key, current) + grants: list[CaseGrant] = [] + for item in values: + if isinstance(item, CaseGrant): + grants.append(item) + elif isinstance(item, Mapping): + grants.append(CaseGrant.from_mapping(item)) + else: + raise CaseStoreError("Case access grants must be objects.") + return tuple(grants) + + +def _normalized_changes(changes: Mapping[str, object]) -> dict[str, object]: + allowed = { + "status_key", + "title", + "access_mode", + "access_grants", + "party_refs", + "assignment_refs", + "evidence_refs", + "decision_refs", + "record_refs", + "deadline_at", + "closed_at", + "metadata", + } + unknown = set(changes) - allowed + if unknown: + raise CaseStoreError( + "Unsupported case update fields: " + ", ".join(sorted(unknown)) + ) + return _json_value(dict(changes)) + + +def _request_sha256(value: object) -> str: + payload = json.dumps( + _json_value(value), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _json_value(value: object) -> Any: + if isinstance(value, datetime): + return value.isoformat() + if hasattr(value, "to_dict"): + return _json_value(value.to_dict()) + if isinstance(value, Mapping): + return {str(key): _json_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_value(item) for item in value] + return value + + +def _status_is_in_use(session: Session, *, tenant_id: str, key: str) -> bool: + return ( + session.query(CaseRecordRevision.id) + .filter( + CaseRecordRevision.tenant_id == tenant_id, + CaseRecordRevision.status_key == key, + CaseRecordRevision.superseded_at.is_(None), + ) + .first() + is not None + ) + + +def _type_is_in_use(session: Session, *, tenant_id: str, key: str) -> bool: + return ( + session.query(CaseRecordRevision.id) + .filter( + CaseRecordRevision.tenant_id == tenant_id, + CaseRecordRevision.case_type_key == key, + CaseRecordRevision.superseded_at.is_(None), + ) + .first() + is not None + ) + + +def _catalog_key(value: str, label: str) -> str: + key = _required_text(value, label, maximum=120).casefold() + if any(character not in "abcdefghijklmnopqrstuvwxyz0123456789._-" for character in key): + raise CaseStoreError( + f"{label} may contain only letters, digits, dot, underscore, and hyphen." + ) + return key + + +def _required_text(value: object, label: str, *, maximum: int) -> str: + result = str(value or "").strip() + if not result: + raise CaseStoreError(f"{label} is required.") + if len(result) > maximum: + raise CaseStoreError(f"{label} is limited to {maximum} characters.") + return result + + +def _optional_text(value: object, *, maximum: int) -> str | None: + result = str(value or "").strip() + if not result: + return None + if len(result) > maximum: + raise CaseStoreError(f"Text is limited to {maximum} characters.") + return result + + +def _require_aware(value: datetime, label: str) -> None: + if value.tzinfo is None or value.utcoffset() is None: + raise CaseStoreError(f"{label} must include a timezone.") + + +def _principal_tenant(principal: object) -> str: + tenant_id = str(getattr(principal, "tenant_id", "") or "").strip() + if not tenant_id: + raise InstitutionalContextError( + "Case operations require a tenant-bound principal." + ) + return tenant_id + + +def _principal_actor(principal: object) -> str | None: + user = getattr(principal, "user", None) + for value in ( + getattr(user, "id", None), + getattr(principal, "account_id", None), + getattr(principal, "identity_id", None), + getattr(principal, "membership_id", None), + ): + candidate = str(value or "").strip() + if candidate: + return candidate + return None + + +def _session(value: object) -> Session: + if not hasattr(value, "query"): + raise InstitutionalContextError("Case registry requires a database session.") + return value # type: ignore[return-value] + + +def _datetime_text(value: datetime | None) -> str | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + return value.isoformat() + + +__all__ = [ + "CAPABILITY_CASES_REGISTRY", + "CaseStoreError", + "SqlCaseRegistry", + "case_history", + "case_timeline", + "create_case", + "create_case_from_intake", + "get_case", + "list_case_catalog", + "list_cases", + "update_case", + "upsert_case_status", + "upsert_case_type", +] diff --git a/src/govoplan_cases/backend/service_intake.py b/src/govoplan_cases/backend/service_intake.py new file mode 100644 index 0000000..c24e95a --- /dev/null +++ b/src/govoplan_cases/backend/service_intake.py @@ -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", +] diff --git a/src/govoplan_cases/backend/service_launcher.py b/src/govoplan_cases/backend/service_launcher.py new file mode 100644 index 0000000..4a53ba8 --- /dev/null +++ b/src/govoplan_cases/backend/service_launcher.py @@ -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"] diff --git a/src/govoplan_cases/py.typed b/src/govoplan_cases/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_cases/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_case_lifecycle.py b/tests/test_case_lifecycle.py new file mode 100644 index 0000000..1b88fa8 --- /dev/null +++ b/tests/test_case_lifecycle.py @@ -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() diff --git a/tests/test_institutional_consumers.py b/tests/test_institutional_consumers.py new file mode 100644 index 0000000..a33081c --- /dev/null +++ b/tests/test_institutional_consumers.py @@ -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() diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..28c7e5c --- /dev/null +++ b/tests/test_migrations.py @@ -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() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..c06baee --- /dev/null +++ b/webui/package.json @@ -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 + } + } +} diff --git a/webui/src/api/cases.ts b/webui/src/api/cases.ts new file mode 100644 index 0000000..943dcf2 --- /dev/null +++ b/webui/src/api/cases.ts @@ -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; + 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; +}; + +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; +}; + +export function listCases( + settings: ApiSettings, + options: { + query?: string; + statuses?: string[]; + caseTypes?: string[]; + offset?: number; + limit?: number; + }, + signal?: AbortSignal +): Promise { + return apiFetch(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 { + return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}`, { signal }); +} + +export function listCaseCatalog( + settings: ApiSettings, + signal?: AbortSignal +): Promise { + return apiFetch(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 { + return apiFetch(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 } + ); +} diff --git a/webui/src/features/cases/CaseDetailPage.tsx b/webui/src/features/cases/CaseDetailPage.tsx new file mode 100644 index 0000000..ff227e2 --- /dev/null +++ b/webui/src/features/cases/CaseDetailPage.tsx @@ -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(null); + const [catalog, setCatalog] = useState({ statuses: [], types: [] }); + const [history, setHistory] = useState([]); + const [timeline, setTimeline] = useState([]); + 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 ( +
+
+
+ + {record && {record.case_number}} + {record && canShare ? ( + } + className="case-share-button" + onClick={() => setShareOpen(true)} + /> + ) : null} +
+ + {error && + + {error} + + } + {loading && } + {!loading && record && +
+
+
+
+ {humanize(record.case_type_key)} +

{record.title}

+
+ +
+ + {canUpdate && +
+ + + + +
+ } + +
+ + + + +
+ + + + + +
+ + +
+ } +
+
+ {record ? ( + setShareOpen(false)} + onSaved={(saved) => { + setRecord(saved); + void load().catch((reason) => { + setError(reason instanceof Error ? reason.message : "Case could not be reloaded."); + }); + }} + /> + ) : null} +
+ ); +} + +function Fact({ label, value }: { label: string; value: string }) { + return
{label}{value}
; +} + +function ReferenceSection({ title, references }: { title: string; references: InstitutionalReference[] }) { + if (references.length === 0) return null; + return ( +
+

{title}

+
+ {references.map((reference) => + + {humanize(reference.kind)} + {reference.object_id} + + )} +
+
+ ); +} + +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()); +} diff --git a/webui/src/features/cases/CaseShareDialog.tsx b/webui/src/features/cases/CaseShareDialog.tsx new file mode 100644 index 0000000..412ae78 --- /dev/null +++ b/webui/src/features/cases/CaseShareDialog.tsx @@ -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(record.access_grants); + const [targetType, setTargetType] = useState("user"); + const [targetId, setTargetId] = useState(""); + const [permission, setPermission] = useState("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 ( + + + + + } + > +
+ {error ? ( + {error} + ) : null} + +

+ 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. +

+ +
+ + + + + + + + + + } + variant="primary" + disabled={busy || !targetId.trim()} + onClick={addGrant} + /> +
+ +
+ {grants.length === 0 ? ( +

No explicit access grants.

+ ) : grants.map((grant) => ( +
+ + {humanize(grant.subject_kind)} + {grant.subject_id} + + + } + variant="danger" + disabled={busy} + onClick={() => setGrants((current) => current.filter((item) => item !== grant))} + /> +
+ ))} +
+ + + setChangeReason(event.target.value)} + placeholder="Why is case access changing?" + /> + +
+
+ ); +} + +function humanize(value: string): string { + return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +} diff --git a/webui/src/features/cases/CasesPage.tsx b/webui/src/features/cases/CasesPage.tsx new file mode 100644 index 0000000..3847f45 --- /dev/null +++ b/webui/src/features/cases/CasesPage.tsx @@ -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([]); + const [catalog, setCatalog] = useState({ 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 ( +
+
+
+
+
+ + {error && + setError("")}> + {error} + + } + {loading && } + {!loading && !error && cases.length === 0 && +
No matching cases.
+ } + {!loading && cases.length > 0 && +
+ {cases.map((item) => { + const statusDefinition = statusLabels.get(item.status_key); + return ( + + ); + })} +
+ } +
+
+
+ ); +} + +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()); +} diff --git a/webui/src/index.ts b/webui/src/index.ts new file mode 100644 index 0000000..0702df8 --- /dev/null +++ b/webui/src/index.ts @@ -0,0 +1,2 @@ +export { default, casesModule } from "./module"; +export * from "./api/cases"; diff --git a/webui/src/module.ts b/webui/src/module.ts new file mode 100644 index 0000000..9ebc26b --- /dev/null +++ b/webui/src/module.ts @@ -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; diff --git a/webui/src/styles/cases.css b/webui/src/styles/cases.css new file mode 100644 index 0000000..b8c16cf --- /dev/null +++ b/webui/src/styles/cases.css @@ -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; + } +}