23 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
zemion f236034e88 feat(cases): link provider-owned evidence references
Module Package Release / publish-packages (push) Successful in 11s
2026-08-21 16:10:48 +02:00
zemion eab2f4b3b2 feat(cases): add governed DSAR coverage 2026-08-21 02:07:34 +02:00
zemion fd7f05ffc9 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:45 +02:00
zemion 2efe574f18 feat: publish case launch context and product area 2026-08-18 21:32:25 +02:00
zemion 83e69565fe Adopt shared WebUI structural primitives 2026-08-18 13:17:31 +02:00
zemion 86878ef7a7 Adopt shared WebUI layout primitives 2026-08-18 10:42:51 +02:00
zemion 23f5091a2b Integrate formal decisions with cases 2026-08-06 12:42:20 +02:00
zemion 2f030e56a9 feat(cases): expose exact revisions for records filing 2026-08-06 01:43:32 +02:00
zemion cfe2389e5a Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:44 +02:00
zemion 0265e135f5 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:33:58 +02:00
zemion ee01662b07 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 19:51:59 +02:00
zemion e7b719a04e Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:10:16 +02:00
zemion 6c980c1865 Make package publication retries hash-safe 2026-08-04 14:32:19 +02:00
zemion 6cf83fc3b3 Harden module package publication 2026-08-04 14:02:39 +02:00
zemion 816e55e29b Add protected package release workflow 2026-08-04 04:14:02 +02:00
zemion 43b4cc8b86 Migrate Cases interface patterns 2026-08-03 14:43:37 +02:00
zemion c7821a5cb0 feat: implement governed cases workspace 2026-08-01 17:48:24 +02:00
zemion 122159de16 Release v0.1.8 2026-07-11 16:49:04 +02:00
zemion 11979005bc Add shared GovOPlaN gitignore 2026-07-10 21:57:21 +02:00
zemion 150a90771d chore: sync GovOPlaN module split state 2026-07-10 12:51:19 +02:00
52 changed files with 12108 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+348
View File
@@ -0,0 +1,348 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Local WebUI test/build scratch directories
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
*.db
# GovOPlaN local runtime state
runtime/
# GovOPlaN WebUI test output
webui/.module-test-build/
webui/.component-test-build/
+66
View File
@@ -0,0 +1,66 @@
# govoplan-cases
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
`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
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;
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`
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.
## Data-subject requests
Cases contributes `privacy.dsar.cases`. It reports exact-tenant access grants
and operator attribution, plus minimized case lifecycle data when an explicit
Cases reference is supplied and corroborated. Raw snapshots, metadata, search
text, free-text reasons, event payloads, evidence identifiers, request digests,
idempotency keys, audit internals, and unrelated access subjects are excluded.
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.
+254
View File
@@ -0,0 +1,254 @@
# govoplan-cases Concept
## Purpose
`govoplan-cases` is the formal administrative case module. It creates a durable
container for a procedure such as a permit application, inspection, complaint,
grant request, or internal administrative matter.
The case is not the workflow engine and not the file store. It is the stable
record that ties together participants, status, assignments, deadlines,
evidence, decisions, communications, audit events, and retention references.
## Ownership
The module owns:
- case identifiers, references, titles, types, and status
- 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
- case comments and internal notes once the collaboration boundary is decided
- case-level access checks and resource ACL contributions
- case event emission for lifecycle changes
- case summary APIs and WebUI route contributions
The module does not own:
- form submissions and validation, owned by forms/forms-runtime
- binary file storage, owned by files or DMS
- workflow state machines and transition execution, owned by workflow
- 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
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`,
and `cases.assign`
- DTOs with stable IDs and labels, not ORM objects
Other modules should link to a case by stable references:
```json
{
"case_id": "case-uuid",
"tenant_id": "tenant-uuid",
"relation": "submission"
}
```
## Reference Journey
Permit-to-payment MVP:
1. Portal/forms runtime receives a public application.
2. Workflow asks cases to create a case record.
3. Files/DMS links uploaded evidence to the case.
4. Tasks assigns an internal review task.
5. Workflow moves the case from intake to review to appointment to decision.
6. Templates generates the permit or decision document and links it to the case.
7. Payments links payment evidence.
8. Audit and records retain the case history.
## Implemented MVP Backend
The persistent backend provides:
- 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
- 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
- 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
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
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 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
Candidate tables:
- `cases`
- `case_parties`
- `case_assignments`
- `case_evidence_links`
- `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.
`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.
The Case detail decision path is a guarded composition boundary. It resolves an
exact Case revision, rechecks Case update access, verifies the current acting
assignment through Access, and requires exactly one effective Mandate for
`cases.formal_decision` and the selected Decision type. Only then does it call
the optional Decisions registry and append the exact Decision reference to a
new Case revision in the same transaction. Deterministic identifiers make an
unchanged retry safe, while changed replay payloads and stale Case revisions
fail closed. Cases still does not store the protected result or reasoning.
Evidence links should store only stable module/resource references and display
metadata snapshots. The owning module remains responsible for the real object.
When Records is enabled, `records.source.cases` resolves one exact immutable
case revision for eAkte filing. Cases rechecks current tenant, scope, and
object-level access, then returns the case number, lifecycle state, represented
valid interval, recorded time, canonical snapshot digest, and launch link.
Records owns the filing decision and chronology; Cases remains authoritative
for the case and its revision history.
When Search is enabled, `cases.cases` indexes current Case titles, numbers,
types, states, and the existing bounded search projection. It includes no
provider-owned evidence content or protected Decision reasoning. Restricted
Case candidates carry bounded ACL tokens and every result is checked again
against the current Case object-access decision before disclosure.
## WebUI
Initial route contributions:
- `/cases`
- `/cases/:caseId`
The detail view renders the module-owned record, stable references, history,
timeline, and provider-resolved formal Decisions when currently authorized. It
can record a direct officer Decision when Access, Mandates, and Decisions are
enabled and can launch exact Case or Decision filing when Records is enabled.
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
The focused suite covers:
- 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
## Data-subject requests
Cases publishes `privacy.dsar.cases`. Canonical account, identity, and
membership selectors cover Cases-owned operator attribution; account and
identity selectors also cover explicit case-access grants. Exact
`cases.case`, `cases.revision`, `cases.access_grant`, and `cases.timeline`
references select lifecycle data. When a canonical and direct selector are
combined, a Cases-owned relationship must corroborate them and all supplied
direct references must identify the same case.
Direct case results are typed projections rather than stored snapshots. They
exclude opaque metadata, search text, free-text change reasons, timeline
payloads and summaries, evidence identifiers, request hashes, idempotency
keys, audit identifiers, and unrelated access subjects. Applicant-to-identity
correlation is owned by Parties; Cases does not infer it from opaque party
identifiers. Immutable case identities, revisions, timelines, and operator
attribution receive retention actions. Current open case facts and active
access grants receive non-executable manual-review actions and can only be
changed through the authorized case/access lifecycle.
## 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.
+39
View File
@@ -0,0 +1,39 @@
# Cases Interface Pattern Migration
This migration applies the GovOPlaN interface pattern language to the case
directory, governed case detail, lifecycle editor, institutional references,
immutable evidence, and object-level access dialog.
## Surface Inventory
| Surface | Archetype | Consequence class | Contract |
| --- | --- | --- | --- |
| `/cases` | Governed directory | Search and select a readable case | Stable loading, empty, error, filter, count, and contextual-help states |
| Case summary | Governed object detail | Inspect current revision and lifecycle state | Privacy-safe title, type, status, dates, revision, and reason rendering |
| Lifecycle editor | Consequential record editor | Append revision or close case | Guarded draft, OCC revision, stable idempotency key, explicit reason, permission explanation, and save/discard |
| Institutional references | Provider-owned reference list | Inspect linked objects | Stable owner/object/version references without copying sibling-module state |
| Timeline and history | Immutable evidence view | Inspect recorded lifecycle evidence | Actor-safe summaries, revision order, timestamps, and no historical mutation controls |
| Access dialog | Governed object-access editor | Change visibility or explicit grants | Searchable account/group references, guarded nested draft, confirmation, OCC revision, reason, and permission boundary |
## Consequence And Availability Rules
- Every accepted title, status, visibility, or grant change appends an immutable
revision and timeline event. Existing history is never rewritten.
- Updates require `cases:case:update`; terminal statuses additionally require
`cases:case:close`; access changes require `cases:case:share`.
- A missing permission leaves the readable case available and explains the
actor, required action, and administrative destination instead of hiding the
entire object.
- Restricted visibility is evaluated by the Cases ACL provider. The WebUI
selector discovers accounts and groups through shared reference providers;
it does not import Access internals.
- Main and access-dialog drafts are guarded. A direct access save requires a
separate confirmation that identifies the case, visibility, and grant count.
- Service, party, assignment, Decision, and record links remain references to
provider-owned objects. Optional modules may enrich those objects without
becoming runtime dependencies of Cases.
Backend and WebUI manifests publish the same navigation, route, section, and
action surface identifiers. English and German catalogues cover module-owned
navigation, blocker, guard, and confirmation vocabulary. Contextual help links
resolve to the module-owned manifest documentation.
+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
}
}
}
+21
View File
@@ -0,0 +1,21 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-cases"
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.30"]
[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"
+5
View File
@@ -0,0 +1,5 @@
"""GovOPlaN Cases module."""
from govoplan_cases.backend.manifest import get_manifest
__all__ = ["get_manifest"]
+1
View File
@@ -0,0 +1 @@
"""Cases backend contracts."""
+30
View File
@@ -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"]
+19
View File
@@ -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",
]
+179
View File
@@ -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",
]
+478
View File
@@ -0,0 +1,478 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import datetime
import uuid
from sqlalchemy.orm import Session
from govoplan_core.core.access import CAPABILITY_ACCESS_SEMANTIC_DIRECTORY
from govoplan_core.core.institutional import (
CAPABILITY_DECISION_REGISTRY,
CAPABILITY_MANDATE_RESOLVER,
ActorRepresentationReference,
DecisionRegistry,
FormalDecision,
GovernedContextEnvelope,
InstitutionalReference,
MandateDefinition,
MandateResolution,
MandateResolutionRequest,
MandateResolver,
TemporalRevision,
resolve_mandate_candidates,
)
from govoplan_cases.backend.domain import CaseRecord
from govoplan_cases.backend.service import (
can_access_case,
get_case,
update_case,
)
DECISION_READ_SCOPE = "decisions:decision:read"
DECISION_SENSITIVE_READ_SCOPE = "decisions:decision:read_sensitive"
DECISION_WRITE_SCOPE = "decisions:decision:write"
_DECISION_NAMESPACE = uuid.uuid5(
uuid.NAMESPACE_URL,
"https://govoplan.add-ideas.de/contracts/cases/formal-decision/v1",
)
class CaseDecisionError(ValueError):
pass
class CaseDecisionUnavailable(CaseDecisionError):
pass
@dataclass(frozen=True, slots=True)
class CaseDecisionCommand:
expected_case_revision: int
effective_at: datetime
decision_type: str
operative_result: str
reasoning: str
change_reason: str
idempotency_key: str
conditions: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class CaseDecisionResult:
case: CaseRecord
decision: FormalDecision
replayed: bool = False
def to_dict(self) -> dict[str, object]:
return {
"case": self.case.to_dict(),
"decision": self.decision.to_dict(include_protected=True),
"replayed": self.replayed,
}
class CaseDecisionPath:
"""Record a formal outcome and its exact Case reference atomically."""
def __init__(self, registry: object | None = None) -> None:
self._registry = registry
def record(
self,
session: Session,
principal: object,
*,
case_id: str,
command: CaseDecisionCommand,
) -> CaseDecisionResult:
_validate_command(command)
source_case = get_case(
session,
principal,
case_id=case_id,
revision=command.expected_case_revision,
)
current = get_case(session, principal, case_id=case_id)
if source_case is None or current 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.")
decision_id = _decision_id(source_case, command.idempotency_key)
existing_ref = next(
(
item
for item in current.decision_refs
if item.owner_module == "decisions" and item.object_id == decision_id
),
None,
)
if existing_ref is None and current.revision != command.expected_case_revision:
raise CaseDecisionError(
"Case revision conflict: reload the Case before recording a Decision."
)
decision_registry = _decision_registry(self._registry)
mandate = _resolve_mandate(
self._registry,
session,
principal,
case=source_case,
command=command,
)
actor = _resolve_actor(
self._registry,
principal,
case=source_case,
mandate=mandate,
effective_at=command.effective_at,
)
decision = _decision(
source_case,
command=command,
decision_id=decision_id,
mandate=mandate,
actor=actor,
)
persisted = decision_registry.record_decision(
session,
principal,
decision=decision,
)
if existing_ref is not None:
return CaseDecisionResult(
case=current,
decision=persisted,
replayed=True,
)
linked = update_case(
session,
principal,
case_id=case_id,
expected_revision=current.revision,
changes={"decision_refs": (*current.decision_refs, persisted.reference)},
recorded_at=command.effective_at,
change_reason=command.change_reason,
idempotency_key=f"formal-decision:{command.idempotency_key}",
)
return CaseDecisionResult(case=linked, decision=persisted)
def linked(
self,
session: Session,
principal: object,
*,
case_id: str,
) -> tuple[FormalDecision, ...]:
case = get_case(session, principal, case_id=case_id)
if case is None:
raise LookupError("Case not found.")
registry = _decision_registry(self._registry)
resolved: list[FormalDecision] = []
for reference in case.decision_refs:
if reference.owner_module != "decisions":
continue
item = registry.get_decision(
session,
principal,
reference=reference,
)
if item is not None:
resolved.append(item)
return tuple(resolved)
def _decision(
case: CaseRecord,
*,
command: CaseDecisionCommand,
decision_id: str,
mandate: MandateDefinition,
actor: ActorRepresentationReference,
) -> FormalDecision:
evidence = tuple(
dict.fromkeys((*case.evidence_refs, *case.context.evidence, *mandate.evidence))
)
legal_bases = tuple(
dict.fromkeys((*case.context.legal_bases, *mandate.legal_bases))
)
if not evidence:
raise CaseDecisionError(
"A formal Decision requires evidence linked to the exact Case revision."
)
if not legal_bases:
raise CaseDecisionError(
"A formal Decision requires an effective legal-basis version."
)
decision_ref = InstitutionalReference(
kind="decision",
owner_module="decisions",
object_id=decision_id,
tenant_id=case.reference.tenant_id,
version="1",
valid_at=command.effective_at,
)
temporal = TemporalRevision(
revision="1",
valid_from=command.effective_at,
recorded_at=command.effective_at,
change_reason=command.change_reason,
)
authority_context: GovernedContextEnvelope = replace(
case.context,
temporal=temporal,
actor=actor,
mandate_ref=mandate.reference,
case_ref=case.reference,
party_refs=case.party_refs or case.context.party_refs,
decision_ref=decision_ref,
record_refs=case.record_refs,
legal_bases=legal_bases,
evidence=evidence,
)
return FormalDecision(
reference=decision_ref,
temporal=temporal,
decision_type=command.decision_type.strip(),
subject_refs=case.party_refs or (case.reference,),
state="decided",
authority_context=authority_context,
fact_evidence=evidence,
legal_bases=legal_bases,
operative_result=command.operative_result.strip(),
reasoning=command.reasoning.strip(),
conditions=tuple(item.strip() for item in command.conditions if item.strip()),
)
def _resolve_mandate(
registry: object | None,
session: Session,
principal: object,
*,
case: CaseRecord,
command: CaseDecisionCommand,
) -> MandateDefinition:
resolver = _capability(registry, CAPABILITY_MANDATE_RESOLVER)
if not isinstance(resolver, MandateResolver):
raise CaseDecisionUnavailable(
"Formal Decisions require an enabled Mandates resolver."
)
request = MandateResolutionRequest(
tenant_id=case.reference.tenant_id,
effective_at=command.effective_at,
task_type="cases.formal_decision",
authority_type=command.decision_type.strip(),
organization_unit_ref=case.context.organization_unit_ref,
function_ref=case.context.function_ref,
jurisdiction_refs=case.context.jurisdiction_refs,
)
resolution: MandateResolution = resolver.resolve_mandate(
session,
principal,
request=request,
)
if not resolution.competent:
raise CaseDecisionError(
resolution.explanation or "The acting function is not competent to decide."
)
if resolution.conflict_refs:
raise CaseDecisionError(
"Mandate resolution has unresolved conflicts: "
+ ", ".join(resolution.conflict_refs)
)
verified = resolve_mandate_candidates(request, resolution.mandates)
if not verified.competent or len(verified.mandates) != 1:
raise CaseDecisionError(
verified.explanation
or "A formal Decision requires exactly one effective active Mandate."
)
return verified.mandates[0]
def _resolve_actor(
registry: object | None,
principal: object,
*,
case: CaseRecord,
mandate: MandateDefinition,
effective_at: datetime,
) -> ActorRepresentationReference:
directory = _capability(registry, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY)
lookup = getattr(directory, "get_function_assignment", None)
if not callable(lookup):
raise CaseDecisionUnavailable(
"Formal Decisions require the Access semantic directory to verify the acting assignment."
)
acting_id = _text(getattr(principal, "acting_assignment_id", None))
granted_ids = tuple(
sorted(
{
str(item).strip()
for item in (getattr(principal, "function_assignment_ids", ()) or ())
if str(item).strip()
}
)
)
if acting_id and acting_id not in granted_ids:
raise PermissionError("The selected acting assignment is not active for this account.")
candidate_ids = (acting_id,) if acting_id else granted_ids
matching = []
for assignment_id in candidate_ids:
assignment = lookup(assignment_id)
if assignment is None or not _assignment_matches(
assignment,
principal,
case=case,
effective_at=effective_at,
):
continue
matching.append(assignment)
if not matching:
raise PermissionError(
"No active acting assignment matches the Case organization and function."
)
if len(matching) > 1:
raise CaseDecisionError(
"Select one acting assignment in the title bar before recording the Decision."
)
assignment = matching[0]
return ActorRepresentationReference(
tenant_id=case.reference.tenant_id,
account_id=_required_principal_text(principal, "account_id"),
identity_id=_text(getattr(principal, "identity_id", None))
or _text(getattr(assignment, "identity_id", None)),
represented_account_id=_text(
getattr(principal, "acting_for_account_id", None)
),
represented_function_ref=case.context.function_ref,
function_assignment_ref=InstitutionalReference(
kind="function_assignment",
owner_module="access",
object_id=str(getattr(assignment, "id")),
tenant_id=case.reference.tenant_id,
valid_at=effective_at,
),
delegation_ref=_text(
getattr(assignment, "delegated_from_assignment_id", None)
),
mandate_ref=mandate.reference,
)
def _assignment_matches(
assignment: object,
principal: object,
*,
case: CaseRecord,
effective_at: datetime,
) -> bool:
organization = case.context.organization_unit_ref
function = case.context.function_ref
if organization is None or function is None:
raise CaseDecisionError(
"The Case lacks the responsible organization and function required for a formal Decision."
)
if str(getattr(assignment, "tenant_id", "")) != case.reference.tenant_id:
return False
if str(getattr(assignment, "account_id", "")) != _required_principal_text(
principal, "account_id"
):
return False
if str(getattr(assignment, "function_id", "")) != function.object_id:
return False
if str(getattr(assignment, "organization_unit_id", "")) != organization.object_id:
return False
if str(getattr(assignment, "status", "active")) != "active":
return False
valid_from = getattr(assignment, "valid_from", None)
valid_until = getattr(assignment, "valid_until", None)
return not (
(valid_from is not None and effective_at < valid_from)
or (valid_until is not None and effective_at >= valid_until)
)
def _decision_registry(registry: object | None) -> DecisionRegistry:
provider = _capability(registry, CAPABILITY_DECISION_REGISTRY)
if not isinstance(provider, DecisionRegistry):
raise CaseDecisionUnavailable(
"Formal Decisions require an enabled Decision registry."
)
return provider
def _decision_id(case: CaseRecord, idempotency_key: str) -> str:
return str(
uuid.uuid5(
_DECISION_NAMESPACE,
":".join(
(
case.reference.tenant_id,
case.reference.object_id,
str(case.revision),
idempotency_key.strip(),
)
),
)
)
def _validate_command(command: CaseDecisionCommand) -> None:
for value, label in (
(command.decision_type, "Decision type"),
(command.operative_result, "Operative result"),
(command.reasoning, "Decision reasoning"),
(command.change_reason, "Decision change reason"),
(command.idempotency_key, "Decision idempotency key"),
):
if not value.strip():
raise CaseDecisionError(f"{label} is required.")
if command.expected_case_revision < 1:
raise CaseDecisionError("Expected Case revision must be positive.")
if command.effective_at.tzinfo is None or command.effective_at.utcoffset() is None:
raise CaseDecisionError("Decision effective_at 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)
def _text(value: object | None) -> str | None:
normalized = str(value or "").strip()
return normalized or None
def _required_principal_text(principal: object, name: str) -> str:
value = _text(getattr(principal, name, None))
if value is None:
raise PermissionError(f"The current principal has no {name.replace('_', ' ')}.")
return value
__all__ = [
"CaseDecisionCommand",
"CaseDecisionError",
"CaseDecisionPath",
"CaseDecisionResult",
"CaseDecisionUnavailable",
"DECISION_READ_SCOPE",
"DECISION_SENSITIVE_READ_SCOPE",
"DECISION_WRITE_SCOPE",
]
+364
View File
@@ -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"]
+729
View File
@@ -0,0 +1,729 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_cases.backend.db.models import (
CaseAccessGrant,
CaseIdentity,
CaseRecordRevision,
CaseTimelineEntry,
)
from govoplan_cases.backend.domain import CaseRecord
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
CASES_DSAR_CAPABILITY = dsar_capability_name("cases")
_MAX_RECORDS = 5_000
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
account_id: str | None
identity_id: str | None
membership_id: str | None
case_id: str | None
revision_id: str | None
access_grant_id: str | None
timeline_id: str | None
@property
def actor_ids(self) -> tuple[str, ...]:
return tuple(
value
for value in (self.account_id, self.identity_id, self.membership_id)
if value
)
@property
def has_canonical_selector(self) -> bool:
return bool(self.actor_ids)
@property
def has_direct_selector(self) -> bool:
return bool(
self.case_id or self.revision_id or self.access_grant_id or self.timeline_id
)
@property
def has_recognized_selector(self) -> bool:
return self.has_canonical_selector or self.has_direct_selector
class CasesDsarProvider:
provider_id = "cases"
module_id = "cases"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None or not selectors.has_recognized_selector:
return ()
identities = _matching_identities(db, tenant_id, selectors)
revisions = _matching_revisions(db, tenant_id, selectors)
grants = _matching_grants(db, tenant_id, selectors)
timeline = _matching_timeline(db, tenant_id, selectors)
if _direct_reference_conflicts(
selectors,
identities=identities,
revisions=revisions,
grants=grants,
timeline=timeline,
):
return ()
direct_case_ids = _direct_case_ids(
selectors,
identities=identities,
revisions=revisions,
grants=grants,
timeline=timeline,
)
records: list[DsarRecordRef] = []
seen: set[tuple[str, str]] = set()
def append(record: DsarRecordRef) -> None:
key = (record.resource_type, record.resource_id)
if key in seen:
return
if len(records) >= _MAX_RECORDS:
raise ValueError(
"Cases DSAR result limit exceeded; narrow the subject selectors."
)
seen.add(key)
records.append(record)
for identity in identities:
if identity.case_id in direct_case_ids:
append(_case_identity_record(identity))
if identity.created_by in selectors.actor_ids:
append(
_operator_record(
resource_id=f"identity:{identity.id}",
case_id=identity.case_id,
activity="created_case_identity",
observed_at=identity.created_at,
)
)
for revision in revisions:
if _revision_is_direct(revision, selectors, direct_case_ids):
record = CaseRecord.from_mapping(revision.snapshot)
append(_revision_record(revision, record))
if revision.superseded_at is None and revision.closed_at is None:
append(_current_fact_record(revision, record))
if revision.changed_by in selectors.actor_ids:
append(
_operator_record(
resource_id=f"revision:{revision.id}",
case_id=revision.case_id,
activity="changed_case_revision",
observed_at=revision.recorded_at,
case_revision=revision.revision,
)
)
for grant in grants:
if _grant_subject_matches(grant, selectors) or (
_grant_is_direct(grant, selectors)
and not selectors.has_canonical_selector
):
append(_access_grant_record(grant))
if grant.created_by in selectors.actor_ids:
append(
_operator_record(
resource_id=f"grant:{grant.id}",
case_id=grant.case_id,
activity="changed_case_access_grant",
observed_at=grant.updated_at,
)
)
for entry in timeline:
direct = _timeline_is_direct(entry, selectors, direct_case_ids)
actor_match = entry.actor_id in selectors.actor_ids
if direct or actor_match:
append(
_timeline_record(
entry,
expose_actor=actor_match,
match_fields=(
(["reference"] if direct else [])
+ (["actor_id"] if actor_match else [])
),
)
)
return tuple(records)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Cases DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
if record.immutable_evidence:
kind = "retain"
title = f"Retain {record.title}"
rationale = record.retention_reason or (
"Case history is retained as institutional evidence."
)
else:
kind = "manual_review"
title = f"Review {record.title}"
rationale = (
"An authorized case operator must amend, close, supersede, or "
"deactivate the current fact through the governed case lifecycle "
"after reviewing legal, procedural, access, and third-party effects."
)
actions.append(
DsarErasureActionRef(
action_id=f"cases:{kind}:{record.resource_type}:{record.resource_id}",
provider_id=self.provider_id,
module_id=self.module_id,
kind=kind,
resource_type=record.resource_type,
resource_id=record.resource_id,
title=title,
rationale=rationale,
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Cases DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if action.executable:
raise ValueError(
"Cases DSAR does not publish executable erasure actions."
)
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Use the governed case or access lifecycle after legal, "
"institutional-evidence, and third-party review."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _matching_identities(
session: Session,
tenant_id: str,
selectors: _SubjectSelectors,
) -> list[CaseIdentity]:
conditions = []
if selectors.actor_ids:
conditions.append(CaseIdentity.created_by.in_(selectors.actor_ids))
if selectors.case_id:
conditions.append(CaseIdentity.case_id == selectors.case_id)
return _query_conditions(session, CaseIdentity, tenant_id, conditions)
def _matching_revisions(
session: Session,
tenant_id: str,
selectors: _SubjectSelectors,
) -> list[CaseRecordRevision]:
conditions = []
if selectors.actor_ids:
conditions.append(CaseRecordRevision.changed_by.in_(selectors.actor_ids))
if selectors.case_id:
conditions.append(CaseRecordRevision.case_id == selectors.case_id)
if selectors.revision_id:
conditions.append(CaseRecordRevision.id == selectors.revision_id)
return _query_conditions(session, CaseRecordRevision, tenant_id, conditions)
def _matching_grants(
session: Session,
tenant_id: str,
selectors: _SubjectSelectors,
) -> list[CaseAccessGrant]:
conditions = []
for kind, value in (
("account", selectors.account_id),
("identity", selectors.identity_id),
):
if value:
conditions.append(
(CaseAccessGrant.subject_kind == kind)
& (CaseAccessGrant.subject_id == value)
)
if selectors.actor_ids:
conditions.append(CaseAccessGrant.created_by.in_(selectors.actor_ids))
if selectors.case_id:
conditions.append(CaseAccessGrant.case_id == selectors.case_id)
if selectors.access_grant_id:
conditions.append(CaseAccessGrant.id == selectors.access_grant_id)
return _query_conditions(session, CaseAccessGrant, tenant_id, conditions)
def _matching_timeline(
session: Session,
tenant_id: str,
selectors: _SubjectSelectors,
) -> list[CaseTimelineEntry]:
conditions = []
if selectors.actor_ids:
conditions.append(CaseTimelineEntry.actor_id.in_(selectors.actor_ids))
if selectors.case_id:
conditions.append(CaseTimelineEntry.case_id == selectors.case_id)
if selectors.timeline_id:
conditions.append(CaseTimelineEntry.id == selectors.timeline_id)
return _query_conditions(session, CaseTimelineEntry, tenant_id, conditions)
def _direct_reference_conflicts(
selectors: _SubjectSelectors,
*,
identities: Sequence[CaseIdentity],
revisions: Sequence[CaseRecordRevision],
grants: Sequence[CaseAccessGrant],
timeline: Sequence[CaseTimelineEntry],
) -> bool:
referenced_case_ids: list[str] = []
if selectors.case_id:
if not any(row.case_id == selectors.case_id for row in identities):
return True
referenced_case_ids.append(selectors.case_id)
if selectors.revision_id:
row = next(
(item for item in revisions if item.id == selectors.revision_id), None
)
if row is None:
return True
referenced_case_ids.append(row.case_id)
if selectors.access_grant_id:
row = next(
(item for item in grants if item.id == selectors.access_grant_id), None
)
if row is None:
return True
if selectors.has_canonical_selector and not _grant_subject_matches(
row, selectors
):
return True
referenced_case_ids.append(row.case_id)
if selectors.timeline_id:
row = next(
(item for item in timeline if item.id == selectors.timeline_id), None
)
if row is None:
return True
if selectors.has_canonical_selector and row.actor_id not in selectors.actor_ids:
return True
referenced_case_ids.append(row.case_id)
if len(set(referenced_case_ids)) > 1:
return True
if not selectors.has_canonical_selector or not referenced_case_ids:
return False
related_case_ids = {
row.case_id for row in identities if row.created_by in selectors.actor_ids
}
related_case_ids.update(
row.case_id for row in revisions if row.changed_by in selectors.actor_ids
)
related_case_ids.update(
row.case_id for row in grants if _grant_subject_matches(row, selectors)
)
related_case_ids.update(
row.case_id for row in timeline if row.actor_id in selectors.actor_ids
)
return referenced_case_ids[0] not in related_case_ids
def _direct_case_ids(
selectors: _SubjectSelectors,
*,
identities: Sequence[CaseIdentity],
revisions: Sequence[CaseRecordRevision],
grants: Sequence[CaseAccessGrant],
timeline: Sequence[CaseTimelineEntry],
) -> set[str]:
values = {selectors.case_id} if selectors.case_id else set()
values.update(row.case_id for row in revisions if row.id == selectors.revision_id)
values.update(row.case_id for row in grants if row.id == selectors.access_grant_id)
values.update(row.case_id for row in timeline if row.id == selectors.timeline_id)
values.update(
row.case_id
for row in identities
if selectors.case_id and row.case_id == selectors.case_id
)
return {value for value in values if value}
def _revision_is_direct(
row: CaseRecordRevision,
selectors: _SubjectSelectors,
direct_case_ids: set[str],
) -> bool:
if selectors.revision_id:
return row.id == selectors.revision_id
return bool(selectors.case_id and row.case_id in direct_case_ids)
def _grant_is_direct(
row: CaseAccessGrant,
selectors: _SubjectSelectors,
) -> bool:
return bool(selectors.access_grant_id and row.id == selectors.access_grant_id)
def _timeline_is_direct(
row: CaseTimelineEntry,
selectors: _SubjectSelectors,
direct_case_ids: set[str],
) -> bool:
if selectors.timeline_id:
return row.id == selectors.timeline_id
return bool(selectors.case_id and row.case_id in direct_case_ids)
def _grant_subject_matches(
row: CaseAccessGrant,
selectors: _SubjectSelectors,
) -> bool:
return bool(
(row.subject_kind == "account" and row.subject_id == selectors.account_id)
or (row.subject_kind == "identity" and row.subject_id == selectors.identity_id)
)
def _case_identity_record(row: CaseIdentity) -> DsarRecordRef:
return _record(
"cases_case_identity",
row.id,
"case_identity",
"Case identity",
{
"match_fields": ["reference"],
"case_id": row.case_id,
"case_number": _bounded_text(row.case_number, 255),
"created_at": _iso(row.created_at),
},
observed_at=row.created_at,
immutable=True,
retention_reason=(
"The stable case identifier and number are retained so immutable case and "
"record evidence remains reconstructable."
),
)
def _revision_record(
row: CaseRecordRevision,
record: CaseRecord,
) -> DsarRecordRef:
return _record(
"cases_case_revision",
row.id,
"case_history",
"Case revision",
{
"match_fields": ["reference"],
"case_id": row.case_id,
"case_number": _bounded_text(record.case_number, 255),
"revision": row.revision,
"previous_revision_id": row.previous_revision_id,
"case_type_key": row.case_type_key,
"status_key": row.status_key,
"title": _bounded_text(row.title, 500),
"access_mode": row.access_mode,
"opened_at": _iso(row.opened_at),
"deadline_at": _iso(row.deadline_at),
"closed_at": _iso(row.closed_at),
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
"service_ref": _institutional_reference(record.service_ref),
"party_reference_count": len(record.party_refs),
"assignment_reference_count": len(record.assignment_refs),
"evidence_reference_count": len(record.evidence_refs),
"decision_reference_count": len(record.decision_refs),
"record_reference_count": len(record.record_refs),
"access_grant_count": len(record.access_grants),
},
observed_at=row.recorded_at,
immutable=True,
retention_reason=(
"Case revisions are immutable procedure and accountability evidence; "
"corrections append a new governed revision."
),
)
def _current_fact_record(
row: CaseRecordRevision,
record: CaseRecord,
) -> DsarRecordRef:
return _record(
"cases_current_case_fact",
row.case_id,
"current_case_fact",
"Current case fact",
{
"match_fields": ["reference"],
"revision_record_id": row.id,
"revision": row.revision,
"case_number": _bounded_text(record.case_number, 255),
"status_key": row.status_key,
"title": _bounded_text(row.title, 500),
"deadline_at": _iso(row.deadline_at),
},
observed_at=row.recorded_at,
)
def _access_grant_record(row: CaseAccessGrant) -> DsarRecordRef:
immutable = not row.active
return _record(
"cases_access_grant",
row.id,
"case_access_fact",
"Case access grant",
{
"match_fields": ["subject"],
"case_id": row.case_id,
"subject_kind": row.subject_kind,
"subject_id": row.subject_id,
"permissions": [str(value)[:40] for value in row.permissions[:20]],
"source": _bounded_text(row.source, 30),
"active": row.active,
"source_revision": row.source_revision,
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
},
observed_at=row.updated_at,
immutable=immutable,
retention_reason=(
"Inactive access-grant state is retained to explain historical case access."
if immutable
else None
),
)
def _timeline_record(
row: CaseTimelineEntry,
*,
expose_actor: bool,
match_fields: Sequence[str],
) -> DsarRecordRef:
return _record(
"cases_timeline_event",
row.id,
"case_lifecycle_evidence",
"Case lifecycle event",
{
"match_fields": list(match_fields),
"case_id": row.case_id,
"event_type": _bounded_text(row.event_type, 120),
"case_revision": row.case_revision,
"occurred_at": _iso(row.occurred_at),
"actor_id": row.actor_id if expose_actor else None,
},
observed_at=row.occurred_at,
immutable=True,
retention_reason=(
"Case timeline events are immutable lifecycle and accountability evidence."
),
)
def _operator_record(
*,
resource_id: str,
case_id: str,
activity: str,
observed_at: datetime | None,
case_revision: int | None = None,
) -> DsarRecordRef:
return _record(
"cases_operator_attribution",
resource_id,
"operator_accountability_evidence",
"Case operator attribution",
{
"match_fields": ["actor_id"],
"case_id": case_id,
"activity": activity,
"case_revision": case_revision,
"observed_at": _iso(observed_at),
},
observed_at=observed_at,
immutable=True,
retention_reason=(
"Operator attribution is retained as accountability evidence; raw case, "
"party, evidence, change-reason, and event payload content is excluded."
),
)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
groups = {
"account_id": (
subject.account_id,
subject.external_references.get("cases.account"),
subject.external_references.get("access.account"),
),
"identity_id": (
subject.identity_id,
subject.external_references.get("cases.identity"),
subject.external_references.get("identity.id"),
),
"membership_id": (
subject.membership_id,
subject.external_references.get("cases.membership"),
subject.external_references.get("tenancy.membership"),
),
"case_id": (subject.external_references.get("cases.case"),),
"revision_id": (subject.external_references.get("cases.revision"),),
"access_grant_id": (subject.external_references.get("cases.access_grant"),),
"timeline_id": (subject.external_references.get("cases.timeline"),),
}
normalized: dict[str, str | None] = {}
for key, values in groups.items():
distinct = {value for item in values if (value := _normalized_id(item))}
if len(distinct) > 1:
return None
normalized[key] = next(iter(distinct), None)
return _SubjectSelectors(**normalized)
def _query_conditions(
session: Session,
model: type,
tenant_id: str,
conditions: Sequence[object],
) -> list[object]:
if not conditions:
return []
rows = (
session.query(model)
.filter(model.tenant_id == tenant_id, or_(*conditions))
.order_by(model.id.asc())
.limit(_MAX_RECORDS + 1)
.all()
)
if len(rows) > _MAX_RECORDS:
raise ValueError("Cases DSAR match limit exceeded; narrow the selectors.")
return rows
def _institutional_reference(value: object | None) -> dict[str, object] | None:
if value is None:
return None
return {
"kind": str(getattr(value, "kind", "")),
"owner_module": str(getattr(value, "owner_module", "")),
"object_id": str(getattr(value, "object_id", "")),
"version": _bounded_text(getattr(value, "version", None), 120),
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "cases" or record.module_id != "cases":
raise ValueError("Cases DSAR received a foreign provider record.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "cases" or action.module_id != "cases":
raise ValueError("Cases DSAR received a foreign provider action.")
def _record(
resource_type: str,
resource_id: str,
category: str,
title: str,
data: Mapping[str, object],
*,
observed_at: datetime | None,
immutable: bool = False,
retention_reason: str | None = None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="cases",
module_id="cases",
resource_type=resource_type,
resource_id=resource_id,
category=category,
title=title,
data=data,
observed_at=observed_at,
immutable_evidence=immutable,
retention_reason=retention_reason,
source_path="/cases",
)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Cases DSAR provider requires a SQLAlchemy session.")
return value
def _bounded_text(value: str | None, limit: int) -> str | None:
return value[:limit] if value else None
def _normalized_id(value: object) -> str | None:
if value is None:
return None
normalized = str(value).strip()
return normalized or None
def _iso(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.isoformat()
__all__ = ["CASES_DSAR_CAPABILITY", "CasesDsarProvider"]
@@ -0,0 +1,239 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime
import hashlib
import json
from urllib.parse import parse_qs, quote, urlsplit
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import EvidenceReference
from govoplan_cases.backend.domain import CaseRecord
from govoplan_cases.backend.service import CaseStoreError, get_case, update_case
_SOURCE_CONTRACT = "cases.quick-access-reference.v1"
_SUPPORTED_REFERENCES = {
("files", "file-version"): "document",
("mail", "message"): "event",
("campaigns", "campaign"): "event",
}
def link_case_evidence(
session: Session,
principal: object,
*,
case_id: str,
expected_revision: int,
reference: Mapping[str, object],
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
) -> CaseRecord:
base = _base_revision(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
)
evidence = _validated_evidence(reference, tenant_id=base.reference.tenant_id)
if any(item.evidence_id == evidence.evidence_id for item in base.evidence_refs):
raise CaseStoreError("The owner reference is already linked to this Case.")
return update_case(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
changes={"evidence_refs": (*base.evidence_refs, evidence)},
recorded_at=recorded_at,
change_reason=change_reason,
idempotency_key=idempotency_key,
)
def unlink_case_evidence(
session: Session,
principal: object,
*,
case_id: str,
evidence_id: str,
expected_revision: int,
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
) -> CaseRecord:
base = _base_revision(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
)
evidence = next(
(item for item in base.evidence_refs if item.evidence_id == evidence_id),
None,
)
if evidence is None or linked_source(evidence) is None:
raise CaseStoreError("The linked owner reference was not found.")
return update_case(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
changes={
"evidence_refs": tuple(
item for item in base.evidence_refs if item.evidence_id != evidence_id
)
},
recorded_at=recorded_at,
change_reason=change_reason,
idempotency_key=idempotency_key,
)
def linked_source(evidence: EvidenceReference) -> Mapping[str, str] | None:
if evidence.source_ref is None:
return None
try:
value = json.loads(evidence.source_ref)
except (TypeError, ValueError):
return None
if not isinstance(value, Mapping) or value.get("contract") != _SOURCE_CONTRACT:
return None
result = {
key: str(value.get(key) or "")
for key in ("owner_module", "kind", "object_id", "path")
}
if (
not all(result.values())
or (result["owner_module"], result["kind"]) not in _SUPPORTED_REFERENCES
):
return None
return result
def _base_revision(
session: Session,
principal: object,
*,
case_id: str,
expected_revision: int,
) -> CaseRecord:
base = get_case(
session,
principal,
case_id=case_id,
revision=expected_revision,
)
if base is None:
raise LookupError("Case revision not found.")
return base
def _validated_evidence(
value: Mapping[str, object],
*,
tenant_id: str,
) -> EvidenceReference:
owner_module = _bounded(value.get("owner_module"), "Owner module", 120)
reference_kind = _bounded(value.get("kind"), "Owner reference kind", 120)
object_id = _bounded(value.get("object_id"), "Owner object id", 1_000)
reference_tenant = _bounded(value.get("tenant_id"), "Owner tenant id", 255)
version = _bounded(value.get("version"), "Owner version", 255)
path = _bounded(value.get("path"), "Owner path", 2_000)
evidence_kind = _SUPPORTED_REFERENCES.get((owner_module, reference_kind))
if evidence_kind is None:
raise CaseStoreError("The owner reference kind is not supported by Cases.")
if reference_tenant != tenant_id:
raise CaseStoreError("Owner references cannot cross Case tenants.")
_validate_owner_path(
owner_module=owner_module,
reference_kind=reference_kind,
object_id=object_id,
version=version,
path=path,
)
canonical = {
"contract": _SOURCE_CONTRACT,
"owner_module": owner_module,
"kind": reference_kind,
"object_id": object_id,
"path": path,
}
encoded = json.dumps(
canonical,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
)
digest = hashlib.sha256(
f"{tenant_id}\x1f{encoded}\x1f{version}".encode()
).hexdigest()
return EvidenceReference(
kind=evidence_kind, # type: ignore[arg-type]
owner_module=owner_module,
evidence_id=f"linked-{digest[:40]}",
tenant_id=tenant_id,
version=version,
source_ref=encoded,
derived_from=(_SOURCE_CONTRACT,),
)
def _validate_owner_path(
*,
owner_module: str,
reference_kind: str,
object_id: str,
version: str,
path: str,
) -> None:
parsed = urlsplit(path)
if parsed.scheme or parsed.netloc or not path.startswith("/") or path.startswith("//"):
raise CaseStoreError("Owner paths must be local platform routes.")
if (owner_module, reference_kind) == ("files", "file-version"):
query = parse_qs(parsed.query, keep_blank_values=True)
if (
parsed.path != "/files"
or query.get("versionId") != [object_id]
or version != object_id
or not query.get("fileId", [""])[0]
):
raise CaseStoreError("Files links require an exact authorized version path.")
return
if (owner_module, reference_kind) == ("mail", "message"):
query = parse_qs(parsed.query, keep_blank_values=True)
profile = query.get("profile", [""])[0]
folder = query.get("folder", [""])[0]
message = query.get("message", [""])[0]
expected_id = f"{profile}:{folder}:{message}"
if (
parsed.path != "/mail"
or not profile
or not folder
or not message
or object_id != expected_id
or version != message
):
raise CaseStoreError("Mail links require an exact mailbox message path.")
return
expected_path = f"/campaigns/{quote(object_id, safe='')}"
if path != expected_path:
raise CaseStoreError("Campaign links require an exact Campaign path.")
def _bounded(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 or any(ord(character) < 32 for character in result):
raise CaseStoreError(f"{label} is invalid.")
return result
__all__ = [
"link_case_evidence",
"linked_source",
"unlink_case_evidence",
]
+972
View File
@@ -0,0 +1,972 @@
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.access import CAPABILITY_ACCESS_SEMANTIC_DIRECTORY
from govoplan_core.core.institutional import (
CAPABILITY_DECISION_REGISTRY,
CAPABILITY_MANDATE_RESOLVER,
CAPABILITY_PARTY_RESOLVER,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
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,
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.dsar_provider import (
CASES_DSAR_CAPABILITY,
CasesDsarProvider,
)
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_cases.backend.record_source import (
CAPABILITY_RECORD_SOURCE_CASES,
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.21"
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 _dsar_provider(context: ModuleContext) -> CasesDsarProvider:
del context
return CasesDsarProvider()
def _ticket_escalation(_context: ModuleContext) -> TicketCaseEscalationProvider:
return TicketCaseEscalationProvider()
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",
"records",
"files",
"mail",
"campaigns",
"quick_access",
),
optional_capabilities=(
CAPABILITY_PARTY_RESOLVER,
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
CAPABILITY_MANDATE_RESOLVER,
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."
),
),
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,
),
),
product_areas=(
ProductAreaContribution(
id="services-cases",
module_id=MODULE_ID,
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",
),
order=20,
),
),
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.list.filters",
module_id=MODULE_ID,
kind="section",
label="Case search and filters",
parent_id="cases.list",
order=10,
),
ViewSurface(
id="cases.detail",
module_id=MODULE_ID,
kind="route",
label="Case details",
order=30,
),
ViewSurface(
id="cases.detail.summary",
module_id=MODULE_ID,
kind="section",
label="Case summary",
parent_id="cases.detail",
order=10,
),
ViewSurface(
id="cases.detail.editor",
module_id=MODULE_ID,
kind="section",
label="Case lifecycle editor",
parent_id="cases.detail",
order=20,
),
ViewSurface(
id="cases.detail.references",
module_id=MODULE_ID,
kind="section",
label="Institutional references",
parent_id="cases.detail",
order=30,
),
ViewSurface(
id="cases.detail.timeline",
module_id=MODULE_ID,
kind="section",
label="Case timeline",
parent_id="cases.detail",
order=40,
),
ViewSurface(
id="cases.detail.history",
module_id=MODULE_ID,
kind="section",
label="Immutable case history",
parent_id="cases.detail",
order=50,
),
ViewSurface(
id="cases.detail.access",
module_id=MODULE_ID,
kind="action",
label="Case access",
parent_id="cases.detail",
order=60,
),
ViewSurface(
id="cases.detail.decision",
module_id=MODULE_ID,
kind="action",
label="Prepare formal decision",
parent_id="cases.detail",
order=70,
),
),
),
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"),
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,
),
),
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_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(
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",
),
CAPABILITY_RECORD_SOURCE_CASES: CapabilityDocumentation(
label="Cases record source",
summary="Resolves currently authorized immutable case revisions for Records filing.",
contract_version="1.0.0",
),
CASES_DSAR_CAPABILITY: CapabilityDocumentation(
label="Cases data-subject request provider",
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,
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(),),
search_sources=(
SearchSourceProviderRegistration(
id="cases.cases",
factory=create_cases_search_source,
),
),
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",
summary=(
"Append or remove an owner-authorized reference without copying "
"the provider's content into Cases."
),
body=(
"On an active Case, Files, Mail, and Campaigns may return an exact "
"tenant-bound selection through the versioned Quick Access result "
"contract. Cases accepts only the supported owner and reference-kind "
"pairs, a matching tenant, a pinned version, and the owner's canonical "
"local route. Linking and unlinking recheck Case update authority, use "
"expected-revision concurrency and idempotency, and append immutable "
"Case history. Cases stores only a typed owner identifier, exact version, "
"and owner route; it never copies file bytes, message headers or bodies, "
"recipients, campaign content, or a source access decision. Opening a "
"link always enters the owning module, which rechecks current access. "
"If the owner module is disabled or source access is later revoked, the "
"Case retains the historical reference but does not disclose source "
"content. Unlinking removes only the reference from the next Case revision "
"and never deletes the owner object. Unsaved Case edits must be saved or "
"discarded before a Quick Access result can be linked."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("case_manager", "operator", "module_admin", "auditor"),
related_modules=("quick_access", "files", "mail", "campaigns"),
order=12,
conditions=(
DocumentationCondition(
required_modules=("cases",),
required_scopes=(UPDATE_SCOPE,),
),
),
links=(
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
DocumentationLink(
label="Quick Access architecture",
href="govoplan/docs/architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md",
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"],
"consequence_classes": {
"link_owner_reference": "Appends an immutable Case revision containing only the exact typed owner reference.",
"unlink_owner_reference": "Appends an immutable Case revision and leaves the owner object unchanged.",
},
},
),
DocumentationTopic(
id="cases.data-subject-requests",
title="Case data-subject requests",
summary="Report governed case facts without guessing applicant identity or exposing opaque case payloads.",
body=(
"The Cases DSAR provider matches exact-tenant account, identity, and membership attribution, account/identity access grants, and explicit Cases references. "
"A canonical selector combined with a direct reference must be corroborated by a Cases-owned relationship or the search fails closed. Direct case and revision references export a typed, minimized lifecycle projection; raw snapshots, metadata, search text, free-text change reasons, timeline payloads and summaries, evidence identifiers, request digests, idempotency keys, audit identifiers, and unrelated access subjects are excluded. "
"Immutable case identities, revisions, timelines, and attribution are retained. Current open case and active access facts require authorized manual review through the normal case/access lifecycle; no automatic erasure is published. Applicant-to-identity linkage remains owned by Parties, so Cases does not infer it from party identifiers."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("parties", "records", "forms_runtime", "portal"),
links=(
DocumentationLink(
label="Cases concept",
href="govoplan-cases/docs/CONCEPT.md",
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",
title="Use task-local tools from an active Case",
summary="Launch an authorized full-page tool and return to the exact Case route and context.",
body=(
"After an exact Case revision loads, the Case detail page publishes a bounded active-object reference to Core. "
"Quick Access may carry that reference together with the current tenant, acting assignment, temporal selection, "
"View revision, and route when opening an owning module's full page. It never carries Case content or an access "
"decision. The destination reauthorizes its data and effects. Unsaved Case edits use the shared save/discard guard "
"before navigation, and the shell provides an explicit return action."
),
layer="available",
documentation_types=("admin", "user"),
audience=("case_manager", "operator", "module_admin"),
related_modules=("quick_access", "views", "access"),
order=13,
links=(
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
DocumentationLink(
label="Quick Access architecture",
href="govoplan/docs/architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md",
kind="repository",
),
),
translations={
"de": {
"title": "Aufgabenbezogene Werkzeuge aus einem aktiven Vorgang verwenden",
"summary": "Ein berechtigtes Werkzeug oeffnen und zum genauen Vorgangskontext zurueckkehren.",
"body": (
"Nach dem Laden einer genauen Vorgangsrevision veroeffentlicht die Detailseite einen begrenzten Objektverweis an Core. "
"Der Schnellzugriff darf diesen Verweis zusammen mit Mandant, handelnder Zuordnung, Zeitbezug, Ansichtsversion und Ruecksprungort uebergeben. "
"Vorgangsinhalte oder Zugriffsentscheidungen werden nicht uebertragen. Das Ziel prueft Daten und Wirkungen erneut; ungespeicherte Aenderungen "
"werden vor dem Wechsel durch den gemeinsamen Speichern-Verwerfen-Dialog geschuetzt."
),
}
},
metadata={"help_contexts": ["cases.detail.quick-access"]},
),
DocumentationTopic(
id="cases.workflow.record-formal-decision",
title="Record a formal Decision from a Case",
summary="Revalidate acting authority and preserve an exact Decision revision in the Case history.",
body=(
"The Case detail action accepts only the operative result, reasoning, conditions, effective time, and change reason. "
"Cases resolves the exact Case revision, verifies object update access, resolves the current acting assignment through "
"Access, and requires exactly one effective Mandate for cases.formal_decision and the selected Decision type. It then "
"records the outcome through the optional Decisions registry and links the exact Decision revision to a new Case revision "
"in the same database transaction. A replay uses a deterministic Decision identifier; changed replay payloads fail closed."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("case_manager", "decision_officer", "operator", "auditor"),
related_modules=("access", "mandates", "decisions", "records"),
order=14,
conditions=(
DocumentationCondition(
required_modules=("cases", "access", "mandates", "decisions"),
required_scopes=("cases:case:update", "decisions:decision:write"),
),
),
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",
),
),
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"],
"prerequisites": [
"The Case has an exact responsible organization, function, evidence, and legal-basis version.",
"An active acting assignment and exactly one effective Mandate authorize the Decision type.",
],
"steps": [
"Open the Case and select the appropriate acting assignment in the title bar.",
"Record the operative result, reasoning, optional conditions, effective time, and change reason.",
"Confirm the exact Decision and Case revisions in the Case timeline.",
"When Records is enabled, file the exact Decision or Case revision into the destination eAkte.",
],
"outcome": "The formal outcome is Decisions-owned and exact-version linked from the Case.",
},
),
DocumentationTopic(
id="cases.workflow.file-exact-revision",
title="File an exact case revision into an eAkte",
summary="Preserve a reconstructable case snapshot in Records without moving case ownership.",
body=(
"When Records is enabled, Cases resolves an exact immutable case revision only after "
"current case permission and object-level access are checked. The filed reference includes "
"the case number, lifecycle state, represented valid interval, recorded time, and a SHA-256 "
"digest of the canonical snapshot. Cases remains authoritative for the case lifecycle."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("case_manager", "records_manager", "operator", "auditor"),
related_modules=("records",),
order=15,
conditions=(
DocumentationCondition(
required_modules=("cases", "records"),
required_scopes=("cases:case:read", "records:workspace:write"),
),
),
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",
),
),
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"],
"prerequisites": [
"You currently may read the case and file items into the destination record.",
"The exact immutable case revision has been identified.",
],
"steps": [
"Open the case and identify the revision that represents the evidence state.",
"Choose the destination record and state the access purpose and filing reason.",
"Confirm filing; Cases rechecks current access and resolves the exact revision.",
"Verify the case revision and snapshot digest in the record chronology.",
],
"outcome": "The eAkte preserves an exact case snapshot reference while Cases retains authority.",
},
),
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. When "
"Search is enabled, Cases contributes a rebuildable metadata-only projection. "
"Evidence and protected Decision content are excluded, and every candidate is "
"rechecked against current Case access before disclosure."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin"),
links=(
DocumentationLink(
label="Cases concept",
href="govoplan-cases/docs/CONCEPT.md",
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",
"cases.detail",
"cases.search.result",
"cases.state.read-only",
"cases.state.restricted",
],
},
),
DocumentationTopic(
id="cases.reference.lifecycle-access-and-evidence",
title="Case lifecycle, access, and evidence reference",
summary="Explains revision, status, access, and reference fields together with their durable consequences.",
body=(
"Title and status changes append an immutable case revision guarded by the "
"expected revision and a stable idempotency key. Every accepted change also "
"appends a timeline entry with actor, time, and change reason; a terminal "
"status additionally requires the case-close permission. Case visibility is "
"tenant-wide or restricted. Restricted cases remain visible only through "
"administrative authority, assignment or unit context, creator authority, or "
"an explicit account/group grant. Access changes append another immutable "
"revision and require confirmation. Service, party, assignment, Decision, and "
"record references identify provider-owned objects; Cases preserves their "
"stable identifiers and versions without copying or silently changing them."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin"),
links=(
DocumentationLink(
label="Cases interface pattern migration",
href="govoplan-cases/docs/INTERFACE_PATTERN_MIGRATION.md",
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",
"cases.field.change-reason",
"cases.field.visibility",
"cases.field.access-grant",
"cases.state.close-unavailable",
],
"consequence_classes": {
"update_case": "append an OCC-guarded immutable revision and timeline event",
"close_case": "append a terminal revision after close-scope authorization",
"change_access": "append a confirmed visibility/grant revision and timeline event",
},
},
),
),
architecture=ModuleArchitectureDeclaration(
layer="human_work_procedure",
kind="domain",
maturity="vertical_slice",
evidence=(
ModuleMaturityEvidence(
kind="test",
reference="tests/test_institutional_consumers.py",
summary="Proves versioned service intake and optional procedure-party resolution.",
),
ModuleMaturityEvidence(
kind="documentation",
reference="docs/CONCEPT.md",
summary="Defines Cases ownership and optional institutional providers.",
),
),
known_limits=(
"The first Cases workspace covers list, detail, status/title revision, history, and timeline; richer procedure-specific panels remain module contributions.",
"The compatibility party path deliberately excludes representation lifecycle ownership.",
),
owned_concepts=("case identity", "case lifecycle", "case-local links"),
non_owned_concepts=(
"institutional service definition",
"party subject master",
"representation power lifecycle",
"formal decision lifecycle",
),
reference_packages=("product.service-to-decision",),
documentation=ModuleArchitectureDocumentation(
migration=("docs/CONCEPT.md",),
recovery=("docs/CONCEPT.md",),
security=("docs/CONCEPT.md",),
operations=("docs/CONCEPT.md",),
),
),
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -0,0 +1 @@
"""Cases Alembic revisions."""
@@ -0,0 +1 @@
"""Cases migration revisions."""
@@ -0,0 +1,139 @@
"""v0.1.8 Cases persistent lifecycle baseline.
Revision ID: c7a8e9f0b1d2
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c7a8e9f0b1d2"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"case_type_definitions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("type_key", sa.String(length=120), nullable=False),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("initial_status_key", sa.String(length=120), nullable=False),
sa.Column("allowed_status_keys", sa.JSON(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_type_definitions")),
sa.UniqueConstraint("tenant_id", "type_key", name="uq_case_type_tenant_key"),
)
op.create_index(op.f("ix_case_type_definitions_tenant_id"), "case_type_definitions", ["tenant_id"], unique=False)
op.create_index(op.f("ix_case_type_definitions_type_key"), "case_type_definitions", ["type_key"], unique=False)
op.create_index(op.f("ix_case_type_definitions_active"), "case_type_definitions", ["active"], unique=False)
op.create_index("ix_case_type_catalog", "case_type_definitions", ["tenant_id", "active", "label"], unique=False)
op.create_table(
"case_status_definitions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("status_key", sa.String(length=120), nullable=False),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("category", sa.String(length=30), nullable=False),
sa.Column("terminal", sa.Boolean(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_status_definitions")),
sa.UniqueConstraint("tenant_id", "status_key", name="uq_case_status_tenant_key"),
)
for column in ("tenant_id", "status_key", "terminal", "active"):
op.create_index(op.f(f"ix_case_status_definitions_{column}"), "case_status_definitions", [column], unique=False)
op.create_index("ix_case_status_catalog", "case_status_definitions", ["tenant_id", "active", "sort_order"], unique=False)
op.create_table(
"case_identities",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("case_id", sa.String(length=255), nullable=False),
sa.Column("case_number", sa.String(length=255), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_identities")),
sa.UniqueConstraint("tenant_id", "case_id", name="uq_case_identity_tenant_id"),
sa.UniqueConstraint("tenant_id", "case_number", name="uq_case_identity_tenant_number"),
)
for column in ("tenant_id", "case_id", "case_number", "created_by"):
op.create_index(op.f(f"ix_case_identities_{column}"), "case_identities", [column], unique=False)
op.create_index("ix_case_identity_catalog", "case_identities", ["tenant_id", "case_number"], unique=False)
op.create_table(
"case_record_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("case_id", sa.String(length=255), nullable=False),
sa.Column("identity_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("case_type_key", sa.String(length=120), nullable=False),
sa.Column("status_key", sa.String(length=120), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("search_text", sa.Text(), nullable=False),
sa.Column("opened_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deadline_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("snapshot", sa.JSON(), nullable=False),
sa.Column("changed_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["identity_id"], ["case_identities.id"], name=op.f("fk_case_record_revisions_identity_id_case_identities"), ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["previous_revision_id"], ["case_record_revisions.id"], name=op.f("fk_case_record_revisions_previous_revision_id_case_record_revisions"), ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_record_revisions")),
sa.UniqueConstraint("tenant_id", "case_id", "revision", name="uq_case_record_revision"),
)
for column in ("tenant_id", "case_id", "identity_id", "previous_revision_id", "case_type_key", "status_key", "opened_at", "deadline_at", "closed_at", "recorded_at", "superseded_at", "changed_by"):
op.create_index(op.f(f"ix_case_record_revisions_{column}"), "case_record_revisions", [column], unique=False)
op.create_index("ix_case_record_current", "case_record_revisions", ["tenant_id", "case_id", "superseded_at"], unique=False)
op.create_index("ix_case_record_list", "case_record_revisions", ["tenant_id", "status_key", "case_type_key", "recorded_at"], unique=False)
op.create_table(
"case_timeline_entries",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("case_id", sa.String(length=255), nullable=False),
sa.Column("event_id", sa.String(length=36), nullable=False),
sa.Column("event_type", sa.String(length=120), nullable=False),
sa.Column("case_revision", sa.Integer(), nullable=False),
sa.Column("summary", sa.String(length=500), nullable=False),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("audit_event_id", sa.String(length=255), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_timeline_entries")),
sa.UniqueConstraint("tenant_id", "event_id", name="uq_case_timeline_event"),
sa.UniqueConstraint("tenant_id", "idempotency_key", name="uq_case_timeline_idempotency"),
)
for column in ("tenant_id", "case_id", "event_id", "event_type", "occurred_at", "actor_id", "audit_event_id"):
op.create_index(op.f(f"ix_case_timeline_entries_{column}"), "case_timeline_entries", [column], unique=False)
op.create_index("ix_case_timeline_case", "case_timeline_entries", ["tenant_id", "case_id", "occurred_at"], unique=False)
def downgrade() -> None:
op.drop_table("case_timeline_entries")
op.drop_table("case_record_revisions")
op.drop_table("case_identities")
op.drop_table("case_status_definitions")
op.drop_table("case_type_definitions")
@@ -0,0 +1,87 @@
"""v0.1.14 case object sharing.
Revision ID: f6d3a8b1c4e7
Revises: c7a8e9f0b1d2
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f6d3a8b1c4e7"
down_revision = "c7a8e9f0b1d2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"case_record_revisions",
sa.Column(
"access_mode",
sa.String(length=30),
nullable=False,
server_default="tenant",
),
)
op.create_index(
op.f("ix_case_record_revisions_access_mode"),
"case_record_revisions",
["access_mode"],
unique=False,
)
op.create_table(
"case_access_grants",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("case_id", sa.String(length=255), nullable=False),
sa.Column("subject_kind", sa.String(length=40), nullable=False),
sa.Column("subject_id", sa.String(length=255), nullable=False),
sa.Column("permissions", sa.JSON(), nullable=False),
sa.Column("source", sa.String(length=30), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("source_revision", sa.Integer(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_access_grants")),
sa.UniqueConstraint(
"tenant_id",
"case_id",
"subject_kind",
"subject_id",
"source",
name="uq_case_access_grant_subject",
),
)
for column in (
"tenant_id",
"case_id",
"subject_kind",
"subject_id",
"source",
"active",
"created_by",
):
op.create_index(
op.f(f"ix_case_access_grants_{column}"),
"case_access_grants",
[column],
unique=False,
)
op.create_index(
"ix_case_access_grant_lookup",
"case_access_grants",
["tenant_id", "case_id", "active", "subject_kind", "subject_id"],
unique=False,
)
def downgrade() -> None:
op.drop_table("case_access_grants")
op.drop_index(
op.f("ix_case_record_revisions_access_mode"),
table_name="case_record_revisions",
)
op.drop_column("case_record_revisions", "access_mode")
+255
View File
@@ -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",
]
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
from collections.abc import Sequence
import hashlib
import json
from urllib.parse import quote
from sqlalchemy.orm import Session
from govoplan_core.core.records import (
RecordContractError,
RecordSourceLocator,
RecordSourceReference,
)
from govoplan_cases.backend.db.models import CaseIdentity, CaseRecordRevision
from govoplan_cases.backend.service import can_access_case
CAPABILITY_RECORD_SOURCE_CASES = "records.source.cases"
class CasesRecordSource:
provider_id = "cases"
def resource_types(self) -> Sequence[str]:
return ("case_revision",)
def resolve(
self,
session: object,
principal: object,
*,
locator: RecordSourceLocator,
purpose: str,
) -> RecordSourceReference:
if not isinstance(session, Session):
raise RecordContractError(
"Case record references require a database session."
)
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
if not tenant_id or locator.tenant_id != tenant_id:
raise RecordContractError("Case record references cannot cross tenants.")
if locator.source_module != "cases" or locator.resource_type != "case_revision":
raise RecordContractError("Unsupported Cases record source type.")
if not str(purpose or "").strip():
raise RecordContractError("Case record references require a purpose.")
if not hasattr(principal, "has") or not (
principal.has("cases:case:read") or principal.has("cases:case:admin")
):
raise RecordContractError("Current Cases read permission is required.")
if not can_access_case(
session, principal, case_id=locator.resource_id, permission="read"
):
raise RecordContractError("The current principal cannot read this case.")
try:
revision = int(locator.source_revision)
except ValueError as exc:
raise RecordContractError("Case source revisions must be numeric.") from exc
row = (
session.query(CaseRecordRevision)
.filter(
CaseRecordRevision.tenant_id == tenant_id,
CaseRecordRevision.case_id == locator.resource_id,
CaseRecordRevision.revision == revision,
)
.one_or_none()
)
if row is None:
raise RecordContractError("The exact case revision does not exist.")
identity = (
session.query(CaseIdentity)
.filter(
CaseIdentity.tenant_id == tenant_id,
CaseIdentity.case_id == locator.resource_id,
)
.one_or_none()
)
if identity is None:
raise RecordContractError("The case identity is unavailable.")
snapshot_json = json.dumps(
row.snapshot,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return RecordSourceReference(
locator=locator,
label=f"{identity.case_number} - {row.title}",
authority_mode="external_authoritative",
content_sha256=hashlib.sha256(snapshot_json).hexdigest(),
content_type="application/vnd.govoplan.case-revision+json",
size_bytes=len(snapshot_json),
valid_from=row.opened_at,
valid_to=row.closed_at,
recorded_at=row.recorded_at,
launch_url=f"/cases/{quote(row.case_id, safe='')}",
metadata={
"case_number": identity.case_number,
"case_type_key": row.case_type_key,
"status_key": row.status_key,
"access_mode": row.access_mode,
"snapshot_sha256": hashlib.sha256(snapshot_json).hexdigest(),
},
)
def create_cases_record_source(_context: object) -> CasesRecordSource:
return CasesRecordSource()
__all__ = [
"CAPABILITY_RECORD_SOURCE_CASES",
"CasesRecordSource",
"create_cases_record_source",
]
+538
View File
@@ -0,0 +1,538 @@
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 (
CaseDecisionRequest,
CaseEvidenceLinkRequest,
CaseEvidenceUnlinkRequest,
CaseHistoryResponse,
CaseListResponse,
CaseStatusWriteRequest,
CaseTimelineResponse,
CaseTypeWriteRequest,
CaseUpdateRequest,
CaseWriteRequest,
)
from govoplan_cases.backend.evidence_links import (
link_case_evidence,
unlink_case_evidence,
)
from govoplan_cases.backend.decision_path import (
CaseDecisionCommand,
CaseDecisionError,
CaseDecisionPath,
CaseDecisionUnavailable,
DECISION_READ_SCOPE,
DECISION_SENSITIVE_READ_SCOPE,
DECISION_WRITE_SCOPE,
)
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, CaseDecisionUnavailable):
code = status.HTTP_424_FAILED_DEPENDENCY
elif 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}/decisions", response_model=dict[str, list[dict[str, Any]]])
def api_case_decisions(
case_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, list[dict[str, Any]]]:
_require(principal, READ_SCOPE)
_require(principal, DECISION_READ_SCOPE)
try:
items = CaseDecisionPath(get_registry()).linked(
session,
principal,
case_id=case_id,
)
except (
CaseDecisionError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
raise _error(exc) from exc
disclose = has_scope(principal, DECISION_SENSITIVE_READ_SCOPE)
return {
"decisions": [
item.to_dict(include_protected=disclose)
for item in items
]
}
@router.post(
"/{case_id}/decisions",
response_model=dict[str, Any],
status_code=status.HTTP_201_CREATED,
)
def api_record_case_decision(
case_id: str,
payload: CaseDecisionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, UPDATE_SCOPE)
_require(principal, DECISION_WRITE_SCOPE)
try:
result = CaseDecisionPath(get_registry()).record(
session,
principal,
case_id=case_id,
command=CaseDecisionCommand(
expected_case_revision=payload.expected_case_revision,
effective_at=payload.effective_at,
decision_type=payload.decision_type,
operative_result=payload.operative_result,
reasoning=payload.reasoning,
conditions=tuple(payload.conditions),
change_reason=payload.change_reason,
idempotency_key=payload.idempotency_key,
),
)
session.commit()
except (
CaseDecisionError,
InstitutionalContextError,
LookupError,
PermissionError,
ValueError,
) as exc:
session.rollback()
raise _error(exc) from exc
return result.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.post("/{case_id}/evidence-links", response_model=dict[str, Any])
def api_link_case_evidence(
case_id: str,
payload: CaseEvidenceLinkRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, UPDATE_SCOPE)
try:
item = link_case_evidence(
session,
principal,
case_id=case_id,
expected_revision=payload.expected_revision,
reference=payload.reference.model_dump(),
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.post(
"/{case_id}/evidence-links/{evidence_id}/unlink",
response_model=dict[str, Any],
)
def api_unlink_case_evidence(
case_id: str,
evidence_id: str,
payload: CaseEvidenceUnlinkRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, UPDATE_SCOPE)
try:
item = unlink_case_evidence(
session,
principal,
case_id=case_id,
evidence_id=evidence_id,
expected_revision=payload.expected_revision,
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"]
+155
View File
@@ -0,0 +1,155 @@
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 CaseOwnerReferenceRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
owner_module: str = Field(min_length=1, max_length=120)
kind: str = Field(min_length=1, max_length=120)
object_id: str = Field(min_length=1, max_length=1_000)
tenant_id: str = Field(min_length=1, max_length=255)
version: str = Field(min_length=1, max_length=255)
path: str = Field(min_length=1, max_length=2_000)
class CaseEvidenceLinkRequest(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)
reference: CaseOwnerReferenceRequest
class CaseEvidenceUnlinkRequest(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)
class CaseDecisionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_case_revision: int = Field(ge=1)
effective_at: datetime
decision_type: str = Field(min_length=1, max_length=120)
operative_result: str = Field(min_length=1, max_length=20_000)
reasoning: str = Field(min_length=1, max_length=50_000)
conditions: list[str] = Field(default_factory=list, max_length=100)
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
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",
"CaseDecisionRequest",
"CaseEvidenceLinkRequest",
"CaseEvidenceUnlinkRequest",
"CaseHistoryResponse",
"CaseListResponse",
"CaseStatusWriteRequest",
"CaseTimelineResponse",
"CaseTypeWriteRequest",
"CaseUpdateRequest",
"CaseWriteRequest",
]
+283
View File
@@ -0,0 +1,283 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.events import PlatformEvent
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchIndexChange,
SearchResourceReference,
SearchResourceType,
)
from govoplan_cases.backend.db.models import (
CaseAccessGrant,
CaseIdentity,
CaseRecordRevision,
)
from govoplan_cases.backend.service import can_access_case
PROVIDER_ID = "cases.cases"
RESOURCE_TYPE = "case"
READ_SCOPE = "cases:case:read"
ADMIN_SCOPE = "cases:case:admin"
class CasesSearchSource:
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=PROVIDER_ID,
module_id="cases",
resource_type=RESOURCE_TYPE,
label="Cases",
requires_authorization_recheck=True,
),
)
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
_assert_source(request.provider_id, request.resource_type)
db = _session(session)
statement = (
select(CaseRecordRevision, CaseIdentity)
.join(CaseIdentity, CaseIdentity.id == CaseRecordRevision.identity_id)
.where(
CaseRecordRevision.tenant_id == request.tenant_id,
CaseRecordRevision.superseded_at.is_(None),
)
)
if request.cursor:
statement = statement.where(CaseRecordRevision.case_id > request.cursor)
rows = list(
db.execute(
statement.order_by(CaseRecordRevision.case_id).limit(request.limit + 1)
).all()
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
grants = _grants_by_case(
db,
request.tenant_id,
[row.case_id for row, _identity in selected],
)
high_watermark = db.scalar(
select(func.max(CaseRecordRevision.recorded_at)).where(
CaseRecordRevision.tenant_id == request.tenant_id,
CaseRecordRevision.superseded_at.is_(None),
)
)
return SearchBackfillPage(
documents=tuple(
_document(
row,
identity=identity,
grants=grants.get(row.case_id, ()),
)
for row, identity in selected
),
next_cursor=selected[-1][0].case_id if has_more and selected else None,
complete=not has_more,
high_watermark=high_watermark.isoformat() if high_watermark else None,
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
decisions = {item.reference.key: False for item in requests}
if not isinstance(principal, ApiPrincipal) or not principal.has(READ_SCOPE):
return decisions
db = _session(session)
for request in requests:
reference = request.reference
if (
reference.tenant_id != principal.tenant_id
or reference.module_id != "cases"
or reference.resource_type != RESOURCE_TYPE
):
continue
decisions[reference.key] = can_access_case(
db,
principal,
case_id=reference.resource_id,
permission="read",
)
return decisions
def index_changes_for_event(
self,
session: object,
*,
event: PlatformEvent,
delivery_key: str,
) -> Sequence[SearchIndexChange]:
if (
event.module_id != "cases"
or event.tenant is None
or event.resource is None
or event.resource.type != RESOURCE_TYPE
or event.resource.id is None
):
return ()
db = _session(session)
row = db.scalar(
select(CaseRecordRevision).where(
CaseRecordRevision.tenant_id == event.tenant.id,
CaseRecordRevision.case_id == event.resource.id,
CaseRecordRevision.superseded_at.is_(None),
)
)
identity = db.scalar(
select(CaseIdentity).where(
CaseIdentity.tenant_id == event.tenant.id,
CaseIdentity.case_id == event.resource.id,
)
)
deleted = row is None or identity is None
cursor = event.event_id
document = None
if not deleted:
document = _document(
row,
identity=identity,
grants=_grants_by_case(
db,
event.tenant.id,
[row.case_id],
).get(row.case_id, ()),
change_cursor=cursor,
)
reference = SearchResourceReference(
tenant_id=event.tenant.id,
module_id="cases",
resource_type=RESOURCE_TYPE,
resource_id=event.resource.id,
)
return (
SearchIndexChange(
change_id=f"{delivery_key}:{PROVIDER_ID}",
provider_id=PROVIDER_ID,
kind="delete" if deleted else "upsert",
reference=reference,
source_revision=(document.source_revision if document else cursor),
cursor=cursor,
document=document,
occurred_at=event.occurred_at,
),
)
def create_cases_search_source(_context: ModuleContext) -> CasesSearchSource:
return CasesSearchSource()
def _document(
row: CaseRecordRevision,
*,
identity: CaseIdentity,
grants: Sequence[CaseAccessGrant],
change_cursor: str | None = None,
) -> SearchDocument:
tokens = [f"scope:{READ_SCOPE}", f"scope:{ADMIN_SCOPE}"]
if identity.created_by:
tokens.extend(
(f"account:{identity.created_by}", f"membership:{identity.created_by}")
)
prefixes = {
"account": "account",
"identity": "identity",
"group": "group",
"role": "role",
"function": "function",
"function_assignment": "function",
}
for grant in grants:
prefix = prefixes.get(grant.subject_kind)
if prefix:
tokens.append(f"{prefix}:{grant.subject_id}")
return SearchDocument(
tenant_id=row.tenant_id,
module_id="cases",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
resource_id=row.case_id,
title=row.title,
url=f"/cases/{quote(row.case_id, safe='')}",
summary=f"{identity.case_number} - {row.status_key}",
body=row.search_text[:200_000],
keywords=(
identity.case_number[:200],
row.case_type_key[:200],
row.status_key[:200],
),
visibility="restricted",
acl_tokens=tuple(dict.fromkeys(tokens)),
metadata={
"case_number": identity.case_number,
"case_type_key": row.case_type_key,
"status_key": row.status_key,
"access_mode": row.access_mode,
},
source_revision=str(row.revision),
change_cursor=change_cursor,
source_updated_at=row.recorded_at,
requires_authorization_recheck=True,
)
def _grants_by_case(
session: Session,
tenant_id: str,
case_ids: Sequence[str],
) -> dict[str, tuple[CaseAccessGrant, ...]]:
grouped: dict[str, list[CaseAccessGrant]] = {
case_id: [] for case_id in case_ids
}
if not case_ids:
return {}
rows = session.scalars(
select(CaseAccessGrant).where(
CaseAccessGrant.tenant_id == tenant_id,
CaseAccessGrant.case_id.in_(tuple(case_ids)),
CaseAccessGrant.active.is_(True),
)
)
for row in rows:
grouped.setdefault(row.case_id, []).append(row)
return {key: tuple(value) for key, value in grouped.items()}
def _assert_source(provider_id: str, resource_type: str) -> None:
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
raise ValueError("Unsupported Cases search source.")
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Cases search requires a SQLAlchemy session.")
return value
__all__ = [
"CasesSearchSource",
"PROVIDER_ID",
"RESOURCE_TYPE",
"create_cases_search_source",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,112 @@
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, ...] = ()
remedy_refs: tuple[str, ...] = ()
service_level_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,
remedy_refs=definition.remedy_refs,
service_level_refs=definition.service_level_refs,
)
def _binding_refs(
definition: ServiceDefinition,
kind: str,
) -> tuple[str, ...]:
return tuple(item.reference for item in definition.bindings if item.kind == kind)
__all__ = [
"CAPABILITY_CASES_SERVICE_INTAKE",
"CaseIntakePlan",
"CaseServiceIntake",
]
@@ -0,0 +1,156 @@
from __future__ import annotations
from collections.abc import Mapping
import re
from urllib.parse import quote
import uuid
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
InstitutionalContextError,
ServiceDefinition,
ServiceLaunchRequest,
ServiceLaunchResult,
)
from govoplan_cases.backend.service import create_case_from_intake, get_case
from govoplan_cases.backend.service_intake import CaseServiceIntake
CAPABILITY_CASES_SERVICE_LAUNCHER = "cases.service_launcher"
_CASE_LAUNCH_NAMESPACE = uuid.uuid5(
uuid.NAMESPACE_URL,
"https://govoplan.add-ideas.de/contracts/cases/service-launch/v1",
)
class CaseServiceLauncher:
"""Start a replay-safe case from an exact institutional Service revision."""
def launch_service(
self,
session: object,
principal: object,
*,
definition: ServiceDefinition,
request: ServiceLaunchRequest,
) -> ServiceLaunchResult:
if not isinstance(session, Session):
raise InstitutionalContextError(
"Case service launch requires a SQLAlchemy session."
)
if request.service_ref != definition.reference:
raise InstitutionalContextError(
"Case service launch must use the requested exact Service revision."
)
if request.binding.kind != "case" or request.binding not in definition.bindings:
raise InstitutionalContextError(
"Case service launch requires a case binding from the Service definition."
)
case_id = str(
uuid.uuid5(
_CASE_LAUNCH_NAMESPACE,
":".join(
(
definition.reference.tenant_id,
definition.reference.object_id,
definition.reference.version or "",
request.idempotency_key,
)
),
)
)
replayed = get_case(session, principal, case_id=case_id) is not None
plan = CaseServiceIntake().plan(
definition,
case_id=case_id,
effective_at=request.requested_at,
)
title = _parameter_text(
request.parameters,
"title",
default=definition.title,
maximum=500,
)
case_number = _parameter_text(
request.parameters,
"case_number",
default=_default_case_number(definition, case_id),
maximum=255,
)
status_key = _optional_parameter_text(
request.parameters,
"status_key",
maximum=120,
)
item = create_case_from_intake(
session,
principal,
plan=plan,
case_number=case_number,
title=title,
status_key=status_key,
opened_at=request.requested_at,
recorded_at=request.requested_at,
change_reason=(
f"Started from service {definition.key}@"
f"{definition.reference.version}."
),
idempotency_key=request.idempotency_key,
metadata={
"service_launch_binding": request.binding.reference,
"service_launch_capability": CAPABILITY_CASES_SERVICE_LAUNCHER,
},
)
return ServiceLaunchResult(
service_ref=definition.reference,
binding=request.binding,
state="started",
target_ref=item.reference,
href=f"/cases/{quote(item.reference.object_id, safe='')}",
replayed=replayed,
metadata={
"case_number": item.case_number,
"case_revision": item.revision,
},
)
def _default_case_number(definition: ServiceDefinition, case_id: str) -> str:
prefix = re.sub(r"[^A-Za-z0-9]+", "-", definition.key).strip("-")
return f"{(prefix or 'CASE')[:40].upper()}-{case_id[:8].upper()}"
def _parameter_text(
parameters: Mapping[str, object],
key: str,
*,
default: str,
maximum: int,
) -> str:
value = str(parameters.get(key) or default).strip()
if not value or len(value) > maximum:
raise InstitutionalContextError(
f"Case launch parameter {key!r} must contain at most {maximum} characters."
)
return value
def _optional_parameter_text(
parameters: Mapping[str, object],
key: str,
*,
maximum: int,
) -> str | None:
raw = parameters.get(key)
if raw is None:
return None
value = str(raw).strip()
if not value or len(value) > maximum:
raise InstitutionalContextError(
f"Case launch parameter {key!r} must contain at most {maximum} characters."
)
return value
__all__ = ["CAPABILITY_CASES_SERVICE_LAUNCHER", "CaseServiceLauncher"]
@@ -0,0 +1,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"]
+1
View File
@@ -0,0 +1 @@
+473
View File
@@ -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()
+350
View File
@@ -0,0 +1,350 @@
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.access import (
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
FunctionAssignmentRef,
)
from govoplan_core.core.institutional import (
CAPABILITY_DECISION_REGISTRY,
CAPABILITY_MANDATE_RESOLVER,
EvidenceReference,
FormalDecision,
GovernedContextEnvelope,
InstitutionalReference,
LegalBasisReference,
MandateDefinition,
MandateResolution,
TemporalRevision,
)
from govoplan_cases.backend.db.models import (
CaseAccessGrant,
CaseIdentity,
CaseRecordRevision,
CaseStatusDefinition,
CaseTimelineEntry,
CaseTypeDefinition,
)
from govoplan_cases.backend.decision_path import (
CaseDecisionCommand,
CaseDecisionError,
CaseDecisionPath,
)
from govoplan_cases.backend.domain import CaseRecord
from govoplan_cases.backend.service import (
create_case,
get_case,
upsert_case_status,
upsert_case_type,
)
NOW = datetime(2026, 8, 6, 9, 0, tzinfo=UTC)
@dataclass
class Principal:
tenant_id: str = "tenant-1"
account_id: str = "account-1"
identity_id: str = "identity-1"
acting_assignment_id: str | None = "assignment-1"
function_assignment_ids: tuple[str, ...] = ("assignment-1",)
scopes: frozenset[str] = frozenset(
{"cases:case:read", "cases:case:update", "decisions:decision:write"}
)
class Registry:
def __init__(self, providers: dict[str, object]) -> None:
self.providers = providers
def has_capability(self, name: str) -> bool:
return name in self.providers
def capability(self, name: str) -> object:
return self.providers[name]
class AccessDirectory:
def get_function_assignment(self, assignment_id: str):
if assignment_id != "assignment-1":
return None
return FunctionAssignmentRef(
id=assignment_id,
tenant_id="tenant-1",
account_id="account-1",
identity_id="identity-1",
function_id="permit-officer",
organization_unit_id="permits",
valid_from=NOW - timedelta(days=30),
valid_until=NOW + timedelta(days=30),
)
class Mandates:
def __init__(self, mandate: MandateDefinition) -> None:
self.mandate = mandate
def resolve_mandate(self, session, principal, *, request):
del session, principal
return MandateResolution(competent=True, mandates=(self.mandate,))
class Decisions:
def __init__(self) -> None:
self.items: dict[tuple[str, str], FormalDecision] = {}
def get_decision(self, session, principal, *, reference):
del session, principal
return self.items.get((reference.object_id, reference.version or ""))
def record_decision(self, session, principal, *, decision, expected_revision=None):
del session, principal, expected_revision
key = (decision.reference.object_id, decision.reference.version or "")
existing = self.items.get(key)
if existing is not None and existing != decision:
raise ValueError("A different Decision payload already uses this revision.")
self.items[key] = decision
return decision
def ref(kind: str, object_id: str, owner: str, *, version: str = "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 legal_basis() -> LegalBasisReference:
return LegalBasisReference(
kind="law",
authority="Example legislature",
reference="permit-law:3",
version="2026-01",
)
def evidence() -> EvidenceReference:
return EvidenceReference(
kind="document",
owner_module="files",
evidence_id="application-1",
tenant_id="tenant-1",
version="3",
captured_at=NOW,
)
def case_record() -> 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.",
),
organization_unit_ref=ref("organization_unit", "permits", "organizations"),
function_ref=ref("function", "permit-officer", "organizations"),
mandate_ref=ref("mandate", "permit-mandate", "mandates", version="4"),
jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),),
service_ref=ref("service", "permit", "services", version="4"),
case_ref=case_ref,
legal_bases=(legal_basis(),),
)
return CaseRecord(
reference=case_ref,
case_number="PERMIT-2026-0001",
case_type_key="permit-application",
status_key="review",
title="Permit application",
context=context,
service_ref=context.service_ref,
party_refs=(ref("party", "applicant", "parties"),),
assignment_refs=(ref("function_assignment", "assignment-1", "access"),),
evidence_refs=(evidence(),),
opened_at=NOW,
recorded_at=NOW,
change_reason="Application received.",
)
def mandate() -> MandateDefinition:
return MandateDefinition(
reference=ref("mandate", "permit-mandate", "mandates", version="4"),
temporal=TemporalRevision(
revision="4",
valid_from=NOW - timedelta(days=30),
valid_to=NOW + timedelta(days=30),
recorded_at=NOW - timedelta(days=31),
change_reason="Permit authority delegated.",
),
task_types=("cases.formal_decision",),
authority_types=("permit",),
organization_unit_refs=(ref("organization_unit", "permits", "organizations"),),
function_refs=(ref("function", "permit-officer", "organizations"),),
jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),),
legal_bases=(legal_basis(),),
evidence=(evidence(),),
)
def command(
*,
key: str = "decision-1",
result: str = "Permit granted.",
expected_case_revision: int = 1,
) -> CaseDecisionCommand:
return CaseDecisionCommand(
expected_case_revision=expected_case_revision,
effective_at=NOW + timedelta(minutes=5),
decision_type="permit",
operative_result=result,
reasoning="The submitted evidence satisfies the effective rule.",
conditions=("Display the permit visibly.",),
change_reason="Formal permit Decision recorded.",
idempotency_key=key,
)
class CaseDecisionPathTests(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="review",
label="Review",
)
upsert_case_type(
self.session,
self.principal,
type_key="permit-application",
label="Permit application",
initial_status_key="review",
allowed_status_keys=("review",),
)
create_case(
self.session,
self.principal,
record=case_record(),
idempotency_key="case-1",
)
self.session.commit()
self.decisions = Decisions()
self.path = CaseDecisionPath(
Registry(
{
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY: AccessDirectory(),
CAPABILITY_MANDATE_RESOLVER: Mandates(mandate()),
CAPABILITY_DECISION_REGISTRY: self.decisions,
}
)
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_records_and_links_exact_decision_in_one_case_revision(self) -> None:
result = self.path.record(
self.session,
self.principal,
case_id="case-1",
command=command(),
)
self.session.commit()
self.assertEqual(2, result.case.revision)
self.assertEqual("Permit granted.", result.decision.operative_result)
self.assertEqual("assignment-1", result.decision.authority_context.actor.function_assignment_ref.object_id)
self.assertEqual("permit-mandate", result.decision.authority_context.mandate_ref.object_id)
self.assertEqual(result.decision.reference, result.case.decision_refs[0])
self.assertEqual(
result.decision,
self.path.linked(self.session, self.principal, case_id="case-1")[0],
)
def test_unchanged_retry_replays_but_changed_payload_fails_closed(self) -> None:
first = self.path.record(
self.session,
self.principal,
case_id="case-1",
command=command(),
)
self.session.commit()
replay = self.path.record(
self.session,
self.principal,
case_id="case-1",
command=command(),
)
self.assertTrue(replay.replayed)
self.assertEqual(first.decision.reference, replay.decision.reference)
with self.assertRaisesRegex(ValueError, "different Decision payload"):
self.path.record(
self.session,
self.principal,
case_id="case-1",
command=command(result="Permit denied."),
)
def test_stale_case_revision_and_missing_acting_assignment_fail_closed(self) -> None:
self.path.record(
self.session,
self.principal,
case_id="case-1",
command=command(),
)
self.session.commit()
with self.assertRaisesRegex(CaseDecisionError, "revision conflict"):
self.path.record(
self.session,
self.principal,
case_id="case-1",
command=command(key="another-decision"),
)
without_assignment = Principal(
acting_assignment_id=None,
function_assignment_ids=(),
)
current = get_case(self.session, self.principal, case_id="case-1")
assert current is not None
with self.assertRaisesRegex(PermissionError, "No active acting assignment"):
self.path.record(
self.session,
without_assignment,
case_id="case-1",
command=command(
key="third-decision",
expected_case_revision=current.revision,
),
)
if __name__ == "__main__":
unittest.main()
+471
View File
@@ -0,0 +1,471 @@
from __future__ import annotations
import json
import unittest
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_cases.backend.domain import CaseGrant, CaseRecord
from govoplan_cases.backend.dsar_provider import (
CASES_DSAR_CAPABILITY,
CasesDsarProvider,
)
from govoplan_cases.backend.manifest import manifest
from govoplan_cases.backend.service import (
create_case,
update_case,
upsert_case_status,
upsert_case_type,
)
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.core.institutional import (
EvidenceReference,
GovernedContextEnvelope,
InstitutionalReference,
TemporalRevision,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
NOW = datetime(2026, 8, 21, 12, 0, tzinfo=UTC)
@dataclass
class _Principal:
tenant_id: str
account_id: str
class _Registry:
def __init__(
self,
provider: CasesDsarProvider,
*,
cases_active: bool = True,
) -> None:
self.provider = provider
self.cases_active = cases_active
def capability_names(self):
return (CASES_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "cases"
def tenant_entitlement_resolver(self):
cases_active = self.cases_active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("cases",) if cases_active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "cases"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != CASES_DSAR_CAPABILITY:
raise KeyError(name)
def _ref(
kind: str,
object_id: str,
owner: str,
*,
tenant_id: str,
version: str | None = "1",
) -> InstitutionalReference:
return InstitutionalReference(
kind=kind, # type: ignore[arg-type]
owner_module=owner,
object_id=object_id,
tenant_id=tenant_id,
version=version,
valid_at=NOW,
)
def _record(
case_id: str,
*,
tenant_id: str = "tenant-1",
title: str = "Subject permit",
grants: tuple[CaseGrant, ...] = (),
) -> CaseRecord:
case_ref = _ref("case", case_id, "cases", tenant_id=tenant_id)
service_ref = _ref("service", "permit", "services", tenant_id=tenant_id)
return CaseRecord(
reference=case_ref,
case_number=f"PERMIT-{case_id}",
case_type_key="permit",
status_key="open",
title=title,
access_mode="restricted" if grants else "tenant",
access_grants=grants,
context=GovernedContextEnvelope(
tenant_id=tenant_id,
temporal=TemporalRevision(
revision="1",
valid_from=NOW,
recorded_at=NOW,
change_reason="private-context-reason-do-not-export",
),
service_ref=service_ref,
case_ref=case_ref,
),
service_ref=service_ref,
party_refs=(
_ref(
"party",
"private-party-reference-do-not-export",
"parties",
tenant_id=tenant_id,
),
),
evidence_refs=(
EvidenceReference(
kind="document",
owner_module="files",
evidence_id="private-evidence-id-do-not-export",
tenant_id=tenant_id,
version="1",
),
),
opened_at=NOW,
recorded_at=NOW,
deadline_at=NOW + timedelta(days=30),
change_reason="private-create-reason-do-not-export",
metadata={"secret": "private-metadata-do-not-export"},
)
class CasesDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = CasesDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
operator = _Principal("tenant-1", "operator-1")
other_operator = _Principal("tenant-1", "operator-other")
tenant_two_operator = _Principal("tenant-2", "operator-2")
for principal in (operator, tenant_two_operator):
upsert_case_status(
self.session,
principal,
status_key="open",
label="Open",
)
upsert_case_type(
self.session,
principal,
type_key="permit",
label="Permit",
initial_status_key="open",
allowed_status_keys=("open",),
)
create_case(
self.session,
operator,
record=_record(
"case-1",
grants=(
CaseGrant("account", "account-subject", ("read",)),
CaseGrant("identity", "identity-subject", ("read", "update")),
),
),
idempotency_key="private-create-key-do-not-export",
)
update_case(
self.session,
operator,
case_id="case-1",
expected_revision=1,
changes={
"title": "Subject permit revised",
"metadata": {"secret": "private-updated-metadata-do-not-export"},
},
recorded_at=NOW + timedelta(minutes=1),
change_reason="private-update-reason-do-not-export",
idempotency_key="private-update-key-do-not-export",
)
create_case(
self.session,
other_operator,
record=_record(
"case-unrelated",
title="private-unrelated-title-do-not-export",
grants=(CaseGrant("account", "account-other", ("read",)),),
),
idempotency_key="unrelated-key-do-not-export",
)
create_case(
self.session,
tenant_two_operator,
record=_record(
"case-other-tenant",
tenant_id="tenant-2",
title="private-other-tenant-title-do-not-export",
grants=(CaseGrant("account", "account-subject", ("read",)),),
),
idempotency_key="other-tenant-key-do-not-export",
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_subject_access_grants_are_exact_tenant_and_minimized(self) -> None:
account_records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="account-subject"),
)
self.assertEqual(1, len(account_records))
self.assertEqual("cases_access_grant", account_records[0].resource_type)
self.assertFalse(account_records[0].immutable_evidence)
self.assertEqual("account-subject", account_records[0].data["subject_id"])
identity_records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(identity_id="identity-subject"),
)
self.assertEqual(1, len(identity_records))
self.assertEqual("identity", identity_records[0].data["subject_kind"])
exported = json.dumps(
[item.to_dict() for item in account_records + identity_records],
sort_keys=True,
)
self.assertNotIn("private-unrelated-title-do-not-export", exported)
self.assertNotIn("private-other-tenant-title-do-not-export", exported)
self.assertNotIn("private-metadata-do-not-export", exported)
self.assertNotIn("private-evidence-id-do-not-export", exported)
def test_direct_case_exports_typed_history_without_opaque_content(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(external_references={"cases.case": "case-1"}),
)
types = [item.resource_type for item in records]
self.assertEqual(1, types.count("cases_case_identity"))
self.assertEqual(2, types.count("cases_case_revision"))
self.assertEqual(1, types.count("cases_current_case_fact"))
self.assertEqual(2, types.count("cases_timeline_event"))
exported = json.dumps([item.to_dict() for item in records], sort_keys=True)
self.assertIn("Subject permit revised", exported)
self.assertNotIn("account-subject", exported)
self.assertNotIn("identity-subject", exported)
self.assertNotIn("private-party-reference-do-not-export", exported)
self.assertNotIn("private-evidence-id-do-not-export", exported)
self.assertNotIn("private-create-reason-do-not-export", exported)
self.assertNotIn("private-update-reason-do-not-export", exported)
self.assertNotIn("private-create-key-do-not-export", exported)
self.assertNotIn("private-update-key-do-not-export", exported)
self.assertNotIn("private-metadata-do-not-export", exported)
self.assertNotIn("private-updated-metadata-do-not-export", exported)
def test_operator_attribution_excludes_raw_case_content(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="operator-1"),
)
self.assertGreaterEqual(len(records), 5)
self.assertTrue(
all(
item.resource_type
in {"cases_operator_attribution", "cases_timeline_event"}
for item in records
)
)
exported = json.dumps([item.to_dict() for item in records], sort_keys=True)
self.assertNotIn("Subject permit revised", exported)
self.assertNotIn("account-subject", exported)
self.assertNotIn("identity-subject", exported)
self.assertNotIn("private-update-reason-do-not-export", exported)
self.assertNotIn("private-updated-metadata-do-not-export", exported)
def test_direct_and_canonical_selector_conflicts_fail_closed(self) -> None:
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-subject",
external_references={"cases.case": "case-unrelated"},
),
)
self.assertEqual((), conflict)
alias_conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-subject",
external_references={"cases.account": "account-other"},
),
)
self.assertEqual((), alias_conflict)
def test_planning_retains_history_and_requires_current_fact_review(self) -> None:
subject = DsarSubjectRef(external_references={"cases.case": "case-1"})
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertEqual(5, sum(item.kind == "retain" for item in actions))
self.assertEqual(1, sum(item.kind == "manual_review" for item in actions))
self.assertTrue(all(not item.executable for item in actions))
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-cases-1",
)
self.assertTrue(all(item.status == "blocked" for item in results))
def test_foreign_records_and_actions_are_rejected(self) -> None:
subject = DsarSubjectRef(account_id="account-subject")
foreign_record = DsarRecordRef(
provider_id="foreign",
module_id="foreign",
resource_type="foreign",
resource_id="foreign-1",
category="foreign",
title="Foreign record",
)
with self.assertRaisesRegex(ValueError, "foreign provider record"):
self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(foreign_record,),
)
foreign_action = DsarErasureActionRef(
action_id="foreign:delete:1",
provider_id="foreign",
module_id="foreign",
kind="delete",
resource_type="foreign",
resource_id="foreign-1",
title="Delete foreign",
rationale="No",
executable=True,
)
with self.assertRaisesRegex(ValueError, "foreign provider action"):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(foreign_action,),
request_id="dsar-cases-1",
)
def test_workflow_discovers_only_the_active_tenant_capability(self) -> None:
active = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-CASES-1",
request_kind="access",
subject=DsarSubjectRef(account_id="account-subject"),
purpose="Subject access request",
legal_basis=None,
due_at=None,
requested_by_account_id="privacy-operator",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=active,
expected_revision=1,
)
self.assertEqual("searched", active.status)
self.assertEqual(
[CASES_DSAR_CAPABILITY], active.coverage["provider_capabilities"]
)
self.assertEqual(["cases"], active.coverage["covered_modules"])
inactive = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-CASES-2",
request_kind="access",
subject=DsarSubjectRef(account_id="account-subject"),
purpose="Inactive module coverage",
legal_basis=None,
due_at=None,
requested_by_account_id="privacy-operator",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider, cases_active=False),
row=inactive,
expected_revision=1,
)
self.assertEqual([], inactive.coverage["provider_capabilities"])
self.assertEqual(
[CASES_DSAR_CAPABILITY],
inactive.coverage["inactive_provider_capabilities"],
)
self.assertEqual(0, inactive.search_result["record_count"])
def test_manifest_registers_and_documents_the_capability(self) -> None:
self.assertIn(CASES_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(CASES_DSAR_CAPABILITY, manifest.capability_documentation)
self.assertIn(
CASES_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertTrue(
any(
topic.id == "cases.data-subject-requests"
and {"admin", "user"}.issubset(topic.documentation_types)
for topic in manifest.documentation
)
)
if __name__ == "__main__":
unittest.main()
+280
View File
@@ -0,0 +1,280 @@
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.institutional import (
GovernedContextEnvelope,
InstitutionalReference,
TemporalRevision,
)
from govoplan_cases.backend.db.models import (
CaseAccessGrant,
CaseIdentity,
CaseRecordRevision,
CaseStatusDefinition,
CaseTimelineEntry,
CaseTypeDefinition,
)
from govoplan_cases.backend.domain import CaseRecord
from govoplan_cases.backend.evidence_links import (
link_case_evidence,
linked_source,
unlink_case_evidence,
)
from govoplan_cases.backend.service import (
CaseStoreError,
case_history,
case_timeline,
create_case,
update_case,
upsert_case_status,
upsert_case_type,
)
NOW = datetime(2026, 8, 21, 14, 0, tzinfo=UTC)
@dataclass
class Principal:
tenant_id: str = "tenant-1"
account_id: str = "account-1"
scopes: frozenset[str] = frozenset()
def ref(kind: str, object_id: str, owner: str) -> InstitutionalReference:
return InstitutionalReference(
kind=kind, # type: ignore[arg-type]
owner_module=owner,
object_id=object_id,
tenant_id="tenant-1",
version="1",
valid_at=NOW,
)
def case_record() -> CaseRecord:
case_ref = ref("case", "case-1", "cases")
return CaseRecord(
reference=case_ref,
case_number="PERMIT-2026-0001",
case_type_key="permit",
status_key="open",
title="Resident permit",
context=GovernedContextEnvelope(
tenant_id="tenant-1",
temporal=TemporalRevision(
revision="1",
valid_from=NOW,
recorded_at=NOW,
change_reason="Case intake.",
),
case_ref=case_ref,
),
opened_at=NOW,
recorded_at=NOW,
change_reason="Case created.",
)
def file_reference(**changes: str) -> dict[str, str]:
result = {
"owner_module": "files",
"kind": "file-version",
"object_id": "version-1",
"tenant_id": "tenant-1",
"version": "version-1",
"path": "/files?fileId=file-1&versionId=version-1",
}
result.update(changes)
return result
class CaseEvidenceLinkTests(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="open",
label="Open",
)
upsert_case_type(
self.session,
self.principal,
type_key="permit",
label="Permit",
initial_status_key="open",
)
create_case(
self.session,
self.principal,
record=case_record(),
idempotency_key="create-case",
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def link(self, **reference_changes: str) -> CaseRecord:
return link_case_evidence(
self.session,
self.principal,
case_id="case-1",
expected_revision=1,
reference=file_reference(**reference_changes),
recorded_at=NOW + timedelta(minutes=1),
change_reason="Linked exact file version.",
idempotency_key="link-file-1",
)
def test_link_appends_minimized_immutable_revision_and_audit_event(self) -> None:
linked = self.link()
self.session.commit()
self.assertEqual(2, linked.revision)
self.assertEqual(1, len(linked.evidence_refs))
evidence = linked.evidence_refs[0]
self.assertEqual("files", evidence.owner_module)
self.assertEqual("version-1", evidence.version)
self.assertTrue(evidence.evidence_id.startswith("linked-"))
source = linked_source(evidence)
self.assertEqual("file-version", source["kind"])
self.assertEqual("/files?fileId=file-1&versionId=version-1", source["path"])
self.assertNotIn("label", evidence.source_ref or "")
self.assertNotIn("content", evidence.source_ref or "")
history = case_history(
self.session,
self.principal,
case_id="case-1",
)
timeline = case_timeline(
self.session,
self.principal,
case_id="case-1",
)
self.assertEqual([2, 1], [item.revision for item in history])
self.assertEqual("cases.case.updated", timeline[0]["event_type"])
self.assertNotIn(evidence.evidence_id, str(timeline[0]["payload"]))
def test_link_is_replay_safe_and_rejects_duplicate_or_stale_changes(self) -> None:
first = self.link()
replay = self.link()
self.assertEqual(first.reference, replay.reference)
self.session.commit()
with self.assertRaisesRegex(CaseStoreError, "already linked"):
link_case_evidence(
self.session,
self.principal,
case_id="case-1",
expected_revision=2,
reference=file_reference(),
recorded_at=NOW + timedelta(minutes=2),
change_reason="Duplicate link.",
idempotency_key="duplicate-link",
)
updated = update_case(
self.session,
self.principal,
case_id="case-1",
expected_revision=2,
changes={"title": "Updated permit"},
recorded_at=NOW + timedelta(minutes=2),
change_reason="Updated title.",
idempotency_key="update-title",
)
self.assertEqual(3, updated.revision)
with self.assertRaisesRegex(CaseStoreError, "stale"):
link_case_evidence(
self.session,
self.principal,
case_id="case-1",
expected_revision=2,
reference={
"owner_module": "campaigns",
"kind": "campaign",
"object_id": "campaign-1",
"tenant_id": "tenant-1",
"version": "campaign-version-1",
"path": "/campaigns/campaign-1",
},
recorded_at=NOW + timedelta(minutes=3),
change_reason="Stale link.",
idempotency_key="stale-link",
)
def test_rejects_cross_tenant_unsupported_and_malformed_owner_paths(self) -> None:
for changes, message in (
({"tenant_id": "tenant-2"}, "cross Case tenants"),
({"owner_module": "unknown"}, "not supported"),
({"path": "/files?versionId=version-1"}, "exact authorized"),
({"path": "https://example.invalid/file"}, "local platform"),
):
with self.subTest(changes=changes):
with self.assertRaisesRegex(CaseStoreError, message):
self.link(**changes)
def test_unlink_only_removes_cases_owned_links_and_is_replay_safe(self) -> None:
linked = self.link()
self.session.commit()
evidence_id = linked.evidence_refs[0].evidence_id
unlinked = unlink_case_evidence(
self.session,
self.principal,
case_id="case-1",
evidence_id=evidence_id,
expected_revision=2,
recorded_at=NOW + timedelta(minutes=2),
change_reason="Removed obsolete link.",
idempotency_key="unlink-file-1",
)
replay = unlink_case_evidence(
self.session,
self.principal,
case_id="case-1",
evidence_id=evidence_id,
expected_revision=2,
recorded_at=NOW + timedelta(minutes=2),
change_reason="Removed obsolete link.",
idempotency_key="unlink-file-1",
)
self.assertEqual(3, unlinked.revision)
self.assertEqual(unlinked.reference, replay.reference)
self.assertEqual((), unlinked.evidence_refs)
with self.assertRaisesRegex(CaseStoreError, "not found"):
unlink_case_evidence(
self.session,
self.principal,
case_id="case-1",
evidence_id="ordinary-evidence",
expected_revision=3,
recorded_at=NOW + timedelta(minutes=3),
change_reason="Invalid unlink.",
idempotency_key="invalid-unlink",
)
if __name__ == "__main__":
unittest.main()
+290
View File
@@ -0,0 +1,290 @@
from __future__ import annotations
from dataclasses import replace
from datetime import UTC, datetime, timedelta
import unittest
from govoplan_core.core.institutional import (
CAPABILITY_PARTY_RESOLVER,
EvidenceReference,
InstitutionalContextError,
InstitutionalReference,
LegalBasisReference,
PartyRepresentation,
PartySubjectReference,
ProcedureParty,
ServiceBinding,
ServiceDefinition,
TemporalRevision,
)
from govoplan_cases.backend.manifest import get_manifest
from govoplan_cases.backend.party_context import (
CasePartyCompatibilityRecord,
CasePartyContext,
)
from govoplan_cases.backend.service_intake import CaseServiceIntake
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
def reference(kind: str, object_id: str) -> InstitutionalReference:
owners = {
"organization_unit": "organizations",
"function": "organizations",
"mandate": "committee",
"jurisdiction": "organizations",
"service": "portal",
"case": "cases",
"party": "cases",
}
return InstitutionalReference(
kind=kind, # type: ignore[arg-type]
owner_module=owners[kind],
object_id=object_id,
tenant_id="tenant-1",
version="1",
valid_at=NOW,
)
def evidence() -> EvidenceReference:
return EvidenceReference(
kind="snapshot",
owner_module="addresses",
evidence_id="contact-snapshot-1",
tenant_id="tenant-1",
version="1",
captured_at=NOW,
)
def service() -> ServiceDefinition:
return ServiceDefinition(
reference=replace(reference("service", "permit"), version="4"),
key="permit.apply",
temporal=TemporalRevision(
revision="4",
valid_from=NOW - timedelta(days=1),
valid_to=NOW + timedelta(days=1),
recorded_at=NOW - timedelta(days=2),
),
title="Apply for a permit",
audience=("resident",),
legal_bases=(
LegalBasisReference(
kind="law",
authority="Example legislature",
reference="permit-law:3",
version="2026-01",
),
),
required_evidence_types=("identity", "application"),
deadline_refs=("deadline:permit:90d",),
responsible_organization_ref=reference("organization_unit", "unit-1"),
responsible_function_ref=reference("function", "function-1"),
mandate_ref=reference("mandate", "mandate-1"),
jurisdiction_refs=(reference("jurisdiction", "city-1"),),
bindings=(
ServiceBinding("case", "permit-application"),
ServiceBinding("form", "forms:permit-application"),
ServiceBinding("workflow", "workflow:permit-review"),
ServiceBinding("result", "decision:permit"),
),
publication_state="published",
)
def procedure_party(*, revoked_at: datetime | None = None) -> ProcedureParty:
represented = reference("party", "applicant")
representative = reference("party", "representative")
return ProcedureParty(
reference=representative,
procedure_ref=reference("case", "case-1"),
role="representative",
subject=PartySubjectReference(
kind="identity",
provider="identity",
subject_id="identity-2",
tenant_id="tenant-1",
version="3",
),
temporal=TemporalRevision(
revision="2",
valid_from=NOW - timedelta(days=2),
recorded_at=NOW - timedelta(days=2),
),
preferred_channels=("postbox",),
permitted_channels=("postbox", "mail"),
delivery_recipient=True,
representations=(
PartyRepresentation(
representative_party_ref=representative,
represented_party_ref=represented,
power_ref="power-1",
permitted_actions=("submit", "receive"),
temporal=TemporalRevision(
revision="1",
valid_from=NOW - timedelta(days=1),
recorded_at=NOW - timedelta(days=1),
),
evidence=(evidence(),),
revoked_at=revoked_at,
),
),
contact_snapshot_refs=("addresses:snapshot-1",),
evidence=(evidence(),),
)
class PartyProvider:
def __init__(self, party: ProcedureParty) -> None:
self.party = party
def list_procedure_parties(self, session, principal, *, procedure_ref, effective_at=None):
return (self.party,)
class DenyingPartyProvider:
def list_procedure_parties(self, session, principal, *, procedure_ref, effective_at=None):
raise InstitutionalContextError("Party provider access denied.")
class Registry:
def __init__(self, provider: object | None = None) -> None:
self.provider = provider
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_PARTY_RESOLVER and self.provider is not None
def capability(self, name: str) -> object:
return self.provider
class InstitutionalConsumerTests(unittest.TestCase):
def test_service_definition_drives_versioned_case_intake(self) -> None:
plan = CaseServiceIntake().plan(service(), case_id="case-1", effective_at=NOW)
self.assertEqual("permit-application", plan.case_type_ref)
self.assertEqual("4", plan.service_ref.version)
self.assertEqual("permit", plan.context.service_ref.object_id)
self.assertEqual("mandate-1", plan.context.mandate_ref.object_id)
self.assertEqual("city-1", plan.context.jurisdiction_refs[0].object_id)
self.assertEqual(("workflow:permit-review",), plan.workflow_refs)
def test_case_party_provider_drives_frozen_delivery_authority(self) -> None:
context = CasePartyContext(Registry(PartyProvider(procedure_party())))
party_set = context.resolve(
None,
None,
case_ref=reference("case", "case-1"),
effective_at=NOW,
)
targets = context.delivery_targets(party_set, channel="postbox")
self.assertEqual("provider", party_set.source)
self.assertEqual(("addresses:snapshot-1",), targets[0].contact_snapshot_refs)
self.assertEqual("applicant", targets[0].represented_party_refs[0].object_id)
def test_revoked_representation_is_not_delivery_authority(self) -> None:
context = CasePartyContext(
Registry(PartyProvider(procedure_party(revoked_at=NOW)))
)
party_set = context.resolve(
None,
None,
case_ref=reference("case", "case-1"),
effective_at=NOW,
)
self.assertEqual(
(),
context.delivery_targets(party_set, channel="postbox")[0].represented_party_refs,
)
def test_cases_only_compatibility_path_is_bounded(self) -> None:
subject = PartySubjectReference(
kind="organization",
provider="organizations",
subject_id="organization-1",
tenant_id="tenant-1",
)
party_set = CasePartyContext().resolve(
None,
None,
case_ref=reference("case", "case-1"),
effective_at=NOW,
compatibility=(
CasePartyCompatibilityRecord(
party_id="party-1",
role="applicant",
subject=subject,
valid_from=NOW - timedelta(days=1),
permitted_channels=("mail",),
delivery_recipient=True,
contact_snapshot_refs=("addresses:snapshot-2",),
),
),
)
self.assertEqual("compatibility", party_set.source)
self.assertEqual("organization-1", party_set.parties[0].subject.subject_id)
self.assertEqual((), party_set.parties[0].representations)
def test_cross_tenant_compatibility_subject_fails_closed(self) -> None:
with self.assertRaisesRegex(InstitutionalContextError, "another tenant"):
CasePartyContext().resolve(
None,
None,
case_ref=reference("case", "case-1"),
effective_at=NOW,
compatibility=(
CasePartyCompatibilityRecord(
party_id="party-1",
role="applicant",
subject=PartySubjectReference(
kind="identity",
provider="identity",
subject_id="identity-9",
tenant_id="tenant-2",
),
valid_from=NOW,
),
),
)
def test_provider_access_denial_never_falls_back_to_local_rows(self) -> None:
compatibility = CasePartyCompatibilityRecord(
party_id="party-1",
role="applicant",
subject=PartySubjectReference(
kind="identity",
provider="identity",
subject_id="identity-1",
tenant_id="tenant-1",
),
valid_from=NOW,
)
with self.assertRaisesRegex(InstitutionalContextError, "access denied"):
CasePartyContext(Registry(DenyingPartyProvider())).resolve(
None,
None,
case_ref=reference("case", "case-1"),
effective_at=NOW,
compatibility=(compatibility,),
)
def test_manifest_exposes_optional_party_provider(self) -> None:
manifest = get_manifest()
self.assertEqual("cases", manifest.id)
self.assertEqual((), manifest.dependencies)
self.assertIn(CAPABILITY_PARTY_RESOLVER, manifest.optional_capabilities)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertIn("cases.registry", manifest.capability_factories)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,102 @@
from __future__ import annotations
from pathlib import Path
import unittest
from govoplan_cases.backend.manifest import get_manifest
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)
surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr]
self.assertEqual(
{
"cases.navigation",
"cases.list",
"cases.list.filters",
"cases.detail",
"cases.detail.summary",
"cases.detail.editor",
"cases.detail.references",
"cases.detail.timeline",
"cases.detail.history",
"cases.detail.access",
"cases.detail.decision",
},
set(surfaces),
)
self.assertEqual("cases.list", surfaces["cases.list.filters"].parent_id)
for surface_id in (
"cases.detail.summary",
"cases.detail.editor",
"cases.detail.references",
"cases.detail.timeline",
"cases.detail.history",
"cases.detail.access",
"cases.detail.decision",
):
self.assertEqual("cases.detail", surfaces[surface_id].parent_id)
def test_help_and_consequence_metadata_remain_published(self) -> None:
topics = {topic.id: topic for topic in get_manifest().documentation}
context = topics["cases.institutional-context"]
reference = topics["cases.reference.lifecycle-access-and-evidence"]
owner_links = topics["cases.workflow.link-owner-references"]
self.assertIn("cases.state.read-only", context.metadata["help_contexts"])
self.assertIn("cases.field.access-grant", reference.metadata["help_contexts"])
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"
)
detail_page = (
REPO_ROOT / "webui/src/features/cases/CaseDetailPage.tsx"
).read_text(encoding="utf-8")
access_dialog = (
REPO_ROOT / "webui/src/features/cases/CaseShareDialog.tsx"
).read_text(encoding="utf-8")
self.assertIn("DocumentationHelpLink", list_page)
for component in (
"ActionBlockerHint",
"DocumentationHelpLink",
"FormField",
"useUnsavedDraftGuard",
"QUICK_ACCESS_RESULT_EVENT",
"linkCaseEvidence",
"unlinkCaseEvidence",
):
self.assertIn(component, detail_page)
for component in (
"ConfirmDialog",
"DocumentationHelpLink",
"ReferenceSelect",
"useUnsavedDraftGuard",
):
self.assertIn(component, access_dialog)
if __name__ == "__main__":
unittest.main()
+53
View File
@@ -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()
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.records import RecordContractError, RecordSourceLocator
from govoplan_cases.backend.db.models import CaseIdentity, CaseRecordRevision
from govoplan_cases.backend.record_source import CasesRecordSource
NOW = datetime(2026, 1, 6, 9, 0, tzinfo=UTC)
@dataclass
class Principal:
tenant_id: str = "tenant-1"
def has(self, scope: str) -> bool:
return scope == "cases:case:read"
class CasesRecordSourceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
CaseIdentity.__table__.create(self.engine)
CaseRecordRevision.__table__.create(self.engine)
self.session = Session(self.engine)
identity = CaseIdentity(
id="identity-1",
tenant_id="tenant-1",
case_id="case-1",
case_number="2026/C-1",
created_by="account-1",
)
revision = CaseRecordRevision(
id="revision-1",
tenant_id="tenant-1",
case_id="case-1",
identity_id="identity-1",
revision=3,
case_type_key="permit",
status_key="review",
title="Permit application",
access_mode="restricted",
search_text="permit application",
opened_at=NOW,
recorded_at=NOW,
snapshot={
"case_id": "case-1",
"revision": 3,
"title": "Permit application",
},
)
self.session.add_all((identity, revision))
self.session.flush()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def locator(self) -> RecordSourceLocator:
return RecordSourceLocator(
tenant_id="tenant-1",
source_module="cases",
resource_type="case_revision",
resource_id="case-1",
source_revision="3",
)
def test_resolves_exact_currently_authorized_case_revision(self) -> None:
with patch(
"govoplan_cases.backend.record_source.can_access_case", return_value=True
):
result = CasesRecordSource().resolve(
self.session,
Principal(),
locator=self.locator(),
purpose="preserve decision basis",
)
self.assertEqual("2026/C-1 - Permit application", result.label)
self.assertEqual(64, len(result.content_sha256 or ""))
self.assertEqual("review", result.metadata["status_key"])
def test_object_access_is_rechecked_and_fails_closed(self) -> None:
with (
patch(
"govoplan_cases.backend.record_source.can_access_case",
return_value=False,
),
self.assertRaisesRegex(RecordContractError, "cannot read"),
):
CasesRecordSource().resolve(
self.session,
Principal(),
locator=self.locator(),
purpose="preserve decision basis",
)
if __name__ == "__main__":
unittest.main()
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent
from govoplan_core.core.institutional import (
GovernedContextEnvelope,
InstitutionalReference,
TemporalRevision,
)
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillRequest,
SearchResourceReference,
)
from govoplan_cases.backend.db.models import (
CaseAccessGrant,
CaseIdentity,
CaseRecordRevision,
CaseStatusDefinition,
CaseTimelineEntry,
CaseTypeDefinition,
)
from govoplan_cases.backend.domain import CaseRecord
from govoplan_cases.backend.search_source import CasesSearchSource, PROVIDER_ID
from govoplan_cases.backend.service import (
create_case,
upsert_case_status,
upsert_case_type,
)
NOW = datetime(2026, 8, 6, 10, 0, tzinfo=UTC)
class CasesSearchSourceTests(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)
principal = _principal({"cases:case:read", "cases:case:create"})
upsert_case_status(
self.session,
principal,
status_key="review",
label="Review",
)
upsert_case_type(
self.session,
principal,
type_key="permit",
label="Permit",
initial_status_key="review",
)
case_ref = _ref("case", "case-1", "cases")
create_case(
self.session,
principal,
record=CaseRecord(
reference=case_ref,
case_number="PERMIT-1",
case_type_key="permit",
status_key="review",
title="Permit application",
context=GovernedContextEnvelope(
tenant_id="tenant-1",
temporal=TemporalRevision(
revision="1",
valid_from=NOW,
recorded_at=NOW,
change_reason="Created.",
),
case_ref=case_ref,
),
opened_at=NOW,
recorded_at=NOW,
change_reason="Created.",
),
idempotency_key="case-create",
)
self.session.commit()
self.source = CasesSearchSource()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_backfill_authorization_and_event_change_are_bounded(self) -> None:
page = self.source.backfill(
self.session,
request=SearchBackfillRequest(
tenant_id="tenant-1",
provider_id=PROVIDER_ID,
resource_type="case",
rebuild_id="rebuild-1",
),
)
self.assertEqual(("case-1",), tuple(item.resource_id for item in page.documents))
self.assertEqual("PERMIT-1", page.documents[0].metadata["case_number"])
reference = SearchResourceReference(
tenant_id="tenant-1",
module_id="cases",
resource_type="case",
resource_id="case-1",
)
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
self.assertTrue(
self.source.authorize(
self.session,
_principal({"cases:case:read"}),
requests=(request,),
)[reference.key]
)
self.assertFalse(
self.source.authorize(
self.session,
_principal(set()),
requests=(request,),
)[reference.key]
)
changes = self.source.index_changes_for_event(
self.session,
event=PlatformEvent(
type="cases.case.updated",
module_id="cases",
tenant=EventTenantRef(id="tenant-1"),
resource=EventObjectRef(type="case", id="case-1"),
),
delivery_key="delivery-1",
)
self.assertEqual("upsert", changes[0].kind)
def _ref(kind: str, object_id: str, owner: str) -> InstitutionalReference:
return InstitutionalReference(
kind=kind, # type: ignore[arg-type]
owner_module=owner,
object_id=object_id,
tenant_id="tenant-1",
version="1",
valid_at=NOW,
)
def _principal(scopes: set[str]) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
scopes=frozenset(scopes),
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="membership-1"),
)
if __name__ == "__main__":
unittest.main()
+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()
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@govoplan/cases-webui",
"version": "0.1.21",
"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.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
}
}
}
+297
View File
@@ -0,0 +1,297 @@
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;
source_ref?: string | null;
derived_from?: string[];
};
export type OwnerReference = {
ownerModule: string;
kind: string;
objectId: string;
tenantId: string;
version?: string | null;
path?: string | null;
};
export type CaseRecord = {
reference: InstitutionalReference;
revision: number;
case_number: string;
case_type_key: string;
status_key: string;
title: string;
access_mode: "tenant" | "restricted";
access_grants: CaseGrant[];
context: Record<string, unknown>;
service_ref?: InstitutionalReference | null;
party_refs: InstitutionalReference[];
assignment_refs: InstitutionalReference[];
evidence_refs: EvidenceReference[];
decision_refs: InstitutionalReference[];
record_refs: InstitutionalReference[];
opened_at: string;
recorded_at: string;
deadline_at?: string | null;
closed_at?: string | null;
change_reason: string;
metadata: Record<string, unknown>;
};
export type CaseGrant = {
subject_kind: "account" | "identity" | "group" | "role" | "function" | "function_assignment" | "organization_unit" | "service_account";
subject_id: string;
permissions: Array<"read" | "update" | "share" | "admin">;
};
export type CaseStatusDefinition = {
status_key: string;
label: string;
category: string;
terminal: boolean;
sort_order: number;
active: boolean;
revision: number;
};
export type CaseTypeDefinition = {
type_key: string;
label: string;
description?: string | null;
initial_status_key: string;
allowed_status_keys: string[];
active: boolean;
revision: number;
};
export type CaseCatalog = {
statuses: CaseStatusDefinition[];
types: CaseTypeDefinition[];
};
export type CaseListResponse = {
cases: CaseRecord[];
total: number;
offset: number;
limit: number;
};
export type CaseTimelineEntry = {
event_id: string;
event_type: string;
case_revision: number;
summary: string;
actor_id?: string | null;
occurred_at: string;
audit_event_id?: string | null;
payload: Record<string, unknown>;
};
export type FormalDecision = {
reference: InstitutionalReference;
temporal: {
revision: string;
valid_from?: string | null;
valid_to?: string | null;
recorded_at?: string | null;
change_reason?: string | null;
};
decision_type: string;
state: string;
assurance_level: string;
operative_result?: string | null;
reasoning?: string | null;
conditions: string[];
authority_context: Record<string, unknown>;
delivery_refs: string[];
remedy_refs: string[];
review_refs: string[];
};
export type CaseDecisionResult = {
case: CaseRecord;
decision: FormalDecision;
replayed: boolean;
};
export function listCases(
settings: ApiSettings,
options: {
query?: string;
statuses?: string[];
caseTypes?: string[];
offset?: number;
limit?: number;
},
signal?: AbortSignal
): Promise<CaseListResponse> {
return apiFetch<CaseListResponse>(settings, apiPath("/api/v1/cases", {
query: options.query,
status_key: options.statuses,
case_type_key: options.caseTypes,
offset: options.offset,
limit: options.limit
}), { signal });
}
export function getCase(
settings: ApiSettings,
caseId: string,
signal?: AbortSignal
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(settings, `/api/v1/cases/${encodeURIComponent(caseId)}`, { signal });
}
export function listCaseCatalog(
settings: ApiSettings,
signal?: AbortSignal
): Promise<CaseCatalog> {
return apiFetch<CaseCatalog>(settings, "/api/v1/cases/catalog", { signal });
}
export function caseHistory(
settings: ApiSettings,
caseId: string,
signal?: AbortSignal
): Promise<{ revisions: CaseRecord[] }> {
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/history`, { signal });
}
export function caseTimeline(
settings: ApiSettings,
caseId: string,
signal?: AbortSignal
): Promise<{ entries: CaseTimelineEntry[] }> {
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { signal });
}
export function listCaseDecisions(
settings: ApiSettings,
caseId: string,
signal?: AbortSignal
): Promise<{ decisions: FormalDecision[] }> {
return apiFetch(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`,
{ signal }
);
}
export function recordCaseDecision(
settings: ApiSettings,
caseId: string,
payload: {
expected_case_revision: number;
effective_at: string;
decision_type: string;
operative_result: string;
reasoning: string;
conditions: string[];
change_reason: string;
idempotency_key: string;
}
): Promise<CaseDecisionResult> {
return apiFetch(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`,
{
method: "POST",
body: JSON.stringify(payload)
}
);
}
export function updateCase(
settings: ApiSettings,
caseId: string,
payload: {
expected_revision: number;
recorded_at: string;
change_reason: string;
idempotency_key: string;
title?: string;
status_key?: string;
access_mode?: "tenant" | "restricted";
access_grants?: CaseGrant[];
}
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(settings, `/api/v1/cases/${encodeURIComponent(caseId)}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function linkCaseEvidence(
settings: ApiSettings,
caseId: string,
payload: {
expected_revision: number;
recorded_at: string;
change_reason: string;
idempotency_key: string;
reference: {
owner_module: string;
kind: string;
object_id: string;
tenant_id: string;
version: string;
path: string;
};
}
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/evidence-links`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export function unlinkCaseEvidence(
settings: ApiSettings,
caseId: string,
evidenceId: string,
payload: {
expected_revision: number;
recorded_at: string;
change_reason: string;
idempotency_key: string;
}
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/evidence-links/${encodeURIComponent(evidenceId)}/unlink`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export function caseShareTargetProvider(
settings: ApiSettings,
caseId: string,
targetType: "user" | "group"
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/share-target-options`,
{ target_type: targetType }
);
}
@@ -0,0 +1,209 @@
import { Scale } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { FormGrid,
Button,
ConfirmDialog,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
FormField,
i18nMessage,
type ApiSettings
} from "@govoplan/core-webui";
import {
recordCaseDecision,
type CaseDecisionResult,
type CaseRecord
} from "../../api/cases";
import { CASES_FIELDS_DOCUMENTATION } from "./interfacePatterns";
export default function CaseDecisionDialog({
settings,
record,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CaseRecord;
open: boolean;
onClose: () => void;
onSaved: (result: CaseDecisionResult) => void;
}) {
const [decisionType, setDecisionType] = useState("");
const [effectiveAt, setEffectiveAt] = useState("");
const [operativeResult, setOperativeResult] = useState("");
const [reasoning, setReasoning] = useState("");
const [conditions, setConditions] = useState("");
const [changeReason, setChangeReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirmOpen, setConfirmOpen] = useState(false);
const idempotencyKey = useRef(crypto.randomUUID());
useEffect(() => {
if (!open) return;
setDecisionType(defaultDecisionType(record));
setEffectiveAt(dateTimeLocalValue(new Date()));
setOperativeResult("");
setReasoning("");
setConditions("");
setChangeReason("");
setError("");
setConfirmOpen(false);
idempotencyKey.current = crypto.randomUUID();
}, [open, record]);
const disabledReason = useMemo(() => {
if (busy) return "The formal Decision is being recorded.";
if (
!decisionType.trim()
|| !effectiveAt
|| !operativeResult.trim()
|| !reasoning.trim()
|| !changeReason.trim()
) {
return "Complete the Decision type, effective time, result, reasoning, and change reason.";
}
return undefined;
}, [busy, changeReason, decisionType, effectiveAt, operativeResult, reasoning]);
async function save() {
if (disabledReason) return;
setBusy(true);
setError("");
try {
const result = await recordCaseDecision(settings, record.reference.object_id, {
expected_case_revision: record.revision,
effective_at: new Date(effectiveAt).toISOString(),
decision_type: decisionType.trim(),
operative_result: operativeResult.trim(),
reasoning: reasoning.trim(),
conditions: conditions.split("\n").map((item) => item.trim()).filter(Boolean),
change_reason: changeReason.trim(),
idempotency_key: idempotencyKey.current
});
setConfirmOpen(false);
onSaved(result);
onClose();
} catch (reason) {
setConfirmOpen(false);
setError(reason instanceof Error ? reason.message : "The formal Decision could not be recorded.");
} finally {
setBusy(false);
}
}
return (
<>
<Dialog
open={open}
title={i18nMessage("i18n:govoplan-cases.decision_title", { value0: record.case_number })}
onClose={onClose}
closeDisabled={busy}
portal
className="case-decision-dialog"
footer={
<>
<Button disabled={busy} onClick={onClose}>Cancel</Button>
<Button
variant="primary"
disabledReason={disabledReason}
onClick={() => setConfirmOpen(true)}
helpContextId="cases.action.decide"
>
<Scale size={16} aria-hidden="true" />
Record decision
</Button>
</>
}
>
<div className="case-decision-content" data-help-context-id="cases.decision.editor">
<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<p className="case-decision-explanation">
The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.
</p>
<FormGrid columns={2} gap="small" collapseAt="workspace" className="case-decision-grid">
<FormField label="Decision type" documentation={CASES_FIELDS_DOCUMENTATION}>
<input
value={decisionType}
disabled={busy}
maxLength={120}
onChange={(event) => setDecisionType(event.target.value)}
/>
</FormField>
<FormField label="Effective at" documentation={CASES_FIELDS_DOCUMENTATION}>
<input
type="datetime-local"
value={effectiveAt}
disabled={busy}
onChange={(event) => setEffectiveAt(event.target.value)}
/>
</FormField>
</FormGrid>
<FormField label="Operative result" documentation={CASES_FIELDS_DOCUMENTATION}>
<textarea
rows={4}
value={operativeResult}
disabled={busy}
maxLength={20_000}
onChange={(event) => setOperativeResult(event.target.value)}
/>
</FormField>
<FormField label="Reasoning" documentation={CASES_FIELDS_DOCUMENTATION}>
<textarea
rows={7}
value={reasoning}
disabled={busy}
maxLength={50_000}
onChange={(event) => setReasoning(event.target.value)}
/>
</FormField>
<FormField label="Conditions (one per line)" documentation={CASES_FIELDS_DOCUMENTATION}>
<textarea
rows={3}
value={conditions}
disabled={busy}
onChange={(event) => setConditions(event.target.value)}
/>
</FormField>
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
<input
value={changeReason}
disabled={busy}
maxLength={1_000}
onChange={(event) => setChangeReason(event.target.value)}
/>
</FormField>
</div>
</Dialog>
<ConfirmDialog
open={confirmOpen}
title="Record formal Decision"
message="Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision."
confirmLabel="Record decision"
busy={busy}
onCancel={() => setConfirmOpen(false)}
onConfirm={() => void save()}
/>
</>
);
}
function defaultDecisionType(record: CaseRecord): string {
const resultRefs = Array.isArray(record.metadata.result_refs)
? record.metadata.result_refs
: [];
const result = resultRefs.find((item): item is string => typeof item === "string");
if (result) return result.replace(/^decision:/, "");
return record.case_type_key.replace(/-application$/, "");
}
function dateTimeLocalValue(value: Date): string {
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
+677
View File
@@ -0,0 +1,677 @@
import { Archive, ArrowLeft, ExternalLink, Save, Scale, Share2, Unlink } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router";
import { ActionToolbar,
ActionBlockerHint,
Button,
DocumentationHelpLink,
DismissibleAlert,
FormField,
IconButton,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
QUICK_ACCESS_RESULT_EVENT,
hasScope,
usePlatformModuleInstalled,
useGuardedNavigate,
useRegisterActiveObject,
useUnsavedDraftGuard,
type PlatformRouteContext,
type QuickAccessLaunchContext,
type QuickAccessResult
} from "@govoplan/core-webui";
import {
caseHistory,
caseTimeline,
getCase,
linkCaseEvidence,
listCaseDecisions,
listCaseCatalog,
updateCase,
unlinkCaseEvidence,
type CaseCatalog,
type CaseRecord,
type CaseTimelineEntry,
type EvidenceReference,
type FormalDecision,
type InstitutionalReference
} from "../../api/cases";
import CaseShareDialog from "./CaseShareDialog";
import CaseDecisionDialog from "./CaseDecisionDialog";
import {
CASES_DOCUMENTATION,
CASES_FIELDS_DOCUMENTATION,
CASES_I18N
} from "./interfacePatterns";
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
const { caseId = "" } = useParams();
const navigate = useGuardedNavigate();
const [record, setRecord] = useState<CaseRecord | null>(null);
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
const [history, setHistory] = useState<CaseRecord[]>([]);
const [timeline, setTimeline] = useState<CaseTimelineEntry[]>([]);
const [decisions, setDecisions] = useState<FormalDecision[]>([]);
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 [decisionOpen, setDecisionOpen] = useState(false);
const [linkingEvidence, setLinkingEvidence] = useState(false);
const idempotencyKey = useRef(crypto.randomUUID());
const canUpdate = hasScope(auth, "cases:case:update");
const canClose = hasScope(auth, "cases:case:close");
const canShare = hasScope(auth, "cases:case:share");
const decisionsAvailable = usePlatformModuleInstalled("decisions");
const mandatesAvailable = usePlatformModuleInstalled("mandates");
const accessAvailable = usePlatformModuleInstalled("access");
const recordsAvailable = usePlatformModuleInstalled("records");
const filesAvailable = usePlatformModuleInstalled("files");
const mailAvailable = usePlatformModuleInstalled("mail");
const campaignsAvailable = usePlatformModuleInstalled("campaigns");
const canReadDecisions = decisionsAvailable && hasScope(auth, "decisions:decision:read");
const decisionDisabledReason = !canUpdate
? "Your account may not update this Case."
: !hasScope(auth, "decisions:decision:write")
? "Your account may not record formal Decisions."
: !accessAvailable
? "Enable Access to verify the acting assignment."
: !mandatesAvailable
? "Enable Mandates to verify formal authority."
: undefined;
const canFileRecords = recordsAvailable && hasScope(auth, "records:workspace:write");
const activeCase = useMemo(() => record ? {
ownerModule: record.reference.owner_module,
kind: record.reference.kind,
objectId: record.reference.object_id,
tenantId: record.reference.tenant_id,
label: `${record.case_number} · ${record.title}`,
version: record.reference.version ?? String(record.revision),
path: `/cases/${encodeURIComponent(record.reference.object_id)}`
} : null, [record]);
useRegisterActiveObject(activeCase);
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),
canReadDecisions
? listCaseDecisions(settings, caseId, signal)
: Promise.resolve({ decisions: [] as FormalDecision[] })
]).
then(([nextRecord, nextCatalog, nextHistory, nextTimeline, nextDecisions]) => {
setRecord(nextRecord);
setCatalog(nextCatalog);
setHistory(nextHistory.revisions);
setTimeline(nextTimeline.entries);
setDecisions(nextDecisions.decisions);
setTitle(nextRecord.title);
setStatus(nextRecord.status_key);
setChangeReason("");
}).
finally(() => setLoading(false));
}, [canReadDecisions, 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));
const draftDirty = Boolean(record && canUpdate && (changed || changeReason.trim()));
useEffect(() => {
if (!record || !canUpdate) return;
const receiveReference = (event: Event) => {
const detail = (event as CustomEvent<{
launchContext?: QuickAccessLaunchContext;
result?: QuickAccessResult;
}>).detail;
const sourceCase = detail?.launchContext?.activeObject;
const result = detail?.result;
if (
!sourceCase
|| sourceCase.ownerModule !== "cases"
|| sourceCase.kind !== "case"
|| sourceCase.objectId !== record.reference.object_id
|| sourceCase.tenantId !== record.reference.tenant_id
|| result?.outcome !== "completed"
|| result.action !== "selected"
|| !result.reference
) return;
if (draftDirty) {
setError(
"Save or discard the current Case edits before linking the selected reference."
);
return;
}
const reference = result.reference;
if (!reference.version || !reference.path) {
setError("The selected owner reference is not an exact versioned link.");
return;
}
setLinkingEvidence(true);
setError("");
void linkCaseEvidence(settings, caseId, {
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: `Linked ${reference.ownerModule}.${reference.kind} through Quick Access.`,
idempotency_key: crypto.randomUUID(),
reference: {
owner_module: reference.ownerModule,
kind: reference.kind,
object_id: reference.objectId,
tenant_id: reference.tenantId,
version: reference.version,
path: reference.path
}
}).then((saved) => {
setRecord(saved);
setTitle(saved.title);
setStatus(saved.status_key);
return load();
}).catch((reason) => {
setError(reason instanceof Error
? reason.message
: "The selected reference could not be linked to this Case.");
}).finally(() => setLinkingEvidence(false));
};
window.addEventListener(QUICK_ACCESS_RESULT_EVENT, receiveReference);
return () => window.removeEventListener(
QUICK_ACCESS_RESULT_EVENT,
receiveReference
);
}, [canUpdate, caseId, draftDirty, load, record, settings]);
function discardDraft() {
if (!record) return;
setTitle(record.title);
setStatus(record.status_key);
setChangeReason("");
}
async function save(): Promise<boolean> {
if (!record || !changed || !title.trim() || !changeReason.trim()) return false;
setSaving(true);
setError("");
try {
const saved = await updateCase(settings, caseId, {
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: idempotencyKey.current,
...(title.trim() !== record.title ? { title: title.trim() } : {}),
...(status !== record.status_key ? { status_key: status } : {})
});
setRecord(saved);
setTitle(saved.title);
setStatus(saved.status_key);
setChangeReason("");
idempotencyKey.current = crypto.randomUUID();
try {
await load();
} catch (reloadError) {
setError(reloadError instanceof Error
? `The case was saved, but its history could not be refreshed. ${reloadError.message}`
: "The case was saved, but its history could not be refreshed.");
}
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Case could not be saved.");
return false;
} finally {
setSaving(false);
}
}
async function unlinkEvidence(evidence: EvidenceReference): Promise<void> {
if (!record || draftDirty || linkingEvidence) return;
if (!window.confirm("Remove this owner reference from the Case? The owner data is not deleted.")) return;
setLinkingEvidence(true);
setError("");
try {
const saved = await unlinkCaseEvidence(
settings,
caseId,
evidence.evidence_id,
{
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: `Unlinked ${evidence.owner_module} reference from the Case.`,
idempotency_key: crypto.randomUUID()
}
);
setRecord(saved);
setTitle(saved.title);
setStatus(saved.status_key);
await load();
} catch (reason) {
setError(reason instanceof Error
? reason.message
: "The owner reference could not be removed from this Case.");
} finally {
setLinkingEvidence(false);
}
}
useUnsavedDraftGuard({
dirty: draftDirty,
title: "i18n:govoplan-cases.unsaved_title",
message: "i18n:govoplan-cases.unsaved_message",
onSave: save,
onDiscard: discardDraft
});
const saveDisabledReason = saving
? CASES_I18N.saving
: !changed
? CASES_I18N.noChanges
: !title.trim() || !changeReason.trim()
? CASES_I18N.incomplete
: undefined;
return (
<main className="cases-page">
<div className="case-detail-shell">
<ActionToolbar className="case-detail-toolbar">
<button type="button" className="btn btn-ghost" onClick={() => navigate("/cases")}>
<ArrowLeft size={16} aria-hidden="true" />
Cases
</button>
{record && <span>{record.case_number}</span>}
{record ? (
<div className="case-detail-actions">
{decisionsAvailable ? (
<IconButton
label="Record formal decision"
icon={<Scale size={16} />}
disabledReason={decisionDisabledReason}
onClick={() => setDecisionOpen(true)}
helpContextId="cases.action.decide"
/>
) : null}
{canFileRecords ? (
<IconButton
label="File Case in eAkte"
icon={<Archive size={16} />}
onClick={() => navigate(recordFilingPath(record.reference, record.case_number, "cases", "case_revision"))}
helpContextId="records.action.file"
/>
) : null}
<IconButton
label="Manage case access"
icon={<Share2 size={16} />}
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
onClick={() => setShareOpen(true)}
/>
</div>
) : null}
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
</ActionToolbar>
<PageScrollViewport className="case-detail-viewport">
{error &&
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
}
{loading && <LoadingIndicator label="Loading case" />}
{!loading && record &&
<div className="case-detail-content">
<section className="case-detail-main">
<div className="case-detail-title-row">
<div>
<span className="case-detail-eyebrow">{humanize(record.case_type_key)}</span>
<h1>{record.title}</h1>
</div>
<StatusBadge status={record.closed_at ? "inactive" : "active"} label={humanize(record.status_key)} />
</div>
{!canUpdate ? (
<ActionBlockerHint
tone="info"
reason={{
summary: "Case editing is read-only",
details: CASES_I18N.updateReason,
requiredAction: "Ask a case manager to make the required lifecycle change.",
actor: "A user with the Cases update permission",
target: "Case role or object access assignment"
}}
documentation={CASES_FIELDS_DOCUMENTATION}
/>
) : null}
{canUpdate && !canClose ? (
<ActionBlockerHint
tone="info"
reason={{
summary: "Terminal case states are unavailable",
details: CASES_I18N.closeReason,
requiredAction: "Ask a case closer to complete the lifecycle transition.",
actor: "A user with the Cases close permission",
target: "Case role assignment"
}}
documentation={CASES_FIELDS_DOCUMENTATION}
/>
) : null}
{canUpdate &&
<div className="case-edit-panel">
<FormField label="Title" documentation={CASES_FIELDS_DOCUMENTATION}>
<input value={title} onChange={(event) => setTitle(event.target.value)} />
</FormField>
<FormField label="Status" documentation={CASES_FIELDS_DOCUMENTATION}>
<select value={status} onChange={(event) => setStatus(event.target.value)}>
{statuses.map((item) => <option key={item.status_key} value={item.status_key}>{item.label}</option>)}
</select>
</FormField>
<div className="case-change-reason">
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
<input value={changeReason} onChange={(event) => setChangeReason(event.target.value)} />
</FormField>
</div>
<Button
variant="primary"
disabledReason={saveDisabledReason}
onClick={() => void save()}>
<Save size={16} aria-hidden="true" />
{saving ? "Saving" : "Save"}
</Button>
</div>
}
<div className="case-facts">
<Fact label="Opened" value={formatDateTime(record.opened_at)} />
<Fact label="Deadline" value={formatDateTime(record.deadline_at)} />
<Fact label="Revision" value={String(record.revision)} />
<Fact label="Last change" value={record.change_reason} />
</div>
<ReferenceSection title="Parties" references={record.party_refs} />
<ReferenceSection title="Assignments" references={record.assignment_refs} />
<EvidenceSection
references={record.evidence_refs}
providers={{
files: filesAvailable,
mail: mailAvailable,
campaigns: campaignsAvailable
}}
canUnlink={canUpdate && !draftDirty && !linkingEvidence}
onUnlink={(evidence) => void unlinkEvidence(evidence)}
/>
{canReadDecisions ? (
<DecisionSection
decisions={decisions}
canFile={canFileRecords}
onFile={(decision) => navigate(recordFilingPath(
decision.reference,
`${humanize(decision.decision_type)} Decision`,
"decisions",
"decision_revision"
))}
/>
) : (
<ReferenceSection title="Decisions" references={record.decision_refs} />
)}
<ReferenceSection title="Records" references={record.record_refs} />
</section>
<aside className="case-detail-aside">
<section>
<h2>Timeline</h2>
<ol className="case-timeline">
{timeline.map((entry) =>
<li key={entry.event_id}>
<strong>{humanize(entry.event_type)}</strong>
<span>{entry.summary}</span>
<time>{formatDateTime(entry.occurred_at)}</time>
</li>
)}
</ol>
</section>
<section>
<h2>History</h2>
<div className="case-history-list">
{history.map((revision) =>
<div key={revision.revision}>
<strong>Revision {revision.revision}</strong>
<span>{revision.change_reason}</span>
<time>{formatDateTime(revision.recorded_at)}</time>
</div>
)}
</div>
</section>
</aside>
</div>
}
</PageScrollViewport>
</div>
{record ? (
<CaseShareDialog
settings={settings}
record={record}
open={shareOpen}
onClose={() => setShareOpen(false)}
onSaved={(saved) => {
setRecord(saved);
void load().catch((reason) => {
setError(reason instanceof Error ? reason.message : "Case could not be reloaded.");
});
}}
/>
) : null}
{record ? (
<CaseDecisionDialog
settings={settings}
record={record}
open={decisionOpen}
onClose={() => setDecisionOpen(false)}
onSaved={(result) => {
setRecord(result.case);
setDecisions((current) => [
result.decision,
...current.filter((item) => item.reference.object_id !== result.decision.reference.object_id)
]);
void load().catch((reason) => {
setError(reason instanceof Error ? reason.message : "Case could not be reloaded.");
});
}}
/>
) : null}
</main>
);
}
function Fact({ label, value }: { label: string; value: string }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
function ReferenceSection({ title, references }: { title: string; references: InstitutionalReference[] }) {
if (references.length === 0) return null;
return (
<section className="case-reference-section">
<h2>{title}</h2>
<div className="case-reference-list">
{references.map((reference) =>
<span key={`${reference.owner_module}:${reference.object_id}:${reference.version ?? "current"}`}>
<strong>{humanize(reference.kind)}</strong>
{reference.object_id}
</span>
)}
</div>
</section>
);
}
function EvidenceSection({
references,
providers,
canUnlink,
onUnlink
}: {
references: EvidenceReference[];
providers: Record<string, boolean>;
canUnlink: boolean;
onUnlink: (reference: EvidenceReference) => void;
}) {
return (
<section className="case-reference-section case-evidence-section">
<h2>Linked files, mail, and campaigns</h2>
<p className="muted small-note">
Select an authorized exact item in Quick Access to link it. Cases stores
only the owner reference; the owning module checks access again when opened.
</p>
{references.length ? (
<div className="case-reference-list">
{references.map((reference) => {
const source = linkedEvidenceSource(reference);
const available = source
? providers[source.owner_module] !== false
: providers[reference.owner_module] !== false;
return (
<span key={`${reference.owner_module}:${reference.evidence_id}:${reference.version ?? "current"}`}>
<strong>{humanize(source?.kind ?? reference.kind)}</strong>
<small>{reference.owner_module} · version {reference.version ?? "not pinned"}</small>
{source && available ? (
<a href={source.path} className="btn btn-secondary">
<ExternalLink size={15} aria-hidden="true" /> Open in owner
</a>
) : source ? (
<small>Owner module unavailable; the reference remains in Case history.</small>
) : null}
{source ? (
<Button
variant="danger"
disabled={!canUnlink}
disabledReason={!canUnlink
? "Save or discard Case edits before removing a link."
: undefined}
onClick={() => onUnlink(reference)}
>
<Unlink size={15} aria-hidden="true" /> Unlink
</Button>
) : null}
</span>
);
})}
</div>
) : (
<p className="muted">No owner references are linked.</p>
)}
</section>
);
}
function linkedEvidenceSource(reference: EvidenceReference): {
owner_module: string;
kind: string;
path: string;
} | null {
if (!reference.source_ref) return null;
try {
const value = JSON.parse(reference.source_ref) as Record<string, unknown>;
const owner = String(value.owner_module ?? "");
const kind = String(value.kind ?? "");
const path = String(value.path ?? "");
if (
value.contract !== "cases.quick-access-reference.v1"
|| !owner
|| !kind
|| !path.startsWith("/")
|| path.startsWith("//")
) return null;
return { owner_module: owner, kind, path };
} catch {
return null;
}
}
function DecisionSection({
decisions,
canFile,
onFile
}: {
decisions: FormalDecision[];
canFile: boolean;
onFile: (decision: FormalDecision) => void;
}) {
if (decisions.length === 0) return null;
return (
<section className="case-reference-section">
<h2>Decisions</h2>
<div className="case-decision-list">
{decisions.map((decision) => (
<article key={`${decision.reference.object_id}:${decision.reference.version ?? "current"}`}>
<div className="case-decision-heading">
<div>
<strong>{humanize(decision.decision_type)}</strong>
<span>Revision {decision.reference.version ?? decision.temporal.revision}</span>
</div>
<StatusBadge status={decision.state === "effective" || decision.state === "decided" ? "active" : "inactive"} label={humanize(decision.state)} />
{canFile ? (
<IconButton
label="File Decision in eAkte"
icon={<Archive size={16} />}
onClick={() => onFile(decision)}
helpContextId="records.action.file"
/>
) : null}
</div>
{decision.operative_result ? <p><strong>Operative result</strong>{decision.operative_result}</p> : null}
{decision.reasoning ? <p><strong>Reasoning</strong>{decision.reasoning}</p> : null}
{!decision.operative_result && !decision.reasoning ? (
<p className="case-decision-protected">Protected Decision details require the sensitive-read permission.</p>
) : null}
{decision.conditions.length > 0 ? (
<ul>{decision.conditions.map((condition) => <li key={condition}>{condition}</li>)}</ul>
) : null}
</article>
))}
</div>
</section>
);
}
function recordFilingPath(
reference: InstitutionalReference,
label: string,
sourceModule: string,
resourceType: string
): string {
const query = new URLSearchParams({
sourceModule,
resourceType,
resourceId: reference.object_id,
sourceRevision: reference.version ?? "",
sourceLabel: label
});
return `/records?${query.toString()}`;
}
function formatDateTime(value?: string | null): string {
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "-";
}
function humanize(value: string): string {
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
@@ -0,0 +1,319 @@
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Button,
ConfirmDialog,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FormField,
IconButton,
ReferenceSelect,
ToggleSwitch,
i18nMessage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings
} from "@govoplan/core-webui";
import {
caseShareTargetProvider,
updateCase,
type CaseGrant,
type CaseRecord
} from "../../api/cases";
import {
CASES_FIELDS_DOCUMENTATION,
CASES_I18N
} from "./interfacePatterns";
type TargetType = "user" | "group";
type Permission = CaseGrant["permissions"][number];
export default function CaseShareDialog({
settings,
record,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CaseRecord;
open: boolean;
onClose: () => void;
onSaved: (record: CaseRecord) => void;
}) {
const [restricted, setRestricted] = useState(record.access_mode === "restricted");
const [grants, setGrants] = useState<CaseGrant[]>(record.access_grants);
const [targetType, setTargetType] = useState<TargetType>("user");
const [targetId, setTargetId] = useState("");
const [permission, setPermission] = useState<Permission>("read");
const [changeReason, setChangeReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirmOpen, setConfirmOpen] = useState(false);
const idempotencyKey = useRef(crypto.randomUUID());
const { requestDiscard } = useUnsavedChanges();
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);
setTargetType("user");
setTargetId("");
setPermission("read");
setChangeReason("");
setError("");
setConfirmOpen(false);
idempotencyKey.current = crypto.randomUUID();
}, [open, record]);
const changed = restricted !== (record.access_mode === "restricted")
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
const draftDirty = changed || Boolean(targetId.trim() || changeReason.trim());
function discardDraft() {
setRestricted(record.access_mode === "restricted");
setGrants(record.access_grants);
setTargetType("user");
setTargetId("");
setPermission("read");
setChangeReason("");
setError("");
}
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(): Promise<boolean> {
if (!changed || !changeReason.trim()) return false;
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: idempotencyKey.current,
access_mode: restricted ? "restricted" : "tenant",
access_grants: grants
});
onSaved(saved);
setRestricted(saved.access_mode === "restricted");
setGrants(saved.access_grants);
setTargetId("");
setChangeReason("");
idempotencyKey.current = crypto.randomUUID();
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Case access could not be saved.");
return false;
} finally {
setBusy(false);
}
}
useUnsavedDraftGuard({
dirty: open && draftDirty,
title: "i18n:govoplan-cases.unsaved_access_title",
message: "i18n:govoplan-cases.unsaved_access_message",
onSave: save,
onDiscard: discardDraft
});
function close() {
if (busy) return;
if (draftDirty) requestDiscard(onClose);
else onClose();
}
async function confirmSave() {
const saved = await save();
if (!saved) return;
setConfirmOpen(false);
onClose();
}
const saveDisabledReason = busy
? CASES_I18N.saving
: !changed
? CASES_I18N.noChanges
: !changeReason.trim()
? CASES_I18N.incomplete
: undefined;
return (
<>
<Dialog
open={open}
title={i18nMessage("i18n:govoplan-cases.case_access_title", { value0: record.case_number })}
onClose={close}
closeDisabled={busy}
portal
className="case-share-dialog"
footer={
<>
<Button disabled={busy} onClick={close}>Cancel</Button>
<Button
variant="primary"
disabledReason={saveDisabledReason}
onClick={() => setConfirmOpen(true)}
>
{busy ? "Saving" : "Save access"}
</Button>
</>
}
>
<div className="case-share-content">
<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>
) : null}
<ToggleSwitch
label="Case visibility"
inactiveLabel="Tenant"
activeLabel="Restricted"
checked={restricted}
disabled={busy}
onChange={setRestricted}
/>
<p className="case-share-explanation">
Tenant cases follow the Cases read permission. Restricted cases are visible only to
their creator, case administrators, assigned functions or units, and the explicit
grants below.
</p>
<div className="case-share-add-row">
<FormField label="Target type" documentation={CASES_FIELDS_DOCUMENTATION}>
<select
value={targetType}
disabled={busy}
onChange={(event) => {
setTargetType(event.target.value as TargetType);
setTargetId("");
}}
>
<option value="user">User</option>
<option value="group">Group</option>
</select>
</FormField>
<FormField label="Target" documentation={CASES_FIELDS_DOCUMENTATION}>
<ReferenceSelect
value={targetId}
onChange={setTargetId}
provider={targetProvider}
disabled={busy}
placeholder={`Select a ${targetType}`}
aria-label={`Case access ${targetType}`}
/>
</FormField>
<FormField label="Permission" documentation={CASES_FIELDS_DOCUMENTATION}>
<select
value={permission}
disabled={busy}
onChange={(event) => setPermission(event.target.value as Permission)}
>
<option value="read">Read</option>
<option value="update">Update</option>
<option value="share">Share</option>
<option value="admin">Administer</option>
</select>
</FormField>
<IconButton
label="Add access grant"
icon={<Plus size={16} />}
variant="primary"
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : undefined}
onClick={addGrant}
/>
</div>
<div className="case-share-grants" aria-label="Explicit access grants">
{grants.length === 0 ? (
<p>No explicit access grants.</p>
) : grants.map((grant) => (
<div
key={`${grant.subject_kind}:${grant.subject_id}`}
className="case-share-grant"
>
<span>
<strong>{humanize(grant.subject_kind)}</strong>
{grant.subject_id}
</span>
<select
aria-label={`Permission for ${grant.subject_id}`}
value={grant.permissions[0] ?? "read"}
disabled={busy}
onChange={(event) => setGrants((current) => current.map((item) =>
item === grant
? { ...item, permissions: [event.target.value as Permission] }
: item
))}
>
<option value="read">Read</option>
<option value="update">Update</option>
<option value="share">Share</option>
<option value="admin">Administer</option>
</select>
<IconButton
label={`Remove access for ${grant.subject_id}`}
icon={<Trash2 size={16} />}
variant="danger"
disabled={busy}
onClick={() => setGrants((current) => current.filter((item) => item !== grant))}
/>
</div>
))}
</div>
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
<input
value={changeReason}
disabled={busy}
maxLength={1000}
onChange={(event) => setChangeReason(event.target.value)}
placeholder="Why is case access changing?"
/>
</FormField>
</div>
</Dialog>
<ConfirmDialog
open={confirmOpen}
title="i18n:govoplan-cases.access_confirm_title"
message={i18nMessage("i18n:govoplan-cases.access_confirm_message", {
value0: record.case_number,
value1: restricted
? "i18n:govoplan-cases.visibility_restricted"
: "i18n:govoplan-cases.visibility_tenant",
value2: grants.length
})}
confirmLabel="Save access"
busy={busy}
onCancel={() => setConfirmOpen(false)}
onConfirm={() => void confirmSave()}
/>
</>
);
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
+161
View File
@@ -0,0 +1,161 @@
import { Search } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Button,
DocumentationHelpLink,
DismissibleAlert,
FilterBar,
LoadingIndicator,
PageScrollViewport,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
i18nMessage,
useGuardedNavigate,
WorkspaceActionBar,
WorkspaceFrame,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
listCaseCatalog,
listCases,
type CaseCatalog,
type CaseRecord
} from "../../api/cases";
import { CASES_DOCUMENTATION, CASES_I18N } from "./interfacePatterns";
export default function CasesPage({ settings }: PlatformRouteContext) {
const navigate = useGuardedNavigate();
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [status, setStatus] = useState("");
const [cases, setCases] = useState<CaseRecord[]>([]);
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [reloadKey, setReloadKey] = useState(0);
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();
}, [reloadKey, settings, status, submittedQuery]);
const statusLabels = useMemo(
() => new Map(catalog.statuses.map((item) => [item.status_key, item])),
[catalog.statuses]
);
const typeLabels = useMemo(
() => new Map(catalog.types.map((item) => [item.type_key, item.label])),
[catalog.types]
);
function submit(event: FormEvent) {
event.preventDefault();
setSubmittedQuery(query.trim());
}
return (
<main className="cases-page">
<WorkspaceFrame className="cases-shell" label="Cases" interfaceId="cases.catalogue" helpContextId="cases.page.catalogue" helpModuleId="cases">
<WorkspaceActionBar
scope="collection-pane"
variant="collection"
refreshable
reloadAction={{ onReload: () => setReloadKey((value) => value + 1), loading }}
className="cases-toolbar"
contextActions={<>
<FilterBar as="form" surface="control" wrap="never" width="default" className="cases-search" onSubmit={submit}>
<Search size={17} aria-hidden="true" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label="Search cases"
placeholder="Search cases"
/>
<Button
type="submit"
variant="primary"
disabledReason={loading ? CASES_I18N.loading : undefined}
>
Search
</Button>
</FilterBar>
<label className="cases-status-filter">
<span>Status</span>
<select value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="">All statuses</option>
{catalog.statuses.map((item) =>
<option key={item.status_key} value={item.status_key}>{item.label}</option>
)}
</select>
</label>
<span className="cases-count">{i18nMessage("i18n:govoplan-cases.case_count", { value0: total })}</span>
</>}
helpAction={<DocumentationHelpLink reference={CASES_DOCUMENTATION} />}
/>
<PageScrollViewport className="cases-list-viewport">
{error &&
<DismissibleAlert tone="error" onDismiss={() => setError("")}>
{error}
</DismissibleAlert>
}
{loading && <LoadingIndicator label="Loading cases" />}
{!loading && !error && cases.length === 0 &&
<StatePanel size="compact" description="No matching cases." />
}
{!loading && cases.length > 0 &&
<SelectionList variant="navigation" label="Cases">
{cases.map((item) => {
const statusDefinition = statusLabels.get(item.status_key);
return (
<SelectionListItem
selected={false}
key={item.reference.object_id}
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}`)}>
<SelectionListItemContent title={item.title} description={`${item.case_number} · ${typeLabels.get(item.case_type_key) ?? humanize(item.case_type_key)} · ${formatDate(item.deadline_at)}`} />
<StatusBadge
status={statusDefinition?.terminal ? "inactive" : "active"}
label={statusDefinition?.label ?? humanize(item.status_key)}
/>
</SelectionListItem>
);
})}
</SelectionList>
}
</PageScrollViewport>
</WorkspaceFrame>
</main>
);
}
function formatDate(value?: string | null): string {
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(new Date(value)) : "No deadline";
}
function humanize(value: string): string {
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
@@ -0,0 +1,22 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const CASES_DOCUMENTATION = {
topicId: "cases.institutional-context",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const CASES_FIELDS_DOCUMENTATION = {
topicId: "cases.reference.lifecycle-access-and-evidence",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const CASES_I18N = {
loading: "i18n:govoplan-cases.loading_reason",
saving: "i18n:govoplan-cases.saving_reason",
updateReason: "i18n:govoplan-cases.update_reason",
closeReason: "i18n:govoplan-cases.close_reason",
shareReason: "i18n:govoplan-cases.share_reason",
noChanges: "i18n:govoplan-cases.no_changes_reason",
incomplete: "i18n:govoplan-cases.incomplete_reason",
targetRequired: "i18n:govoplan-cases.target_required_reason"
} as const;
+209
View File
@@ -0,0 +1,209 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-cases.cases": "Cases",
"i18n:govoplan-cases.navigation": "Cases navigation",
"i18n:govoplan-cases.list": "Case list",
"i18n:govoplan-cases.filters": "Case search and filters",
"i18n:govoplan-cases.detail": "Case details",
"i18n:govoplan-cases.summary": "Case summary",
"i18n:govoplan-cases.editor": "Case lifecycle editor",
"i18n:govoplan-cases.references": "Institutional references",
"i18n:govoplan-cases.timeline": "Case timeline",
"i18n:govoplan-cases.history": "Immutable case history",
"i18n:govoplan-cases.access": "Case access",
"i18n:govoplan-cases.decision": "Record formal Decision",
"i18n:govoplan-cases.decision_title": "Record formal Decision - {value0}",
"i18n:govoplan-cases.loading_reason": "The case is still loading.",
"i18n:govoplan-cases.saving_reason": "The case change is still being saved.",
"i18n:govoplan-cases.update_reason": "Your account may inspect this case but may not change its title or lifecycle state.",
"i18n:govoplan-cases.close_reason": "Closing a case requires the case-close permission.",
"i18n:govoplan-cases.share_reason": "Changing case visibility or grants requires the case-share permission.",
"i18n:govoplan-cases.no_changes_reason": "There are no case changes to save.",
"i18n:govoplan-cases.incomplete_reason": "Enter a title and a reason that explains the recorded change.",
"i18n:govoplan-cases.target_required_reason": "Select a user or group before adding an access grant.",
"i18n:govoplan-cases.unsaved_title": "Unsaved case change",
"i18n:govoplan-cases.unsaved_message": "Save or discard the case title, status, and reason before leaving.",
"i18n:govoplan-cases.unsaved_access_title": "Unsaved case access change",
"i18n:govoplan-cases.unsaved_access_message": "Save or discard the visibility and grant changes before leaving this dialog.",
"i18n:govoplan-cases.access_confirm_title": "Change case access",
"i18n:govoplan-cases.access_confirm_message": "Change {value0} to {value1} visibility with {value2} explicit grant(s)? This appends an immutable case revision and timeline entry.",
"i18n:govoplan-cases.case_access_title": "Case access - {value0}",
"i18n:govoplan-cases.case_count": "{value0} cases",
"i18n:govoplan-cases.visibility_tenant": "tenant",
"i18n:govoplan-cases.visibility_restricted": "restricted",
"Case editing is read-only": "Case editing is read-only",
"Ask a case manager to make the required lifecycle change.": "Ask a case manager to make the required lifecycle change.",
"A user with the Cases update permission": "A user with the Cases update permission",
"Case role or object access assignment": "Case role or object access assignment",
"Terminal case states are unavailable": "Terminal case states are unavailable",
"Ask a case closer to complete the lifecycle transition.": "Ask a case closer to complete the lifecycle transition.",
"A user with the Cases close permission": "A user with the Cases close permission",
"Case role assignment": "Case role assignment",
"Cases": "Cases",
"Loading cases": "Loading cases",
"Loading case": "Loading case",
"User": "User",
"Group": "Group",
"Read": "Read",
"Update": "Update",
"Share": "Share",
"Administer": "Administer",
"Cancel": "Cancel",
"Add access grant": "Add access grant",
"Explicit access grants": "Explicit access grants",
"Why is case access changing?": "Why is case access changing?",
"Search cases": "Search cases",
"Search": "Search",
"Status": "Status",
"All statuses": "All statuses",
"No matching cases.": "No matching cases.",
"Manage case access": "Manage case access",
"Title": "Title",
"Change reason": "Change reason",
"Save": "Save",
"Saving": "Saving",
"Opened": "Opened",
"Deadline": "Deadline",
"Revision": "Revision",
"Last change": "Last change",
"Parties": "Parties",
"Assignments": "Assignments",
"Decisions": "Decisions",
"Records": "Records",
"Timeline": "Timeline",
"History": "History",
"Case visibility": "Case visibility",
"Tenant": "Tenant",
"Restricted": "Restricted",
"Target type": "Target type",
"Target": "Target",
"Permission": "Permission",
"Save access": "Save access",
"No explicit access grants.": "No explicit access grants.",
"Record formal decision": "Record formal decision",
"File Case in eAkte": "File Case in eAkte",
"File Decision in eAkte": "File Decision in eAkte",
"Record decision": "Record decision",
"The formal Decision is being recorded.": "The formal Decision is being recorded.",
"Complete the Decision type, effective time, result, reasoning, and change reason.": "Complete the Decision type, effective time, result, reasoning, and change reason.",
"The formal Decision could not be recorded.": "The formal Decision could not be recorded.",
"The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.": "The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.",
"Decision type": "Decision type",
"Effective at": "Effective at",
"Operative result": "Operative result",
"Reasoning": "Reasoning",
"Conditions (one per line)": "Conditions (one per line)",
"Record formal Decision": "Record formal Decision",
"Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision.": "Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision.",
"Protected Decision details require the sensitive-read permission.": "Protected Decision details require the sensitive-read permission.",
"Your account may not update this Case.": "Your account may not update this Case.",
"Your account may not record formal Decisions.": "Your account may not record formal Decisions.",
"Enable Access to verify the acting assignment.": "Enable Access to verify the acting assignment.",
"Enable Mandates to verify formal authority.": "Enable Mandates to verify formal authority."
} as const;
const de: Record<keyof typeof en, string> = {
"i18n:govoplan-cases.cases": "Vorgänge",
"i18n:govoplan-cases.navigation": "Vorgangsnavigation",
"i18n:govoplan-cases.list": "Vorgangsliste",
"i18n:govoplan-cases.filters": "Vorgangssuche und Filter",
"i18n:govoplan-cases.detail": "Vorgangsdetails",
"i18n:govoplan-cases.summary": "Vorgangszusammenfassung",
"i18n:govoplan-cases.editor": "Vorgangsstatus bearbeiten",
"i18n:govoplan-cases.references": "Institutionelle Referenzen",
"i18n:govoplan-cases.timeline": "Vorgangszeitachse",
"i18n:govoplan-cases.history": "Unveränderliche Vorgangshistorie",
"i18n:govoplan-cases.access": "Vorgangszugriff",
"i18n:govoplan-cases.decision": "Formelle Entscheidung erfassen",
"i18n:govoplan-cases.decision_title": "Formelle Entscheidung erfassen - {value0}",
"i18n:govoplan-cases.loading_reason": "Der Vorgang wird noch geladen.",
"i18n:govoplan-cases.saving_reason": "Die Vorgangsänderung wird noch gespeichert.",
"i18n:govoplan-cases.update_reason": "Ihr Konto darf diesen Vorgang einsehen, aber Titel und Status nicht ändern.",
"i18n:govoplan-cases.close_reason": "Zum Schließen eines Vorgangs ist die Berechtigung zum Vorgangsabschluss erforderlich.",
"i18n:govoplan-cases.share_reason": "Zum Ändern von Sichtbarkeit oder Freigaben ist die Freigabeberechtigung erforderlich.",
"i18n:govoplan-cases.no_changes_reason": "Es gibt keine Vorgangsänderungen zu speichern.",
"i18n:govoplan-cases.incomplete_reason": "Geben Sie einen Titel und eine Begründung für die protokollierte Änderung ein.",
"i18n:govoplan-cases.target_required_reason": "Wählen Sie eine Person oder Gruppe aus, bevor Sie eine Zugriffsfreigabe hinzufügen.",
"i18n:govoplan-cases.unsaved_title": "Ungespeicherte Vorgangsänderung",
"i18n:govoplan-cases.unsaved_message": "Speichern oder verwerfen Sie Titel, Status und Begründung, bevor Sie fortfahren.",
"i18n:govoplan-cases.unsaved_access_title": "Ungespeicherte Zugriffsänderung",
"i18n:govoplan-cases.unsaved_access_message": "Speichern oder verwerfen Sie Sichtbarkeit und Freigaben, bevor Sie den Dialog verlassen.",
"i18n:govoplan-cases.access_confirm_title": "Vorgangszugriff ändern",
"i18n:govoplan-cases.access_confirm_message": "Sichtbarkeit von {value0} auf {value1} mit {value2} ausdrücklichen Freigabe(n) ändern? Dadurch werden eine unveränderliche Vorgangsrevision und ein Zeitachseneintrag angelegt.",
"i18n:govoplan-cases.case_access_title": "Vorgangszugriff - {value0}",
"i18n:govoplan-cases.case_count": "{value0} Vorgänge",
"i18n:govoplan-cases.visibility_tenant": "mandantenweit",
"i18n:govoplan-cases.visibility_restricted": "eingeschränkt",
"Case editing is read-only": "Der Vorgang kann nur gelesen werden",
"Ask a case manager to make the required lifecycle change.": "Bitten Sie eine Vorgangsverwaltung, die erforderliche Statusänderung vorzunehmen.",
"A user with the Cases update permission": "Eine Person mit der Berechtigung zur Vorgangsänderung",
"Case role or object access assignment": "Vorgangsrolle oder Objektfreigabe",
"Terminal case states are unavailable": "Abschließende Vorgangsstatus sind nicht verfügbar",
"Ask a case closer to complete the lifecycle transition.": "Bitten Sie eine berechtigte Person, den Vorgangsabschluss vorzunehmen.",
"A user with the Cases close permission": "Eine Person mit der Berechtigung zum Vorgangsabschluss",
"Case role assignment": "Vorgangsrollenzuweisung",
"Cases": "Vorgänge",
"Loading cases": "Vorgänge werden geladen",
"Loading case": "Vorgang wird geladen",
"User": "Person",
"Group": "Gruppe",
"Read": "Lesen",
"Update": "Ändern",
"Share": "Freigeben",
"Administer": "Verwalten",
"Cancel": "Abbrechen",
"Add access grant": "Zugriffsfreigabe hinzufügen",
"Explicit access grants": "Ausdrückliche Zugriffsfreigaben",
"Why is case access changing?": "Warum wird der Vorgangszugriff geändert?",
"Search cases": "Vorgänge suchen",
"Search": "Suchen",
"Status": "Status",
"All statuses": "Alle Status",
"No matching cases.": "Keine passenden Vorgänge.",
"Manage case access": "Vorgangszugriff verwalten",
"Title": "Titel",
"Change reason": "Änderungsbegründung",
"Save": "Speichern",
"Saving": "Speichert",
"Opened": "Eröffnet",
"Deadline": "Frist",
"Revision": "Revision",
"Last change": "Letzte Änderung",
"Parties": "Beteiligte",
"Assignments": "Zuweisungen",
"Decisions": "Entscheidungen",
"Records": "Akten",
"Timeline": "Zeitachse",
"History": "Historie",
"Case visibility": "Vorgangssichtbarkeit",
"Tenant": "Mandant",
"Restricted": "Eingeschränkt",
"Target type": "Zieltyp",
"Target": "Ziel",
"Permission": "Berechtigung",
"Save access": "Zugriff speichern",
"No explicit access grants.": "Keine ausdrücklichen Zugriffsfreigaben.",
"Record formal decision": "Formelle Entscheidung erfassen",
"File Case in eAkte": "Vorgang in eAkte verakten",
"File Decision in eAkte": "Entscheidung in eAkte verakten",
"Record decision": "Entscheidung erfassen",
"The formal Decision is being recorded.": "Die formelle Entscheidung wird erfasst.",
"Complete the Decision type, effective time, result, reasoning, and change reason.": "Vervollständigen Sie Entscheidungsart, Gültigkeitszeitpunkt, Entscheidungssatz, Begründung und Änderungsbegründung.",
"The formal Decision could not be recorded.": "Die formelle Entscheidung konnte nicht erfasst werden.",
"The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.": "Der Server prüft vor der Erfassung den aktuellen Funktionskontext, das wirksame Mandat, die exakte Vorgangsrevision, die Nachweise und die Rechtsgrundlage.",
"Decision type": "Entscheidungsart",
"Effective at": "Gültig ab",
"Operative result": "Entscheidungssatz",
"Reasoning": "Begründung",
"Conditions (one per line)": "Nebenbestimmungen (eine pro Zeile)",
"Record formal Decision": "Formelle Entscheidung erfassen",
"Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision.": "Diese formelle Entscheidung zur exakten Vorgangsrevision erfassen? Entscheidungssatz und Begründung werden als unveränderliche Entscheidungsrevision gespeichert.",
"Protected Decision details require the sensitive-read permission.": "Geschützte Entscheidungsinhalte erfordern die Berechtigung zum Lesen sensibler Entscheidungen.",
"Your account may not update this Case.": "Ihr Konto darf diesen Vorgang nicht ändern.",
"Your account may not record formal Decisions.": "Ihr Konto darf keine formellen Entscheidungen erfassen.",
"Enable Access to verify the acting assignment.": "Aktivieren Sie Access, damit der Funktionskontext geprüft werden kann.",
"Enable Mandates to verify formal authority.": "Aktivieren Sie Mandates, damit die formelle Zuständigkeit geprüft werden kann."
};
export const generatedTranslations: PlatformTranslations = { en, de };
+2
View File
@@ -0,0 +1,2 @@
export { default, casesModule } from "./module";
export * from "./api/cases";
+67
View File
@@ -0,0 +1,67 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
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: "i18n:govoplan-cases.cases",
version: "0.1.21",
optionalDependencies: [
"access",
"addresses",
"services",
"parties",
"mandates",
"decisions",
"forms_runtime",
"workflow_engine",
"records"
],
translations: generatedTranslations,
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: "i18n:govoplan-cases.cases",
iconName: "briefcase-business",
anyOf: ["cases:case:read"],
order: 35,
surfaceId: "cases.navigation"
}
],
viewSurfaces: [
{ id: "cases.navigation", moduleId: "cases", kind: "navigation", label: "i18n:govoplan-cases.navigation", order: 10 },
{ id: "cases.list", moduleId: "cases", kind: "route", label: "i18n:govoplan-cases.list", order: 20 },
{ id: "cases.list.filters", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.filters", parentId: "cases.list", order: 10 },
{ id: "cases.detail", moduleId: "cases", kind: "route", label: "i18n:govoplan-cases.detail", order: 30 },
{ id: "cases.detail.summary", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.summary", parentId: "cases.detail", order: 10 },
{ id: "cases.detail.editor", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.editor", parentId: "cases.detail", order: 20 },
{ id: "cases.detail.references", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.references", parentId: "cases.detail", order: 30 },
{ id: "cases.detail.timeline", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.timeline", parentId: "cases.detail", order: 40 },
{ id: "cases.detail.history", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.history", parentId: "cases.detail", order: 50 },
{ id: "cases.detail.access", moduleId: "cases", kind: "action", label: "i18n:govoplan-cases.access", parentId: "cases.detail", order: 60 },
{ id: "cases.detail.decision", moduleId: "cases", kind: "action", label: "i18n:govoplan-cases.decision", parentId: "cases.detail", order: 70 }
]
};
export default casesModule;
+358
View File
@@ -0,0 +1,358 @@
.cases-page,
.case-detail-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.case-detail-shell {
display: flex;
flex-direction: column;
background: var(--surface);
}
.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 { flex: 1 1 560px; }
.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-detail-actions {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.cases-list-viewport,
.case-detail-viewport {
flex: 1;
min-height: 0;
padding: 16px 18px 24px;
}
.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-main > .action-blocker-hint {
margin-top: 16px;
}
.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: var(--radius-compact);
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: var(--radius-compact);
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-evidence-section .case-reference-list > span {
align-items: center;
flex-wrap: wrap;
}
.case-evidence-section .case-reference-list small {
color: var(--text-soft);
}
.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);
}
.case-share-dialog {
width: min(860px, calc(100vw - 32px));
max-height: min(760px, calc(100vh - 32px));
}
.case-decision-dialog {
width: min(900px, calc(100vw - 32px));
max-height: min(820px, calc(100vh - 32px));
}
.case-decision-content {
display: flex;
min-height: 0;
flex-direction: column;
gap: 14px;
}
.case-decision-explanation,
.case-decision-protected {
margin: 0;
color: var(--text-soft);
font-size: 0.84rem;
}
.case-decision-grid {
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.7fr);
}
.case-decision-content textarea {
resize: vertical;
}
.case-decision-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.case-decision-list > article {
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-compact);
background: var(--surface-raised);
}
.case-decision-heading {
display: flex;
align-items: center;
gap: 10px;
}
.case-decision-heading > div:first-child {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 2px;
}
.case-decision-heading span {
color: var(--text-soft);
font-size: 0.78rem;
}
.case-decision-list p {
display: flex;
flex-direction: column;
gap: 3px;
margin: 10px 0 0;
white-space: pre-wrap;
}
.case-decision-list ul {
margin: 10px 0 0;
padding-left: 20px;
}
.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: 900px) {
.cases-count {
margin-left: 0;
}
.case-detail-content {
grid-template-columns: 1fr;
}
.case-share-add-row {
grid-template-columns: 1fr;
}
.case-detail-toolbar {
flex-wrap: wrap;
}
.case-share-add-row .icon-button {
justify-self: end;
}
}