3 Commits
Author SHA1 Message Date
zemion e1cffd3e53 docs(cases): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 20:19:14 +02:00
zemion d395b22301 build(webui): expose tagged Cases package
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 11:48:43 +02:00
zemion d3a70f87cd feat(cases): accept replay-safe ticket escalations 2026-08-22 11:41:45 +02:00
10 changed files with 603 additions and 29 deletions
+15 -1
View File
@@ -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.
+10
View File
@@ -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.
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@govoplan/cases-webui",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/cases.css": "./webui/src/styles/cases.css"
},
"files": [
"webui/src",
"README.md",
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.30",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-cases"
version = "0.1.19"
version = "0.1.21"
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"]
+259 -20
View File
@@ -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,
@@ -42,7 +43,10 @@ from govoplan_cases.backend.party_context import (
)
from govoplan_cases.backend.acl import CaseAclProvider
from govoplan_cases.backend.db import models as case_models
from govoplan_cases.backend.dsar_provider import CASES_DSAR_CAPABILITY, CasesDsarProvider
from govoplan_cases.backend.dsar_provider import (
CASES_DSAR_CAPABILITY,
CasesDsarProvider,
)
from govoplan_cases.backend.service_intake import (
CAPABILITY_CASES_SERVICE_INTAKE,
CaseServiceIntake,
@@ -60,11 +64,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.21"
READ_SCOPE = "cases:case:read"
CREATE_SCOPE = "cases:case:create"
UPDATE_SCOPE = "cases:case:update"
@@ -98,6 +103,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
@@ -163,20 +172,47 @@ manifest = ModuleManifest(
CAPABILITY_DECISION_REGISTRY,
),
permissions=(
_permission(READ_SCOPE, "View cases", "Read tenant cases and their governed history."),
_permission(CREATE_SCOPE, "Create cases", "Open a case from a configured case type or service intake."),
_permission(UPDATE_SCOPE, "Update cases", "Create a guarded immutable case revision."),
_permission(ASSIGN_SCOPE, "Assign cases", "Change stable function, assignment, or work-item references on a case."),
_permission(CLOSE_SCOPE, "Close cases", "Move a case to a configured terminal status."),
_permission(SHARE_SCOPE, "Share cases", "Restrict a case and grant object-level access to selected principals."),
_permission(ADMIN_SCOPE, "Administer cases", "Configure tenant case types and statuses."),
_permission(
READ_SCOPE, "View cases", "Read tenant cases and their governed history."
),
_permission(
CREATE_SCOPE,
"Create cases",
"Open a case from a configured case type or service intake.",
),
_permission(
UPDATE_SCOPE, "Update cases", "Create a guarded immutable case revision."
),
_permission(
ASSIGN_SCOPE,
"Assign cases",
"Change stable function, assignment, or work-item references on a case.",
),
_permission(
CLOSE_SCOPE, "Close cases", "Move a case to a configured terminal status."
),
_permission(
SHARE_SCOPE,
"Share cases",
"Restrict a case and grant object-level access to selected principals.",
),
_permission(
ADMIN_SCOPE, "Administer cases", "Configure tenant case types and statuses."
),
),
role_templates=(
RoleTemplate(
slug="case_manager",
name="Case manager",
description="Create, assign, update, and close tenant cases.",
permissions=(READ_SCOPE, CREATE_SCOPE, UPDATE_SCOPE, ASSIGN_SCOPE, CLOSE_SCOPE, SHARE_SCOPE),
permissions=(
READ_SCOPE,
CREATE_SCOPE,
UPDATE_SCOPE,
ASSIGN_SCOPE,
CLOSE_SCOPE,
SHARE_SCOPE,
),
),
RoleTemplate(
slug="case_reader",
@@ -188,7 +224,15 @@ manifest = ModuleManifest(
slug="case_administrator",
name="Case administrator",
description="Configure case types and statuses and manage cases.",
permissions=(READ_SCOPE, CREATE_SCOPE, UPDATE_SCOPE, ASSIGN_SCOPE, CLOSE_SCOPE, SHARE_SCOPE, ADMIN_SCOPE),
permissions=(
READ_SCOPE,
CREATE_SCOPE,
UPDATE_SCOPE,
ASSIGN_SCOPE,
CLOSE_SCOPE,
SHARE_SCOPE,
ADMIN_SCOPE,
),
),
),
route_factory=_router,
@@ -234,7 +278,11 @@ manifest = ModuleManifest(
label="i18n:govoplan-core.product_area.services_cases",
icon="landmark",
description="i18n:govoplan-core.product_area.services_cases_description",
surface_ids=("cases.nav.cases", "cases.route.cases", "cases.route.cases.caseid"),
surface_ids=(
"cases.nav.cases",
"cases.route.cases",
"cases.route.cases.caseid",
),
order=20,
),
),
@@ -333,13 +381,41 @@ 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),
ModuleInterfaceRequirement(name="parties.procedure", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="parties.representation", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="mandates.resolution", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="decisions.formal_outcome", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(
name="services.definition",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="parties.procedure",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="parties.representation",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="mandates.resolution",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="decisions.formal_outcome",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
),
capability_factories={
CAPABILITY_CASES_SERVICE_INTAKE: _service_intake,
@@ -348,6 +424,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 +457,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 +499,47 @@ 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",
@@ -460,6 +583,27 @@ manifest = ModuleManifest(
kind="repository",
),
),
translations={
"de": {
"title": "Exakte Files-, Mail- und Campaign-Verweise mit einem Vorgang verknüpfen",
"summary": (
"Einen vom zuständigen Modul autorisierten Verweis hinzufügen oder entfernen, ohne dessen Inhalt nach Cases zu kopieren."
),
"body": (
"Für einen aktiven Vorgang können Files, Mail und Campaigns über den versionierten Schnellzugriff-Ergebnisvertrag eine "
"exakte mandantengebundene Auswahl zurückgeben. Cases akzeptiert nur unterstützte Kombinationen aus zuständigem Modul "
"und Verweisart, den passenden Mandanten, eine festgelegte Version und die kanonische lokale Route des zuständigen Moduls. "
"Verknüpfen und Entfernen prüfen die Änderungsberechtigung für den Vorgang erneut, verwenden erwartete Revision und "
"Idempotenz und ergänzen eine unveränderliche Vorgangshistorie. Cases speichert nur typisierte Modulkennung, exakte "
"Version und Modulroute; Dateibytes, Nachrichtenkopf oder -inhalt, Empfangende, Campaign-Inhalt oder eine frühere "
"Zugriffsentscheidung werden nie kopiert. Das Öffnen wechselt stets in das zuständige Modul, das den aktuellen Zugriff "
"erneut prüft. Wird dieses Modul deaktiviert oder der Quellzugriff später entzogen, behält der Vorgang den historischen "
"Verweis, legt aber keinen Quellinhalt offen. Das Entfernen nimmt nur den Verweis aus der nächsten Vorgangsrevision und "
"löscht niemals das Quellobjekt. Ungespeicherte Vorgangsänderungen müssen vor dem Verknüpfen eines Schnellzugriff-Ergebnisses "
"gespeichert oder verworfen werden."
),
}
},
metadata={
"kind": "workflow",
"help_contexts": ["cases.detail.references"],
@@ -489,6 +633,27 @@ manifest = ModuleManifest(
kind="repository",
),
),
translations={
"de": {
"title": "Betroffenenanfragen für Vorgänge",
"summary": (
"Gesteuerte Vorgangstatsachen ausgeben, ohne die antragstellende Identität zu erraten oder undurchsichtige "
"Vorgangsdaten offenzulegen."
),
"body": (
"Der DSAR-Provider von Cases gleicht im exakten Mandanten Konten-, Identitäts- und Mitgliedschaftszuordnungen, "
"Zugriffsfreigaben für Konten oder Identitäten und ausdrückliche Cases-Verweise ab. Wird ein kanonischer Selektor mit "
"einem direkten Verweis kombiniert, muss eine Cases-eigene Beziehung beide bestätigen; andernfalls schließt die Suche "
"sicher. Direkte Vorgangs- und Revisionsverweise exportieren eine typisierte, minimierte Lebenszyklusprojektion. Rohe "
"Snapshots, Metadaten, Suchtext, Freitext-Änderungsgründe, Timeline-Nutzdaten und -Zusammenfassungen, Nachweiskennungen, "
"Anfrage-Digests, Idempotenzschlüssel, Auditkennungen und unbeteiligte Zugriffsbetroffene sind ausgeschlossen. "
"Unveränderliche Vorgangskennungen, Revisionen, Timelines und Zuordnungen werden aufbewahrt. Aktuelle offene Vorgänge und "
"aktive Zugriffsmerkmale erfordern eine berechtigte manuelle Prüfung im normalen Vorgangs-/Zugriffslebenszyklus; eine "
"automatische Löschung wird nicht angeboten. Die Verbindung zwischen antragstellender Person und Identität gehört Parties; "
"Cases leitet sie daher nicht aus Beteiligtenkennungen ab."
),
}
},
),
DocumentationTopic(
id="cases.workflow.quick-access-context",
@@ -552,9 +717,31 @@ manifest = ModuleManifest(
),
links=(
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"),
DocumentationLink(label="Decisions domain", href="govoplan-decisions/docs/DECISIONS_DOMAIN.md", kind="repository"),
DocumentationLink(
label="Cases concept",
href="govoplan-cases/docs/CONCEPT.md",
kind="repository",
),
DocumentationLink(
label="Decisions domain",
href="govoplan-decisions/docs/DECISIONS_DOMAIN.md",
kind="repository",
),
),
translations={
"de": {
"title": "Förmliche Entscheidung aus einem Vorgang festhalten",
"summary": "Handlungsbefugnis erneut prüfen und eine exakte Decision-Revision in der Vorgangshistorie bewahren.",
"body": (
"Die Aktion in der Vorgangsdetailansicht akzeptiert nur Tenor, Begründung, Bedingungen, Wirksamkeitszeitpunkt und "
"Änderungsgrund. Cases löst die exakte Vorgangsrevision auf, prüft den objektbezogenen Änderungszugriff, ermittelt über "
"Access die aktuelle Handlungszuweisung und verlangt genau ein wirksames Mandat für cases.formal_decision und den gewählten "
"Decision-Typ. Anschließend wird das Ergebnis über das optionale Decisions-Register aufgezeichnet und die exakte "
"Decision-Revision in derselben Datenbanktransaktion mit einer neuen Vorgangsrevision verknüpft. Eine Wiederholung "
"verwendet eine deterministische Decision-Kennung; abweichende Nutzdaten schließen sicher."
),
}
},
metadata={
"kind": "workflow",
"help_contexts": ["cases.action.decide", "cases.decision.editor"],
@@ -595,8 +782,24 @@ manifest = ModuleManifest(
links=(
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
DocumentationLink(label="Records", href="/records", kind="runtime"),
DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"),
DocumentationLink(
label="Cases concept",
href="govoplan-cases/docs/CONCEPT.md",
kind="repository",
),
),
translations={
"de": {
"title": "Exakte Vorgangsrevision in eine eAkte ablegen",
"summary": "Einen rekonstruierbaren Vorgangs-Snapshot in Records bewahren, ohne die Zuständigkeit von Cases zu verlagern.",
"body": (
"Ist Records aktiviert, löst Cases eine exakte unveränderliche Vorgangsrevision erst auf, nachdem aktuelle "
"Vorgangsberechtigung und objektbezogener Zugriff geprüft wurden. Der abgelegte Verweis enthält Vorgangsnummer, "
"Lebenszyklusstatus, dargestelltes Gültigkeitsintervall, Aufzeichnungszeitpunkt und einen SHA-256-Digest des kanonischen "
"Snapshots. Cases bleibt für den Vorgangslebenszyklus maßgeblich."
),
}
},
metadata={
"kind": "workflow",
"help_contexts": ["cases.detail", "records.action.file"],
@@ -638,6 +841,23 @@ manifest = ModuleManifest(
kind="repository",
),
),
translations={
"de": {
"title": "Institutioneller Kontext eines Vorgangs",
"summary": (
"Cases bewahrt Service- und Beteiligtenverweise, ohne institutionelle Definitionen oder Betroffenenstammdaten zu übernehmen."
),
"body": (
"Vorgangstypen und -status sind Mandantenkonfiguration. Jede Anlage oder Änderung schreibt eine unveränderliche, durch "
"optimistische Nebenläufigkeitskontrolle geschützte Revision und ein wiederholungssicheres Timeline-Ereignis. Die "
"Service-Aufnahme hält exakte Bindungen an Service, Mandat, Zuständigkeit, Rechtsgrundlage, Formular, Workflow und Ergebnis "
"fest. Verfahrensbeteiligte stammen von einem optionalen Provider oder einer begrenzten Cases-Kompatibilitätsprojektion. "
"Zuweisungs-, Nachweis-, Decision- und Aktenverweise bleiben stabile Referenzen im Eigentum ihrer Quellmodule. Ist Search "
"aktiviert, liefert Cases eine neu aufbaubare reine Metadatenprojektion. Nachweise und geschützte Decision-Inhalte sind "
"ausgeschlossen; jeder Treffer wird vor der Offenlegung erneut gegen den aktuellen Vorgangszugriff geprüft."
),
}
},
metadata={
"help_contexts": [
"cases.list",
@@ -674,7 +894,26 @@ manifest = ModuleManifest(
kind="repository",
),
),
translations={
"de": {
"title": "Referenz für Vorgangslebenszyklus, Zugriff und Nachweise",
"summary": (
"Revisions-, Status-, Zugriffs- und Verweisfelder gemeinsam mit ihren dauerhaften Folgen erläutern."
),
"body": (
"Änderungen an Titel und Status ergänzen eine unveränderliche Vorgangsrevision, geschützt durch erwartete Revision und "
"stabilen Idempotenzschlüssel. Jede akzeptierte Änderung ergänzt außerdem einen Timeline-Eintrag mit handelnder Person, "
"Zeitpunkt und Änderungsgrund; ein abschließender Status verlangt zusätzlich die Berechtigung zum Schließen. Die Sichtbarkeit "
"eines Vorgangs ist mandantenweit oder eingeschränkt. Eingeschränkte Vorgänge bleiben nur durch administrative Befugnis, "
"Zuweisungs- oder Einheitenkontext, Erstellerbefugnis oder eine ausdrückliche Konto-/Gruppenfreigabe sichtbar. "
"Zugriffsänderungen ergänzen eine weitere unveränderliche Revision und verlangen Bestätigung. Service-, Beteiligten-, "
"Zuweisungs-, Decision- und Aktenverweise bezeichnen Objekte anderer Provider; Cases bewahrt ihre stabilen Kennungen und "
"Versionen, ohne sie zu kopieren oder stillschweigend zu verändern."
),
}
},
metadata={
"kind": "reference",
"help_contexts": [
"cases.field.title",
"cases.field.status",
@@ -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"]
+12 -3
View File
@@ -10,6 +10,14 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
class CasesInterfaceDocumentationContractTests(unittest.TestCase):
def test_all_static_topics_have_complete_german_content(self) -> None:
for topic in get_manifest().documentation:
german = (topic.translations or {}).get("de", {})
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
self.assertTrue(
all(str(value).strip() for value in german.values()), topic.id
)
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
frontend = get_manifest().frontend
self.assertIsNotNone(frontend)
@@ -53,15 +61,16 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
self.assertIn("update_case", reference.metadata["consequence_classes"])
self.assertIn("close_case", reference.metadata["consequence_classes"])
self.assertIn("change_access", reference.metadata["consequence_classes"])
self.assertEqual("reference", reference.metadata["kind"])
self.assertIn(
"link_owner_reference",
owner_links.metadata["consequence_classes"],
)
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
list_page = (
REPO_ROOT / "webui/src/features/cases/CasesPage.tsx"
).read_text(encoding="utf-8")
list_page = (REPO_ROOT / "webui/src/features/cases/CasesPage.tsx").read_text(
encoding="utf-8"
)
detail_page = (
REPO_ROOT / "webui/src/features/cases/CaseDetailPage.tsx"
).read_text(encoding="utf-8")
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.tickets import TicketCaseEscalationCommand
from govoplan_core.db.base import Base
from govoplan_cases.backend.db import models as _case_models
from govoplan_cases.backend.service import upsert_case_status, upsert_case_type
from govoplan_cases.backend.ticket_escalation import TicketCaseEscalationProvider
NOW = datetime(2026, 8, 22, 10, 0, tzinfo=UTC)
class _Principal:
def __init__(self, tenant_id: str = "tenant-1", *, can_create: bool = True) -> None:
self.tenant_id = tenant_id
self.account_id = "case-manager-1"
self.identity_id = None
self.membership_id = "membership-1"
self.group_ids = frozenset()
self.role_ids = frozenset()
self.function_assignment_ids = frozenset()
self.acting_assignment_id = None
self.scopes = frozenset({"cases:case:create", "cases:case:read"} if can_create else {"cases:case:read"})
self.user = SimpleNamespace(id=self.account_id)
def has(self, scope: str) -> bool:
return scope in self.scopes
class TicketEscalationTests(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.Session = sessionmaker(bind=engine, expire_on_commit=False)
self.principal = _Principal()
def test_escalation_creates_one_linked_case_and_replays(self) -> None:
with self.Session() as session:
upsert_case_status(
session,
self.principal,
status_key="open",
label="Open",
)
upsert_case_type(
session,
self.principal,
type_key="service-request",
label="Service request",
initial_status_key="open",
)
command = TicketCaseEscalationCommand(
tenant_id="tenant-1",
ticket_id="ticket-1",
ticket_number="TKT-2026-0001",
title="Broken streetlight",
case_type_key="service-request",
occurred_at=NOW,
idempotency_key="escalation-1",
handoff_note="Formal procedure required.",
metadata={"ticket_revision": 3},
)
provider = TicketCaseEscalationProvider()
created = provider.escalate_ticket(session, self.principal, command=command)
session.commit()
replay = provider.escalate_ticket(session, self.principal, command=command)
self.assertFalse(created.replayed)
self.assertTrue(replay.replayed)
self.assertEqual(created.case_id, replay.case_id)
self.assertEqual("service-request", created.metadata["case_type_key"])
self.assertTrue(created.case_url.startswith("/cases/"))
self.assertEqual(1, session.query(_case_models.CaseIdentity).count())
current = session.query(_case_models.CaseRecordRevision).filter(
_case_models.CaseRecordRevision.case_id == created.case_id,
_case_models.CaseRecordRevision.superseded_at.is_(None),
).one()
self.assertEqual("tickets", current.snapshot["context"]["work_item_ref"]["owner_module"])
self.assertEqual("ticket-1", current.snapshot["metadata"]["source_resource_id"])
def test_escalation_fails_closed_for_cross_tenant_or_missing_permission(self) -> None:
command = TicketCaseEscalationCommand(
tenant_id="tenant-1",
ticket_id="ticket-1",
ticket_number="TKT-1",
title="Request",
case_type_key="service-request",
occurred_at=NOW,
idempotency_key="escalation-2",
)
provider = TicketCaseEscalationProvider()
with self.Session() as session:
with self.assertRaises(PermissionError):
provider.escalate_ticket(session, _Principal("tenant-2"), command=command)
with self.assertRaises(PermissionError):
provider.escalate_ticket(session, _Principal(can_create=False), command=command)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/cases-webui",
"version": "0.1.19",
"version": "0.1.21",
"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",
+1 -1
View File
@@ -10,7 +10,7 @@ const CaseDetailPage = lazy(() => import("./features/cases/CaseDetailPage"));
export const casesModule: PlatformWebModule = {
id: "cases",
label: "i18n:govoplan-cases.cases",
version: "0.1.8",
version: "0.1.21",
optionalDependencies: [
"access",
"addresses",