From d3a70f87cd6918b892d19a77ebabf50a55971bba Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 22 Aug 2026 11:41:45 +0200 Subject: [PATCH] feat(cases): accept replay-safe ticket escalations --- README.md | 16 +- docs/CONCEPT.md | 10 ++ pyproject.toml | 4 +- src/govoplan_cases/backend/manifest.py | 53 +++++- .../backend/ticket_escalation.py | 161 ++++++++++++++++++ tests/test_ticket_escalation.py | 108 ++++++++++++ webui/package.json | 4 +- webui/src/module.ts | 2 +- 8 files changed, 351 insertions(+), 7 deletions(-) create mode 100644 src/govoplan_cases/backend/ticket_escalation.py create mode 100644 tests/test_ticket_escalation.py diff --git a/README.md b/README.md index babc9a5..1f69490 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,10 @@ The executable backend slice now exposes: assignment/evidence/Decision/record references, and replay-safe lifecycle events; and - `cases.service_launcher`, which opens exactly one deterministic case from an - exact published Service revision and safely replays the same Portal launch. + exact published Service revision and safely replays the same Portal launch; + and +- `tickets.case_escalation`, which creates exactly one formal Case from an + authorized Ticket handoff while preserving a governed Ticket reference. Cases does not own institutional Service, Party, representation, identity, address, Mandate, Decision, file, workflow, or task lifecycles. The `/cases` @@ -50,3 +53,14 @@ Historical case evidence is retained; current open case and active access facts require authorized manual review through the existing lifecycle. Applicant identity correlation remains a Parties responsibility and is never guessed from a case's party references. + +## Ticket escalation + +When Tickets requests escalation, Cases rechecks tenant membership and +`cases:case:create`, resolves the exact active Case type and its initial status, +and derives a deterministic Case ID from the Ticket and idempotency key. A retry +returns the same Case. The Case keeps only the stable Ticket reference, source +revision, number, and authorized handoff note in governed context; it does not +copy Ticket comments, attachments, participants, or lifecycle state. If Cases +is absent or the configured type is unavailable, Tickets remains usable and +shows the integration consequence to the operator. diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index 155e29a..0494246 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -90,6 +90,7 @@ The persistent backend provides: - 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 +- deterministic, replay-safe Case escalation from an authorized Ticket handoff The `/cases` list and `/cases/:caseId` detail workspace provide server-side search/status filtering, case facts, typed references, history, timeline, and @@ -159,6 +160,15 @@ reference. Retrying the same launch returns the existing case; a changed request cannot reuse the key. Portal therefore cannot create duplicate cases after an ambiguous network response and does not gain access to Cases tables. +`tickets.case_escalation` is the optional effect boundary used by Tickets. It +rechecks `cases:case:create`, tenant identity, and the requested active Case +type, then derives a stable Case identifier from the Ticket and idempotency key. +The Case stores a governed `work_item` reference plus bounded source and handoff +metadata. It never imports Tickets or copies Ticket messages, files, +participants, assignment, or lifecycle. Replaying the same handoff returns the +existing Case, while an unavailable Cases capability leaves the operational +Ticket intact. + Cases links formal Decision records and may retain a current outcome/status projection. It does not own decision authority, rule versions, reasoning, correction, revocation, or remedy semantics. diff --git a/pyproject.toml b/pyproject.toml index fb33b44..1f006cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-cases" -version = "0.1.19" +version = "0.1.20" description = "GovOPlaN administrative case context module." readme = "README.md" requires-python = ">=3.12" authors = [{ name = "GovOPlaN" }] -dependencies = ["govoplan-core>=0.1.18"] +dependencies = ["govoplan-core>=0.1.30"] [tool.setuptools.packages.find] where = ["src"] diff --git a/src/govoplan_cases/backend/manifest.py b/src/govoplan_cases/backend/manifest.py index a813156..4a665f8 100644 --- a/src/govoplan_cases/backend/manifest.py +++ b/src/govoplan_cases/backend/manifest.py @@ -31,6 +31,7 @@ from govoplan_core.core.modules import ( ) from govoplan_core.core.views import ViewSurface from govoplan_core.core.search import SearchSourceProviderRegistration +from govoplan_core.core.tickets import CAPABILITY_TICKET_CASE_ESCALATION from govoplan_core.core.provider_governance import ( ModuleArchitectureDeclaration, ModuleArchitectureDocumentation, @@ -60,11 +61,12 @@ from govoplan_cases.backend.record_source import ( create_cases_record_source, ) from govoplan_cases.backend.search_source import create_cases_search_source +from govoplan_cases.backend.ticket_escalation import TicketCaseEscalationProvider from govoplan_core.db.base import Base MODULE_ID = "cases" -MODULE_VERSION = "0.1.19" +MODULE_VERSION = "0.1.20" READ_SCOPE = "cases:case:read" CREATE_SCOPE = "cases:case:create" UPDATE_SCOPE = "cases:case:update" @@ -98,6 +100,10 @@ def _dsar_provider(context: ModuleContext) -> CasesDsarProvider: return CasesDsarProvider() +def _ticket_escalation(_context: ModuleContext) -> TicketCaseEscalationProvider: + return TicketCaseEscalationProvider() + + def _router(context: ModuleContext): del context from govoplan_cases.backend.router import router @@ -333,6 +339,7 @@ manifest = ModuleManifest( ModuleInterfaceProvider(name="cases.service_launcher", version="0.1.0"), ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_CASES, version="1.0.0"), ModuleInterfaceProvider(name=CASES_DSAR_CAPABILITY, version="0.1.0"), + ModuleInterfaceProvider(name=CAPABILITY_TICKET_CASE_ESCALATION, version="1.0.0"), ), requires_interfaces=( ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), @@ -348,6 +355,7 @@ manifest = ModuleManifest( CAPABILITY_CASES_SERVICE_LAUNCHER: _service_launcher, CAPABILITY_RECORD_SOURCE_CASES: create_cases_record_source, CASES_DSAR_CAPABILITY: _dsar_provider, + CAPABILITY_TICKET_CASE_ESCALATION: _ticket_escalation, }, capability_documentation={ CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation( @@ -380,6 +388,11 @@ manifest = ModuleManifest( summary="Finds minimized, tenant-scoped case access, attribution, and explicitly referenced lifecycle facts.", contract_version="0.1.0", ), + CAPABILITY_TICKET_CASE_ESCALATION: CapabilityDocumentation( + label="Ticket-to-Case escalation", + summary="Creates one replay-safe formal Case from a Ticket handoff and returns only the stable Case reference.", + contract_version="1.0.0", + ), }, migration_spec=MigrationSpec( module_id=MODULE_ID, @@ -417,6 +430,44 @@ manifest = ModuleManifest( ), tenant_summary_providers=(_tenant_summary,), documentation=( + DocumentationTopic( + id="cases.workflow.ticket-escalation", + title="Create a formal Case from a Ticket escalation", + summary="Preserve the operational Ticket and start one replay-safe formal procedure with a stable cross-reference.", + body=( + "An authorized ticket triage actor selects an active Case type and submits a handoff note. " + "Cases rechecks the active tenant and cases:case:create permission, derives a stable Case identity from the Ticket and idempotency key, " + "uses the configured initial status, and records the exact Ticket work-item reference in the governed Case context. " + "The Ticket remains authoritative for intake, queue work, comments, and service resolution; the Case becomes authoritative for parties, evidence, decisions, deadlines, records, and retention. " + "Replaying the same request returns the existing Case. A changed request or missing/inactive Case type fails closed." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("case_manager", "ticket_manager", "operator", "auditor"), + related_modules=("tickets",), + order=11, + conditions=( + DocumentationCondition( + required_modules=("cases", "tickets"), + required_scopes=(CREATE_SCOPE, "tickets:ticket:triage"), + ), + ), + translations={ + "de": { + "title": "Aus einer Ticket-Eskalation einen förmlichen Vorgang anlegen", + "summary": "Das operative Ticket erhalten und ein wiederholungssicheres förmliches Verfahren mit stabilem Querverweis starten.", + "body": "Eine berechtigte Triage wählt einen aktiven Vorgangstyp und übergibt einen Hinweis. Cases prüft den aktiven Mandanten und die Berechtigung cases:case:create erneut, leitet aus Ticket und Idempotenzschlüssel eine stabile Vorgangskennung ab, verwendet den konfigurierten Anfangsstatus und hält den genauen Ticket-Verweis im geregelten Vorgangskontext fest. Das Ticket bleibt für Aufnahme, Warteschlangenarbeit, Kommentare und Servicelösung maßgeblich; der Vorgang wird für Beteiligte, Nachweise, Entscheidungen, Fristen, Akten und Aufbewahrung maßgeblich. Dieselbe Anfrage liefert den vorhandenen Vorgang; geänderte Anfragen sowie fehlende oder inaktive Vorgangstypen werden abgewiesen.", + } + }, + metadata={ + "kind": "workflow", + "help_contexts": ["tickets.action.escalate", "cases.state.ticket-escalation"], + "consequence_classes": { + "create_case": "Creates one Cases-owned formal procedure and returns its stable reference.", + "preserve_ticket": "Does not convert, copy, or mutate the Ticket history.", + }, + }, + ), DocumentationTopic( id="cases.workflow.link-owner-references", title="Link exact Files, Mail, and Campaign references to a Case", diff --git a/src/govoplan_cases/backend/ticket_escalation.py b/src/govoplan_cases/backend/ticket_escalation.py new file mode 100644 index 0000000..2a1c691 --- /dev/null +++ b/src/govoplan_cases/backend/ticket_escalation.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from urllib.parse import quote +import uuid + +from sqlalchemy.orm import Session + +from govoplan_core.core.institutional import ( + GovernedContextEnvelope, + InstitutionalReference, + TemporalRevision, +) +from govoplan_core.core.tickets import ( + TicketCaseEscalationCommand, + TicketCaseEscalationResult, +) +from govoplan_core.security.module_permissions import scopes_grant_compatible +from govoplan_cases.backend.domain import CaseRecord +from govoplan_cases.backend.service import create_case, get_case, list_case_catalog + + +_CASE_NAMESPACE = uuid.uuid5( + uuid.NAMESPACE_URL, + "https://govoplan.add-ideas.de/contracts/tickets/case-escalation/v1", +) + + +class TicketCaseEscalationProvider: + """Create one replay-safe formal Case while keeping Ticket history separate.""" + + def escalate_ticket( + self, + session: object, + principal: object, + *, + command: TicketCaseEscalationCommand, + ) -> TicketCaseEscalationResult: + db = _session(session) + tenant_id = str(getattr(principal, "tenant_id", "") or "").strip() + if command.tenant_id != tenant_id: + raise PermissionError("Ticket escalation cannot cross tenants.") + if not _has_scope(principal, "cases:case:create"): + raise PermissionError("Ticket escalation requires cases:case:create.") + case_id = str( + uuid.uuid5( + _CASE_NAMESPACE, + "\0".join((tenant_id, command.ticket_id, command.idempotency_key)), + ) + ) + existing = get_case(db, principal, case_id=case_id) + if existing is not None: + return _result(existing, replayed=True) + + catalog = list_case_catalog(db, principal) + case_type = next( + ( + item + for item in catalog["types"] + if item.get("type_key") == command.case_type_key and item.get("active") + ), + None, + ) + if case_type is None: + raise ValueError( + f"Case type {command.case_type_key!r} is not configured or active." + ) + case_ref = InstitutionalReference( + kind="case", + owner_module="cases", + object_id=case_id, + tenant_id=tenant_id, + version="1", + valid_at=command.occurred_at, + ) + ticket_ref = InstitutionalReference( + kind="work_item", + owner_module="tickets", + object_id=command.ticket_id, + tenant_id=tenant_id, + version=str(command.metadata.get("ticket_revision") or "1"), + valid_at=command.occurred_at, + label=command.ticket_number, + ) + change_reason = f"Escalated from ticket {command.ticket_number}." + context = GovernedContextEnvelope( + tenant_id=tenant_id, + temporal=TemporalRevision( + revision="1", + valid_from=command.occurred_at, + recorded_at=command.occurred_at, + change_reason=change_reason, + ), + case_ref=case_ref, + work_item_ref=ticket_ref, + ) + record = CaseRecord( + reference=case_ref, + case_number=_case_number(command, case_id), + case_type_key=command.case_type_key, + status_key=str(case_type["initial_status_key"]), + title=command.title, + context=context, + opened_at=command.occurred_at, + recorded_at=command.occurred_at, + change_reason=change_reason, + metadata={ + "source_module": "tickets", + "source_resource_type": "ticket", + "source_resource_id": command.ticket_id, + "source_resource_number": command.ticket_number, + "source_revision": str(command.metadata.get("ticket_revision") or "1"), + "handoff_note": command.handoff_note, + "integration_contract": "tickets.case_escalation/v1", + }, + ) + created = create_case( + db, + principal, + record=record, + idempotency_key=f"ticket-escalation:{command.idempotency_key}", + ) + return _result(created, replayed=False) + + +def _result(record: CaseRecord, *, replayed: bool) -> TicketCaseEscalationResult: + return TicketCaseEscalationResult( + provider_id="cases", + case_id=record.reference.object_id, + case_number=record.case_number, + case_url=f"/cases/{quote(record.reference.object_id, safe='')}", + replayed=replayed, + metadata={ + "case_revision": record.revision, + "case_type_key": record.case_type_key, + "status_key": record.status_key, + }, + ) + + +def _case_number(command: TicketCaseEscalationCommand, case_id: str) -> str: + prefix = f"CASE-{command.ticket_number}"[:230].rstrip("-") + return f"{prefix}-{case_id[:8].upper()}" + + +def _has_scope(principal: object, scope: str) -> bool: + method = getattr(principal, "has", None) + if callable(method): + return bool(method(scope)) + return scopes_grant_compatible( + frozenset(getattr(principal, "scopes", ()) or ()), + scope, + ) + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Ticket Case escalation requires a SQLAlchemy session.") + return value + + +__all__ = ["TicketCaseEscalationProvider"] diff --git a/tests/test_ticket_escalation.py b/tests/test_ticket_escalation.py new file mode 100644 index 0000000..b2d44e7 --- /dev/null +++ b/tests/test_ticket_escalation.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_core.core.tickets import TicketCaseEscalationCommand +from govoplan_core.db.base import Base +from govoplan_cases.backend.db import models as _case_models +from govoplan_cases.backend.service import upsert_case_status, upsert_case_type +from govoplan_cases.backend.ticket_escalation import TicketCaseEscalationProvider + + +NOW = datetime(2026, 8, 22, 10, 0, tzinfo=UTC) + + +class _Principal: + def __init__(self, tenant_id: str = "tenant-1", *, can_create: bool = True) -> None: + self.tenant_id = tenant_id + self.account_id = "case-manager-1" + self.identity_id = None + self.membership_id = "membership-1" + self.group_ids = frozenset() + self.role_ids = frozenset() + self.function_assignment_ids = frozenset() + self.acting_assignment_id = None + self.scopes = frozenset({"cases:case:create", "cases:case:read"} if can_create else {"cases:case:read"}) + self.user = SimpleNamespace(id=self.account_id) + + def has(self, scope: str) -> bool: + return scope in self.scopes + + +class TicketEscalationTests(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.Session = sessionmaker(bind=engine, expire_on_commit=False) + self.principal = _Principal() + + def test_escalation_creates_one_linked_case_and_replays(self) -> None: + with self.Session() as session: + upsert_case_status( + session, + self.principal, + status_key="open", + label="Open", + ) + upsert_case_type( + session, + self.principal, + type_key="service-request", + label="Service request", + initial_status_key="open", + ) + command = TicketCaseEscalationCommand( + tenant_id="tenant-1", + ticket_id="ticket-1", + ticket_number="TKT-2026-0001", + title="Broken streetlight", + case_type_key="service-request", + occurred_at=NOW, + idempotency_key="escalation-1", + handoff_note="Formal procedure required.", + metadata={"ticket_revision": 3}, + ) + provider = TicketCaseEscalationProvider() + + created = provider.escalate_ticket(session, self.principal, command=command) + session.commit() + replay = provider.escalate_ticket(session, self.principal, command=command) + + self.assertFalse(created.replayed) + self.assertTrue(replay.replayed) + self.assertEqual(created.case_id, replay.case_id) + self.assertEqual("service-request", created.metadata["case_type_key"]) + self.assertTrue(created.case_url.startswith("/cases/")) + self.assertEqual(1, session.query(_case_models.CaseIdentity).count()) + current = session.query(_case_models.CaseRecordRevision).filter( + _case_models.CaseRecordRevision.case_id == created.case_id, + _case_models.CaseRecordRevision.superseded_at.is_(None), + ).one() + self.assertEqual("tickets", current.snapshot["context"]["work_item_ref"]["owner_module"]) + self.assertEqual("ticket-1", current.snapshot["metadata"]["source_resource_id"]) + + def test_escalation_fails_closed_for_cross_tenant_or_missing_permission(self) -> None: + command = TicketCaseEscalationCommand( + tenant_id="tenant-1", + ticket_id="ticket-1", + ticket_number="TKT-1", + title="Request", + case_type_key="service-request", + occurred_at=NOW, + idempotency_key="escalation-2", + ) + provider = TicketCaseEscalationProvider() + with self.Session() as session: + with self.assertRaises(PermissionError): + provider.escalate_ticket(session, _Principal("tenant-2"), command=command) + with self.assertRaises(PermissionError): + provider.escalate_ticket(session, _Principal(can_create=False), command=command) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json index 6bbe968..64a111a 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/cases-webui", - "version": "0.1.19", + "version": "0.1.20", "private": true, "type": "module", "main": "src/index.ts", @@ -14,7 +14,7 @@ "./styles/cases.css": "./src/styles/cases.css" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.30", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20", diff --git a/webui/src/module.ts b/webui/src/module.ts index 1836065..6f448f4 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -10,7 +10,7 @@ const CaseDetailPage = lazy(() => import("./features/cases/CaseDetailPage")); export const casesModule: PlatformWebModule = { id: "cases", label: "i18n:govoplan-cases.cases", - version: "0.1.8", + version: "0.1.20", optionalDependencies: [ "access", "addresses",