27 Commits
Author SHA1 Message Date
zemion 0337e0cf0b perf(cases): paginate authorized records in SQL
Module Package Release / publish-packages (push) Successful in 13s
Release v0.1.25. Coordinated integrity review: GovOPlaN/govoplan-core#298.
2026-09-08 12:19:37 +02:00
zemion afda3a5ac5 Release govoplan-cases v0.1.24: align published package metadata
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 01:32:27 +02:00
zemion 17415d8831 feat(cases): enforce purpose-bound case governance
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 20:05:31 +02:00
zemion 9435116299 docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 10s
2026-08-24 01:15:32 +02:00
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
55 changed files with 12956 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/
+70
View File
@@ -0,0 +1,70 @@
# 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. Restricted
access requires a current custodian, explicit subject, or assignment-derived
grant whose permission and exact allowed purpose both match the operation.
`cases.casework` is the interactive default and `cases.search` is reserved for
Search rechecks. The detail surface uses the shared reference selector to
manage grants and purposes; list, detail, history, timeline, update, evidence,
Decision, Search, and Records-source paths apply the same fail-closed decision.
Current grants also govern historical revision reads.
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,
including their bounded allowed purposes,
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.
+279
View File
@@ -0,0 +1,279 @@
# 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 purpose-bound, revisioned grant snapshots
- 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 require a current custodian, explicit subject, or matching
function, function-assignment, or organization-unit grant. The grant must imply
the requested permission and contain the exact declared purpose. The same
decision filters list, detail, history, timeline, update, evidence, Decision,
Search, and Records-source operations so an inaccessible identifier does not
disclose case existence. Current grants govern historical revisions. 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 with current purpose-bound
custodian, assignment-derived, and explicit subject grants. Creating or newly
restricting a case establishes a custodian grant for the attributable actor.
`cases.casework` is the stable interactive default; Search rechecks use only
`cases.search`; Records filing supplies its declared purpose. Assignment-derived
grants admit `cases.casework`, `cases.search`, and `cases.records`. Each accepted
timeline event stores the evaluated purpose. Restricted access snapshots remain
versioned with the case record while current grant state governs disclosure.
## 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 for the caller-declared purpose, 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 using the fixed `cases.search`
purpose before disclosure. Search links preserve that purpose in the Case URL.
## Information governance adoption
Cases enforces temporal browsing for immutable case revisions while always
using current authorization for historical reads. It enforces exact-purpose
access for restricted cases and records the evaluated purpose in lifecycle
evidence. Governed context validates and persists tenant, service, party,
organization, function, acting assignment/delegation, mandate, jurisdiction,
legal-basis, evidence, Decision, and record references at their owning
boundaries.
Retention remains contract-only. Cases preserves immutable revisions and
timeline evidence and publishes DSAR retention/manual-review consequences, but
does not yet apply configurable schedules, legal holds, appraisal, or
disposition gates. Those controls must be implemented through the Records
boundary or a future explicit Cases retention policy before retention can be
claimed as enforced.
## 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 and matching
account, identity, or membership 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.25",
"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.25"
description = "GovOPlaN administrative case context module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.46"]
[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",
]
+185
View File
@@ -0,0 +1,185 @@
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)
allowed_purposes: 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)
purpose: Mapped[str] = mapped_column(
String(255), nullable=False, default="cases.casework", 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",
]
+494
View File
@@ -0,0 +1,494 @@
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 DEFAULT_CASE_ACCESS_PURPOSE, 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
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE
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,
purpose=command.purpose,
)
current = get_case(
session,
principal,
case_id=case_id,
purpose=command.purpose,
)
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",
purpose=command.purpose,
):
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}",
purpose=command.purpose,
)
return CaseDecisionResult(case=linked, decision=persisted)
def linked(
self,
session: Session,
principal: object,
*,
case_id: str,
purpose: str | None = None,
) -> tuple[FormalDecision, ...]:
case = get_case(
session,
principal,
case_id=case_id,
purpose=purpose,
)
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"),
(command.purpose, "Case access purpose"),
):
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",
]
+394
View File
@@ -0,0 +1,394 @@
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",
"membership",
"group",
"role",
"function",
"function_assignment",
"organization_unit",
"service_account",
"delegation",
}
)
CASE_ACCESS_PERMISSIONS = frozenset({"read", "update", "share", "admin"})
DEFAULT_CASE_ACCESS_PURPOSE = "cases.casework"
MAX_CASE_ACCESS_PURPOSES = 100
@dataclass(frozen=True, slots=True)
class CaseGrant:
subject_kind: str
subject_id: str
permissions: tuple[str, ...] = ("read",)
allowed_purposes: tuple[str, ...] = (DEFAULT_CASE_ACCESS_PURPOSE,)
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)
purposes = tuple(
dict.fromkeys(
str(value).strip() for value in self.allowed_purposes if str(value).strip()
)
)
if not purposes:
raise InstitutionalContextError(
"Case access grants require at least one allowed purpose."
)
if len(purposes) > MAX_CASE_ACCESS_PURPOSES or any(
len(value) > 255 for value in purposes
):
raise InstitutionalContextError(
"Case access grant purposes exceed the supported bounds."
)
object.__setattr__(self, "allowed_purposes", purposes)
def to_dict(self) -> dict[str, object]:
return {
"subject_kind": self.subject_kind,
"subject_id": self.subject_id,
"permissions": list(self.permissions),
"allowed_purposes": list(self.allowed_purposes),
}
@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."
)
raw_purposes = value.get(
"allowed_purposes", (DEFAULT_CASE_ACCESS_PURPOSE,)
)
if not isinstance(raw_purposes, (list, tuple)):
raise InstitutionalContextError(
"Case access purposes 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),
allowed_purposes=tuple(str(item) for item in raw_purposes),
)
@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"]
+738
View File
@@ -0,0 +1,738 @@
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),
("membership", selectors.membership_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)
or (
row.subject_kind == "membership"
and row.subject_id == selectors.membership_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]],
"allowed_purposes": [
str(value)[:255] for value in row.allowed_purposes[:100]
],
"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),
"purpose": _bounded_text(row.purpose, 255),
"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,247 @@
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 DEFAULT_CASE_ACCESS_PURPOSE, 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,
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
) -> CaseRecord:
base = _base_revision(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
purpose=purpose,
)
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,
purpose=purpose,
)
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,
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
) -> CaseRecord:
base = _base_revision(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
purpose=purpose,
)
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,
purpose=purpose,
)
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,
purpose: str,
) -> CaseRecord:
base = get_case(
session,
principal,
case_id=case_id,
revision=expected_revision,
purpose=purpose,
)
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",
]
@@ -0,0 +1,100 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'cases.reference.lifecycle-access-and-evidence': {'consequence_classes': {'change_access': 'Beifügen '
'eines '
'bestätigten '
'Ereignisses '
'hinsichtlich '
'der '
'Sichtbarkeit/der '
'Überarbeitung '
'der '
'Finanzhilfe '
'und '
'des '
'Zeitplans',
'close_case': 'Anfügen '
'einer '
'Terminalrevision '
'nach '
'Close-Scope-Autorisierung',
'update_case': 'Anfügen '
'eines '
'OCC-geschützten '
'unveränderlichen '
'Revisions- '
'und '
'Zeitleistenereignisses'}},
'cases.workflow.file-exact-revision': {'outcome': 'Der eAkte behält eine genaue '
'Fall-Snapshot-Referenz, während Cases die '
'Autorität behält.',
'prerequisites': ['Sie können derzeit den Fall lesen und '
'Elemente in den Zieldatensatz eingeben.',
'Die genaue Revision des '
'unveränderlichen Falles wurde '
'ermittelt.'],
'steps': ['Öffnen Sie den Fall und identifizieren Sie die '
'Revision, die den Beweiszustand darstellt.',
'Wählen Sie den Zieldatensatz und geben Sie den '
'Zugangszweck und den Anmeldegrund an.',
'Bestätigen Sie die Einreichung; Cases überprüft '
'den aktuellen Zugriff erneut und löst die '
'genaue Überarbeitung.',
'Überprüfen Sie die Fallrevision und Snapshot '
'Digest in der Datensatzchronologie.']},
'cases.workflow.link-owner-references': {'consequence_classes': {'link_owner_reference': 'Eine '
'unveränderliche '
'Fallrevision, '
'die nur '
'die '
'genaue '
'typisierte '
'Eigentümerreferenz '
'enthält.',
'unlink_owner_reference': 'Hängt '
'eine '
'unveränderliche '
'Case-Revision '
'an '
'und '
'lässt '
'das '
'Owner-Objekt '
'unverändert.'}},
'cases.workflow.record-formal-decision': {'outcome': 'Das formale Ergebnis ist '
'Entscheidungsbesitz und exakte Version aus '
'dem Fall verknüpft.',
'prerequisites': ['Der Fall hat eine genaue '
'verantwortliche Organisation, '
'Funktion, Nachweise und rechtliche '
'Grundlage Version.',
'Eine aktive Handlungszuweisung und '
'genau ein wirksames Mandat '
'autorisieren den Entscheidungstyp.'],
'steps': ['Öffnen Sie den Fall und wählen Sie die '
'entsprechende Handlungszuweisung in der '
'Titelleiste aus.',
'Notieren Sie das operative Ergebnis, die '
'Begründung, die optionalen Bedingungen, die '
'effektive Zeit und den Änderungsgrund.',
'Bestätigen Sie die genauen Entscheidungs- '
'und Fallrevisionen in der Fallzeitleiste.',
'Wenn Records aktiviert ist, archivieren Sie '
'die genaue Entscheidungs- oder Fallrevision '
'im Zielort eAkte.']},
'cases.workflow.ticket-escalation': {'consequence_classes': {'create_case': 'Erstellt ein '
'Fall-eigenes '
'formales Verfahren '
'und gibt seine '
'stabile Referenz '
'zurück.',
'preserve_ticket': 'Konvertiert, '
'kopiert oder '
'verändert den '
'Ticketverlauf '
'nicht.'}}}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
"""Cases Alembic revisions."""
@@ -0,0 +1 @@
"""Cases migration revisions."""
@@ -0,0 +1,51 @@
"""v0.1.23 purpose-bound case access.
Revision ID: a7c4e2f9b1d6
Revises: f6d3a8b1c4e7
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a7c4e2f9b1d6"
down_revision = "f6d3a8b1c4e7"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"case_access_grants",
sa.Column(
"allowed_purposes",
sa.JSON(),
nullable=False,
server_default='["cases.casework"]',
),
)
op.add_column(
"case_timeline_entries",
sa.Column(
"purpose",
sa.String(length=255),
nullable=False,
server_default="cases.casework",
),
)
op.create_index(
op.f("ix_case_timeline_entries_purpose"),
"case_timeline_entries",
["purpose"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f("ix_case_timeline_entries_purpose"),
table_name="case_timeline_entries",
)
op.drop_column("case_timeline_entries", "purpose")
op.drop_column("case_access_grants", "allowed_purposes")
@@ -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",
]
+122
View File
@@ -0,0 +1,122 @@
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",
purpose=purpose,
):
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='')}"
f"?purpose={quote(purpose, 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",
]
+561
View File
@@ -0,0 +1,561 @@
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),
purpose: str | None = Query(default=None, max_length=255),
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,
purpose=purpose,
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,
purpose=payload.purpose,
)
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,
purpose: str | None = Query(default=None, max_length=255),
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,
purpose=purpose,
)
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,
purpose=payload.purpose,
),
)
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),
purpose: str | None = Query(default=None, max_length=255),
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,
purpose=purpose,
)
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,
purpose=payload.purpose,
)
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,
purpose=payload.purpose,
)
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,
purpose=payload.purpose,
)
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,
purpose: str = Query(default="cases.casework", min_length=1, max_length=255),
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",
purpose=purpose,
):
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),
purpose: str | None = Query(default=None, max_length=255),
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,
purpose=purpose,
)
]
)
@router.get("/{case_id}/timeline", response_model=CaseTimelineResponse)
def api_case_timeline(
case_id: str,
limit: int = Query(default=200, ge=1, le=500),
purpose: str | None = Query(default=None, max_length=255),
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,
purpose=purpose,
)
)
)
def _update_changes(payload: CaseUpdateRequest) -> dict[str, object]:
excluded = {
"expected_revision",
"recorded_at",
"change_reason",
"idempotency_key",
"purpose",
}
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"]
+167
View File
@@ -0,0 +1,167 @@
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]
purpose: str = Field(default="cases.casework", min_length=1, max_length=255)
idempotency_key: str = Field(min_length=1, max_length=255)
class CaseGrantRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
subject_kind: Literal[
"account",
"identity",
"membership",
"group",
"role",
"function",
"function_assignment",
"organization_unit",
"service_account",
"delegation",
]
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,
)
allowed_purposes: list[str] = Field(
default_factory=lambda: ["cases.casework"],
min_length=1,
max_length=100,
)
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)
purpose: str = Field(default="cases.casework", 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)
purpose: str = Field(default="cases.casework", 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)
purpose: str = Field(default="cases.casework", 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)
purpose: str = Field(default="cases.casework", 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",
]
+289
View File
@@ -0,0 +1,289 @@
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"
SEARCH_ACCESS_PURPOSE = "cases.search"
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",
purpose=SEARCH_ACCESS_PURPOSE,
)
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='')}"
f"?purpose={quote(SEARCH_ACCESS_PURPOSE, 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",
"SEARCH_ACCESS_PURPOSE",
"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 @@
+573
View File
@@ -0,0 +1,573 @@
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,
purpose="cases.casework",
)[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.assertIsNone(get_case(self.session, reader, case_id="case-1"))
self.assertIsNone(
get_case(
self.session,
reader,
case_id="case-1",
purpose="cases.unrelated",
)
)
self.assertIsNotNone(
get_case(
self.session,
reader,
case_id="case-1",
purpose="cases.casework",
)
)
administrator = Principal(
account_id="account-6",
scopes=frozenset({"cases:case:admin"}),
)
self.assertIsNone(
get_case(
self.session,
administrator,
case_id="case-1",
purpose="cases.casework",
)
)
self.assertEqual(
1,
list_cases(
self.session,
reader,
purpose="cases.casework",
)[1],
)
self.assertEqual(
1,
len(
case_history(
self.session,
reader,
case_id="case-1",
purpose="cases.casework",
)
),
)
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",
purpose="cases.casework",
)
)
self.assertEqual(
3,
len(
case_history(
self.session,
self.principal,
case_id="case-1",
purpose="cases.casework",
)
),
)
revoked = update_case(
self.session,
self.principal,
case_id="case-1",
expected_revision=3,
changes={"access_grants": []},
recorded_at=NOW + timedelta(minutes=3),
change_reason="Removed direct collaboration access.",
idempotency_key="case-share-revoke",
purpose="cases.casework",
)
self.session.commit()
self.assertEqual(4, revoked.revision)
self.assertIsNone(
get_case(
self.session,
Principal(account_id="account-5"),
case_id="case-1",
revision=2,
purpose="cases.casework",
)
)
self.assertEqual(
"cases.casework",
case_timeline(
self.session,
self.principal,
case_id="case-1",
purpose="cases.casework",
)[0]["purpose"],
)
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()
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
from dataclasses import replace
import unittest
from sqlalchemy import event, select
from sqlalchemy.dialects import postgresql
import test_case_lifecycle as fixture
from govoplan_cases.backend import service
from govoplan_cases.backend.db.models import CaseAccessGrant, CaseRecordRevision
class CaseListQueryTests(unittest.TestCase):
def setUp(self):
self.fixture = fixture.CaseLifecycleTests()
self.fixture.setUp()
def tearDown(self):
self.fixture.tearDown()
def seed(self, count=50, *, restricted=False):
for index in range(count):
original = fixture.record()
reference = replace(original.reference, object_id=f"case-{index:03}")
record = replace(
original, reference=reference, case_number=f"CASE-{index:03}",
context=replace(original.context, case_ref=reference),
access_mode="restricted" if restricted else "tenant",
)
service.create_case(self.fixture.session, self.fixture.principal, record=record, idempotency_key=f"fixture-{index}")
self.fixture.session.add(CaseAccessGrant(
tenant_id="tenant-1", case_id=reference.object_id, subject_kind="account", subject_id="other-account",
permissions=["read"], allowed_purposes=["cases.casework"], source="manual", active=True, source_revision=1,
))
self.fixture.session.commit()
self.fixture.session.expunge_all()
def test_filtered_empty_page_does_not_materialize_grants_or_case_ids(self):
self.seed()
loaded = []
queries = []
def row_loaded(target, context):
loaded.append(target.id)
def executed(conn, cursor, statement, parameters, context, executemany):
if statement.lstrip().upper().startswith("SELECT"):
queries.append((statement, len(parameters)))
event.listen(CaseAccessGrant, "load", row_loaded)
event.listen(self.fixture.engine, "before_cursor_execute", executed)
try:
result = service.list_cases(
self.fixture.session, self.fixture.principal,
query="no-fixture-matches-this", purpose="cases.casework", limit=1,
)
self.assertEqual(((), 0), result)
self.assertEqual([], loaded)
self.assertEqual(2, len(queries))
self.assertTrue(all(count < 30 for _, count in queries))
self.assertIn("LIMIT", queries[-1][0])
finally:
event.remove(CaseAccessGrant, "load", row_loaded)
event.remove(self.fixture.engine, "before_cursor_execute", executed)
def test_current_grants_exact_purpose_and_permissions_govern_total(self):
self.seed(4, restricted=True)
reader = fixture.Principal(account_id="other-account")
session = self.fixture.session
self.assertEqual(((), 0), service.list_cases(session, reader, limit=1))
self.assertEqual(((), 0), service.list_cases(session, reader, purpose="cases.case", limit=1))
page, total = service.list_cases(session, reader, purpose="cases.casework", offset=1, limit=1)
self.assertEqual(4, total)
self.assertEqual("case-001", page[0].reference.object_id)
grants = session.query(CaseAccessGrant).filter(CaseAccessGrant.subject_id == "other-account").order_by(CaseAccessGrant.case_id).all()
grants[0].active = False
grants[1].permissions = ["reader"]
grants[2].allowed_purposes = ["cases.casework.extra"]
grants[3].permissions = ["update"]
session.commit()
self.assertEqual(1, service.list_cases(session, reader, purpose="cases.casework", limit=1)[1])
grants[3].allowed_purposes = {"not_an_array": "cases.casework"}
session.commit()
self.assertEqual(((), 0), service.list_cases(session, reader, purpose="cases.casework", limit=1))
self.assertEqual(((), 0), service.list_cases(session, fixture.Principal(tenant_id="tenant-2", account_id="other-account"), purpose="cases.casework", limit=1))
def test_access_predicate_compiles_for_postgresql_without_case_id_lists(self):
compiled = select(CaseRecordRevision.case_id).where(
service._case_access_predicate(self.fixture.principal, permission="read", purpose="cases.casework"),
).compile(dialect=postgresql.dialect())
self.assertIn("EXISTS", str(compiled))
self.assertIn("json_array_elements", str(compiled))
self.assertNotIn("cases.casework", str(compiled))
+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()
+479
View File
@@ -0,0 +1,479 @@
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"])
self.assertEqual(
["cases.casework"],
account_records[0].data["allowed_purposes"],
)
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_access_grant",
"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,116 @@
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_information_governance_claims_are_bounded_and_documented(self) -> None:
manifest = get_manifest()
governance = manifest.information_governance
self.assertEqual("enforced", governance.temporal_browsing.adoption)
self.assertEqual("enforced", governance.purpose_aware_access.adoption)
self.assertEqual("enforced", governance.institutional_context.adoption)
self.assertEqual("contract_only", governance.retention.adoption)
self.assertIn("legal holds", governance.retention.limitation or "")
self.assertTrue(governance.current_authorization_for_historical_reads)
topics = {topic.id: topic for topic in manifest.documentation}
purpose = topics["cases.governance.purpose-bound-access"]
self.assertIn("cases.field.access-purpose", purpose.metadata["help_contexts"])
self.assertIn("cases.field.allowed-purposes", purpose.metadata["help_contexts"])
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()
+67
View File
@@ -0,0 +1,67 @@
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")
},
)
self.assertIn(
"allowed_purposes",
{
item["name"]
for item in inspector.get_columns("case_access_grants")
},
)
self.assertIn(
"purpose",
{
item["name"]
for item in inspector.get_columns("case_timeline_entries")
},
)
with engine.connect() as connection:
self.assertIn(
"a7c4e2f9b1d6",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+118
View File
@@ -0,0 +1,118 @@
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
) as can_access:
result = CasesRecordSource().resolve(
self.session,
Principal(),
locator=self.locator(),
purpose="preserve decision basis",
)
can_access.assert_called_once_with(
self.session,
unittest.mock.ANY,
case_id="case-1",
permission="read",
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"])
self.assertEqual(
"/cases/case-1?purpose=preserve%20decision%20basis",
result.launch_url,
)
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()
+188
View File
@@ -0,0 +1,188 @@
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 CaseGrant, CaseRecord
from govoplan_cases.backend.search_source import (
SEARCH_ACCESS_PURPOSE,
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",
access_mode="restricted",
access_grants=(
CaseGrant(
"account",
"account-1",
("read",),
(SEARCH_ACCESS_PURPOSE,),
),
),
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"])
self.assertIn("purpose=cases.search", page.documents[0].url)
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.25",
"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
}
}
}
+312
View File
@@ -0,0 +1,312 @@
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" | "membership" | "group" | "role" | "function" | "function_assignment" | "organization_unit" | "service_account" | "delegation";
subject_id: string;
permissions: Array<"read" | "update" | "share" | "admin">;
allowed_purposes: string[];
};
export const DEFAULT_CASE_ACCESS_PURPOSE = "cases.casework";
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;
purpose: 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[];
purpose?: 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,
purpose: options.purpose,
offset: options.offset,
limit: options.limit
}), { signal });
}
export function getCase(
settings: ApiSettings,
caseId: string,
purpose: string,
signal?: AbortSignal
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}`, { purpose }), { 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,
purpose: string,
signal?: AbortSignal
): Promise<{ revisions: CaseRecord[] }> {
return apiFetch(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/history`, { purpose }), { signal });
}
export function caseTimeline(
settings: ApiSettings,
caseId: string,
purpose: string,
signal?: AbortSignal
): Promise<{ entries: CaseTimelineEntry[] }> {
return apiFetch(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { purpose }), { signal });
}
export function listCaseDecisions(
settings: ApiSettings,
caseId: string,
purpose: string,
signal?: AbortSignal
): Promise<{ decisions: FormalDecision[] }> {
return apiFetch(
settings,
apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`, { purpose }),
{ 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;
purpose: 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;
purpose: 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;
purpose: 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;
purpose: 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",
purpose: string
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/share-target-options`,
{ target_type: targetType, purpose }
);
}
@@ -0,0 +1,212 @@
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,
purpose,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CaseRecord;
purpose: string;
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,
purpose
});
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);
}
+689
View File
@@ -0,0 +1,689 @@
import { Archive, ArrowLeft, ExternalLink, Save, Scale, Share2, Unlink } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useSearchParams } 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 {
DEFAULT_CASE_ACCESS_PURPOSE,
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 [searchParams] = useSearchParams();
const accessPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
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)}?purpose=${encodeURIComponent(accessPurpose)}`
} : null, [accessPurpose, record]);
useRegisterActiveObject(activeCase);
const load = useCallback((signal?: AbortSignal) => {
setLoading(true);
setError("");
return Promise.all([
getCase(settings, caseId, accessPurpose, signal),
listCaseCatalog(settings, signal),
caseHistory(settings, caseId, accessPurpose, signal),
caseTimeline(settings, caseId, accessPurpose, signal),
canReadDecisions
? listCaseDecisions(settings, caseId, accessPurpose, 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));
}, [accessPurpose, 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(),
purpose: accessPurpose,
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
);
}, [accessPurpose, 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,
purpose: accessPurpose,
...(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(),
purpose: accessPurpose
}
);
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?purpose=${encodeURIComponent(accessPurpose)}`)}>
<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)}
helpContextId="cases.detail.access"
/>
</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="Access purpose" value={accessPurpose} />
<Fact label="Access mode" value={humanize(record.access_mode)} />
<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>
<span>{entry.purpose}</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}
purpose={accessPurpose}
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}
purpose={accessPurpose}
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,352 @@
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,
purpose,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CaseRecord;
purpose: string;
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 [allowedPurposes, setAllowedPurposes] = useState(purpose);
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, purpose),
[purpose, record.reference.object_id, settings, targetType]
);
useEffect(() => {
if (!open) return;
setRestricted(record.access_mode === "restricted");
setGrants(record.access_grants);
setTargetType("user");
setTargetId("");
setPermission("read");
setAllowedPurposes(purpose);
setChangeReason("");
setError("");
setConfirmOpen(false);
idempotencyKey.current = crypto.randomUUID();
}, [open, purpose, 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");
setAllowedPurposes(purpose);
setChangeReason("");
setError("");
}
function addGrant() {
const subjectId = targetId.trim();
const purposes = splitPurposes(allowedPurposes);
if (!subjectId || purposes.length === 0) 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],
allowed_purposes: purposes
}
]);
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,
purpose,
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"
helpContextId="cases.detail.access"
footer={
<>
<Button disabled={busy} onClick={close}>Cancel</Button>
<Button
variant="primary"
disabledReason={saveDisabledReason}
onClick={() => setConfirmOpen(true)}
helpContextId="cases.detail.access"
>
{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 require a current
grant whose permission and exact allowed purpose both match the attempted action.
The creating account receives a purpose-bound custodian grant.
</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>
<FormField label="Allowed purposes" helpContextId="cases.field.allowed-purposes" documentation={CASES_FIELDS_DOCUMENTATION}>
<input
value={allowedPurposes}
disabled={busy}
onChange={(event) => setAllowedPurposes(event.target.value)}
placeholder="cases.casework, cases.search"
/>
</FormField>
<IconButton
label="Add access grant"
icon={<Plus size={16} />}
variant="primary"
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : splitPurposes(allowedPurposes).length === 0 ? "Declare at least one allowed purpose." : 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>
<input
aria-label={`Allowed purposes for ${grant.subject_id}`}
value={grant.allowed_purposes.join(", ")}
disabled={busy}
onChange={(event) => setGrants((current) => current.map((item) =>
item === grant
? { ...item, allowed_purposes: splitPurposes(event.target.value) }
: item
))}
/>
<IconButton
label={`Remove access for ${grant.subject_id}`}
icon={<Trash2 size={16} />}
variant="danger"
disabled={busy}
helpContextId="cases.detail.access"
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());
}
function splitPurposes(value: string): string[] {
return Array.from(new Set(value.split(/[\n,]+/).map((item) => item.trim()).filter(Boolean)));
}
+177
View File
@@ -0,0 +1,177 @@
import { Search } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router";
import { Button,
DocumentationHelpLink,
DismissibleAlert,
FilterBar,
LoadingIndicator,
PageScrollViewport,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
i18nMessage,
useGuardedNavigate,
WorkspaceActionBar,
WorkspaceFrame,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
DEFAULT_CASE_ACCESS_PURPOSE,
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 [searchParams] = useSearchParams();
const initialPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [purpose, setPurpose] = useState(initialPurpose);
const [submittedPurpose, setSubmittedPurpose] = useState(initialPurpose);
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,
purpose: submittedPurpose,
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, submittedPurpose, 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());
setSubmittedPurpose(purpose.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"
/>
<input
value={purpose}
onChange={(event) => setPurpose(event.target.value)}
aria-label="Case access purpose"
placeholder="Case access purpose"
data-help-context-id="cases.field.access-purpose"
required
/>
<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)}?purpose=${encodeURIComponent(submittedPurpose)}`)}>
<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;
+221
View File
@@ -0,0 +1,221 @@
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.",
"Case access purpose": "Case access purpose",
"Access purpose": "Access purpose",
"Access mode": "Access mode",
"Allowed purposes": "Allowed purposes",
"Declare at least one allowed purpose.": "Declare at least one allowed purpose.",
"Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.": "Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.",
"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.",
"Case access purpose": "Zugriffszweck für Vorgänge",
"Access purpose": "Zugriffszweck",
"Access mode": "Zugriffsmodus",
"Allowed purposes": "Erlaubte Zwecke",
"Declare at least one allowed purpose.": "Geben Sie mindestens einen erlaubten Zweck an.",
"Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.": "Mandantenweit sichtbare Vorgänge folgen der Cases-Leseberechtigung. Eingeschränkte Vorgänge verlangen eine aktuelle Freigabe, deren Berechtigung und exakter erlaubter Zweck beide zur Aktion passen. Das anlegende Konto erhält eine zweckgebundene Custodian-Freigabe.",
"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;
}
}