22 Commits
Author SHA1 Message Date
zemion 7c2ec4ec17 fix(packaging): expose immutable WebUI Git package for v0.1.21
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 02:06:08 +02:00
zemion e84fb631f0 Release govoplan-committee v0.1.21: unify interface contracts and documentation 2026-09-08 01:32:27 +02:00
zemion 46ba69fbfe fix(webui): review committee identifier risk
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:34 +02:00
zemion 432f8bbd02 docs(committee): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 11s
2026-08-23 19:31:22 +02:00
zemion 049ea80385 feat(committee): add governed DSAR coverage 2026-08-21 12:16:00 +02:00
zemion 3259741765 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:45 +02:00
zemion be411e4ca8 feat: align committee with shared UI foundations 2026-08-18 21:32:34 +02:00
zemion 75c9488f66 Adopt shared WebUI structural primitives 2026-08-18 13:17:31 +02:00
zemion 0e03f6a70c Adopt shared WebUI layout primitives 2026-08-18 10:42:51 +02:00
zemion 1351338590 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:44 +02:00
zemion 21052accee Release v0.1.17
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 20:33:58 +02:00
zemion 0ae25ed350 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 19:51:59 +02:00
zemion bbddc8b52a Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:10:18 +02:00
zemion 01ebd44d93 Make package publication retries hash-safe 2026-08-04 14:32:19 +02:00
zemion c06de41bdc Harden module package publication 2026-08-04 14:02:39 +02:00
zemion bcf7dc53b4 Add protected package release workflow 2026-08-04 04:14:03 +02:00
zemion e64af30534 Migrate Committee interface patterns 2026-08-03 12:48:55 +02:00
zemion 11b946ea78 feat: integrate confidential ballot provider evidence 2026-08-02 03:41:05 +02:00
zemion 133e55987e Integrate committee ballots with Voting 2026-08-01 20:57:25 +02:00
zemion 758b59ca03 feat: implement committee decision workspace 2026-08-01 17:48:25 +02:00
zemion 232329ac52 refactor: target workflow engine runtime 2026-07-31 16:59:21 +02:00
zemion aedc245cc2 Release v0.1.8 2026-07-11 16:49:01 +02:00
37 changed files with 7199 additions and 42 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
+6
View File
@@ -1,5 +1,11 @@
# GovOPlaN Committee Codex Guide
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Committee internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Scope
This repository owns the GovOPlaN Committee platform module seed.
+62 -4
View File
@@ -1,15 +1,38 @@
# GovOPlaN Committee
`govoplan-committee` is the GovOPlaN platform module seed for committee, board, council, and senate workflows for meetings, agendas, minutes, decisions, voting, and follow-up tasks.
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
This repository is initialized as a discoverable module seed. It exposes a module manifest, initial permissions, role templates, documentation metadata, Gitea workflow templates, and a focused manifest test. It intentionally does not yet add HTTP routes, database models, migrations, or WebUI navigation.
`govoplan-committee` is the GovOPlaN module for committee, board, council, and senate workflows for meetings, agendas, minutes, deliberation, voting, formal decision references, and follow-up tasks.
The backend now exposes versioned `committee.workspace` and
`committee.decision_path` capabilities. The workspace persists immutable,
OCC-guarded revisions for bodies, meetings, agenda items, governed vote
results, and minutes, with replay-safe lifecycle events. The decision path
combines that committee context with an effective Mandate,
including organization, function, and jurisdiction coverage, with approval,
legal bases, evidence, reasoning, and effects to create the shared
formal Decision contract. Mandate resolution and Decision persistence remain
optional providers. If Decisions is absent, Committee retains a bounded,
protected local Decision projection so the outcome remains reconstructable.
The `/committee` WebUI provides the body, meeting, agenda, vote, and minutes
workspace using the same immutable revisions and lifecycle guards as the API.
When Voting is installed, Committee creates and follows a governed
`voting.ballots` record while retaining only its meeting/agendum reference and
verified aggregate outcome. Voting owns the frozen electorate, casting,
tallying, certification, challenge, and annulment lifecycle. The older
`committee.ballot_finalizer` / `committee.ballot_adapter.<provider>` path
remains a 0.1 compatibility boundary for existing external integrations;
individual provider ballots never enter Committee persistence.
## Initial Ownership
- committee bodies
- meeting agendas
- minutes
- decision records
- deliberation/vote context and formal decision references
- votes
- follow-up assignments
@@ -20,6 +43,8 @@ This module does not own:
- generic task execution
- document storage
- calendar event storage
- the generic formal decision lifecycle; a future Decisions provider owns
authority, facts/rules, reasoning, effects, review, correction, and revocation
Detailed boundary notes are in [docs/COMMITTEE_DOMAIN_BOUNDARY.md](docs/COMMITTEE_DOMAIN_BOUNDARY.md).
@@ -33,6 +58,7 @@ Expected optional integrations:
- tasks
- workflow
- approvals
- voting
## Development Install
@@ -50,6 +76,20 @@ cd /mnt/DATA/git/govoplan-committee
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests
```
## API And Recovery
`/api/v1/committee/workspace/{body|meeting|agenda_item|vote|minute}` supports
bounded list/read/write and immutable history. Writes require an idempotency
key and expected revision after creation. Protected local Decision projections
use a separate read permission. Database restore is the semantic recovery unit;
linked Calendar, Files, Records, Tasks, Approvals, and Decisions objects retain
their own recovery responsibility.
`POST /api/v1/committee/workspace/vote/{vote_id}/finalize-provider` is the 0.1
compatibility effect boundary for an installed ballot adapter. It requires the
`committee:ballot:finalize` permission and preserves provider receipt/hash and
evidence without exposing or persisting individual votes.
## Gitea Workflow
Issue templates are installed under `.gitea/`, and the shared label taxonomy is copied to `docs/gitea-labels.json` with the module label `module/committee`.
@@ -58,5 +98,23 @@ From the core checkout, labels can be synced once a local `GITEA_TOKEN` is avail
```bash
cd /mnt/DATA/git/govoplan-core
./scripts/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-committee --apply
/mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-committee --apply
```
## Git-source WebUI package
The repository root exposes `@govoplan/committee-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/committee-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+68 -8
View File
@@ -9,7 +9,7 @@ Committee, board, council, and senate workflows for meetings, agendas, minutes,
- committee bodies
- meeting agendas
- minutes
- decision records
- deliberation and vote context plus references to formal decision records
- votes
- follow-up assignments
@@ -18,6 +18,9 @@ Committee, board, council, and senate workflows for meetings, agendas, minutes,
- generic task execution
- document storage
- calendar event storage
- generic approval gates
- the cross-domain formal decision lifecycle, including authority, facts,
applicable rules, reasoning, effects, review, correction, and revocation
## Integration Candidates
@@ -28,19 +31,76 @@ Committee, board, council, and senate workflows for meetings, agendas, minutes,
- workflow
- approvals
## Seed State
## Current Persistent Backend Slice
The current repository state is intentionally small:
The current repository state is intentionally bounded:
- module manifest and entry point
- tenant-level permission definitions
- manager and viewer role templates
- documentation topic describing the module boundary
- documentation topic and architecture/evidence declaration
- `committee.workspace` and `committee.decision_path` interfaces and capabilities
- tenant-scoped body, meeting, agenda-item, vote-result, and minute persistence
- immutable revisions, OCC, replay-safe lifecycle events, migrations, uninstall
guards, API routes, and tenant summary counts
- a governed assembler for one formal committee outcome
- a protected local Decision projection when the optional Decisions provider is
absent
- a three-pane `/committee` workspace for bodies, meetings, agendas, governed
vote results, and minutes
- a provider-neutral ballot-finalization contract for external and secret
ballots that retains aggregate evidence rather than individual ballots
- an optional primary integration with Voting, which owns frozen electorates,
vote casting/replacement, tally, certification, challenge, and annulment
- Gitea issue workflow templates
- manifest contract test
- manifest and decision reconstruction contract tests
No runtime API, database model, migration, WebUI route, or navigation item is registered yet. The first implementation slice should preserve the boundary above and only add user-visible surfaces once the workflow model is clear.
The decision path accepts or resolves one effective
Mandate covering the deciding unit, function, and jurisdiction; requires
approval, fact evidence, versioned legal bases, operative
result, and reasoning, and emits the shared formal Decision contract. If a
Decision registry is installed it records there; otherwise the result is
retained in the Committee-owned fallback projection and is available only
through the protected-read permission.
## First Implementation Slice
The workspace records the result of a governed vote rather than becoming a
general remote-balloting system. Local closure requires unique choices,
eligible/cast counts, matching result counts, an explicit quorum result, an
Approval reference, and evidence. Decided agenda items require a formal
Decision reference, and meetings cannot close while agenda items remain
unfinished. Accepted or corrected minutes require a Records reference,
Approval, and evidence.
Define committee body, meeting, agenda item, decision, vote, minute, and follow-up task references.
A governed ballot is delegated to `voting.ballots` when Voting is installed;
Committee retains the meeting/agendum linkage and verified aggregate result.
For provider-backed Voting ballots, Committee also snapshots the assurance
profile, provider and ballot references, and sanitized provider evidence. A
reference `local_confidential` result remains explicitly uncertified; its
server-encrypted casts do not satisfy secret-ballot, anonymity,
coercion-resistance, or legal-certification requirements.
The following direct provider path remains a 0.1 compatibility contract only.
A provider-bound vote on that path is finalized through
`committee.ballot_adapter.<provider>`. The adapter receives tenant, vote,
choices, eligible count, external ballot reference, request time, and
idempotency key. Its result must cover exactly the configured choices, sum to
the cast count, stay within eligibility, carry same-tenant evidence, and supply
a lowercase SHA-256 result digest plus provider receipt. Committee persists
that aggregate and does not persist voter choices or provider credentials.
Database restore is the module's semantic recovery unit. Calendar events,
documents, records, tasks, approvals, and externally conducted votes remain
recoverable through their owning providers and are linked by stable references.
## Decision Reconstruction Proof
`tests/test_decision_path.py` proves effective-time authority, organization,
function, and jurisdiction coverage, approval, legal basis/evidence versions, requested effects,
information governance, responsible actor/automation assurance, and a protected
reconstruction payload. A vote remains
an approval reference and is not made indistinguishable from the formal
institutional outcome.
`tests/test_workspace.py` proves parent and lifecycle constraints, immutable
history, replay and stale-write rejection, tenant isolation, committed-only
events, vote/quorum evidence, adapter-only provider closure, aggregate-only
secret-ballot persistence, minutes, and the local Decision projection.
+42
View File
@@ -0,0 +1,42 @@
# Committee Interface Pattern Migration
This document records the bounded migration of Committee-owned WebUI surfaces
to the GovOPlaN interface pattern language. Core owns shared controls, help,
blocking explanations, draft guards, confirmations, and host shells. Committee
owns bodies, meetings, agenda context, vote context, minutes, and the bounded
projection of their institutional evidence.
## Surface Inventory
| Surface | Archetype | Consequence class | Contract |
| --- | --- | --- | --- |
| `/committee` toolbar and body list | Searchable workspace directory | Change query projection or create body | Shared search/button/help controls, explicit loading/error/permission states |
| Body and meeting selectors | Master-detail selection | Change active context | Stable keyboard buttons, selected/hover state, bounded independent scrolling |
| Meeting detail | Governed record workspace | Create or revise agenda, vote, and minute records | Shared sections, statuses, action explanations, locale-aware dates |
| Committee record dialog | Consequential record editor | Save immutable revision or change lifecycle | Contextual field help, guarded draft, required change reason, lifecycle confirmation |
| Ballot finalization dialog | Governed provider action | Import aggregate result and evidence | Permission explanation, guarded draft, explicit confirmation, no individual ballots |
## Consequence And Availability Rules
- Every save creates a new Committee revision and retains the change reason.
- Terminal lifecycle states are immutable; disabled edit actions explain that
constraint rather than disappearing.
- Cancelling, withdrawing, or retiring stops future work but never removes
existing revisions, minutes, decisions, or evidence.
- Provider and Voting integrations remain optional capability boundaries.
Committee stores only the aggregate result and assurance evidence.
- Missing write or ballot-finalization permission identifies the required
action, responsible administrator, and Access destination.
- Unsaved editors intercept route, browser, backdrop, and explicit close
attempts through the shared draft guard.
## State And Accessibility Evidence
The module uses shared buttons, icon buttons, dialogs, confirmations, alerts,
status badges, loading indicators, action blockers, field help, and draft
guards. Native buttons preserve keyboard order and shared dialogs retain focus.
The existing three-region desktop layout and two-region compact layout keep
lists and details independently scrollable. English and German catalogues cover
module metadata and owned UI copy, while dates follow the selected platform
locale. Manifest topics expose stable route, field, blocker, privacy, and
consequence references without importing optional sibling modules.
+29 -3
View File
@@ -1,8 +1,34 @@
{
"name": "@govoplan/committee",
"version": "0.1.7",
"name": "@govoplan/committee-webui",
"version": "0.1.21",
"private": true,
"description": "GovOPlaN Committee platform module seed.",
"type": "module",
"peerDependencies": {}
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"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/committee.css": "./webui/src/styles/committee.css"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
}
+4 -4
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-committee"
version = "0.1.7"
description = "GovOPlaN Committee platform module seed."
version = "0.1.21"
description = "GovOPlaN committee governance and formal-decision integration module."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.7",
"govoplan-access>=0.1.7",
"govoplan-core>=0.1.18",
"govoplan-access>=0.1.18",
]
[tool.setuptools.packages.find]
+408
View File
@@ -0,0 +1,408 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, replace
from datetime import datetime
import re
from typing import Protocol, runtime_checkable
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
EvidenceReference,
InstitutionalReference,
)
from govoplan_core.core.voting import (
CAPABILITY_VOTING_BALLOTS,
VotingBallotProvider,
)
from govoplan_committee.backend.workspace import (
CommitteeWorkspaceError,
CommitteeWorkspaceRecord,
get_workspace_object,
record_workspace_object,
)
CAPABILITY_COMMITTEE_BALLOT_FINALIZER = "committee.ballot_finalizer"
_PROVIDER_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,79}$")
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
def ballot_adapter_capability(provider_id: str) -> str:
value = str(provider_id or "").strip()
if not _PROVIDER_RE.fullmatch(value):
raise CommitteeWorkspaceError("Committee ballot provider id is invalid.")
return f"committee.ballot_adapter.{value}"
@dataclass(frozen=True, slots=True)
class BallotFinalizationRequest:
tenant_id: str
vote_id: str
provider_id: str
provider_ballot_ref: str
choices: tuple[str, ...]
eligible_count: int
requested_at: datetime
idempotency_key: str
def __post_init__(self) -> None:
ballot_adapter_capability(self.provider_id)
if not self.tenant_id.strip() or not self.vote_id.strip():
raise CommitteeWorkspaceError(
"Committee ballot tenant and vote identifiers are required."
)
if not self.provider_ballot_ref.strip():
raise CommitteeWorkspaceError(
"Committee provider ballot reference is required."
)
if len(self.choices) < 2 or len(self.choices) != len(set(self.choices)):
raise CommitteeWorkspaceError("Committee ballot choices must be unique.")
if self.eligible_count < 0:
raise CommitteeWorkspaceError(
"Committee ballot eligible count cannot be negative."
)
if self.requested_at.tzinfo is None:
raise CommitteeWorkspaceError(
"Committee ballot requested_at must include a timezone."
)
if not self.idempotency_key.strip():
raise CommitteeWorkspaceError(
"Committee ballot idempotency key is required."
)
@dataclass(frozen=True, slots=True)
class BallotFinalizationResult:
provider_id: str
provider_ballot_ref: str
counts: Mapping[str, int]
cast_count: int
quorum_met: bool
receipt_ref: str
result_sha256: str
evidence: tuple[EvidenceReference, ...]
def __post_init__(self) -> None:
ballot_adapter_capability(self.provider_id)
if not self.provider_ballot_ref.strip() or not self.receipt_ref.strip():
raise CommitteeWorkspaceError(
"Committee ballot provider and receipt references are required."
)
if not _SHA256_RE.fullmatch(self.result_sha256):
raise CommitteeWorkspaceError(
"Committee ballot result hash must be a lowercase SHA-256 digest."
)
if self.cast_count < 0 or any(
not isinstance(value, int) or value < 0 for value in self.counts.values()
):
raise CommitteeWorkspaceError("Committee ballot counts cannot be negative.")
if not self.evidence:
raise CommitteeWorkspaceError(
"Committee ballot finalization requires provider evidence."
)
@runtime_checkable
class CommitteeBallotAdapter(Protocol):
def finalize_ballot(
self,
session: object,
principal: object,
*,
request: BallotFinalizationRequest,
) -> BallotFinalizationResult: ...
class CommitteeBallotFinalizer:
def __init__(self, registry: object | None = None) -> None:
self._registry = registry
def finalize(
self,
session: Session,
principal: object,
*,
vote_id: str,
provider_id: str,
provider_ballot_ref: str,
approval_ref: InstitutionalReference,
expected_revision: int,
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
) -> CommitteeWorkspaceRecord:
current = get_workspace_object(
session,
principal,
object_kind="vote",
object_id=vote_id,
)
if current is None:
raise LookupError("Committee vote not found.")
if current.state != "open":
raise CommitteeWorkspaceError(
"Only an open Committee vote can be finalized by a ballot provider."
)
if current.revision != expected_revision:
raise CommitteeWorkspaceError(
"Committee revision conflict: the expected revision is stale."
)
configured_provider = str(current.attributes.get("provider_id") or "").strip()
if configured_provider and configured_provider != provider_id:
raise CommitteeWorkspaceError(
"Committee vote is bound to another ballot provider."
)
choices = tuple(str(item) for item in current.attributes.get("choices", ()))
eligible_count = int(current.attributes.get("eligible_count") or 0)
request = BallotFinalizationRequest(
tenant_id=current.tenant_id,
vote_id=current.object_id,
provider_id=provider_id,
provider_ballot_ref=provider_ballot_ref,
choices=choices,
eligible_count=eligible_count,
requested_at=recorded_at,
idempotency_key=idempotency_key,
)
adapter = _capability(
self._registry,
ballot_adapter_capability(provider_id),
)
if not isinstance(adapter, CommitteeBallotAdapter):
raise CommitteeWorkspaceError(
f"Committee ballot provider is unavailable: {provider_id}."
)
result = adapter.finalize_ballot(
session,
principal,
request=request,
)
_validate_result(result, request=request)
counts = {choice: int(result.counts.get(choice, 0)) for choice in choices}
next_record = replace(
current,
revision=current.revision + 1,
state="closed",
recorded_at=recorded_at,
change_reason=change_reason,
attributes={
**dict(current.attributes),
"provider_id": provider_id,
"provider_ballot_ref": provider_ballot_ref,
"provider_receipt_ref": result.receipt_ref,
"provider_result_sha256": result.result_sha256,
"counts": counts,
"cast_count": result.cast_count,
"quorum_met": result.quorum_met,
"approval_ref": approval_ref.to_dict(),
},
evidence=_merge_evidence(current.evidence, result.evidence),
)
return record_workspace_object(
session,
principal,
record=next_record,
expected_revision=expected_revision,
idempotency_key=idempotency_key,
_provider_finalization=True,
)
def finalize_voting_ballot(
self,
session: Session,
principal: object,
*,
vote_id: str,
voting_ballot_id: str,
voting_expected_revision: int,
approval_ref: InstitutionalReference,
expected_revision: int,
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
) -> CommitteeWorkspaceRecord:
"""Close a Voting-owned ballot and project its aggregate into Committee."""
current = get_workspace_object(
session,
principal,
object_kind="vote",
object_id=vote_id,
)
if current is None:
raise LookupError("Committee vote not found.")
if current.state != "open":
raise CommitteeWorkspaceError(
"Only an open Committee vote can be finalized."
)
if current.revision != expected_revision:
raise CommitteeWorkspaceError(
"Committee revision conflict: the expected revision is stale."
)
configured_id = str(current.attributes.get("voting_ballot_id") or "").strip()
if not configured_id or configured_id != voting_ballot_id:
raise CommitteeWorkspaceError(
"Committee vote is not bound to the requested Voting ballot."
)
provider = _capability(self._registry, CAPABILITY_VOTING_BALLOTS)
if not isinstance(provider, VotingBallotProvider):
raise CommitteeWorkspaceError("Voting ballot capability is unavailable.")
ballot = provider.get_ballot(
session,
principal,
ballot_id=voting_ballot_id,
)
if ballot is None:
raise CommitteeWorkspaceError("Referenced Voting ballot was not found.")
context = ballot.get("context")
if isinstance(context, Mapping):
context_module = str(context.get("module") or "").strip()
context_id = str(context.get("resource_id") or "").strip()
if context_module and context_module != "committee":
raise CommitteeWorkspaceError(
"Referenced Voting ballot belongs to another module context."
)
if context_id and context_id != vote_id:
raise CommitteeWorkspaceError(
"Referenced Voting ballot belongs to another Committee vote."
)
result = provider.close_ballot(
session,
principal,
ballot_id=voting_ballot_id,
expected_revision=voting_expected_revision,
idempotency_key=f"committee:{idempotency_key}",
)
assurance_profile = str(
ballot.get("assurance_profile") or "recorded"
).strip()
if assurance_profile != "recorded" and not result.evidence:
raise CommitteeWorkspaceError(
"Provider-backed Voting results require sanitized provider evidence."
)
choices = tuple(str(item) for item in current.attributes.get("choices", ()))
if set(result.counts) != set(choices):
raise CommitteeWorkspaceError(
"Voting result options do not match the Committee vote choices."
)
evidence = EvidenceReference(
kind="snapshot",
owner_module="voting",
evidence_id=f"result-{result.result_sha256}",
tenant_id=current.tenant_id,
version=str(result.revision),
checksum=result.result_sha256,
source_ref=f"voting:{voting_ballot_id}",
captured_at=recorded_at,
)
next_record = replace(
current,
revision=current.revision + 1,
state="closed",
recorded_at=recorded_at,
change_reason=change_reason,
attributes={
**dict(current.attributes),
"voting_ballot_id": voting_ballot_id,
"voting_ballot_revision": result.revision,
"voting_result_sha256": result.result_sha256,
"voting_assurance_profile": assurance_profile,
"voting_provider_id": ballot.get("provider_id"),
"voting_provider_ballot_ref": ballot.get("provider_ballot_ref"),
"voting_provider_evidence": [
dict(item) for item in result.evidence
],
"counts": {key: int(value) for key, value in result.counts.items()},
"weighted_counts": {
key: int(value) for key, value in result.weighted_counts.items()
},
"cast_count": result.cast_count,
"cast_weight": result.cast_weight,
"quorum_met": result.quorum_met,
"threshold_met": result.threshold_met,
"winning_options": list(result.winning_options),
"approval_ref": approval_ref.to_dict(),
},
evidence=_merge_evidence(current.evidence, (evidence,)),
)
return record_workspace_object(
session,
principal,
record=next_record,
expected_revision=expected_revision,
idempotency_key=idempotency_key,
_provider_finalization=True,
)
def _validate_result(
result: BallotFinalizationResult,
*,
request: BallotFinalizationRequest,
) -> None:
if (
result.provider_id != request.provider_id
or result.provider_ballot_ref != request.provider_ballot_ref
):
raise CommitteeWorkspaceError(
"Committee ballot provider returned a result for another ballot."
)
if set(result.counts) != set(request.choices):
raise CommitteeWorkspaceError(
"Committee ballot provider result must cover exactly the configured choices."
)
if sum(result.counts.values()) != result.cast_count:
raise CommitteeWorkspaceError(
"Committee ballot provider counts do not match cast_count."
)
if result.cast_count > request.eligible_count:
raise CommitteeWorkspaceError(
"Committee ballot provider cast_count exceeds eligible_count."
)
if any(item.tenant_id != request.tenant_id for item in result.evidence):
raise CommitteeWorkspaceError(
"Committee ballot provider evidence cannot cross tenants."
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
def _merge_evidence(
existing: tuple[EvidenceReference, ...],
incoming: tuple[EvidenceReference, ...],
) -> tuple[EvidenceReference, ...]:
merged: list[EvidenceReference] = []
seen: set[tuple[str, str, str, str, str]] = set()
for item in (*existing, *incoming):
key = (
item.kind,
item.owner_module,
item.evidence_id,
item.tenant_id,
item.version,
)
if key in seen:
continue
seen.add(key)
merged.append(item)
return tuple(merged)
__all__ = [
"CAPABILITY_COMMITTEE_BALLOT_FINALIZER",
"BallotFinalizationRequest",
"BallotFinalizationResult",
"CommitteeBallotAdapter",
"CommitteeBallotFinalizer",
"ballot_adapter_capability",
]
@@ -0,0 +1,13 @@
"""Committee database models."""
from govoplan_committee.backend.db.models import (
CommitteeDecisionProjection,
CommitteeWorkspaceEvent,
CommitteeWorkspaceRevision,
)
__all__ = [
"CommitteeDecisionProjection",
"CommitteeWorkspaceEvent",
"CommitteeWorkspaceRevision",
]
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import 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 CommitteeWorkspaceRevision(Base, TimestampMixin):
__tablename__ = "committee_workspace_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"object_kind",
"object_id",
"revision",
name="uq_committee_workspace_revision",
),
Index(
"ix_committee_workspace_current",
"tenant_id",
"object_kind",
"object_id",
"superseded_at",
),
Index(
"ix_committee_workspace_parent",
"tenant_id",
"parent_kind",
"parent_id",
"object_kind",
"state",
),
)
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)
object_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
object_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("committee_workspace_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
parent_kind: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
parent_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(500), nullable=False)
search_text: Mapped[str] = mapped_column(Text, nullable=False)
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)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
changed_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
class CommitteeWorkspaceEvent(Base, TimestampMixin):
__tablename__ = "committee_workspace_events"
__table_args__ = (
UniqueConstraint("tenant_id", "event_id", name="uq_committee_workspace_event"),
UniqueConstraint("tenant_id", "idempotency_key", name="uq_committee_workspace_idempotency"),
Index(
"ix_committee_workspace_event_object",
"tenant_id",
"object_kind",
"object_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)
object_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
object_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
object_revision: Mapped[int] = mapped_column(Integer, nullable=False)
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class CommitteeDecisionProjection(Base, TimestampMixin):
__tablename__ = "committee_decision_projections"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"decision_id",
"revision",
name="uq_committee_decision_projection_revision",
),
Index(
"ix_committee_decision_projection_current",
"tenant_id",
"decision_id",
"superseded_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)
decision_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
revision: Mapped[str] = mapped_column(String(120), nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("committee_decision_projections.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
meeting_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
agenda_item_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
state: Mapped[str] = mapped_column(String(30), nullable=False, 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)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
changed_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
__all__ = [
"CommitteeDecisionProjection",
"CommitteeWorkspaceEvent",
"CommitteeWorkspaceRevision",
]
@@ -0,0 +1,333 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from govoplan_core.core.institutional import (
CAPABILITY_DECISION_REGISTRY,
CAPABILITY_MANDATE_RESOLVER,
ActorRepresentationReference,
DecisionAssuranceLevel,
DecisionEffectReference,
DecisionRegistry,
EvidenceReference,
FormalDecision,
GovernedContextEnvelope,
InformationGovernanceReference,
InstitutionalContextError,
InstitutionalReference,
LegalBasisReference,
MandateDefinition,
MandateResolution,
MandateResolutionRequest,
MandateResolver,
TemporalRevision,
resolve_mandate_candidates,
)
from govoplan_committee.backend.workspace import CAPABILITY_COMMITTEE_WORKSPACE
CAPABILITY_COMMITTEE_DECISION_PATH = "committee.decision_path"
@dataclass(frozen=True, slots=True)
class CommitteeDecisionProposal:
tenant_id: str
decision_id: str
revision: str
effective_at: datetime
meeting_ref: str
agenda_item_ref: str
decision_type: str
subject_refs: tuple[InstitutionalReference, ...]
organization_unit_ref: InstitutionalReference
function_ref: InstitutionalReference
actor: ActorRepresentationReference
approval_refs: tuple[InstitutionalReference, ...]
fact_evidence: tuple[EvidenceReference, ...]
legal_bases: tuple[LegalBasisReference, ...]
operative_result: str
reasoning: str
case_ref: InstitutionalReference | None = None
jurisdiction_refs: tuple[InstitutionalReference, ...] = ()
party_refs: tuple[InstitutionalReference, ...] = ()
record_refs: tuple[InstitutionalReference, ...] = ()
conditions: tuple[str, ...] = ()
requested_effects: tuple[DecisionEffectReference, ...] = ()
remedy_refs: tuple[str, ...] = ()
review_refs: tuple[str, ...] = ()
information_governance: InformationGovernanceReference | None = None
assurance_level: DecisionAssuranceLevel = "human"
automation_preparation_refs: tuple[str, ...] = ()
def __post_init__(self) -> None:
if self.effective_at.tzinfo is None or self.effective_at.utcoffset() is None:
raise InstitutionalContextError(
"Committee decision effective_at must include a timezone."
)
if not self.meeting_ref.strip() or not self.agenda_item_ref.strip():
raise InstitutionalContextError(
"Committee meeting and agenda item references are required."
)
if not self.subject_refs:
raise InstitutionalContextError(
"Committee decisions require at least one subject."
)
if not self.approval_refs:
raise InstitutionalContextError(
"Committee decisions require an accepted approval reference."
)
if not self.fact_evidence or not self.legal_bases:
raise InstitutionalContextError(
"Committee decisions require fact evidence and legal basis versions."
)
if not self.operative_result.strip() or not self.reasoning.strip():
raise InstitutionalContextError(
"Committee decisions require an operative result and reasoning."
)
references = (
*self.subject_refs,
self.organization_unit_ref,
self.function_ref,
*self.approval_refs,
self.case_ref,
*self.jurisdiction_refs,
*self.party_refs,
*self.record_refs,
)
if any(
item is not None and item.tenant_id != self.tenant_id
for item in references
):
raise InstitutionalContextError(
"Committee decision references cannot cross tenants."
)
if self.organization_unit_ref.kind != "organization_unit":
raise InstitutionalContextError(
"Committee decision organization reference must identify an organization unit."
)
if self.function_ref.kind != "function":
raise InstitutionalContextError(
"Committee decision function reference must identify a function."
)
if any(item.kind != "approval" for item in self.approval_refs):
raise InstitutionalContextError(
"Committee decision approval references must have approval kind."
)
if any(item.kind != "jurisdiction" for item in self.jurisdiction_refs):
raise InstitutionalContextError(
"Committee decision jurisdiction references must have jurisdiction kind."
)
@dataclass(frozen=True, slots=True)
class CommitteeDecisionPathResult:
decision: FormalDecision
mandate: MandateDefinition
persisted_by_decision_registry: bool
persisted_by_committee_projection: bool = False
def reconstruction_payload(self) -> dict[str, object]:
return {
"decision": self.decision.to_dict(include_protected=True),
"mandate_ref": self.mandate.reference.to_dict(),
"mandate_revision": self.mandate.temporal.to_dict(),
"persisted_by_decision_registry": self.persisted_by_decision_registry,
"persisted_by_committee_projection": self.persisted_by_committee_projection,
}
class CommitteeDecisionPath:
"""Build one reconstructable formal outcome without owning Decision storage."""
def __init__(self, registry: object | None = None) -> None:
self._registry = registry
def decide(
self,
session: object,
principal: object,
*,
proposal: CommitteeDecisionProposal,
mandate_resolution: MandateResolution | None = None,
observed_effects: tuple[DecisionEffectReference, ...] = (),
expected_revision: str | None = None,
) -> CommitteeDecisionPathResult:
resolution = mandate_resolution or self._resolve_mandate(
session,
principal,
proposal=proposal,
)
mandate = _accepted_mandate(proposal, resolution)
decision_registry = _capability(
self._registry,
CAPABILITY_DECISION_REGISTRY,
)
decision_ref = InstitutionalReference(
kind="decision",
owner_module=(
"decisions"
if isinstance(decision_registry, DecisionRegistry)
else "committee"
),
object_id=proposal.decision_id,
tenant_id=proposal.tenant_id,
version=proposal.revision,
valid_at=proposal.effective_at,
)
temporal = TemporalRevision(
revision=proposal.revision,
valid_from=proposal.effective_at,
recorded_at=proposal.effective_at,
change_reason="Committee decision accepted",
)
evidence = tuple(
dict.fromkeys((*proposal.fact_evidence, *mandate.evidence, *resolution.evidence))
)
legal_bases = tuple(
dict.fromkeys((*proposal.legal_bases, *mandate.legal_bases))
)
authority_context = GovernedContextEnvelope(
tenant_id=proposal.tenant_id,
temporal=temporal,
actor=proposal.actor,
organization_unit_ref=proposal.organization_unit_ref,
function_ref=proposal.function_ref,
mandate_ref=mandate.reference,
jurisdiction_refs=proposal.jurisdiction_refs,
case_ref=proposal.case_ref,
party_refs=proposal.party_refs,
approval_refs=proposal.approval_refs,
decision_ref=decision_ref,
record_refs=proposal.record_refs,
legal_bases=legal_bases,
evidence=evidence,
information_governance=proposal.information_governance,
)
decision = FormalDecision(
reference=decision_ref,
temporal=temporal,
decision_type=proposal.decision_type,
subject_refs=proposal.subject_refs,
state="decided",
authority_context=authority_context,
fact_evidence=evidence,
legal_bases=legal_bases,
assurance_level=proposal.assurance_level,
automation_preparation_refs=proposal.automation_preparation_refs,
operative_result=proposal.operative_result,
reasoning=proposal.reasoning,
conditions=proposal.conditions,
requested_effects=proposal.requested_effects,
observed_effects=observed_effects,
publication_refs=(
f"committee-meeting:{proposal.meeting_ref}",
f"committee-agenda-item:{proposal.agenda_item_ref}",
),
remedy_refs=proposal.remedy_refs,
review_refs=proposal.review_refs,
)
persisted = False
projected = False
if isinstance(decision_registry, DecisionRegistry):
decision = decision_registry.record_decision(
session,
principal,
decision=decision,
expected_revision=expected_revision,
)
persisted = True
else:
workspace = _capability(self._registry, CAPABILITY_COMMITTEE_WORKSPACE)
if workspace is not None and hasattr(workspace, "record_local_decision"):
decision = workspace.record_local_decision(
session,
principal,
decision=decision,
meeting_id=proposal.meeting_ref,
agenda_item_id=proposal.agenda_item_ref,
expected_revision=expected_revision,
)
projected = True
return CommitteeDecisionPathResult(
decision=decision,
mandate=mandate,
persisted_by_decision_registry=persisted,
persisted_by_committee_projection=projected,
)
def _resolve_mandate(
self,
session: object,
principal: object,
*,
proposal: CommitteeDecisionProposal,
) -> MandateResolution:
resolver = _capability(self._registry, CAPABILITY_MANDATE_RESOLVER)
if not isinstance(resolver, MandateResolver):
raise InstitutionalContextError(
"Committee decision requires a Mandate resolver or an explicit governed resolution."
)
return resolver.resolve_mandate(
session,
principal,
request=_mandate_request(proposal),
)
def _accepted_mandate(
proposal: CommitteeDecisionProposal,
resolution: MandateResolution,
) -> MandateDefinition:
if not resolution.competent:
raise InstitutionalContextError(
resolution.explanation or "The acting function is not competent to decide."
)
if resolution.conflict_refs:
raise InstitutionalContextError(
"Mandate resolution has unresolved conflicts: "
+ ", ".join(resolution.conflict_refs)
)
verified = resolve_mandate_candidates(
_mandate_request(proposal),
resolution.mandates,
)
if not verified.competent:
raise InstitutionalContextError(
verified.explanation
or "Committee decision requires exactly one effective active mandate."
)
return verified.mandates[0]
def _mandate_request(
proposal: CommitteeDecisionProposal,
) -> MandateResolutionRequest:
return MandateResolutionRequest(
tenant_id=proposal.tenant_id,
effective_at=proposal.effective_at,
task_type="committee.formal_decision",
authority_type=proposal.decision_type,
organization_unit_ref=proposal.organization_unit_ref,
function_ref=proposal.function_ref,
jurisdiction_refs=proposal.jurisdiction_refs,
)
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_COMMITTEE_DECISION_PATH",
"CommitteeDecisionPath",
"CommitteeDecisionPathResult",
"CommitteeDecisionProposal",
]
@@ -0,0 +1,396 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from govoplan_committee.backend.db.models import (
CommitteeDecisionProjection,
CommitteeWorkspaceEvent,
CommitteeWorkspaceRevision,
)
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
COMMITTEE_DSAR_CAPABILITY = dsar_capability_name("committee")
_MAX_RECORDS = 5_000
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
actor_ids: tuple[str, ...]
object_kind: str | None
object_id: str | None
decision_id: str | None
class CommitteeDsarProvider:
provider_id = "committee"
module_id = "committee"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
records: list[DsarRecordRef] = []
revisions = db.query(CommitteeWorkspaceRevision).filter(
CommitteeWorkspaceRevision.tenant_id == tenant_id,
CommitteeWorkspaceRevision.changed_by.in_(selectors.actor_ids),
)
events = db.query(CommitteeWorkspaceEvent).filter(
CommitteeWorkspaceEvent.tenant_id == tenant_id,
CommitteeWorkspaceEvent.actor_id.in_(selectors.actor_ids),
)
decisions = db.query(CommitteeDecisionProjection).filter(
CommitteeDecisionProjection.tenant_id == tenant_id,
CommitteeDecisionProjection.changed_by.in_(selectors.actor_ids),
)
if selectors.object_kind:
revisions = revisions.filter(
CommitteeWorkspaceRevision.object_kind == selectors.object_kind
)
events = events.filter(
CommitteeWorkspaceEvent.object_kind == selectors.object_kind
)
if selectors.object_id:
revisions = revisions.filter(
CommitteeWorkspaceRevision.object_id == selectors.object_id
)
events = events.filter(
CommitteeWorkspaceEvent.object_id == selectors.object_id
)
if selectors.object_kind == "meeting":
decisions = decisions.filter(
CommitteeDecisionProjection.meeting_id == selectors.object_id
)
elif selectors.object_kind in {"agenda", "agenda_item"}:
decisions = decisions.filter(
CommitteeDecisionProjection.agenda_item_id == selectors.object_id
)
elif selectors.object_kind == "decision":
decisions = decisions.filter(
CommitteeDecisionProjection.decision_id == selectors.object_id
)
if selectors.decision_id:
decisions = decisions.filter(
CommitteeDecisionProjection.decision_id == selectors.decision_id
)
records.extend(
_revision_attribution(row)
for row in _limited(
revisions,
CommitteeWorkspaceRevision.recorded_at,
CommitteeWorkspaceRevision.id,
label="workspace revision attribution",
)
)
records.extend(
_event_attribution(row)
for row in _limited(
events,
CommitteeWorkspaceEvent.occurred_at,
CommitteeWorkspaceEvent.id,
label="workspace event attribution",
)
)
records.extend(
_decision_attribution(row)
for row in _limited(
decisions,
CommitteeDecisionProjection.recorded_at,
CommitteeDecisionProjection.id,
label="Decision projection attribution",
)
)
if len(records) > _MAX_RECORDS:
raise ValueError(
"Committee DSAR combined result limit exceeded; narrow the selectors."
)
return tuple(
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
)
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("Committee DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
actions.append(
DsarErasureActionRef(
action_id=(
f"committee:retain:{record.resource_type}:{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Retain {record.title}",
rationale=record.retention_reason
or "Committee attribution is immutable evidence.",
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("Committee DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if action.executable or action.kind != "retain":
raise ValueError("Committee DSAR publishes retain-only actions.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Committee workspace, event, and Decision attribution remains "
"immutable institutional evidence."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("committee.account"),
references.get("access.account"),
),
"membership_id": _coalesce(
subject.membership_id,
references.get("committee.membership"),
references.get("tenancy.membership"),
),
"identity_id": _coalesce(
subject.identity_id,
references.get("committee.identity"),
references.get("identity.id"),
),
"actor_id": _coalesce(
references.get("committee.actor"),
references.get("committee.changed_by"),
),
"object_kind": _coalesce(
references.get("committee.object_kind"),
references.get("committee.kind"),
),
"object_id": _coalesce(
references.get("committee.object"),
references.get("committee.object_id"),
),
"decision_id": _coalesce(
references.get("committee.decision"),
references.get("committee.decision_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
actor_ids = tuple(
dict.fromkeys(
value
for value in (
_optional_string(values["account_id"]),
_prefixed("account", values["account_id"]),
_optional_string(values["membership_id"]),
_prefixed("membership", values["membership_id"]),
_optional_string(values["identity_id"]),
_prefixed("identity", values["identity_id"]),
)
if value
)
)
direct_actor = _optional_string(values["actor_id"])
if direct_actor:
if actor_ids and direct_actor not in actor_ids:
return None
if not actor_ids:
actor_ids = (direct_actor,)
if not actor_ids:
return None
return _SubjectSelectors(
actor_ids=actor_ids,
object_kind=_optional_string(values["object_kind"]),
object_id=_optional_string(values["object_id"]),
decision_id=_optional_string(values["decision_id"]),
)
def _revision_attribution(row: CommitteeWorkspaceRevision) -> DsarRecordRef:
return DsarRecordRef(
provider_id="committee",
module_id="committee",
resource_type="committee_workspace_actor_attribution",
resource_id=row.id,
category="committee_workspace_accountability",
title="Committee workspace revision attribution",
data={
"object_kind": row.object_kind,
"object_id": row.object_id,
"revision": row.revision,
"parent_kind": row.parent_kind,
"parent_id": row.parent_id,
"state": row.state,
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
"activity": "recorded_workspace_revision",
},
observed_at=_aware(row.recorded_at),
immutable_evidence=True,
retention_reason="Committee workspace attribution is immutable evidence.",
)
def _event_attribution(row: CommitteeWorkspaceEvent) -> DsarRecordRef:
return DsarRecordRef(
provider_id="committee",
module_id="committee",
resource_type="committee_event_actor_attribution",
resource_id=row.id,
category="committee_workspace_accountability",
title="Committee lifecycle event attribution",
data={
"object_kind": row.object_kind,
"object_id": row.object_id,
"object_revision": row.object_revision,
"event_id": row.event_id,
"event_type": row.event_type,
"occurred_at": _iso(row.occurred_at),
},
observed_at=_aware(row.occurred_at),
immutable_evidence=True,
retention_reason="Committee event attribution is immutable evidence.",
)
def _decision_attribution(row: CommitteeDecisionProjection) -> DsarRecordRef:
return DsarRecordRef(
provider_id="committee",
module_id="committee",
resource_type="committee_decision_actor_attribution",
resource_id=row.id,
category="committee_decision_accountability",
title="Committee fallback Decision attribution",
data={
"decision_id": row.decision_id,
"revision": row.revision,
"meeting_id": row.meeting_id,
"agenda_item_id": row.agenda_item_id,
"state": row.state,
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
"activity": "recorded_decision_projection",
},
observed_at=_aware(row.recorded_at),
immutable_evidence=True,
retention_reason=(
"Committee fallback Decision attribution is immutable evidence."
),
)
def _limited(query, first, second, *, label: str):
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
if len(rows) > _MAX_RECORDS:
raise ValueError(f"Committee DSAR {label} limit exceeded; narrow selectors.")
return rows
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _prefixed(prefix: str, value: object) -> str | None:
normalized = _optional_string(value)
return f"{prefix}:{normalized}" if normalized else None
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Committee DSAR requires a SQLAlchemy Session.")
return value
_RESOURCE_TYPES = {
"committee_workspace_actor_attribution",
"committee_event_actor_attribution",
"committee_decision_actor_attribution",
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "committee" or record.module_id != "committee":
raise ValueError("Committee DSAR cannot plan a foreign provider record.")
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
raise ValueError("Committee DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "committee" or action.module_id != "committee":
raise ValueError("Committee DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("committee:retain:"):
raise ValueError("Committee DSAR action identity is invalid.")
__all__ = ["COMMITTEE_DSAR_CAPABILITY", "CommitteeDsarProvider"]
+562 -18
View File
@@ -1,23 +1,163 @@
from __future__ import annotations
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
from pathlib import Path
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.core.institutional import (
CAPABILITY_DECISION_REGISTRY,
CAPABILITY_MANDATE_RESOLVER,
)
from govoplan_core.core.voting import CAPABILITY_VOTING_BALLOTS
from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
ModuleMaturityEvidence,
)
from govoplan_committee.backend.decision_path import (
CAPABILITY_COMMITTEE_DECISION_PATH,
CommitteeDecisionPath,
)
from govoplan_committee.backend.ballots import (
CAPABILITY_COMMITTEE_BALLOT_FINALIZER,
CommitteeBallotFinalizer,
)
from govoplan_committee.backend.db import models as committee_models
from govoplan_committee.backend.dsar_provider import (
COMMITTEE_DSAR_CAPABILITY,
CommitteeDsarProvider,
)
from govoplan_committee.backend.workspace import (
CAPABILITY_COMMITTEE_WORKSPACE,
SqlCommitteeWorkspace,
)
from govoplan_core.db.base import Base
MODULE_ID = "committee"
MODULE_NAME = "Committee"
MODULE_VERSION = "0.1.7"
MODULE_VERSION = "0.1.21"
READ_SCOPE = "committee:workspace:read"
WRITE_SCOPE = "committee:workspace:write"
BALLOT_SCOPE = "committee:ballot:finalize"
ADMIN_SCOPE = "committee:workspace:admin"
PROTECTED_READ_SCOPE = "committee:decision:protected_read"
OPTIONAL_DEPENDENCIES = (
"calendar",
"docs",
"files",
"mandates",
"decisions",
"tasks",
"workflow",
"voting",
"workflow_engine",
"approvals",
)
ARCHITECTURE = ModuleArchitectureDeclaration(
layer="communication_participation",
kind="domain",
maturity="vertical_slice",
evidence=(
ModuleMaturityEvidence(
kind="test",
reference="tests/test_decision_path.py",
summary="Proves effective mandate, approval, evidence, effect, and reconstruction semantics for a committee decision.",
),
ModuleMaturityEvidence(
kind="documentation",
reference="docs/COMMITTEE_DOMAIN_BOUNDARY.md",
summary="Defines the Committee/Decision/Mandate ownership boundary.",
),
),
known_limits=(
"The Committee WebUI covers the governed body, meeting, agenda, vote, and minute workspace; domain-specific deliberation panels can extend this surface.",
"Governed ballot execution is delegated to Voting when installed. The Committee-owned provider adapter remains a 0.1 compatibility path; Committee stores only aggregate result evidence.",
"Formal Decision persistence and Mandate resolution remain optional provider capabilities; the local projection is a bounded fallback.",
),
owned_concepts=(
"committee bodies",
"meeting and agenda context",
"deliberation and vote context",
),
non_owned_concepts=(
"formal decision lifecycle",
"mandate and jurisdiction lifecycle",
"generic approvals",
),
reference_packages=("product.service-to-decision",),
documentation=ModuleArchitectureDocumentation(
security=("docs/COMMITTEE_DOMAIN_BOUNDARY.md",),
operations=("docs/COMMITTEE_DOMAIN_BOUNDARY.md",),
),
)
def _decision_path(context: ModuleContext) -> CommitteeDecisionPath:
return CommitteeDecisionPath(context.registry)
def _workspace(context: ModuleContext) -> SqlCommitteeWorkspace:
del context
return SqlCommitteeWorkspace()
def _ballot_finalizer(context: ModuleContext) -> CommitteeBallotFinalizer:
return CommitteeBallotFinalizer(context.registry)
def _dsar_provider(_context: ModuleContext) -> CommitteeDsarProvider:
return CommitteeDsarProvider()
def _router(context: ModuleContext):
from govoplan_committee.backend.router import configure_registry, router
configure_registry(context.registry)
return router
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
current = session.query(committee_models.CommitteeWorkspaceRevision).filter(
committee_models.CommitteeWorkspaceRevision.tenant_id == tenant_id,
committee_models.CommitteeWorkspaceRevision.superseded_at.is_(None),
)
return {
"committee_bodies": current.filter(
committee_models.CommitteeWorkspaceRevision.object_kind == "body",
committee_models.CommitteeWorkspaceRevision.state == "active",
).count(),
"committee_meetings": current.filter(
committee_models.CommitteeWorkspaceRevision.object_kind == "meeting",
committee_models.CommitteeWorkspaceRevision.state.in_(
("scheduled", "open")
),
).count(),
}
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
@@ -34,9 +174,31 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
PERMISSIONS = (
_permission(READ_SCOPE, "View committee workspace", "Read committee records, configuration, and workflow context."),
_permission(WRITE_SCOPE, "Manage committee workspace", "Create and update committee records and workflow state."),
_permission(ADMIN_SCOPE, "Administer committee workspace", "Configure committee policies, templates, and tenant-level administration."),
_permission(
READ_SCOPE,
"View committee workspace",
"Read committee records, configuration, and workflow context.",
),
_permission(
WRITE_SCOPE,
"Manage committee workspace",
"Create and update committee records and workflow state.",
),
_permission(
ADMIN_SCOPE,
"Administer committee workspace",
"Configure committee policies, templates, and tenant-level administration.",
),
_permission(
BALLOT_SCOPE,
"Finalize provider ballots",
"Import a verifiable aggregate result from an installed external or secret ballot provider.",
),
_permission(
PROTECTED_READ_SCOPE,
"Read protected committee decisions",
"Read reasoning and protected institutional context from a Committee-owned fallback Decision projection.",
),
)
ROLE_TEMPLATES = (
@@ -44,7 +206,7 @@ ROLE_TEMPLATES = (
slug="committee_manager",
name="Committee manager",
description="Manage committee records and workflow state.",
permissions=(READ_SCOPE, WRITE_SCOPE),
permissions=(READ_SCOPE, WRITE_SCOPE, BALLOT_SCOPE, PROTECTED_READ_SCOPE),
),
RoleTemplate(
slug="committee_viewer",
@@ -58,15 +220,23 @@ DOCUMENTATION = (
DocumentationTopic(
id=f"{MODULE_ID}.module-boundary",
title=f"{MODULE_NAME} module boundary",
summary="Committee, board, council, and senate workflows for meetings, agendas, minutes, decisions, voting, and follow-up tasks.",
summary="Committee, board, council, and senate workflows for meetings, agendas, minutes, deliberation, voting, formal decision references, and follow-up tasks.",
body=(
"This repository is currently a platform module seed. It registers the domain boundary, "
"permission surface, role templates, and documentation metadata before runtime APIs, "
"database models, migrations, and WebUI routes are introduced."
"The persistent workspace keeps immutable bodies, meetings, agenda items, governed "
"vote results, minutes, and lifecycle events. The decision path constructs a "
"reconstructable formal Decision from that context, an effective Mandate resolution, "
"approval, versioned legal bases, evidence, reasoning, and requested or observed "
"effects. Committee does not own generic Mandate or Decision persistence; optional "
"providers resolve and record those objects when installed, while a protected local "
"projection preserves the bounded fallback. Provider-bound ballots are finalized "
"through adapter capabilities without retaining individual ballots. Voting-backed "
"results preserve sanitized provider assurance evidence and never promote the "
"uncertified local confidential reference provider to a certified profile."
),
layer="available",
documentation_types=("admin",),
audience=("operator", "module_admin", "product_owner"),
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"),
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
order=100,
related_modules=OPTIONAL_DEPENDENCIES,
links=(
@@ -77,9 +247,139 @@ DOCUMENTATION = (
),
),
metadata={
"kind": "workflow",
"seed": True,
"domain_objects": ['committee bodies', 'meeting agendas', 'minutes', 'decision records', 'votes', 'follow-up assignments'],
"first_slice": "Define committee body, meeting, agenda item, decision, vote, minute, and follow-up task references.",
"help_contexts": [
"committee.navigation",
"committee.workspace",
"committee.state.read-only",
"committee.state.permission-blocked",
],
"privacy_notes": [
"Committee lists expose only records authorized by the active tenant and permission context.",
"Provider-backed voting stores aggregate result evidence, not individual confidential ballots.",
"Protected decision reasoning requires the dedicated protected-read permission.",
],
"domain_objects": [
"committee bodies",
"meeting agendas",
"minutes",
"deliberation and vote context",
"formal decision references",
"votes",
"follow-up assignments",
],
"first_slice": "Persist committee body, meeting, agenda item, governed vote result, minute, and formal Decision references.",
"does_not_own": [
"generic formal decision authority, reasoning, effect, review, correction, or revocation lifecycle"
],
},
translations={
"de": {
"title": "Abgrenzung des Committee-Moduls",
"summary": (
"Arbeitsabläufe für Ausschüsse, Vorstände, Räte und Senate mit Sitzungen, Tagesordnungen, Protokollen, Beratung, Abstimmung, formellen Entscheidungsverweisen und Folgeaufgaben."
),
"body": (
"Der dauerhafte Arbeitsbereich bewahrt unveränderliche Gremien, Sitzungen, Tagesordnungspunkte, gesteuerte "
"Abstimmungsergebnisse, Protokolle und Lebenszyklusereignisse. Der Entscheidungspfad konstruiert daraus, aus "
"einer wirksamen Mandatsauflösung, Genehmigung, versionierten Rechtsgrundlagen, Nachweisen, Begründung und "
"beantragten oder beobachteten Wirkungen eine rekonstruierbare formelle Entscheidung. Committee führt keine "
"generische Mandats- oder Entscheidungspersistenz; optionale Anbieter lösen diese Objekte auf und speichern "
"sie, während eine geschützte lokale Projektion den begrenzten Rückfall bewahrt. An einen Anbieter gebundene "
"Stimmabgaben werden über Adapterfähigkeiten abgeschlossen, ohne einzelne Stimmen aufzubewahren. Von Voting "
"gelieferte Ergebnisse bewahren bereinigte Anbieterzusicherungen und stufen den nicht zertifizierten lokalen "
"vertraulichen Referenzanbieter niemals zu einem zertifizierten Profil hoch."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"privacy_notes": [
"Committee-Listen zeigen nur Datensätze, die im aktiven Mandanten- und Berechtigungskontext autorisiert sind.",
"Anbietergestützte Abstimmungen speichern aggregierte Ergebnisnachweise, keine einzelnen vertraulichen Stimmen.",
"Geschützte Entscheidungsbegründungen erfordern die besondere Berechtigung zum geschützten Lesen.",
]
}
},
),
DocumentationTopic(
id=f"{MODULE_ID}.reference.fields-and-consequences",
title="Committee fields and consequences",
summary="Field provenance, lifecycle restrictions, optional provider references, and evidence consequences for Committee records.",
body=(
"Body organization-unit references determine institutional context but do not grant access by themselves. "
"Meeting times establish agenda context. Subject, Decision, Approval, record, and ballot identifiers are stable "
"cross-module references and remain optional only where the current lifecycle allows it. State transitions create "
"new immutable revisions; terminal records cannot be edited in place. Closing a vote or accepting minutes requires "
"the corresponding governed evidence. Provider ballot finalization imports only an aggregate result and assurance "
"evidence. Change reasons are retained with every revision. Cancelling, withdrawing, or retiring a record stops future "
"work but does not erase existing revisions, decisions, minutes, or audit evidence."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
order=110,
related_modules=OPTIONAL_DEPENDENCIES,
links=(
DocumentationLink(
label="Committee domain boundary",
href="govoplan-committee/docs/COMMITTEE_DOMAIN_BOUNDARY.md",
kind="repository",
),
),
metadata={
"kind": "reference",
"seed": True,
"help_contexts": [
"committee.field.state",
"committee.field.organization-unit-reference",
"committee.field.subject-reference",
"committee.field.decision-reference",
"committee.field.approval-reference",
"committee.field.evidence-reference",
"committee.field.ballot-provider-reference",
"committee.field.change-reason",
"committee.action.change-state",
"committee.action.finalize-ballot",
],
"consequence_classes": {
"save_revision": "Creates a new immutable Committee record revision with its change reason.",
"change_lifecycle": "Changes which future actions remain possible; terminal states are immutable.",
"finalize_ballot": "Imports a governed aggregate result and evidence without retaining individual ballots.",
"cancel_or_retire": "Stops future use while retaining institutional and audit evidence.",
},
},
translations={
"de": {
"title": "Committee-Felder und Folgen",
"summary": (
"Feldherkunft, Lebenszyklusbeschränkungen, optionale Anbieterverweise und Nachweisfolgen für Committee-Datensätze."
),
"body": (
"Organisationseinheitsverweise eines Gremiums bestimmen den institutionellen Kontext, gewähren aber selbst "
"keinen Zugriff. Sitzungszeiten begründen den Tagesordnungskontext. Gegenstands-, Decision-, Approval-, "
"Records- und Abstimmungskennungen sind stabile modulübergreifende Verweise und bleiben nur optional, soweit "
"der aktuelle Lebenszyklus dies erlaubt. Zustandsübergänge erzeugen neue unveränderliche Revisionen; endgültige "
"Datensätze können nicht an Ort und Stelle bearbeitet werden. Der Abschluss einer Abstimmung oder die Annahme "
"eines Protokolls erfordert den zugehörigen gesteuerten Nachweis. Die anbietergebundene Finalisierung importiert "
"nur ein aggregiertes Ergebnis und Zusicherungsnachweise. Änderungsgründe bleiben mit jeder Revision erhalten. "
"Abbruch, Rücknahme oder Außerbetriebnahme stoppt künftige Arbeit, löscht aber keine bestehenden Revisionen, "
"Entscheidungen, Protokolle oder Prüfnachweise."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"save_revision": "Erzeugt eine neue unveränderliche Committee-Revision mit ihrem Änderungsgrund.",
"change_lifecycle": "Ändert die künftig möglichen Aktionen; endgültige Zustände sind unveränderlich.",
"finalize_ballot": "Importiert ein gesteuertes aggregiertes Ergebnis und Nachweise, ohne einzelne Stimmen aufzubewahren.",
"cancel_or_retire": "Stoppt die künftige Nutzung und bewahrt institutionelle sowie Prüfnachweise.",
}
}
},
),
)
@@ -90,10 +390,254 @@ manifest = ModuleManifest(
version=MODULE_VERSION,
dependencies=("access",),
optional_dependencies=OPTIONAL_DEPENDENCIES,
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
optional_capabilities=(
CAPABILITY_MANDATE_RESOLVER,
CAPABILITY_DECISION_REGISTRY,
CAPABILITY_VOTING_BALLOTS,
),
route_factory=_router,
nav_items=(
NavItem(
path="/committee",
label="Committee",
icon="gavel",
required_any=(READ_SCOPE,),
order=38,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/committee-webui",
routes=(
FrontendRoute(
path="/committee",
component="CommitteePage",
required_any=(READ_SCOPE,),
order=38,
),
),
nav_items=(
NavItem(
path="/committee",
label="Committee",
icon="gavel",
required_any=(READ_SCOPE,),
order=38,
),
),
product_areas=(
ProductAreaContribution(
id="meetings-decisions",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.meetings_decisions",
icon="calendar",
description="i18n:govoplan-core.product_area.meetings_decisions_description",
surface_ids=("committee.nav.committee", "committee.route.committee"),
order=50,
),
),
view_surfaces=(
ViewSurface(
id="committee.navigation",
module_id=MODULE_ID,
kind="navigation",
label="Committee navigation",
order=10,
),
ViewSurface(
id="committee.workspace",
module_id=MODULE_ID,
kind="route",
label="Committee workspace",
order=20,
),
),
),
provides_interfaces=(
ModuleInterfaceProvider(
name="committee.decision_path",
version="0.1.0",
),
ModuleInterfaceProvider(
name="committee.workspace",
version="0.1.0",
),
ModuleInterfaceProvider(
name=CAPABILITY_COMMITTEE_BALLOT_FINALIZER,
version="0.1.0",
),
ModuleInterfaceProvider(name=COMMITTEE_DSAR_CAPABILITY, version="0.1.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
name="mandates.resolution",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="decisions.formal_outcome",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="decisions.reconstruction",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_VOTING_BALLOTS,
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
),
capability_factories={
CAPABILITY_COMMITTEE_DECISION_PATH: _decision_path,
CAPABILITY_COMMITTEE_WORKSPACE: _workspace,
CAPABILITY_COMMITTEE_BALLOT_FINALIZER: _ballot_finalizer,
COMMITTEE_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
CAPABILITY_COMMITTEE_DECISION_PATH: CapabilityDocumentation(
label="Committee decision path",
summary="Builds a formal, evidence-backed Decision from governed committee context.",
contract_version="0.1.0",
),
CAPABILITY_COMMITTEE_WORKSPACE: CapabilityDocumentation(
label="Committee workspace",
summary="Persists versioned bodies, meetings, agenda items, vote results, minutes, and fallback Decision projections.",
contract_version="0.1.0",
),
CAPABILITY_COMMITTEE_BALLOT_FINALIZER: CapabilityDocumentation(
label="Committee ballot finalizer",
summary="Imports aggregate result evidence from provider-neutral ballot adapters without persisting individual ballots.",
contract_version="0.1.0",
),
COMMITTEE_DSAR_CAPABILITY: CapabilityDocumentation(
label="Committee data-subject request provider",
summary=(
"Exports minimized workspace, event, and fallback-Decision actor "
"attribution without deliberation or minute payloads."
),
contract_version="0.1.0",
),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
committee_models.CommitteeDecisionProjection,
committee_models.CommitteeWorkspaceEvent,
committee_models.CommitteeWorkspaceRevision,
label="Committee",
),
retirement_notes="Destructive retirement requires a database snapshot and removes Committee workspace revisions, lifecycle events, and local Decision projections.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
committee_models.CommitteeWorkspaceRevision,
committee_models.CommitteeWorkspaceEvent,
committee_models.CommitteeDecisionProjection,
label="Committee",
),
),
tenant_summary_providers=(_tenant_summary,),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
documentation=DOCUMENTATION,
documentation=(
DocumentationTopic(
id="committee.workspace-layout",
title="Committee workspace layout",
summary="Find workspace actions and read consistently arranged content.",
body="Reload and New body share the workspace-wide action bar at the upper right; Reload sits directly before New. Searching and selecting a body, meeting, or agenda item leaves those workspace actions in place. Item editing, ballots, and finalization remain scoped to the selected record. Disabled controls explain missing authority; administrators grant the existing management permissions rather than changing layouts.",
layer="static",
documentation_types=("user", "admin"),
audience=("user", "module_admin", "operator"),
order=5,
translations={"de": {
"title": "Gremien: Aufbau des Arbeitsbereichs",
"summary": "Arbeitsbereichsaktionen finden und einheitlich angeordnete Inhalte lesen.",
"body": "Neu laden und Neues Gremium stehen oben rechts in der gemeinsamen Arbeitsbereichsleiste; Neu laden steht unmittelbar vor Neu. Suche und Auswahl eines Gremiums, einer Sitzung oder eines Tagesordnungspunkts verändern diese Positionen nicht. Bearbeitung, Abstimmungen und Abschluss bleiben dem ausgewählten Datensatz zugeordnet. Deaktivierte Aktionen erklären fehlende Berechtigungen; Administratoren vergeben die bestehenden Verwaltungsrechte, statt das Layout zu ändern.",
}},
),
DocumentationTopic(
id="committee.data-subject-requests",
title="Committee data-subject requests",
summary=(
"Export minimized actor attribution from immutable Committee records "
"without disclosing deliberation or ballot content."
),
body=(
"Committee correlates exact account, membership, identity, or explicit "
"actor identifiers inside the active tenant. Optional object-kind, "
"object, or Decision identifiers only narrow an already verified actor "
"search. Workspace results contain stable object, parent, revision, "
"state, and timing context. Event and fallback-Decision results contain "
"only lifecycle attribution and stable meeting or agenda references. "
"Search text, minutes, deliberation payloads, individual ballots, "
"request hashes, idempotency values, and referenced provider records "
"are excluded and never traversed. All returned attribution is retained "
"as immutable institutional evidence."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("core", "decisions", "voting", "records"),
metadata={
"kind": "reference",
"help_contexts": [
"committee.workspace",
"privacy.data-subject-requests",
],
"consequence_classes": {
"export_actor_attribution": (
"Returns stable lifecycle context without institutional payloads."
),
"retain_committee_history": (
"Preserves immutable meeting and Decision accountability evidence."
),
},
},
translations={
"de": {
"title": "Datenschutzanfragen zu Committee",
"summary": (
"Minimierte Akteurszuordnungen aus unveränderlichen Committee-Datensätzen ausgeben, ohne Beratungs- oder Abstimmungsinhalte offenzulegen."
),
"body": (
"Committee gleicht innerhalb des aktiven Mandanten exakte Konto-, Mitgliedschafts-, Identitäts- oder "
"Akteurskennungen ab. Optionale Objektart-, Objekt- oder Decision-Kennungen schränken nur eine bereits "
"verifizierte Akteurssuche ein. Arbeitsbereichsergebnisse enthalten stabilen Objekt-, Eltern-, Revisions-, "
"Zustands- und Zeitkontext. Ereignis- und Rückfall-Decision-Ergebnisse enthalten ausschließlich "
"Lebenszykluszuordnungen sowie stabile Sitzungs- oder Tagesordnungsverweise. Suchtext, Protokolle, "
"Beratungsinhalte, einzelne Stimmen, Anfrageprüfsummen, Idempotenzwerte und referenzierte Anbieterdatensätze "
"werden ausgeschlossen und nie traversiert. Alle zurückgegebenen Zuordnungen bleiben unveränderliche "
"institutionelle Nachweise."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"export_actor_attribution": "Gibt stabilen Lebenszykluskontext ohne institutionelle Inhalte zurück.",
"retain_committee_history": "Bewahrt unveränderliche Verantwortungsnachweise zu Sitzungen und Decisions.",
}
}
},
),
*DOCUMENTATION,
),
architecture=ARCHITECTURE,
)
@@ -0,0 +1 @@
"""Committee Alembic revisions."""
@@ -0,0 +1 @@
"""Committee migration revisions."""
@@ -0,0 +1,99 @@
"""v0.1.8 Committee workspace baseline.
Revision ID: d8b9f0a1c2e3
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d8b9f0a1c2e3"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"committee_workspace_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("object_kind", sa.String(length=30), nullable=False),
sa.Column("object_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("parent_kind", sa.String(length=30), nullable=True),
sa.Column("parent_id", sa.String(length=255), nullable=True),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("search_text", sa.Text(), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("payload", 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(["previous_revision_id"], ["committee_workspace_revisions.id"], name=op.f("fk_committee_workspace_revisions_previous_revision_id_committee_workspace_revisions"), ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_committee_workspace_revisions")),
sa.UniqueConstraint("tenant_id", "object_kind", "object_id", "revision", name="uq_committee_workspace_revision"),
)
for column in ("tenant_id", "object_kind", "object_id", "previous_revision_id", "parent_kind", "parent_id", "state", "recorded_at", "superseded_at", "changed_by"):
op.create_index(op.f(f"ix_committee_workspace_revisions_{column}"), "committee_workspace_revisions", [column], unique=False)
op.create_index("ix_committee_workspace_current", "committee_workspace_revisions", ["tenant_id", "object_kind", "object_id", "superseded_at"], unique=False)
op.create_index("ix_committee_workspace_parent", "committee_workspace_revisions", ["tenant_id", "parent_kind", "parent_id", "object_kind", "state"], unique=False)
op.create_table(
"committee_workspace_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("object_kind", sa.String(length=30), nullable=False),
sa.Column("object_id", sa.String(length=255), nullable=False),
sa.Column("object_revision", sa.Integer(), nullable=False),
sa.Column("event_id", sa.String(length=36), nullable=False),
sa.Column("event_type", sa.String(length=120), 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("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_committee_workspace_events")),
sa.UniqueConstraint("tenant_id", "event_id", name="uq_committee_workspace_event"),
sa.UniqueConstraint("tenant_id", "idempotency_key", name="uq_committee_workspace_idempotency"),
)
for column in ("tenant_id", "object_kind", "object_id", "event_id", "event_type", "occurred_at", "actor_id"):
op.create_index(op.f(f"ix_committee_workspace_events_{column}"), "committee_workspace_events", [column], unique=False)
op.create_index("ix_committee_workspace_event_object", "committee_workspace_events", ["tenant_id", "object_kind", "object_id", "occurred_at"], unique=False)
op.create_table(
"committee_decision_projections",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("decision_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.String(length=120), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("meeting_id", sa.String(length=255), nullable=False),
sa.Column("agenda_item_id", sa.String(length=255), nullable=False),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("payload", 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(["previous_revision_id"], ["committee_decision_projections.id"], name=op.f("fk_committee_decision_projections_previous_revision_id_committee_decision_projections"), ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_committee_decision_projections")),
sa.UniqueConstraint("tenant_id", "decision_id", "revision", name="uq_committee_decision_projection_revision"),
)
for column in ("tenant_id", "decision_id", "previous_revision_id", "meeting_id", "agenda_item_id", "state", "recorded_at", "superseded_at", "changed_by"):
op.create_index(op.f(f"ix_committee_decision_projections_{column}"), "committee_decision_projections", [column], unique=False)
op.create_index("ix_committee_decision_projection_current", "committee_decision_projections", ["tenant_id", "decision_id", "superseded_at"], unique=False)
def downgrade() -> None:
op.drop_table("committee_decision_projections")
op.drop_table("committee_workspace_events")
op.drop_table("committee_workspace_revisions")
+270
View File
@@ -0,0 +1,270 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.institutional import (
InstitutionalContextError,
InstitutionalReference,
)
from govoplan_core.db.session import get_session
from govoplan_committee.backend.ballots import CommitteeBallotFinalizer
from govoplan_committee.backend.manifest import (
BALLOT_SCOPE,
PROTECTED_READ_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
)
from govoplan_committee.backend.schemas import (
CommitteeBallotFinalizeRequest,
CommitteeVotingFinalizeRequest,
CommitteeWorkspaceHistoryResponse,
CommitteeWorkspaceListResponse,
CommitteeWorkspaceWriteRequest,
)
from govoplan_committee.backend.workspace import (
CommitteeWorkspaceError,
CommitteeWorkspaceRecord,
get_local_decision,
get_workspace_object,
list_workspace_objects,
record_workspace_object,
workspace_history,
)
router = APIRouter(prefix="/committee", tags=["committee"])
_registry: object | None = None
def configure_registry(registry: object | None) -> None:
global _registry
_registry = registry
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()
code = (
409
if any(word in lowered for word in ("conflict", "stale", "already"))
else 400
)
return HTTPException(status_code=code, detail=message)
@router.get("/decision-projections/{decision_id}", response_model=dict[str, Any])
def api_get_local_decision(
decision_id: str,
revision: str | None = Query(default=None, max_length=120),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, PROTECTED_READ_SCOPE)
item = get_local_decision(
session,
principal,
decision_id=decision_id,
revision=revision,
)
if item is None:
raise HTTPException(
status_code=404, detail="Committee Decision projection not found"
)
return item.to_dict(include_protected=True)
@router.get("/workspace/{object_kind}", response_model=CommitteeWorkspaceListResponse)
def api_list_workspace(
object_kind: str,
parent_id: str | None = Query(default=None, max_length=255),
state: list[str] | None = Query(default=None),
query: str = "",
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),
) -> CommitteeWorkspaceListResponse:
_require(principal, READ_SCOPE)
try:
records, total = list_workspace_objects(
session,
principal,
object_kind=object_kind,
parent_id=parent_id,
states=state,
query=query,
offset=offset,
limit=limit,
)
except CommitteeWorkspaceError as exc:
raise _error(exc) from exc
return CommitteeWorkspaceListResponse(
records=[item.to_dict() for item in records],
total=total,
offset=offset,
limit=limit,
)
@router.post(
"/workspace/{object_kind}",
response_model=dict[str, Any],
status_code=status.HTTP_201_CREATED,
)
def api_record_workspace(
object_kind: str,
payload: CommitteeWorkspaceWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
raw = dict(payload.record)
if str(raw.get("object_kind") or "") != object_kind:
raise HTTPException(
status_code=400,
detail="Committee object kind in path and payload must match.",
)
try:
item = record_workspace_object(
session,
principal,
record=CommitteeWorkspaceRecord.from_mapping(raw),
idempotency_key=payload.idempotency_key,
expected_revision=payload.expected_revision,
)
session.commit()
except (CommitteeWorkspaceError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.post(
"/workspace/vote/{vote_id}/finalize-provider",
response_model=dict[str, Any],
)
def api_finalize_provider_vote(
vote_id: str,
payload: CommitteeBallotFinalizeRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, BALLOT_SCOPE)
try:
item = CommitteeBallotFinalizer(_registry).finalize(
session,
principal,
vote_id=vote_id,
provider_id=payload.provider_id,
provider_ballot_ref=payload.provider_ballot_ref,
approval_ref=InstitutionalReference.from_mapping(payload.approval_ref),
expected_revision=payload.expected_revision,
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
idempotency_key=payload.idempotency_key,
)
session.commit()
except LookupError as exc:
session.rollback()
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (CommitteeWorkspaceError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.post(
"/workspace/vote/{vote_id}/finalize-voting",
response_model=dict[str, Any],
)
def api_finalize_voting_vote(
vote_id: str,
payload: CommitteeVotingFinalizeRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, BALLOT_SCOPE)
try:
item = CommitteeBallotFinalizer(_registry).finalize_voting_ballot(
session,
principal,
vote_id=vote_id,
voting_ballot_id=payload.voting_ballot_id,
voting_expected_revision=payload.voting_expected_revision,
approval_ref=InstitutionalReference.from_mapping(payload.approval_ref),
expected_revision=payload.expected_revision,
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
idempotency_key=payload.idempotency_key,
)
session.commit()
except LookupError as exc:
session.rollback()
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (CommitteeWorkspaceError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.get("/workspace/{object_kind}/{object_id}", response_model=dict[str, Any])
def api_get_workspace(
object_kind: str,
object_id: str,
revision: int | None = Query(default=None, ge=1),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, READ_SCOPE)
try:
item = get_workspace_object(
session,
principal,
object_kind=object_kind,
object_id=object_id,
revision=revision,
)
except CommitteeWorkspaceError as exc:
raise _error(exc) from exc
if item is None:
raise HTTPException(status_code=404, detail="Committee object not found")
return item.to_dict()
@router.get(
"/workspace/{object_kind}/{object_id}/history",
response_model=CommitteeWorkspaceHistoryResponse,
)
def api_workspace_history(
object_kind: str,
object_id: str,
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> CommitteeWorkspaceHistoryResponse:
_require(principal, READ_SCOPE)
try:
revisions = workspace_history(
session,
principal,
object_kind=object_kind,
object_id=object_id,
limit=limit,
)
except CommitteeWorkspaceError as exc:
raise _error(exc) from exc
return CommitteeWorkspaceHistoryResponse(
revisions=[item.to_dict() for item in revisions]
)
__all__ = ["configure_registry", "router"]
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class CommitteeWorkspaceWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
record: dict[str, Any]
idempotency_key: str = Field(min_length=1, max_length=255)
expected_revision: int | None = Field(default=None, ge=1)
class CommitteeWorkspaceListResponse(BaseModel):
records: list[dict[str, Any]]
total: int
offset: int
limit: int
class CommitteeWorkspaceHistoryResponse(BaseModel):
revisions: list[dict[str, Any]]
class CommitteeBallotFinalizeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
provider_id: str = Field(min_length=1, max_length=80)
provider_ballot_ref: str = Field(min_length=1, max_length=255)
approval_ref: dict[str, Any]
expected_revision: int = Field(ge=1)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
class CommitteeVotingFinalizeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
voting_ballot_id: str = Field(min_length=1, max_length=255)
voting_expected_revision: int = Field(ge=1)
approval_ref: dict[str, Any]
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)
__all__ = [
"CommitteeWorkspaceHistoryResponse",
"CommitteeWorkspaceListResponse",
"CommitteeWorkspaceWriteRequest",
"CommitteeBallotFinalizeRequest",
"CommitteeVotingFinalizeRequest",
]
File diff suppressed because it is too large Load Diff
+240
View File
@@ -0,0 +1,240 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime, timedelta
from govoplan_core.core.institutional import (
CAPABILITY_DECISION_REGISTRY,
CAPABILITY_MANDATE_RESOLVER,
ActorRepresentationReference,
DecisionEffectReference,
EvidenceReference,
InformationGovernanceReference,
InstitutionalContextError,
InstitutionalReference,
LegalBasisReference,
MandateDefinition,
MandateResolution,
TemporalRevision,
)
from govoplan_committee.backend.decision_path import (
CommitteeDecisionPath,
CommitteeDecisionProposal,
)
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
def reference(kind: str, object_id: str) -> InstitutionalReference:
return InstitutionalReference(
kind=kind, # type: ignore[arg-type]
owner_module=(
"committee"
if kind in {"decision", "record"}
else "organizations"
if kind in {"organization_unit", "function", "jurisdiction"}
else "approvals"
if kind == "approval"
else "cases"
),
object_id=object_id,
tenant_id="tenant-1",
valid_at=NOW,
)
def mandate() -> MandateDefinition:
return MandateDefinition(
reference=InstitutionalReference(
kind="mandate",
owner_module="committee",
object_id="mandate-1",
tenant_id="tenant-1",
version="7",
valid_at=NOW,
),
temporal=TemporalRevision(
revision="7",
valid_from=NOW - timedelta(days=30),
valid_to=NOW + timedelta(days=30),
recorded_at=NOW - timedelta(days=40),
),
task_types=("committee.formal_decision",),
authority_types=("committee.resolution",),
organization_unit_refs=(reference("organization_unit", "board-1"),),
function_refs=(reference("function", "chair"),),
jurisdiction_refs=(reference("jurisdiction", "city-1"),),
legal_bases=(legal_basis(),),
evidence=(evidence("mandate-evidence"),),
)
def legal_basis() -> LegalBasisReference:
return LegalBasisReference(
kind="statute",
authority="Example council",
reference="rules:committee:12",
version="2026-01",
effective_from=NOW - timedelta(days=100),
)
def evidence(evidence_id: str) -> EvidenceReference:
return EvidenceReference(
kind="record",
owner_module="committee",
evidence_id=evidence_id,
tenant_id="tenant-1",
version="1",
checksum="sha256:example",
captured_at=NOW,
)
def proposal() -> CommitteeDecisionProposal:
return CommitteeDecisionProposal(
tenant_id="tenant-1",
decision_id="decision-1",
revision="1",
effective_at=NOW,
meeting_ref="meeting-4",
agenda_item_ref="item-7",
decision_type="committee.resolution",
subject_refs=(reference("case", "case-1"),),
organization_unit_ref=reference("organization_unit", "board-1"),
function_ref=reference("function", "chair"),
actor=ActorRepresentationReference(
tenant_id="tenant-1",
account_id="account-1",
identity_id="identity-1",
represented_function_ref=reference("function", "chair"),
mandate_ref=mandate().reference,
),
approval_refs=(reference("approval", "vote-approval-1"),),
fact_evidence=(evidence("minutes-4"),),
legal_bases=(legal_basis(),),
operative_result="The proposal is accepted.",
reasoning="The submitted evidence satisfies the applicable rule.",
case_ref=reference("case", "case-1"),
jurisdiction_refs=(reference("jurisdiction", "city-1"),),
record_refs=(reference("record", "minutes-4"),),
requested_effects=(
DecisionEffectReference(
effect_key="postbox.notify_parties",
state="requested",
resource_refs=("postbox:case-1",),
),
),
review_refs=("review:administrative-court",),
information_governance=InformationGovernanceReference(
classification="restricted",
purposes=("formal_decision",),
legal_basis_refs=("rules:committee:12@2026-01",),
disclosure_state="partly_disclosable",
),
assurance_level="human_reviewed_automation",
automation_preparation_refs=("dataflow:recommendation-1",),
)
class FakeDecisionRegistry:
def __init__(self) -> None:
self.recorded = None
def get_decision(self, session, principal, *, reference):
return self.recorded
def record_decision(self, session, principal, *, decision, expected_revision=None):
self.recorded = decision
return decision
class FakeMandateResolver:
def __init__(self, resolution: MandateResolution) -> None:
self.resolution = resolution
self.request = None
def resolve_mandate(self, session, principal, *, request):
self.request = request
return self.resolution
class FakeRegistry:
def __init__(self, capabilities: dict[str, object]) -> None:
self.capabilities = capabilities
def has_capability(self, name: str) -> bool:
return name in self.capabilities
def capability(self, name: str) -> object:
return self.capabilities[name]
class CommitteeDecisionPathTests(unittest.TestCase):
def test_decision_reconstructs_authority_approval_evidence_and_effects(self) -> None:
registry = FakeDecisionRegistry()
resolution = MandateResolution(
competent=True,
mandates=(mandate(),),
explanation="Chair is competent for this agenda item.",
)
mandate_resolver = FakeMandateResolver(resolution)
path = CommitteeDecisionPath(
FakeRegistry(
{
CAPABILITY_MANDATE_RESOLVER: mandate_resolver,
CAPABILITY_DECISION_REGISTRY: registry,
}
)
)
result = path.decide(None, None, proposal=proposal())
payload = result.reconstruction_payload()
self.assertTrue(result.persisted_by_decision_registry)
self.assertIs(registry.recorded, result.decision)
self.assertEqual("mandate-1", payload["mandate_ref"]["object_id"])
self.assertEqual(
"vote-approval-1",
payload["decision"]["authority_context"]["approval_refs"][0]["object_id"],
)
self.assertEqual(
"postbox.notify_parties",
payload["decision"]["requested_effects"][0]["effect_key"],
)
self.assertEqual(
"The submitted evidence satisfies the applicable rule.",
payload["decision"]["reasoning"],
)
self.assertEqual(
"city-1", mandate_resolver.request.jurisdiction_refs[0].object_id
)
self.assertEqual(
"human_reviewed_automation",
payload["decision"]["assurance_level"],
)
self.assertEqual(
["dataflow:recommendation-1"],
payload["decision"]["automation_preparation_refs"],
)
def test_decision_rejects_ambiguous_or_ineffective_mandate(self) -> None:
accepted = mandate()
with self.assertRaisesRegex(
InstitutionalContextError,
"exactly one effective active mandate",
):
CommitteeDecisionPath().decide(
None,
None,
proposal=proposal(),
mandate_resolution=MandateResolution(
competent=True,
mandates=(accepted, accepted),
),
)
if __name__ == "__main__":
unittest.main()
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
localizable_documentation_metadata_keys,
)
from govoplan_committee.backend.manifest import manifest
class CommitteeDocumentationTests(unittest.TestCase):
def test_german_reference_documentation_is_complete(self) -> None:
topics = manifest.documentation
by_id = {topic.id: topic for topic in topics}
self.assertEqual(len(topics), len(by_id), "Documentation topic IDs must be unique")
self.assertLessEqual(
{
"committee.module-boundary",
"committee.reference.fields-and-consequences",
"committee.data-subject-requests",
"committee.workspace-layout",
},
set(by_id),
)
self.assertLessEqual(
{"user", "admin"},
set(by_id["committee.workspace-layout"].documentation_types),
)
for topic in topics:
translation = topic.translations.get("de", {})
self.assertTrue(translation.get("title"), topic.id)
self.assertTrue(translation.get("summary"), topic.id)
self.assertTrue(translation.get("body"), topic.id)
if localizable_documentation_metadata_keys(topic):
self.assertEqual("1", topic.structured_translation_version, topic.id)
self.assertIn("de", topic.structured_translations, topic.id)
self.assertEqual((), documentation_structured_translation_issues(topic))
kinds = {topic.metadata.get("kind") for topic in topics}
self.assertIn("workflow", kinds)
self.assertIn("reference", kinds)
workflow = next(topic for topic in topics if topic.metadata.get("kind") == "workflow")
self.assertTrue(workflow.conditions)
if __name__ == "__main__":
unittest.main()
+258
View File
@@ -0,0 +1,258 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_committee.backend.db.models import (
CommitteeDecisionProjection,
CommitteeWorkspaceEvent,
CommitteeWorkspaceRevision,
)
from govoplan_committee.backend.dsar_provider import (
COMMITTEE_DSAR_CAPABILITY,
CommitteeDsarProvider,
)
from govoplan_committee.backend.manifest import manifest
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
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, 17, 0, tzinfo=UTC)
class _Registry:
def __init__(self, provider: CommitteeDsarProvider) -> None:
self.provider = provider
def capability_names(self):
return (COMMITTEE_DSAR_CAPABILITY,)
def capability_owner(self, name):
if name != COMMITTEE_DSAR_CAPABILITY:
raise KeyError(name)
return "committee"
def tenant_entitlement_resolver(self):
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type("State", (), {"effective_modules": ("committee",)})()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
if name != COMMITTEE_DSAR_CAPABILITY:
raise KeyError(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "committee"})(),)
class CommitteeDsarProviderTests(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 = CommitteeDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
self.session.add_all(
(
CommitteeWorkspaceRevision(
id="workspace-revision-1",
tenant_id="tenant-1",
object_kind="meeting",
object_id="meeting-1",
revision=1,
parent_kind="body",
parent_id="body-1",
state="scheduled",
title="Private meeting title do not export",
search_text="private search text do not export",
recorded_at=NOW,
payload={"minutes": "private minutes do not export"},
changed_by="account-1",
),
CommitteeWorkspaceRevision(
id="workspace-revision-other",
tenant_id="tenant-1",
object_kind="meeting",
object_id="meeting-other",
revision=1,
state="scheduled",
title="Other meeting",
search_text="other search text",
recorded_at=NOW,
payload={"private": "other payload"},
changed_by="account-other",
),
)
)
self.session.add(
CommitteeWorkspaceEvent(
id="event-1",
tenant_id="tenant-1",
object_kind="meeting",
object_id="meeting-1",
object_revision=1,
event_id="event-external-1",
event_type="meeting.scheduled",
occurred_at=NOW,
actor_id="account-1",
idempotency_key="event-idempotency-do-not-export",
request_sha256="event-request-hash-do-not-export",
payload={"secret": "event-payload-do-not-export"},
)
)
self.session.add(
CommitteeDecisionProjection(
id="decision-projection-1",
tenant_id="tenant-1",
decision_id="decision-1",
revision="1",
meeting_id="meeting-1",
agenda_item_id="agenda-1",
state="effective",
recorded_at=NOW,
payload={
"reasoning": "decision-reasoning-do-not-export",
"ballot": "individual-ballot-do-not-export",
},
changed_by="account-1",
)
)
@staticmethod
def _subject() -> DsarSubjectRef:
return DsarSubjectRef(account_id="account-1")
def test_search_exports_minimized_committee_attribution(self) -> None:
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self._subject()
)
self.assertEqual(
{
"committee_workspace_actor_attribution",
"committee_event_actor_attribution",
"committee_decision_actor_attribution",
},
{record.resource_type for record in records},
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("meeting-1", exported)
self.assertIn("agenda-1", exported)
for excluded in (
"Private meeting title do not export",
"private search text do not export",
"private minutes do not export",
"event-idempotency-do-not-export",
"event-request-hash-do-not-export",
"event-payload-do-not-export",
"decision-reasoning-do-not-export",
"individual-ballot-do-not-export",
"meeting-other",
):
self.assertNotIn(excluded, exported)
def test_object_narrowing_and_actor_conflicts_fail_closed(self) -> None:
narrowed = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={
"committee.object_kind": "meeting",
"committee.object": "meeting-1",
},
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"committee.actor": "account-other"},
),
)
object_only = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"committee.object": "meeting-1"}
),
)
self.assertEqual(
{
"committee_workspace_actor_attribution",
"committee_event_actor_attribution",
"committee_decision_actor_attribution",
},
{record.resource_type for record in narrowed},
)
self.assertEqual((), conflict)
self.assertEqual((), object_only)
def test_erasure_is_retain_only(self) -> None:
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self._subject()
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
records=records,
)
self.assertTrue(all(action.kind == "retain" for action in actions))
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
actions=actions,
request_id="dsar-committee-1",
)
self.assertTrue(all(result.status == "blocked" for result in results))
self.assertEqual(2, self.session.query(CommitteeWorkspaceRevision).count())
def test_manifest_and_core_workflow_discover_provider(self) -> None:
self.assertIn(COMMITTEE_DSAR_CAPABILITY, manifest.capability_factories)
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-COMMITTEE-1",
request_kind="access",
subject=self._subject(),
purpose="Committee attribution access request",
legal_basis=None,
due_at=None,
requested_by_account_id="operator-1",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=row.resource_revision,
)
self.assertEqual("searched", row.status)
self.assertEqual(3, row.search_result["record_count"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,38 @@
from __future__ import annotations
import unittest
from govoplan_committee.backend.manifest import manifest
class CommitteeInterfaceDocumentationContractTests(unittest.TestCase):
def test_route_and_surfaces_remain_declared(self) -> None:
frontend = manifest.frontend
self.assertIsNotNone(frontend)
self.assertEqual(
{"/committee"},
{route.path for route in frontend.routes}, # type: ignore[union-attr]
)
self.assertEqual(
{"committee.navigation", "committee.workspace"},
{surface.id for surface in frontend.view_surfaces}, # type: ignore[union-attr]
)
def test_topics_publish_help_privacy_and_consequence_metadata(self) -> None:
topics = {topic.id: topic for topic in manifest.documentation}
self.assertIn("committee.module-boundary", topics)
self.assertIn("committee.reference.fields-and-consequences", topics)
guide = topics["committee.module-boundary"]
self.assertIn("committee.workspace", guide.metadata["help_contexts"])
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
reference = topics["committee.reference.fields-and-consequences"]
self.assertIn("committee.field.state", reference.metadata["help_contexts"])
self.assertIn("committee.action.finalize-ballot", reference.metadata["help_contexts"])
self.assertIn("change_lifecycle", reference.metadata["consequence_classes"])
self.assertIn("finalize_ballot", reference.metadata["consequence_classes"])
if __name__ == "__main__":
unittest.main()
+22 -5
View File
@@ -2,7 +2,14 @@ from __future__ import annotations
import unittest
from govoplan_committee.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest
from govoplan_committee.backend.manifest import (
ADMIN_SCOPE,
BALLOT_SCOPE,
PROTECTED_READ_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
get_manifest,
)
class ManifestSeedTests(unittest.TestCase):
@@ -12,12 +19,22 @@ class ManifestSeedTests(unittest.TestCase):
self.assertEqual(manifest.id, "committee")
self.assertEqual(manifest.name, "Committee")
self.assertEqual(manifest.dependencies, ("access",))
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
self.assertEqual(
{permission.scope for permission in manifest.permissions},
{READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE, BALLOT_SCOPE, PROTECTED_READ_SCOPE},
)
self.assertEqual({role.slug for role in manifest.role_templates}, {"committee_manager", "committee_viewer"})
self.assertTrue(manifest.documentation)
self.assertIsNone(manifest.route_factory)
self.assertIsNone(manifest.migration_spec)
self.assertIsNone(manifest.frontend)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend)
self.assertEqual("@govoplan/committee-webui", manifest.frontend.package_name)
self.assertEqual("/committee", manifest.frontend.routes[0].path)
self.assertEqual(
{"committee.navigation", "committee.workspace"},
{surface.id for surface in manifest.frontend.view_surfaces},
)
self.assertIn("committee.workspace", manifest.capability_factories)
if __name__ == "__main__":
+42
View File
@@ -0,0 +1,42 @@
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_committee.backend.manifest import get_manifest
from govoplan_core.db.migrations import migrate_database
class CommitteeMigrationTests(unittest.TestCase):
def test_fresh_migration_creates_committee_workspace_and_head(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-committee-migration-") as directory:
url = f"sqlite:///{Path(directory) / 'committee.db'}"
migrate_database(
database_url=url,
enabled_modules=("committee",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
self.assertTrue(
{
"committee_decision_projections",
"committee_workspace_events",
"committee_workspace_revisions",
}.issubset(inspect(engine).get_table_names())
)
with engine.connect() as connection:
self.assertIn(
"d8b9f0a1c2e3",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+755
View File
@@ -0,0 +1,755 @@
from __future__ import annotations
from dataclasses import dataclass, replace
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 (
ActorRepresentationReference,
EvidenceReference,
InformationGovernanceReference,
InstitutionalReference,
LegalBasisReference,
MandateDefinition,
MandateResolution,
TemporalRevision,
)
from govoplan_core.core.voting import CAPABILITY_VOTING_BALLOTS, VotingResult
from govoplan_committee.backend.db.models import (
CommitteeDecisionProjection,
CommitteeWorkspaceEvent,
CommitteeWorkspaceRevision,
)
from govoplan_committee.backend.ballots import (
BallotFinalizationResult,
CommitteeBallotFinalizer,
ballot_adapter_capability,
)
from govoplan_committee.backend.decision_path import (
CommitteeDecisionPath,
CommitteeDecisionProposal,
)
from govoplan_committee.backend.workspace import (
CommitteeWorkspaceError,
CommitteeWorkspaceRecord,
SqlCommitteeWorkspace,
get_local_decision,
list_workspace_objects,
record_workspace_object,
workspace_history,
)
NOW = datetime(2026, 8, 1, 13, 0, tzinfo=UTC)
@dataclass
class Principal:
tenant_id: str = "tenant-1"
account_id: str = "account-1"
class BallotAdapter:
def finalize_ballot(self, session, principal, *, request):
return BallotFinalizationResult(
provider_id=request.provider_id,
provider_ballot_ref=request.provider_ballot_ref,
counts={"yes": 3, "no": 1},
cast_count=4,
quorum_met=True,
receipt_ref="receipt:secret-ballot-1",
result_sha256="a" * 64,
evidence=(evidence("secret-ballot-result"),),
)
class BallotRegistry:
def __init__(self) -> None:
self.name = ballot_adapter_capability("secure-vote")
self.adapter = BallotAdapter()
def has_capability(self, name: str) -> bool:
return name == self.name
def capability(self, name: str) -> object:
if name != self.name:
raise KeyError(name)
return self.adapter
class VotingBallots:
def create_ballot(self, *args, **kwargs):
raise NotImplementedError
def get_ballot(self, session, principal, *, ballot_id):
return {
"id": ballot_id,
"revision": 2,
"state": "open",
"assurance_profile": "confidential",
"provider_id": "local_confidential",
"provider_ballot_ref": "local-confidential:ballot-1",
"context": {"module": "committee", "resource_id": "vote-voting"},
}
def open_ballot(self, *args, **kwargs):
raise NotImplementedError
def cast_ballot(self, *args, **kwargs):
raise NotImplementedError
def close_ballot(
self, session, principal, *, ballot_id, expected_revision, idempotency_key
):
if expected_revision != 2:
raise AssertionError("unexpected Voting revision")
return VotingResult(
ballot_id=ballot_id,
revision=3,
counts={"yes": 2, "no": 1},
weighted_counts={"yes": 3, "no": 1},
cast_count=3,
cast_weight=4,
eligible_count=4,
eligible_weight=5,
quorum_met=True,
threshold_met=True,
winning_options=("yes",),
result_sha256="c" * 64,
evidence=(
{
"kind": "reference_provider_result",
"provider_id": "local_confidential",
"certified": False,
},
),
)
def certify_ballot(self, *args, **kwargs):
raise NotImplementedError
class VotingRegistry:
def __init__(self) -> None:
self.provider = VotingBallots()
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_VOTING_BALLOTS
def capability(self, name: str) -> object:
if name != CAPABILITY_VOTING_BALLOTS:
raise KeyError(name)
return self.provider
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 evidence(evidence_id: str) -> EvidenceReference:
return EvidenceReference(
kind="record",
owner_module="records",
evidence_id=evidence_id,
tenant_id="tenant-1",
version="1",
captured_at=NOW,
)
def workspace_record(
kind: str,
object_id: str,
*,
state: str,
attributes: dict[str, object],
parent_id: str | None = None,
revision: int = 1,
evidence_refs: tuple[EvidenceReference, ...] = (),
) -> CommitteeWorkspaceRecord:
return CommitteeWorkspaceRecord(
tenant_id="tenant-1",
object_kind=kind, # type: ignore[arg-type]
object_id=object_id,
revision=revision,
state=state,
title=f"{kind.replace('_', ' ').title()} {object_id}",
parent_id=parent_id,
recorded_at=NOW + timedelta(minutes=revision - 1),
change_reason="Governed Committee update.",
attributes=attributes,
evidence=evidence_refs,
)
def mandate() -> MandateDefinition:
return MandateDefinition(
reference=ref("mandate", "mandate-1", "mandates"),
temporal=TemporalRevision(
revision="1",
valid_from=NOW - timedelta(days=1),
recorded_at=NOW - timedelta(days=2),
),
task_types=("committee.formal_decision",),
authority_types=("committee.resolution",),
organization_unit_refs=(ref("organization_unit", "board-1", "organizations"),),
function_refs=(ref("function", "chair", "organizations"),),
jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),),
legal_bases=(legal_basis(),),
evidence=(evidence("mandate-proof"),),
)
def legal_basis() -> LegalBasisReference:
return LegalBasisReference(
kind="statute",
authority="Example council",
reference="committee-rules:12",
version="2026-01",
)
def proposal() -> CommitteeDecisionProposal:
function_ref = ref("function", "chair", "organizations")
return CommitteeDecisionProposal(
tenant_id="tenant-1",
decision_id="decision-1",
revision="1",
effective_at=NOW,
meeting_ref="meeting-1",
agenda_item_ref="agenda-1",
decision_type="committee.resolution",
subject_refs=(ref("case", "case-1", "cases"),),
organization_unit_ref=ref("organization_unit", "board-1", "organizations"),
function_ref=function_ref,
actor=ActorRepresentationReference(
tenant_id="tenant-1",
account_id="account-1",
identity_id="identity-1",
represented_function_ref=function_ref,
mandate_ref=mandate().reference,
),
approval_refs=(ref("approval", "vote-1", "approvals"),),
fact_evidence=(evidence("vote-result-1"),),
legal_bases=(legal_basis(),),
operative_result="The application is approved.",
reasoning="The evidence and vote meet the applicable rules.",
jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),),
information_governance=InformationGovernanceReference(
classification="restricted",
purposes=("formal_decision",),
),
)
class CommitteeWorkspaceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
for table in (
CommitteeWorkspaceRevision.__table__,
CommitteeWorkspaceEvent.__table__,
CommitteeDecisionProjection.__table__,
):
table.create(self.engine)
self.session = Session(self.engine)
self.principal = Principal()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_full_workspace_lifecycle_and_local_decision_projection(self) -> None:
bus = EventBus()
events = []
bus.subscribe("*", events.append)
with event_bus_context(bus):
body = workspace_record(
"body",
"body-1",
state="active",
attributes={
"organization_unit_ref": ref(
"organization_unit",
"board-1",
"organizations",
).to_dict(),
"function_refs": [
ref("function", "chair", "organizations").to_dict()
],
"quorum": {"minimum_count": 3},
},
)
record_workspace_object(
self.session,
self.principal,
record=body,
idempotency_key="body-create",
)
meeting = workspace_record(
"meeting",
"meeting-1",
state="scheduled",
parent_id="body-1",
attributes={
"starts_at": NOW.isoformat(),
"ends_at": (NOW + timedelta(hours=2)).isoformat(),
"location": "Council chamber",
},
)
record_workspace_object(
self.session,
self.principal,
record=meeting,
idempotency_key="meeting-create",
)
agenda = workspace_record(
"agenda_item",
"agenda-1",
state="scheduled",
parent_id="meeting-1",
attributes={
"position": 1,
"subject_refs": [ref("case", "case-1", "cases").to_dict()],
},
)
record_workspace_object(
self.session,
self.principal,
record=agenda,
idempotency_key="agenda-create",
)
deliberating = replace(
agenda,
revision=2,
state="deliberating",
recorded_at=NOW + timedelta(minutes=1),
)
record_workspace_object(
self.session,
self.principal,
record=deliberating,
expected_revision=1,
idempotency_key="agenda-deliberating",
)
vote = workspace_record(
"vote",
"vote-1",
state="open",
parent_id="agenda-1",
attributes={
"method": "recorded",
"choices": ["yes", "no", "abstain"],
"eligible_count": 5,
"cast_count": 0,
},
)
record_workspace_object(
self.session,
self.principal,
record=vote,
idempotency_key="vote-open",
)
closed_vote = replace(
vote,
revision=2,
state="closed",
recorded_at=NOW + timedelta(minutes=1),
attributes={
**dict(vote.attributes),
"cast_count": 5,
"counts": {"yes": 4, "no": 1, "abstain": 0},
"quorum_met": True,
"approval_ref": ref(
"approval",
"vote-1",
"approvals",
).to_dict(),
},
evidence=(evidence("vote-result-1"),),
)
record_workspace_object(
self.session,
self.principal,
record=closed_vote,
expected_revision=1,
idempotency_key="vote-close",
)
decision = (
CommitteeDecisionPath()
.decide(
self.session,
self.principal,
proposal=proposal(),
mandate_resolution=MandateResolution(
competent=True,
mandates=(mandate(),),
),
)
.decision
)
workspace = SqlCommitteeWorkspace()
projected = workspace.record_local_decision(
self.session,
self.principal,
decision=decision,
meeting_id="meeting-1",
agenda_item_id="agenda-1",
)
decided = replace(
deliberating,
revision=3,
state="decided",
recorded_at=NOW + timedelta(minutes=2),
attributes={
**dict(deliberating.attributes),
"decision_ref": projected.reference.to_dict(),
},
)
record_workspace_object(
self.session,
self.principal,
record=decided,
expected_revision=2,
idempotency_key="agenda-decide",
)
open_meeting = replace(
meeting,
revision=2,
state="open",
recorded_at=NOW + timedelta(minutes=1),
)
record_workspace_object(
self.session,
self.principal,
record=open_meeting,
expected_revision=1,
idempotency_key="meeting-open",
)
closed_meeting = replace(
open_meeting,
revision=3,
state="closed",
recorded_at=NOW + timedelta(hours=2),
)
record_workspace_object(
self.session,
self.principal,
record=closed_meeting,
expected_revision=2,
idempotency_key="meeting-close",
)
minute = workspace_record(
"minute",
"minute-1",
state="accepted",
parent_id="meeting-1",
attributes={
"content_ref": ref("record", "minutes-1", "records").to_dict(),
"approval_ref": ref(
"approval", "minutes-ok", "approvals"
).to_dict(),
},
evidence_refs=(evidence("minutes-signature"),),
)
record_workspace_object(
self.session,
self.principal,
record=minute,
idempotency_key="minute-accept",
)
self.assertEqual([], events)
self.session.commit()
self.assertEqual(
"decision-1",
get_local_decision(
self.session,
self.principal,
decision_id="decision-1",
).reference.object_id,
)
agenda_history = workspace_history(
self.session,
self.principal,
object_kind="agenda_item",
object_id="agenda-1",
)
self.assertEqual([3, 2, 1], [item.revision for item in agenda_history])
meetings, total = list_workspace_objects(
self.session,
self.principal,
object_kind="meeting",
states=("closed",),
)
self.assertEqual(1, total)
self.assertEqual("meeting-1", meetings[0].object_id)
self.assertIn("committee.decision.projected", [item.type for item in events])
def test_parent_state_occ_replay_and_tenant_boundaries_fail_closed(self) -> None:
body = workspace_record(
"body",
"body-1",
state="active",
attributes={
"organization_unit_ref": ref(
"organization_unit",
"board-1",
"organizations",
).to_dict(),
},
)
first = record_workspace_object(
self.session,
self.principal,
record=body,
idempotency_key="body-1",
)
replay = record_workspace_object(
self.session,
self.principal,
record=body,
idempotency_key="body-1",
)
self.assertEqual(first, replay)
with self.assertRaisesRegex(CommitteeWorkspaceError, "idempotency conflict"):
record_workspace_object(
self.session,
self.principal,
record=replace(body, title="Changed"),
idempotency_key="body-1",
)
with self.assertRaisesRegex(CommitteeWorkspaceError, "existing body"):
record_workspace_object(
self.session,
self.principal,
record=workspace_record(
"meeting",
"meeting-orphan",
state="scheduled",
parent_id="missing",
attributes={
"starts_at": NOW.isoformat(),
"ends_at": (NOW + timedelta(hours=1)).isoformat(),
},
),
idempotency_key="meeting-orphan",
)
with self.assertRaisesRegex(CommitteeWorkspaceError, "stale"):
record_workspace_object(
self.session,
self.principal,
record=replace(body, revision=2),
expected_revision=99,
idempotency_key="body-stale",
)
with self.assertRaisesRegex(CommitteeWorkspaceError, "cross tenants"):
record_workspace_object(
self.session,
Principal(tenant_id="tenant-2"),
record=body,
idempotency_key="cross-tenant",
)
def test_provider_ballot_finalization_persists_only_aggregate_evidence(
self,
) -> None:
records = (
workspace_record(
"body",
"body-ballot",
state="active",
attributes={
"organization_unit_ref": ref(
"organization_unit",
"board-1",
"organizations",
).to_dict(),
"function_refs": [],
},
),
workspace_record(
"meeting",
"meeting-ballot",
state="open",
parent_id="body-ballot",
attributes={
"starts_at": NOW.isoformat(),
"ends_at": (NOW + timedelta(hours=2)).isoformat(),
},
),
workspace_record(
"agenda_item",
"agenda-ballot",
state="deliberating",
parent_id="meeting-ballot",
attributes={
"position": 1,
"subject_refs": [ref("case", "case-1", "cases").to_dict()],
},
),
workspace_record(
"vote",
"vote-provider",
state="open",
parent_id="agenda-ballot",
attributes={
"method": "secret",
"provider_id": "secure-vote",
"choices": ["yes", "no"],
"eligible_count": 5,
"cast_count": 0,
},
),
)
for index, item in enumerate(records):
record_workspace_object(
self.session,
self.principal,
record=item,
idempotency_key=f"ballot-setup-{index}",
)
self.session.commit()
with self.assertRaisesRegex(CommitteeWorkspaceError, "ballot adapter"):
record_workspace_object(
self.session,
self.principal,
record=replace(
records[-1],
revision=2,
state="closed",
recorded_at=NOW + timedelta(minutes=4),
evidence=(evidence("manual-result"),),
attributes={
**records[-1].attributes,
"counts": {"yes": 3, "no": 1},
"cast_count": 4,
"quorum_met": True,
"provider_receipt_ref": "forged-receipt",
"provider_result_sha256": "b" * 64,
"approval_ref": ref(
"approval", "vote-approval", "approvals"
).to_dict(),
},
),
expected_revision=1,
idempotency_key="ballot-manual-close",
)
closed = CommitteeBallotFinalizer(BallotRegistry()).finalize(
self.session,
self.principal,
vote_id="vote-provider",
provider_id="secure-vote",
provider_ballot_ref="external-ballot-7",
approval_ref=ref("approval", "vote-approval", "approvals"),
expected_revision=1,
recorded_at=NOW + timedelta(minutes=5),
change_reason="Imported verified secret ballot aggregate.",
idempotency_key="ballot-finalize-1",
)
self.session.commit()
self.assertEqual("closed", closed.state)
self.assertEqual({"yes": 3, "no": 1}, closed.attributes["counts"])
self.assertEqual("a" * 64, closed.attributes["provider_result_sha256"])
self.assertNotIn("ballots", closed.attributes)
self.assertEqual("secret-ballot-result", closed.evidence[0].evidence_id)
def test_voting_ballot_finalization_projects_only_aggregate_result(self) -> None:
records = (
workspace_record(
"body",
"body-voting",
state="active",
attributes={
"organization_unit_ref": ref(
"organization_unit", "board-1", "organizations"
).to_dict(),
"function_refs": [],
},
),
workspace_record(
"meeting",
"meeting-voting",
state="open",
parent_id="body-voting",
attributes={
"starts_at": NOW.isoformat(),
"ends_at": (NOW + timedelta(hours=2)).isoformat(),
},
),
workspace_record(
"agenda_item",
"agenda-voting",
state="deliberating",
parent_id="meeting-voting",
attributes={
"position": 1,
"subject_refs": [ref("case", "case-1", "cases").to_dict()],
},
),
workspace_record(
"vote",
"vote-voting",
state="open",
parent_id="agenda-voting",
attributes={
"method": "recorded",
"voting_ballot_id": "ballot-1",
"choices": ["yes", "no"],
"eligible_count": 4,
"cast_count": 0,
},
),
)
for index, item in enumerate(records):
record_workspace_object(
self.session,
self.principal,
record=item,
idempotency_key=f"voting-setup-{index}",
)
self.session.commit()
closed = CommitteeBallotFinalizer(VotingRegistry()).finalize_voting_ballot(
self.session,
self.principal,
vote_id="vote-voting",
voting_ballot_id="ballot-1",
voting_expected_revision=2,
approval_ref=ref("approval", "vote-approval", "approvals"),
expected_revision=1,
recorded_at=NOW + timedelta(minutes=5),
change_reason="Closed the governed Voting ballot.",
idempotency_key="voting-finalize-1",
)
self.session.commit()
self.assertEqual("closed", closed.state)
self.assertEqual({"yes": 2, "no": 1}, closed.attributes["counts"])
self.assertEqual("c" * 64, closed.attributes["voting_result_sha256"])
self.assertEqual(
"confidential",
closed.attributes["voting_assurance_profile"],
)
self.assertFalse(closed.attributes["voting_provider_evidence"][0]["certified"])
self.assertEqual("voting", closed.evidence[0].owner_module)
self.assertNotIn("selections", closed.attributes)
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@govoplan/committee-webui",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/committee.css": "./src/styles/committee.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"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
}
}
}
+152
View File
@@ -0,0 +1,152 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type CommitteeObjectKind = "body" | "meeting" | "agenda_item" | "vote" | "minute";
export type CommitteeRecord = {
tenant_id: string;
object_kind: CommitteeObjectKind;
object_id: string;
revision: number;
state: string;
title: string;
parent_id?: string | null;
recorded_at: string;
change_reason: string;
attributes: Record<string, unknown>;
context?: Record<string, unknown> | null;
evidence: Array<Record<string, unknown>>;
record_refs: Array<Record<string, unknown>>;
};
export type CommitteeRecordList = {
records: CommitteeRecord[];
total: number;
offset: number;
limit: number;
};
export function listCommitteeRecords(
settings: ApiSettings,
kind: CommitteeObjectKind,
options: {
parentId?: string;
query?: string;
states?: string[];
limit?: number;
} = {},
signal?: AbortSignal
): Promise<CommitteeRecordList> {
return apiFetch<CommitteeRecordList>(
settings,
apiPath(`/api/v1/committee/workspace/${kind}`, {
parent_id: options.parentId,
query: options.query,
state: options.states,
limit: options.limit ?? 200
}),
{ signal }
);
}
export function saveCommitteeRecord(
settings: ApiSettings,
record: CommitteeRecord,
expectedRevision?: number
): Promise<CommitteeRecord> {
return apiFetch<CommitteeRecord>(
settings,
`/api/v1/committee/workspace/${record.object_kind}`,
{
method: "POST",
body: JSON.stringify({
record,
idempotency_key: crypto.randomUUID(),
expected_revision: expectedRevision
})
}
);
}
export function committeeRecordHistory(
settings: ApiSettings,
record: CommitteeRecord,
signal?: AbortSignal
): Promise<{ revisions: CommitteeRecord[] }> {
return apiFetch(
settings,
`/api/v1/committee/workspace/${record.object_kind}/${encodeURIComponent(record.object_id)}/history`,
{ signal }
);
}
export function finalizeProviderBallot(
settings: ApiSettings,
record: CommitteeRecord,
input: {
providerBallotRef: string;
approvalId: string;
changeReason: string;
}
): Promise<CommitteeRecord> {
const providerId = String(record.attributes.provider_id ?? "").trim();
return apiFetch<CommitteeRecord>(
settings,
`/api/v1/committee/workspace/vote/${encodeURIComponent(record.object_id)}/finalize-provider`,
{
method: "POST",
body: JSON.stringify({
provider_id: providerId,
provider_ballot_ref: input.providerBallotRef.trim(),
approval_ref: {
kind: "approval",
owner_module: "approvals",
object_id: input.approvalId.trim(),
tenant_id: record.tenant_id,
version: "1"
},
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: input.changeReason.trim(),
idempotency_key: crypto.randomUUID()
})
}
);
}
export async function finalizeVotingBallot(
settings: ApiSettings,
record: CommitteeRecord,
input: {
approvalId: string;
changeReason: string;
}
): Promise<CommitteeRecord> {
const ballotId = String(record.attributes.voting_ballot_id ?? "").trim();
const ballot = await apiFetch<{ revision: number }>(
settings,
`/api/v1/voting/${encodeURIComponent(ballotId)}`
);
return apiFetch<CommitteeRecord>(
settings,
`/api/v1/committee/workspace/vote/${encodeURIComponent(record.object_id)}/finalize-voting`,
{
method: "POST",
body: JSON.stringify({
voting_ballot_id: ballotId,
voting_expected_revision: ballot.revision,
approval_ref: {
kind: "approval",
owner_module: "approvals",
object_id: input.approvalId.trim(),
tenant_id: record.tenant_id,
version: "1"
},
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: input.changeReason.trim(),
idempotency_key: crypto.randomUUID()
})
}
);
}
@@ -0,0 +1,156 @@
import { useEffect, useState } from "react";
import {
Button,
ConfirmDialog,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FormField,
i18nMessage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings
} from "@govoplan/core-webui";
import {
finalizeProviderBallot,
finalizeVotingBallot,
type CommitteeRecord
} from "../../api/committee";
import {
COMMITTEE_FIELD_DOCUMENTATION,
COMMITTEE_INTERFACE_I18N
} from "./interfacePatterns";
export default function CommitteeBallotDialog({
settings,
record,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CommitteeRecord;
open: boolean;
onClose: () => void;
onSaved: (record: CommitteeRecord) => void;
}) {
const [providerBallotRef, setProviderBallotRef] = useState("");
const [approvalId, setApprovalId] = useState("");
const [changeReason, setChangeReason] = useState("Imported verified ballot aggregate.");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirming, setConfirming] = useState(false);
const { requestDiscard } = useUnsavedChanges();
useEffect(() => {
if (!open) return;
setProviderBallotRef("");
setApprovalId("");
setChangeReason("Imported verified ballot aggregate.");
setError("");
setConfirming(false);
}, [open, record.object_id]);
const dirty = Boolean(providerBallotRef || approvalId || changeReason !== "Imported verified ballot aggregate.");
async function finalize(closeAfter = true): Promise<boolean> {
setBusy(true);
setError("");
try {
const saved = votingBallotId
? await finalizeVotingBallot(settings, record, { approvalId, changeReason })
: await finalizeProviderBallot(settings, record, {
providerBallotRef,
approvalId,
changeReason
});
onSaved(saved);
if (closeAfter) onClose();
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Ballot result could not be imported.");
return false;
} finally {
setBusy(false);
}
}
useUnsavedDraftGuard({
dirty: open && dirty,
onSave: () => finalize(false),
onDiscard: () => {
setProviderBallotRef("");
setApprovalId("");
setChangeReason("Imported verified ballot aggregate.");
},
title: "i18n:govoplan-committee.unsaved_title",
message: "i18n:govoplan-committee.unsaved_message"
});
function requestClose() {
if (busy) return;
if (dirty) requestDiscard(onClose);
else onClose();
}
const providerId = String(record.attributes.provider_id ?? "");
const votingBallotId = String(record.attributes.voting_ballot_id ?? "").trim();
const incomplete = (!votingBallotId && !providerBallotRef.trim()) || !approvalId.trim() || !changeReason.trim();
return (
<>
<Dialog
open={open}
title={votingBallotId ? "Finalize Voting ballot" : "Finalize provider ballot"}
onClose={requestClose}
closeDisabled={busy}
portal
className="committee-ballot-dialog"
footer={
<>
<Button disabled={busy} disabledReason={busy ? COMMITTEE_INTERFACE_I18N.busy : undefined} onClick={requestClose}>Cancel</Button>
<Button
variant="primary"
disabled={busy || incomplete}
disabledReason={busy ? COMMITTEE_INTERFACE_I18N.busy : incomplete ? COMMITTEE_INTERFACE_I18N.incomplete : undefined}
onClick={() => setConfirming(true)}
>
{busy ? "Importing" : "Finalize"}
</Button>
</>
}
>
<div className="committee-record-form">
<div className="committee-dialog-help"><DocumentationHelpLink reference={COMMITTEE_FIELD_DOCUMENTATION} /></div>
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<p className="committee-dialog-note">
{votingBallotId
? <>Voting ballot <strong>{votingBallotId}</strong> will be closed and its aggregate result recorded in the Committee minutes.</>
: <>Provider <strong>{providerId}</strong> returns only the verified aggregate result, receipt hash and evidence.</>}
</p>
{!votingBallotId ? <FormField label="Provider ballot reference" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={providerBallotRef} disabled={busy} onChange={(event) => setProviderBallotRef(event.target.value)} />
</FormField> : null}
<FormField label="Approval ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={approvalId} disabled={busy} onChange={(event) => setApprovalId(event.target.value)} />
</FormField>
<FormField label="Change reason" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={changeReason} maxLength={1000} disabled={busy} onChange={(event) => setChangeReason(event.target.value)} />
</FormField>
</div>
</Dialog>
<ConfirmDialog
open={confirming}
title="i18n:govoplan-committee.finalize_title"
message={i18nMessage("i18n:govoplan-committee.confirm_ballot_finalization", { title: record.title })}
confirmLabel="Finalize"
busy={busy}
onConfirm={() => {
setConfirming(false);
void finalize();
}}
onCancel={() => setConfirming(false)}
/>
</>
);
}
@@ -0,0 +1,498 @@
import {
CalendarPlus,
FilePlus2,
ListPlus,
Pencil,
Plus,
Search,
ShieldCheck,
Vote
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { ActionBlockerHint,
Button,
DocumentationHelpLink,
DismissibleAlert,
FilterBar,
IconButton,
LoadingIndicator,
PageScrollViewport,
StatePanel,
StatusBadge,
hasScope,
i18nMessage,
usePlatformLanguage,
WorkspaceActionBar,
WorkspaceFrame,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
listCommitteeRecords,
type CommitteeObjectKind,
type CommitteeRecord
} from "../../api/committee";
import CommitteeBallotDialog from "./CommitteeBallotDialog";
import CommitteeRecordDialog from "./CommitteeRecordDialog";
import {
COMMITTEE_DOCUMENTATION,
COMMITTEE_INTERFACE_I18N,
committeeDisabledReason
} from "./interfacePatterns";
import { canReviseCommitteeRecord } from "./lifecycle";
type EditorTarget = {
kind: CommitteeObjectKind;
parentId?: string | null;
record?: CommitteeRecord | null;
};
export default function CommitteePage({ settings, auth }: PlatformRouteContext) {
const { language, translateText } = usePlatformLanguage();
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
const canWrite = hasScope(auth, "committee:workspace:write");
const canFinalizeBallot = hasScope(auth, "committee:ballot:finalize");
const [query, setQuery] = useState("");
const [bodies, setBodies] = useState<CommitteeRecord[]>([]);
const [meetings, setMeetings] = useState<CommitteeRecord[]>([]);
const [agendaItems, setAgendaItems] = useState<CommitteeRecord[]>([]);
const [votes, setVotes] = useState<CommitteeRecord[]>([]);
const [minutes, setMinutes] = useState<CommitteeRecord[]>([]);
const [bodyId, setBodyId] = useState("");
const [meetingId, setMeetingId] = useState("");
const [agendaId, setAgendaId] = useState("");
const [editor, setEditor] = useState<EditorTarget | null>(null);
const [ballotRecord, setBallotRecord] = useState<CommitteeRecord | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const selectedBody = bodies.find((item) => item.object_id === bodyId) ?? null;
const selectedMeeting = meetings.find((item) => item.object_id === meetingId) ?? null;
const selectedAgenda = agendaItems.find((item) => item.object_id === agendaId) ?? null;
const loadBodies = useCallback(async (signal?: AbortSignal) => {
const response = await listCommitteeRecords(
settings,
"body",
{ query: query.trim(), limit: 200 },
signal
);
setBodies(response.records);
setBodyId((current) => response.records.some((item) => item.object_id === current)
? current
: response.records[0]?.object_id ?? "");
}, [query, settings]);
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
await loadBodies();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Committee workspace could not be loaded.");
} finally {
setLoading(false);
}
}, [loadBodies]);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
loadBodies(controller.signal).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Committee bodies could not be loaded.");
}
}).
finally(() => setLoading(false));
return () => controller.abort();
}, [loadBodies]);
useEffect(() => {
if (!bodyId) {
setMeetings([]);
setMeetingId("");
return;
}
const controller = new AbortController();
listCommitteeRecords(settings, "meeting", { parentId: bodyId }, controller.signal).
then((response) => {
setMeetings(response.records);
setMeetingId((current) => response.records.some((item) => item.object_id === current)
? current
: response.records[0]?.object_id ?? "");
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Committee meetings could not be loaded.");
}
});
return () => controller.abort();
}, [bodyId, settings]);
useEffect(() => {
if (!meetingId) {
setAgendaItems([]);
setMinutes([]);
setAgendaId("");
return;
}
const controller = new AbortController();
Promise.all([
listCommitteeRecords(settings, "agenda_item", { parentId: meetingId }, controller.signal),
listCommitteeRecords(settings, "minute", { parentId: meetingId }, controller.signal)
]).
then(([agenda, nextMinutes]) => {
const ordered = [...agenda.records].sort(
(left, right) => Number(left.attributes.position ?? 0) - Number(right.attributes.position ?? 0)
);
setAgendaItems(ordered);
setMinutes(nextMinutes.records);
setAgendaId((current) => ordered.some((item) => item.object_id === current)
? current
: ordered[0]?.object_id ?? "");
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Meeting details could not be loaded.");
}
});
return () => controller.abort();
}, [meetingId, settings]);
useEffect(() => {
if (!agendaId) {
setVotes([]);
return;
}
const controller = new AbortController();
listCommitteeRecords(settings, "vote", { parentId: agendaId }, controller.signal).
then((response) => setVotes(response.records)).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Votes could not be loaded.");
}
});
return () => controller.abort();
}, [agendaId, settings]);
const meetingTime = useMemo(
() => selectedMeeting
? `${formatDateTime(selectedMeeting.attributes.starts_at, language)} - ${formatTime(selectedMeeting.attributes.ends_at, language)}`
: "",
[language, selectedMeeting]
);
return (
<main className="committee-page">
<WorkspaceFrame className="committee-shell" label="Committee workspace" interfaceId="committee.workspace" helpContextId="committee.page.workspace" helpModuleId="committee">
<WorkspaceActionBar
scope="workspace"
variant="collection"
refreshable
reloadAction={{ onReload: () => void refresh(), loading }}
className="committee-toolbar"
contextActions={<FilterBar as="form" surface="control" wrap="never" width="compact" onSubmit={(event) => { event.preventDefault(); void refresh(); }} className="committee-search">
<Search size={16} aria-hidden="true" />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search bodies" aria-label="Search committee bodies" />
</FilterBar>}
createAction={<Button
variant="primary"
disabled={!canWrite || loading}
disabledReason={committeeDisabledReason({ loading, permitted: canWrite })}
onClick={() => setEditor({ kind: "body" })}
>
<Plus size={16} aria-hidden="true" />
New body
</Button>}
helpAction={<DocumentationHelpLink reference={COMMITTEE_DOCUMENTATION} />}
/>
{error ? <DismissibleAlert tone="danger" resetKey={error} className="committee-alert">{error}</DismissibleAlert> : null}
{loading && bodies.length === 0 ? <LoadingIndicator label="Loading committee workspace" /> : null}
{!canWrite ? (
<ActionBlockerHint
tone="info"
reason={{
summary: "No Committee management permission",
details: COMMITTEE_INTERFACE_I18N.writeReason,
requiredAction: COMMITTEE_INTERFACE_I18N.permissionAction,
actor: COMMITTEE_INTERFACE_I18N.permissionActor,
target: COMMITTEE_INTERFACE_I18N.permissionDestination
}}
labels={{
requiredAction: COMMITTEE_INTERFACE_I18N.requiredAction,
actor: COMMITTEE_INTERFACE_I18N.actor,
target: COMMITTEE_INTERFACE_I18N.destination
}}
documentation={COMMITTEE_DOCUMENTATION}
/>
) : null}
<div className="committee-workspace">
<section className="committee-panel committee-body-panel" aria-label="Committee bodies">
<PanelHeading title="Bodies" count={bodies.length} />
<PageScrollViewport className="committee-panel-scroll">
<RecordList records={bodies} selectedId={bodyId} onSelect={setBodyId} />
</PageScrollViewport>
{selectedBody ? (
<div className="committee-panel-actions">
<IconButton
label="Edit body"
icon={<Pencil size={16} />}
disabled={!canWrite || !canReviseCommitteeRecord(selectedBody)}
disabledReason={committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(selectedBody) })}
onClick={() => setEditor({ kind: "body", record: selectedBody })}
/>
<Button
variant="primary"
disabled={!canWrite}
disabledReason={committeeDisabledReason({ permitted: canWrite })}
onClick={() => setEditor({ kind: "meeting", parentId: selectedBody.object_id })}
>
<CalendarPlus size={16} aria-hidden="true" />
New meeting
</Button>
</div>
) : null}
</section>
<section className="committee-panel committee-meeting-panel" aria-label="Meetings">
<PanelHeading title="Meetings" count={meetings.length} />
<PageScrollViewport className="committee-panel-scroll">
<RecordList records={meetings} selectedId={meetingId} onSelect={setMeetingId} secondary={(record) => meetingSecondary(record, language, translateText)} />
</PageScrollViewport>
</section>
<section className="committee-detail" aria-label="Meeting workspace">
{!selectedMeeting ? (
<StatePanel size="fill" title="Meetings" description="Select or create a meeting." />
) : (
<>
<div className="committee-detail-heading">
<div>
<span>{meetingTime}</span>
<h1>{selectedMeeting.title}</h1>
</div>
<StatusBadge status={statusTone(selectedMeeting.state)} label={stateLabel(selectedMeeting.state)} />
<IconButton
label="Edit meeting"
icon={<Pencil size={16} />}
disabled={!canWrite || !canReviseCommitteeRecord(selectedMeeting)}
disabledReason={committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(selectedMeeting) })}
onClick={() => setEditor({ kind: "meeting", record: selectedMeeting })}
/>
</div>
<PageScrollViewport className="committee-detail-scroll">
<WorkspaceSection
title="Agenda"
action={(
<Button
variant="ghost"
disabled={!canWrite}
disabledReason={committeeDisabledReason({ permitted: canWrite })}
onClick={() => setEditor({ kind: "agenda_item", parentId: selectedMeeting.object_id })}
>
<ListPlus size={16} aria-hidden="true" />
Add item
</Button>
)}
>
<div className="committee-agenda-list">
{agendaItems.map((item) => (
<div key={item.object_id} className={item.object_id === agendaId ? "is-selected" : ""}>
<button type="button" onClick={() => setAgendaId(item.object_id)}>
<span className="committee-agenda-position">{String(item.attributes.position ?? "-")}</span>
<span><strong>{item.title}</strong><small>{stateLabel(item.state)}</small></span>
</button>
<IconButton
label="Edit agenda item"
icon={<Pencil size={15} />}
disabled={!canWrite || !canReviseCommitteeRecord(item)}
disabledReason={committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(item) })}
onClick={() => setEditor({ kind: "agenda_item", record: item })}
/>
</div>
))}
{agendaItems.length === 0 ? <StatePanel size="inline" description="No agenda items." /> : null}
</div>
</WorkspaceSection>
{selectedAgenda ? (
<WorkspaceSection
title={i18nMessage("i18n:govoplan-committee.votes_for", { title: selectedAgenda.title })}
action={(
<Button
variant="ghost"
disabled={!canWrite}
disabledReason={committeeDisabledReason({ permitted: canWrite })}
onClick={() => setEditor({ kind: "vote", parentId: selectedAgenda.object_id })}
>
<Vote size={16} aria-hidden="true" />
Add vote
</Button>
)}
>
<RecordRows
records={votes}
editDisabledReason={(record) => committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(record) })}
onEdit={(record) => setEditor({ kind: "vote", record })}
detail={(record) => voteSummary(record, translateText)}
secondaryAction={(record) => isProviderBallotReady(record) ? (
<IconButton
label={i18nMessage("i18n:govoplan-committee.finalize_provider_record", { title: record.title })}
icon={<ShieldCheck size={15} />}
disabled={!canFinalizeBallot}
disabledReason={!canFinalizeBallot ? COMMITTEE_INTERFACE_I18N.finalizeReason : undefined}
onClick={() => setBallotRecord(record)}
/>
) : null}
/>
</WorkspaceSection>
) : null}
<WorkspaceSection
title="Minutes"
action={(
<Button
variant="ghost"
disabled={!canWrite}
disabledReason={committeeDisabledReason({ permitted: canWrite })}
onClick={() => setEditor({ kind: "minute", parentId: selectedMeeting.object_id })}
>
<FilePlus2 size={16} aria-hidden="true" />
Add minutes
</Button>
)}
>
<RecordRows
records={minutes}
editDisabledReason={(record) => committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(record) })}
onEdit={(record) => setEditor({ kind: "minute", record })}
detail={(record) => i18nMessage("i18n:govoplan-committee.record_reference", { id: String((record.attributes.content_ref as Record<string, unknown> | undefined)?.object_id ?? "-") })}
/>
</WorkspaceSection>
</PageScrollViewport>
</>
)}
</section>
</div>
</WorkspaceFrame>
{editor ? (
<CommitteeRecordDialog
settings={settings}
tenantId={tenantId}
kind={editor.kind}
parentId={editor.parentId}
record={editor.record}
open
onClose={() => setEditor(null)}
onSaved={() => void refresh()}
/>
) : null}
{ballotRecord ? (
<CommitteeBallotDialog
settings={settings}
record={ballotRecord}
open
onClose={() => setBallotRecord(null)}
onSaved={() => void refresh()}
/>
) : null}
</main>
);
}
function PanelHeading({ title, count }: { title: string; count: number }) {
return <div className="committee-panel-heading"><h2>{title}</h2><span>{count}</span></div>;
}
function RecordList({ records, selectedId, onSelect, secondary }: {
records: CommitteeRecord[];
selectedId: string;
onSelect: (id: string) => void;
secondary?: (record: CommitteeRecord) => string;
}) {
if (records.length === 0) return <StatePanel size="inline" description="No records." />;
return <div className="committee-record-list">{records.map((record) => (
<button key={record.object_id} type="button" className={record.object_id === selectedId ? "is-selected" : ""} onClick={() => onSelect(record.object_id)}>
<strong>{record.title}</strong>
<span>{secondary?.(record) ?? stateLabel(record.state)}</span>
</button>
))}</div>;
}
function WorkspaceSection({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
return <section className="committee-workspace-section"><div><h2>{title}</h2>{action}</div>{children}</section>;
}
function RecordRows({ records, editDisabledReason, onEdit, detail, secondaryAction }: {
records: CommitteeRecord[];
editDisabledReason: (record: CommitteeRecord) => string | undefined;
onEdit: (record: CommitteeRecord) => void;
detail: (record: CommitteeRecord) => string;
secondaryAction?: (record: CommitteeRecord) => ReactNode;
}) {
if (records.length === 0) return <StatePanel size="inline" description="No records." />;
return <div className="committee-record-rows">{records.map((record) => (
<div key={record.object_id}>
<span><strong>{record.title}</strong><small>{detail(record)}</small></span>
<StatusBadge status={statusTone(record.state)} label={stateLabel(record.state)} />
<span className="committee-row-actions">
{secondaryAction?.(record)}
<IconButton
label={i18nMessage("i18n:govoplan-committee.edit_record", { title: record.title })}
icon={<Pencil size={15} />}
disabled={Boolean(editDisabledReason(record))}
disabledReason={editDisabledReason(record)}
onClick={() => onEdit(record)}
/>
</span>
</div>
))}</div>;
}
function meetingSecondary(record: CommitteeRecord, locale: string, translateText: (value: string) => string): string {
return `${formatDateTime(record.attributes.starts_at, locale)} - ${translateText(stateLabel(record.state))}`;
}
function voteSummary(record: CommitteeRecord, translateText: (value: string) => string): string {
const cast = Number(record.attributes.cast_count ?? 0);
const eligible = Number(record.attributes.eligible_count ?? 0);
return i18nMessage("i18n:govoplan-committee.vote_summary", {
method: translateText(domainLabel(String(record.attributes.method ?? "recorded"))),
cast,
eligible
});
}
function isProviderBallotReady(record: CommitteeRecord): boolean {
return record.state === "open" && Boolean(
String(record.attributes.voting_ballot_id ?? "").trim()
|| String(record.attributes.provider_id ?? "").trim()
);
}
function statusTone(state: string): "active" | "inactive" | "warning" {
if (["active", "open", "accepted", "decided", "closed"].includes(state)) return "active";
if (["cancelled", "retired", "withdrawn"].includes(state)) return "inactive";
return "warning";
}
function formatDateTime(value: unknown, locale?: string): string {
if (!value) return "Date not set";
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(String(value)));
}
function formatTime(value: unknown, locale?: string): string {
if (!value) return "-";
return new Intl.DateTimeFormat(locale, { timeStyle: "short" }).format(new Date(String(value)));
}
function stateLabel(value: string): string {
return `i18n:govoplan-committee.state_${value}`;
}
function domainLabel(value: string): string {
return `i18n:govoplan-committee.domain_${value}`;
}
@@ -0,0 +1,519 @@
import { useEffect, useMemo, useState } from "react";
import { FormGrid,
Button,
ConfirmDialog,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FormField,
i18nMessage,
usePlatformLanguage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings
} from "@govoplan/core-webui";
import {
saveCommitteeRecord,
type CommitteeObjectKind,
type CommitteeRecord
} from "../../api/committee";
import { COMMITTEE_STATES, committeeStateOptions } from "./lifecycle";
import {
COMMITTEE_FIELD_DOCUMENTATION,
COMMITTEE_INTERFACE_I18N,
committeeDisabledReason
} from "./interfacePatterns";
type Draft = {
title: string;
state: string;
changeReason: string;
organizationUnitId: string;
startsAt: string;
endsAt: string;
position: string;
subjectKind: string;
subjectId: string;
choices: string;
method: string;
eligibleCount: string;
castCount: string;
counts: Record<string, string>;
quorumMet: boolean;
approvalId: string;
evidenceId: string;
providerId: string;
votingBallotId: string;
contentRecordId: string;
decisionId: string;
};
export default function CommitteeRecordDialog({
settings,
tenantId,
kind,
parentId,
record,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
tenantId: string;
kind: CommitteeObjectKind;
parentId?: string | null;
record?: CommitteeRecord | null;
open: boolean;
onClose: () => void;
onSaved: (record: CommitteeRecord) => void;
}) {
const { translateText } = usePlatformLanguage();
const initialDraft = useMemo(() => draftFromRecord(kind, record), [kind, record]);
const [draft, setDraft] = useState<Draft>(initialDraft);
const [baseline, setBaseline] = useState<Draft>(initialDraft);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirmLifecycleChange, setConfirmLifecycleChange] = useState(false);
const { requestDiscard } = useUnsavedChanges();
const choices = useMemo(
() => draft.choices.split(",").map((item) => item.trim()).filter(Boolean),
[draft.choices]
);
const stateOptions = useMemo(
() => committeeStateOptions(kind, record),
[kind, record]
);
useEffect(() => {
if (!open) return;
setDraft(initialDraft);
setBaseline(initialDraft);
setError("");
setConfirmLifecycleChange(false);
}, [initialDraft, open]);
const dirty = draftKey(draft) !== draftKey(baseline);
const incomplete = !draft.title.trim() || !draft.changeReason.trim();
async function save(closeAfter = true): Promise<boolean> {
setBusy(true);
setError("");
try {
const payload = recordFromDraft({
tenantId,
kind,
parentId,
record,
draft,
choices
});
const saved = await saveCommitteeRecord(
settings,
payload,
record?.revision
);
const nextDraft = draftFromRecord(kind, saved);
setDraft(nextDraft);
setBaseline(nextDraft);
onSaved(saved);
if (closeAfter) onClose();
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Committee record could not be saved.");
return false;
} finally {
setBusy(false);
}
}
useUnsavedDraftGuard({
dirty: open && dirty,
onSave: () => save(false),
onDiscard: () => setDraft(baseline),
title: "i18n:govoplan-committee.unsaved_title",
message: "i18n:govoplan-committee.unsaved_message"
});
function requestClose() {
if (busy) return;
if (dirty) requestDiscard(onClose);
else onClose();
}
function requestSave() {
if (record && draft.state !== record.state) {
setConfirmLifecycleChange(true);
return;
}
void save();
}
return (
<Dialog
open={open}
title={i18nMessage("i18n:govoplan-committee.record_dialog_title", {
action: translateText(record ? "Edit" : "New"),
kind: translateText(kindLabel(kind))
})}
onClose={requestClose}
closeDisabled={busy}
portal
className="committee-record-dialog"
footer={
<>
<Button disabled={busy} disabledReason={committeeDisabledReason({ busy })} onClick={requestClose}>Cancel</Button>
<Button
variant="primary"
disabled={busy || incomplete}
disabledReason={busy ? COMMITTEE_INTERFACE_I18N.busy : incomplete ? COMMITTEE_INTERFACE_I18N.incomplete : undefined}
onClick={requestSave}
>
{busy ? "Saving" : "Save"}
</Button>
</>
}
>
<div className="committee-record-form">
<div className="committee-dialog-help"><DocumentationHelpLink reference={COMMITTEE_FIELD_DOCUMENTATION} /></div>
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Title" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input
value={draft.title}
maxLength={500}
disabled={busy}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
/>
</FormField>
<FormField label="State" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<select
value={draft.state}
disabled={busy}
onChange={(event) => setDraft({ ...draft, state: event.target.value })}
>
{stateOptions.map((state) => <option key={state} value={state}>{stateLabel(state)}</option>)}
</select>
</FormField>
</FormGrid>
{kind === "body" ? (
<FormField label="Responsible organization unit ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input
value={draft.organizationUnitId}
disabled={busy}
onChange={(event) => setDraft({ ...draft, organizationUnitId: event.target.value })}
/>
</FormField>
) : null}
{kind === "meeting" ? (
<FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Starts">
<input type="datetime-local" value={draft.startsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, startsAt: event.target.value })} />
</FormField>
<FormField label="Ends">
<input type="datetime-local" value={draft.endsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, endsAt: event.target.value })} />
</FormField>
</FormGrid>
) : null}
{kind === "agenda_item" ? (
<>
<FormGrid columns={3} gap="compact" collapseAt="narrow">
<FormField label="Position">
<input type="number" min="1" value={draft.position} disabled={busy} onChange={(event) => setDraft({ ...draft, position: event.target.value })} />
</FormField>
<FormField label="Subject type">
<select value={draft.subjectKind} disabled={busy} onChange={(event) => setDraft({ ...draft, subjectKind: event.target.value })}>
<option value="case">Case</option>
<option value="service">Service</option>
<option value="work_item">Work item</option>
<option value="record">Record</option>
</select>
</FormField>
<FormField label="Subject ID">
<input value={draft.subjectId} disabled={busy} onChange={(event) => setDraft({ ...draft, subjectId: event.target.value })} />
</FormField>
</FormGrid>
{draft.state === "decided" ? (
<FormField label="Formal Decision ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input data-help-risk-reviewed="standard" value={draft.decisionId} disabled={busy} onChange={(event) => setDraft({ ...draft, decisionId: event.target.value })} />
</FormField>
) : null}
</>
) : null}
{kind === "vote" ? (
<>
<FormGrid columns={3} gap="compact" collapseAt="narrow">
<FormField label="Method">
<select value={draft.method} disabled={busy} onChange={(event) => setDraft({ ...draft, method: event.target.value })}>
<option value="recorded">Recorded</option>
<option value="public">Public</option>
<option value="secret">Secret</option>
</select>
</FormField>
<FormField label="Eligible voters">
<input type="number" min="0" value={draft.eligibleCount} disabled={busy} onChange={(event) => setDraft({ ...draft, eligibleCount: event.target.value })} />
</FormField>
<FormField label="Ballot provider (optional)" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} />
</FormField>
</FormGrid>
<FormField label="Voting ballot ID (optional)" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.votingBallotId} disabled={busy} onChange={(event) => setDraft({ ...draft, votingBallotId: event.target.value })} />
</FormField>
<FormField label="Choices (comma separated)">
<input value={draft.choices} disabled={busy} onChange={(event) => setDraft({ ...draft, choices: event.target.value })} />
</FormField>
{draft.state === "closed" ? (
<div className="committee-vote-result-fields">
<FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Votes cast">
<input type="number" min="0" value={draft.castCount} disabled={busy} onChange={(event) => setDraft({ ...draft, castCount: event.target.value })} />
</FormField>
<FormField label="Quorum">
<select value={draft.quorumMet ? "met" : "not-met"} disabled={busy} onChange={(event) => setDraft({ ...draft, quorumMet: event.target.value === "met" })}>
<option value="met">Met</option>
<option value="not-met">Not met</option>
</select>
</FormField>
</FormGrid>
<div className="committee-count-grid">
{choices.map((choice) => (
<FormField key={choice} label={`${choice} votes`}>
<input
type="number"
min="0"
value={draft.counts[choice] ?? "0"}
disabled={busy}
onChange={(event) => setDraft({
...draft,
counts: { ...draft.counts, [choice]: event.target.value }
})}
/>
</FormField>
))}
</div>
<EvidenceFields draft={draft} busy={busy} setDraft={setDraft} />
</div>
) : null}
</>
) : null}
{kind === "minute" ? (
<>
<FormField label="Minutes record ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.contentRecordId} disabled={busy} onChange={(event) => setDraft({ ...draft, contentRecordId: event.target.value })} />
</FormField>
{draft.state === "accepted" || draft.state === "corrected" ? (
<EvidenceFields draft={draft} busy={busy} setDraft={setDraft} />
) : null}
</>
) : null}
<FormField label="Change reason" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input
value={draft.changeReason}
maxLength={1000}
disabled={busy}
onChange={(event) => setDraft({ ...draft, changeReason: event.target.value })}
/>
</FormField>
</div>
<ConfirmDialog
open={confirmLifecycleChange}
title="i18n:govoplan-committee.save_state_title"
message={record ? i18nMessage("i18n:govoplan-committee.confirm_state_change", {
kind: translateText(kindLabel(kind)),
from: translateText(stateLabel(record.state)),
to: translateText(stateLabel(draft.state))
}) : ""}
confirmLabel="Save"
tone={["cancelled", "retired", "withdrawn"].includes(draft.state) ? "danger" : "default"}
busy={busy}
onConfirm={() => {
setConfirmLifecycleChange(false);
void save();
}}
onCancel={() => setConfirmLifecycleChange(false)}
/>
</Dialog>
);
}
function EvidenceFields({ draft, busy, setDraft }: { draft: Draft; busy: boolean; setDraft: (draft: Draft) => void }) {
return (
<FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Approval ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.approvalId} disabled={busy} onChange={(event) => setDraft({ ...draft, approvalId: event.target.value })} />
</FormField>
<FormField label="Evidence record ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.evidenceId} disabled={busy} onChange={(event) => setDraft({ ...draft, evidenceId: event.target.value })} />
</FormField>
</FormGrid>
);
}
function draftFromRecord(kind: CommitteeObjectKind, record?: CommitteeRecord | null): Draft {
const attributes = record?.attributes ?? {};
const startsAt = localDateTime(attributes.starts_at, 60);
const endsAt = localDateTime(attributes.ends_at, 120);
const subject = firstMapping(attributes.subject_refs);
const approval = mapping(attributes.approval_ref);
const content = mapping(attributes.content_ref);
const decision = mapping(attributes.decision_ref);
const organization = mapping(attributes.organization_unit_ref);
const counts = mapping(attributes.counts);
return {
title: record?.title ?? "",
state: record?.state ?? COMMITTEE_STATES[kind][0],
changeReason: record ? "" : `Created ${labelForKind(kind)}.`,
organizationUnitId: text(organization.object_id),
startsAt,
endsAt,
position: String(attributes.position ?? 1),
subjectKind: text(subject.kind) || "case",
subjectId: text(subject.object_id),
choices: array(attributes.choices).join(", ") || "yes, no, abstain",
method: text(attributes.method) || "recorded",
eligibleCount: String(attributes.eligible_count ?? 0),
castCount: String(attributes.cast_count ?? 0),
counts: Object.fromEntries(Object.entries(counts).map(([key, value]) => [key, String(value)])),
quorumMet: attributes.quorum_met !== false,
approvalId: text(approval.object_id),
evidenceId: text(firstMapping(record?.evidence).evidence_id),
providerId: text(attributes.provider_id),
votingBallotId: text(attributes.voting_ballot_id),
contentRecordId: text(content.object_id),
decisionId: text(decision.object_id)
};
}
function recordFromDraft({ tenantId, kind, parentId, record, draft, choices }: {
tenantId: string;
kind: CommitteeObjectKind;
parentId?: string | null;
record?: CommitteeRecord | null;
draft: Draft;
choices: string[];
}): CommitteeRecord {
const attributes = attributesFromDraft(tenantId, kind, draft, choices);
const evidence = needsEvidence(kind, draft.state) && draft.evidenceId.trim()
? [{
kind: "record",
owner_module: "records",
evidence_id: draft.evidenceId.trim(),
tenant_id: tenantId,
version: "1",
captured_at: new Date().toISOString()
}]
: record?.evidence ?? [];
return {
tenant_id: tenantId,
object_kind: kind,
object_id: record?.object_id ?? crypto.randomUUID(),
revision: (record?.revision ?? 0) + 1,
state: draft.state,
title: draft.title.trim(),
parent_id: record?.parent_id ?? parentId ?? null,
recorded_at: new Date().toISOString(),
change_reason: draft.changeReason.trim(),
attributes,
context: record?.context ?? null,
evidence,
record_refs: record?.record_refs ?? []
};
}
function attributesFromDraft(tenantId: string, kind: CommitteeObjectKind, draft: Draft, choices: string[]): Record<string, unknown> {
if (kind === "body") return {
organization_unit_ref: reference("organization_unit", "organizations", draft.organizationUnitId, tenantId),
function_refs: []
};
if (kind === "meeting") return {
starts_at: new Date(draft.startsAt).toISOString(),
ends_at: new Date(draft.endsAt).toISOString()
};
if (kind === "agenda_item") return {
position: Number(draft.position),
subject_refs: [reference(draft.subjectKind, ownerForKind(draft.subjectKind), draft.subjectId, tenantId)],
...(draft.state === "decided" ? {
decision_ref: reference("decision", "decisions", draft.decisionId, tenantId)
} : {})
};
if (kind === "vote") return {
method: draft.method,
choices,
eligible_count: Number(draft.eligibleCount),
cast_count: Number(draft.castCount),
...(draft.providerId.trim() ? { provider_id: draft.providerId.trim() } : {}),
...(draft.votingBallotId.trim() ? { voting_ballot_id: draft.votingBallotId.trim() } : {}),
...(draft.state === "closed" ? {
counts: Object.fromEntries(choices.map((choice) => [choice, Number(draft.counts[choice] ?? 0)])),
quorum_met: draft.quorumMet,
approval_ref: reference("approval", "approvals", draft.approvalId, tenantId)
} : {})
};
return {
content_ref: reference("record", "records", draft.contentRecordId, tenantId),
...(draft.state === "accepted" || draft.state === "corrected" ? {
approval_ref: reference("approval", "approvals", draft.approvalId, tenantId)
} : {})
};
}
function reference(kind: string, owner: string, objectId: string, tenantId: string) {
return { kind, owner_module: owner, object_id: objectId.trim(), tenant_id: tenantId, version: "1" };
}
function ownerForKind(kind: string): string {
return { case: "cases", service: "services", work_item: "workflow_engine", record: "records" }[kind] ?? kind;
}
function needsEvidence(kind: CommitteeObjectKind, state: string): boolean {
return (kind === "vote" && state === "closed")
|| (kind === "minute" && ["accepted", "corrected"].includes(state));
}
function localDateTime(value: unknown, offsetMinutes: number): string {
const date = value ? new Date(String(value)) : new Date(Date.now() + offsetMinutes * 60_000);
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function mapping(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function firstMapping(value: unknown): Record<string, unknown> {
return mapping(Array.isArray(value) ? value[0] : undefined);
}
function array(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
function text(value: unknown): string {
return typeof value === "string" ? value : "";
}
function labelForKind(kind: CommitteeObjectKind): string {
return humanize(kind);
}
function kindLabel(kind: CommitteeObjectKind): string {
return `i18n:govoplan-committee.kind_${kind}`;
}
function stateLabel(state: string): string {
return `i18n:govoplan-committee.state_${state}`;
}
function draftKey(draft: Draft): string {
return JSON.stringify(draft);
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
@@ -0,0 +1,44 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const COMMITTEE_DOCUMENTATION = {
topicId: "committee.module-boundary",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const COMMITTEE_FIELD_DOCUMENTATION = {
topicId: "committee.reference.fields-and-consequences",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const COMMITTEE_INTERFACE_I18N = {
loading: "i18n:govoplan-committee.loading_reason",
busy: "i18n:govoplan-committee.busy_reason",
writeReason: "i18n:govoplan-committee.write_permission_reason",
finalizeReason: "i18n:govoplan-committee.finalize_permission_reason",
lifecycleReason: "i18n:govoplan-committee.lifecycle_reason",
incomplete: "i18n:govoplan-committee.incomplete_reason",
requiredAction: "i18n:govoplan-committee.required_action",
actor: "i18n:govoplan-committee.responsible_actor",
destination: "i18n:govoplan-committee.destination",
permissionAction: "i18n:govoplan-committee.permission_action",
permissionActor: "i18n:govoplan-committee.permission_actor",
permissionDestination: "i18n:govoplan-committee.permission_destination"
} as const;
export function committeeDisabledReason({
loading = false,
busy = false,
permitted = true,
lifecycleBlocked = false
}: {
loading?: boolean;
busy?: boolean;
permitted?: boolean;
lifecycleBlocked?: boolean;
}): string | undefined {
if (loading) return COMMITTEE_INTERFACE_I18N.loading;
if (busy) return COMMITTEE_INTERFACE_I18N.busy;
if (!permitted) return COMMITTEE_INTERFACE_I18N.writeReason;
if (lifecycleBlocked) return COMMITTEE_INTERFACE_I18N.lifecycleReason;
return undefined;
}
+60
View File
@@ -0,0 +1,60 @@
import type { CommitteeObjectKind, CommitteeRecord } from "../../api/committee";
export const COMMITTEE_STATES: Record<CommitteeObjectKind, string[]> = {
body: ["draft", "active", "suspended", "retired"],
meeting: ["draft", "scheduled", "open", "closed", "cancelled"],
agenda_item: ["draft", "scheduled", "deliberating", "decided", "withdrawn"],
vote: ["draft", "open", "closed", "cancelled"],
minute: ["draft", "proposed", "accepted", "corrected"]
};
const TRANSITIONS: Record<CommitteeObjectKind, Record<string, string[]>> = {
body: {
draft: ["draft", "active", "retired"],
active: ["active", "suspended", "retired"],
suspended: ["active", "suspended", "retired"],
retired: []
},
meeting: {
draft: ["draft", "scheduled", "cancelled"],
scheduled: ["scheduled", "open", "cancelled"],
open: ["open", "closed", "cancelled"],
closed: [],
cancelled: []
},
agenda_item: {
draft: ["draft", "scheduled", "withdrawn"],
scheduled: ["scheduled", "deliberating", "withdrawn"],
deliberating: ["deliberating", "decided", "withdrawn"],
decided: [],
withdrawn: []
},
vote: {
draft: ["draft", "open", "cancelled"],
open: ["open", "closed", "cancelled"],
closed: [],
cancelled: []
},
minute: {
draft: ["draft", "proposed"],
proposed: ["proposed", "accepted"],
accepted: ["corrected"],
corrected: ["corrected"]
}
};
export function committeeStateOptions(
kind: CommitteeObjectKind,
record?: CommitteeRecord | null
): string[] {
if (!record) return COMMITTEE_STATES[kind];
const options = TRANSITIONS[kind][record.state] ?? [];
const providerBound = kind === "vote" && Boolean(String(record.attributes.provider_id ?? "").trim());
const allowed = providerBound ? options.filter((state) => state !== "closed") : options;
return allowed.length > 0 ? allowed : [record.state];
}
export function canReviseCommitteeRecord(record: CommitteeRecord): boolean {
return (TRANSITIONS[record.object_kind][record.state] ?? []).length > 0;
}
+223
View File
@@ -0,0 +1,223 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-committee.committee": "Committee",
"i18n:govoplan-committee.loading_reason": "Committee data is still loading.",
"i18n:govoplan-committee.busy_reason": "Another Committee action is still running.",
"i18n:govoplan-committee.write_permission_reason": "Your account may not manage Committee records.",
"i18n:govoplan-committee.finalize_permission_reason": "Your account may not finalize governed ballot results.",
"i18n:govoplan-committee.lifecycle_reason": "This record is immutable in its current lifecycle state.",
"i18n:govoplan-committee.incomplete_reason": "Complete the required title and change reason first.",
"i18n:govoplan-committee.required_action": "Required action",
"i18n:govoplan-committee.responsible_actor": "Responsible actor",
"i18n:govoplan-committee.destination": "Destination",
"i18n:govoplan-committee.permission_action": "Ask for the corresponding Committee management permission.",
"i18n:govoplan-committee.permission_actor": "An Access or tenant administrator",
"i18n:govoplan-committee.permission_destination": "Access role assignments",
"i18n:govoplan-committee.confirm_state_change": "Save {kind} and change its state from {from} to {to}? The reason and revision are retained as evidence.",
"i18n:govoplan-committee.confirm_ballot_finalization": "Finalize {title}? The aggregate result, approval and evidence become part of the governed Committee record.",
"i18n:govoplan-committee.finalize_title": "Finalize ballot result",
"i18n:govoplan-committee.save_state_title": "Confirm lifecycle change",
"i18n:govoplan-committee.unsaved_title": "Unsaved Committee record",
"i18n:govoplan-committee.unsaved_message": "Save or discard the Committee record before leaving this surface.",
"i18n:govoplan-committee.votes_for": "Votes - {title}",
"i18n:govoplan-committee.finalize_provider_record": "Finalize {title} from ballot provider",
"i18n:govoplan-committee.record_reference": "Record {id}",
"i18n:govoplan-committee.edit_record": "Edit {title}",
"i18n:govoplan-committee.vote_summary": "{method}, {cast}/{eligible} cast",
"i18n:govoplan-committee.record_dialog_title": "{action} {kind}",
"i18n:govoplan-committee.kind_body": "body",
"i18n:govoplan-committee.kind_meeting": "meeting",
"i18n:govoplan-committee.kind_agenda_item": "agenda item",
"i18n:govoplan-committee.kind_vote": "vote",
"i18n:govoplan-committee.kind_minute": "minutes",
"i18n:govoplan-committee.state_draft": "Draft",
"i18n:govoplan-committee.state_active": "Active",
"i18n:govoplan-committee.state_suspended": "Suspended",
"i18n:govoplan-committee.state_retired": "Retired",
"i18n:govoplan-committee.state_scheduled": "Scheduled",
"i18n:govoplan-committee.state_open": "Open",
"i18n:govoplan-committee.state_closed": "Closed",
"i18n:govoplan-committee.state_cancelled": "Cancelled",
"i18n:govoplan-committee.state_deliberating": "Deliberating",
"i18n:govoplan-committee.state_decided": "Decided",
"i18n:govoplan-committee.state_withdrawn": "Withdrawn",
"i18n:govoplan-committee.state_proposed": "Proposed",
"i18n:govoplan-committee.state_accepted": "Accepted",
"i18n:govoplan-committee.state_corrected": "Corrected",
"i18n:govoplan-committee.domain_recorded": "Recorded",
"i18n:govoplan-committee.domain_public": "Public",
"i18n:govoplan-committee.domain_secret": "Secret",
"Committee": "Committee",
"Search bodies": "Search bodies",
"Search committee bodies": "Search committee bodies",
"Refresh": "Refresh",
"New body": "New body",
"Loading committee workspace": "Loading committee workspace",
"Committee bodies": "Committee bodies",
"Bodies": "Bodies",
"Edit body": "Edit body",
"New meeting": "New meeting",
"Meetings": "Meetings",
"Meeting workspace": "Meeting workspace",
"Select or create a meeting.": "Select or create a meeting.",
"Edit meeting": "Edit meeting",
"Agenda": "Agenda",
"Add item": "Add item",
"No agenda items.": "No agenda items.",
"Add vote": "Add vote",
"Minutes": "Minutes",
"Add minutes": "Add minutes",
"No records.": "No records.",
"Cancel": "Cancel",
"Save": "Save",
"Saving": "Saving",
"Title": "Title",
"State": "State",
"Responsible organization unit ID": "Responsible organization unit ID",
"Starts": "Starts",
"Ends": "Ends",
"Position": "Position",
"Subject type": "Subject type",
"Subject ID": "Subject ID",
"Formal Decision ID": "Formal Decision ID",
"Method": "Method",
"Eligible voters": "Eligible voters",
"Ballot provider (optional)": "Ballot provider (optional)",
"Voting ballot ID (optional)": "Voting ballot ID (optional)",
"Choices (comma separated)": "Choices (comma separated)",
"Votes cast": "Votes cast",
"Quorum": "Quorum",
"Met": "Met",
"Not met": "Not met",
"Approval ID": "Approval ID",
"Evidence record ID": "Evidence record ID",
"Minutes record ID": "Minutes record ID",
"Change reason": "Change reason",
"Provider ballot reference": "Provider ballot reference",
"Importing": "Importing",
"Finalize": "Finalize",
"Date not set": "Date not set",
"No Committee management permission": "No Committee management permission",
"Edit": "Edit",
"New": "New",
"Recorded": "Recorded",
"Public": "Public",
"Secret": "Secret",
"Case": "Case",
"Service": "Service",
"Work Item": "Work item",
"Record": "Record"
} as const;
const de: Record<keyof typeof en, string> = {
"i18n:govoplan-committee.committee": "Gremien",
"i18n:govoplan-committee.loading_reason": "Gremiendaten werden noch geladen.",
"i18n:govoplan-committee.busy_reason": "Eine andere Gremienaktion läuft noch.",
"i18n:govoplan-committee.write_permission_reason": "Ihr Konto darf Gremiendatensätze nicht verwalten.",
"i18n:govoplan-committee.finalize_permission_reason": "Ihr Konto darf geregelte Abstimmungsergebnisse nicht abschließen.",
"i18n:govoplan-committee.lifecycle_reason": "Dieser Datensatz ist in seinem aktuellen Lebenszyklus unveränderlich.",
"i18n:govoplan-committee.incomplete_reason": "Füllen Sie zuerst Titel und Änderungsgrund aus.",
"i18n:govoplan-committee.required_action": "Erforderliche Aktion",
"i18n:govoplan-committee.responsible_actor": "Verantwortliche Stelle",
"i18n:govoplan-committee.destination": "Ziel",
"i18n:govoplan-committee.permission_action": "Fordern Sie die entsprechende Berechtigung zur Gremienverwaltung an.",
"i18n:govoplan-committee.permission_actor": "Eine Zugriffs- oder Mandantenadministration",
"i18n:govoplan-committee.permission_destination": "Zugriff und Rollenzuweisungen",
"i18n:govoplan-committee.confirm_state_change": "{kind} speichern und den Status von {from} auf {to} ändern? Grund und Revision werden als Nachweis aufbewahrt.",
"i18n:govoplan-committee.confirm_ballot_finalization": "{title} abschließen? Gesamtergebnis, Genehmigung und Nachweis werden Bestandteil des geregelten Gremiendatensatzes.",
"i18n:govoplan-committee.finalize_title": "Abstimmungsergebnis abschließen",
"i18n:govoplan-committee.save_state_title": "Lebenszyklusänderung bestätigen",
"i18n:govoplan-committee.unsaved_title": "Ungespeicherter Gremiendatensatz",
"i18n:govoplan-committee.unsaved_message": "Speichern oder verwerfen Sie den Gremiendatensatz, bevor Sie diese Oberfläche verlassen.",
"i18n:govoplan-committee.votes_for": "Abstimmungen - {title}",
"i18n:govoplan-committee.finalize_provider_record": "{title} über Abstimmungsanbieter abschließen",
"i18n:govoplan-committee.record_reference": "Datensatz {id}",
"i18n:govoplan-committee.edit_record": "{title} bearbeiten",
"i18n:govoplan-committee.vote_summary": "{method}, {cast}/{eligible} abgegeben",
"i18n:govoplan-committee.record_dialog_title": "{kind}: {action}",
"i18n:govoplan-committee.kind_body": "Gremium",
"i18n:govoplan-committee.kind_meeting": "Sitzung",
"i18n:govoplan-committee.kind_agenda_item": "Tagesordnungspunkt",
"i18n:govoplan-committee.kind_vote": "Abstimmung",
"i18n:govoplan-committee.kind_minute": "Protokoll",
"i18n:govoplan-committee.state_draft": "Entwurf",
"i18n:govoplan-committee.state_active": "Aktiv",
"i18n:govoplan-committee.state_suspended": "Ausgesetzt",
"i18n:govoplan-committee.state_retired": "Stillgelegt",
"i18n:govoplan-committee.state_scheduled": "Geplant",
"i18n:govoplan-committee.state_open": "Offen",
"i18n:govoplan-committee.state_closed": "Geschlossen",
"i18n:govoplan-committee.state_cancelled": "Abgesagt",
"i18n:govoplan-committee.state_deliberating": "In Beratung",
"i18n:govoplan-committee.state_decided": "Entschieden",
"i18n:govoplan-committee.state_withdrawn": "Zurückgezogen",
"i18n:govoplan-committee.state_proposed": "Vorgeschlagen",
"i18n:govoplan-committee.state_accepted": "Angenommen",
"i18n:govoplan-committee.state_corrected": "Berichtigt",
"i18n:govoplan-committee.domain_recorded": "Namentlich",
"i18n:govoplan-committee.domain_public": "Öffentlich",
"i18n:govoplan-committee.domain_secret": "Geheim",
"Committee": "Gremien",
"Search bodies": "Gremien suchen",
"Search committee bodies": "Gremien durchsuchen",
"Refresh": "Aktualisieren",
"New body": "Neues Gremium",
"Loading committee workspace": "Gremienarbeitsbereich wird geladen",
"Committee bodies": "Gremien",
"Bodies": "Gremien",
"Edit body": "Gremium bearbeiten",
"New meeting": "Neue Sitzung",
"Meetings": "Sitzungen",
"Meeting workspace": "Sitzungsarbeitsbereich",
"Select or create a meeting.": "Wählen oder erstellen Sie eine Sitzung.",
"Edit meeting": "Sitzung bearbeiten",
"Agenda": "Tagesordnung",
"Add item": "Punkt hinzufügen",
"No agenda items.": "Keine Tagesordnungspunkte.",
"Add vote": "Abstimmung hinzufügen",
"Minutes": "Protokolle",
"Add minutes": "Protokoll hinzufügen",
"No records.": "Keine Datensätze.",
"Cancel": "Abbrechen",
"Save": "Speichern",
"Saving": "Speichern",
"Title": "Titel",
"State": "Status",
"Responsible organization unit ID": "ID der zuständigen Organisationseinheit",
"Starts": "Beginn",
"Ends": "Ende",
"Position": "Position",
"Subject type": "Gegenstandsart",
"Subject ID": "Gegenstands-ID",
"Formal Decision ID": "Formelle Entscheidungs-ID",
"Method": "Verfahren",
"Eligible voters": "Stimmberechtigte",
"Ballot provider (optional)": "Abstimmungsanbieter (optional)",
"Voting ballot ID (optional)": "Abstimmungs-ID (optional)",
"Choices (comma separated)": "Auswahlmöglichkeiten (kommagetrennt)",
"Votes cast": "Abgegebene Stimmen",
"Quorum": "Quorum",
"Met": "Erfüllt",
"Not met": "Nicht erfüllt",
"Approval ID": "Genehmigungs-ID",
"Evidence record ID": "Nachweisdatensatz-ID",
"Minutes record ID": "Protokolldatensatz-ID",
"Change reason": "Änderungsgrund",
"Provider ballot reference": "Referenz des Abstimmungsanbieters",
"Importing": "Importieren",
"Finalize": "Abschließen",
"Date not set": "Datum nicht festgelegt",
"No Committee management permission": "Keine Berechtigung zur Gremienverwaltung",
"Edit": "Bearbeiten",
"New": "Neu",
"Recorded": "Namentlich",
"Public": "Öffentlich",
"Secret": "Geheim",
"Case": "Fall",
"Service": "Leistung",
"Work Item": "Arbeitsschritt",
"Record": "Datensatz"
};
export const generatedTranslations: PlatformTranslations = { en, de };
+2
View File
@@ -0,0 +1,2 @@
export { default, committeeModule } from "./module";
export * from "./api/committee";
+34
View File
@@ -0,0 +1,34 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/committee.css";
const CommitteePage = lazy(() => import("./features/committee/CommitteePage"));
export const committeeModule: PlatformWebModule = {
id: "committee",
label: "i18n:govoplan-committee.committee",
version: "0.1.8",
optionalDependencies: ["calendar", "files", "mandates", "decisions", "approvals"],
translations: generatedTranslations,
navItems: [
{
to: "/committee",
label: "i18n:govoplan-committee.committee",
iconName: "gavel",
anyOf: ["committee:workspace:read"],
order: 38
}
],
routes: [
{
path: "/committee",
anyOf: ["committee:workspace:read"],
order: 38,
render: (context) => createElement(CommitteePage, context)
}
]
};
export default committeeModule;
+280
View File
@@ -0,0 +1,280 @@
.committee-page {
height: 100%;
min-height: 0;
overflow: hidden;
}
.committee-search {
flex: 1 1 460px;
margin-right: auto;
}
.committee-alert {
margin: 10px 16px 0;
}
.committee-shell > .action-blocker-hint {
margin: 10px 16px 0;
}
.committee-workspace {
display: grid;
grid-template-columns: minmax(220px, 0.7fr) minmax(260px, 0.9fr) minmax(420px, 2fr);
flex: 1;
min-height: 0;
}
.committee-panel,
.committee-detail {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
border-right: 1px solid var(--border);
}
.committee-detail {
border-right: 0;
}
.committee-panel-heading,
.committee-detail-heading,
.committee-workspace-section > div:first-child {
display: flex;
align-items: center;
gap: 10px;
}
.committee-panel-heading {
min-height: 48px;
padding: 8px 13px;
border-bottom: 1px solid var(--border);
}
.committee-panel-heading h2,
.committee-workspace-section h2 {
margin: 0;
font-size: 0.92rem;
letter-spacing: 0;
}
.committee-panel-heading span {
margin-left: auto;
color: var(--text-soft);
font-size: 0.78rem;
}
.committee-panel-scroll,
.committee-detail-scroll {
flex: 1;
min-height: 0;
}
.committee-record-list {
display: flex;
flex-direction: column;
}
.committee-record-list > button {
display: flex;
min-height: 58px;
flex-direction: column;
gap: 3px;
padding: 9px 13px;
border: 0;
border-bottom: 1px solid var(--border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.committee-record-list > button:hover,
.committee-record-list > button.is-selected,
.committee-agenda-list > div:hover,
.committee-agenda-list > div.is-selected {
background: var(--hover-bg);
}
.committee-record-list span,
.committee-agenda-list small,
.committee-record-rows small,
.committee-detail-heading span {
color: var(--text-soft);
font-size: 0.77rem;
}
.committee-panel-actions {
display: flex;
justify-content: flex-end;
gap: 7px;
padding: 9px;
border-top: 1px solid var(--border);
}
.committee-detail-heading {
min-height: 70px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
}
.committee-detail-heading > div:first-child {
min-width: 0;
margin-right: auto;
}
.committee-detail-heading h1 {
overflow: hidden;
margin: 3px 0 0;
font-size: 1.08rem;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.committee-workspace-section {
padding: 16px;
border-bottom: 1px solid var(--border);
}
.committee-workspace-section > div:first-child {
min-height: 36px;
margin-bottom: 8px;
}
.committee-workspace-section > div:first-child .btn {
margin-left: auto;
}
.committee-agenda-list,
.committee-record-rows {
border-top: 1px solid var(--border);
}
.committee-agenda-list > div {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
width: 100%;
min-height: 52px;
border-bottom: 1px solid var(--border);
}
.committee-agenda-list > div > button:first-child {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: center;
gap: 8px;
min-width: 0;
min-height: 51px;
padding: 6px 8px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.committee-agenda-list > div > button:first-child > span:nth-child(2),
.committee-record-rows > div > span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.committee-agenda-position {
color: var(--text-soft);
font-size: 0.82rem;
text-align: center;
}
.committee-record-rows > div {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
min-height: 52px;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.committee-row-actions {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 4px !important;
}
.committee-record-dialog {
width: min(820px, calc(100vw - 32px));
max-height: min(820px, calc(100vh - 32px));
}
.committee-ballot-dialog {
width: min(620px, calc(100vw - 32px));
}
.committee-dialog-note {
margin: 0;
color: var(--text-soft);
line-height: 1.45;
}
.committee-record-form {
display: flex;
flex-direction: column;
gap: 13px;
}
.committee-dialog-help {
display: flex;
justify-content: flex-end;
}
.committee-count-grid {
display: grid;
gap: 10px;
}
.committee-count-grid {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
.committee-vote-result-fields {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--border);
}
@media (max-width: 1100px) {
.committee-workspace {
grid-template-columns: minmax(190px, 0.7fr) minmax(220px, 0.9fr) minmax(360px, 1.6fr);
}
}
@media (max-width: 760px) {
.committee-workspace {
grid-template-columns: minmax(150px, 0.8fr) minmax(0, 1.8fr);
grid-template-rows: repeat(2, minmax(0, 1fr));
}
.committee-body-panel {
grid-column: 1;
grid-row: 1;
border-bottom: 1px solid var(--border);
}
.committee-meeting-panel {
grid-column: 1;
grid-row: 2;
}
.committee-detail {
grid-column: 2;
grid-row: 1 / span 2;
}
}