Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1cffd3e53 | ||
|
|
d395b22301 | ||
|
|
d3a70f87cd |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user