Implement unified work inbox module

This commit is contained in:
2026-08-06 16:06:17 +02:00
parent 2e89204a0c
commit 3cdab599ff
31 changed files with 3540 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+7
View File
@@ -3,3 +3,10 @@
<!-- govoplan-repository-type:start --> <!-- govoplan-repository-type:start -->
**Repository type:** module (domain). **Repository type:** module (domain).
<!-- govoplan-repository-type:end --> <!-- govoplan-repository-type:end -->
GovOPlaN Tasks owns replay-safe explicit work items and presents a unified work
inbox over module-owned attention items. Workflow Engine keeps process state,
Notifications keeps delivery state, and each domain module keeps its business
objects and commands.
See [Tasks Domain And Unified Work Inbox](docs/TASKS_DOMAIN.md).
+65
View File
@@ -0,0 +1,65 @@
# Tasks Domain And Unified Work Inbox
## Boundary
Tasks owns explicit work items, their typed assignments, due dates, priorities,
state transitions, source references, and the unified work-inbox presentation.
It does not own the state of a Workflow instance, approval, notification,
Postbox message, Case, Campaign, record, or any other contributed object.
Modules contribute currently authorized `WorkItem` projections through Core's
versioned provider-registration contract. The projection links back to the
source owner, which remains responsible for commands, validation, concurrency,
audit, retention, and recovery. A failing provider is isolated and shown as an
unavailable work source; it cannot make other work disappear.
## Explicit Tasks
An explicit task has:
- a tenant and replay-safe idempotency key;
- title, optional summary, priority, due date, and required action;
- one or more account, group, role, function, function-assignment, or broad
tenant assignments;
- typed source references and provenance;
- an optimistic-concurrency revision and immutable transition evidence.
Function assignments are resolved at read time through the optional IDM
directory. This lets work assigned to an institutional function follow current
responsibility without turning an incumbent into the permanent owner. When IDM
is absent, account, group, role, and explicit assignment references continue to
work; unresolved function work fails closed.
## State And Recovery
Supported states are `open`, `in_progress`, `deferred`, `blocked`, `completed`,
and `cancelled`. Consequential transitions require a strong `If-Match`
precondition and compare-and-set the revision. Replaying task creation with the
same idempotency key returns the same task only when the canonical request is
identical.
Task changes emit Core change-sequence events so optional Notifications,
Workflow Engine, Search, Audit, or reporting consumers can react transactionally.
Database backup and restore is the recovery unit. Domain effects linked from a
task remain subject to the recovery rules of their owner.
## Permissions
- `tasks:item:read`: discover assigned explicit and contributed work;
- `tasks:item:write`: create explicit tasks and advance visible task state;
- `tasks:item:admin`: inspect and recover tenant-wide explicit work.
Current authorization is always rechecked, including for historical or deferred
work. The inbox never widens the permissions of its sources.
## User And Administrator Guidance
Users open **Work**, filter the current inbox, inspect the responsible source,
and either open the source-owned action or advance an explicit task. Completed
and cancelled work is hidden from the default view but remains available through
the history filter.
Administrators assign the Work participant or Work supervisor role and ensure
that IDM is enabled when function-bound work is required. Disabling Tasks
preserves explicit task state. Contributed source work remains with each source
module and becomes visible again when Tasks is re-enabled.
+8
View File
@@ -0,0 +1,8 @@
{
"name": "@govoplan/tasks",
"version": "0.1.18",
"private": true,
"description": "Governed work items and unified work inbox for GovOPlaN.",
"type": "module",
"peerDependencies": {}
}
+24
View File
@@ -0,0 +1,24 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-tasks"
version = "0.1.18"
description = "Governed work items and unified work inbox for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.18",
"govoplan-access>=0.1.18",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_tasks = ["py.typed"]
[project.entry-points."govoplan.modules"]
tasks = "govoplan_tasks.backend.manifest:get_manifest"
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN Tasks module."""
__version__ = "0.1.18"
+1
View File
@@ -0,0 +1 @@
"""Tasks backend."""
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.tasks import WorkItem, WorkItemPage, WorkItemQuery
from govoplan_tasks.backend.schemas import WorkProviderDiagnostic
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class WorkAggregation:
items: tuple[WorkItem, ...]
total: int
truncated: bool = False
diagnostics: tuple[WorkProviderDiagnostic, ...] = ()
def aggregate_work_items(
registry: PlatformRegistry,
session: Session,
principal: ApiPrincipal,
*,
query: WorkItemQuery,
) -> WorkAggregation:
"""Aggregate current work while isolating optional provider failures."""
items: list[WorkItem] = []
total = 0
truncated = False
diagnostics: list[WorkProviderDiagnostic] = []
for registered, provider in registry.work_item_providers():
provider_id = registered.registration.id
if query.provider_ids and provider_id not in query.provider_ids:
continue
if query.owner_modules and registered.module_id not in query.owner_modules:
continue
try:
page = provider.list_items(session, principal, query=query)
_validate_page(registered.module_id, provider_id, query, page)
except Exception: # provider isolation is part of the aggregation contract
logger.exception("Work-item provider failed provider=%s", provider_id)
diagnostics.append(
WorkProviderDiagnostic(
provider_id=provider_id,
owner_module=registered.module_id,
code="provider_unavailable",
message="This work source is temporarily unavailable.",
)
)
continue
items.extend(page.items)
total += page.total
truncated = truncated or page.truncated
items.sort(key=_sort_key)
if len(items) > query.limit:
truncated = True
items = items[: query.limit]
return WorkAggregation(
items=tuple(items),
total=total,
truncated=truncated or total > len(items),
diagnostics=tuple(diagnostics),
)
def _validate_page(
owner_module: str,
provider_id: str,
query: WorkItemQuery,
page: WorkItemPage,
) -> None:
if len(page.items) > query.limit:
raise ValueError("Work-item provider exceeded the requested limit.")
for item in page.items:
if item.provider_id != provider_id:
raise ValueError("Work-item provider returned another provider id.")
if item.owner_module != owner_module:
raise ValueError("Work-item provider returned another owner module.")
if item.tenant_id != query.tenant_id:
raise ValueError("Work-item provider returned another tenant.")
def _sort_key(item: WorkItem) -> tuple[object, ...]:
priorities = {"urgent": 0, "high": 1, "normal": 2, "low": 3}
due = _aware(item.due_at) if item.due_at else datetime.max.replace(tzinfo=UTC)
updated_rank = -_aware(item.updated_at).timestamp() if item.updated_at else 0.0
return (priorities[item.priority], due, updated_rank, item.provider_id, item.id)
def _aware(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
__all__ = ["WorkAggregation", "aggregate_work_items"]
@@ -0,0 +1,3 @@
from govoplan_tasks.backend.db.models import TaskAssignment, TaskItem
__all__ = ["TaskAssignment", "TaskItem"]
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class TaskItem(Base, TimestampMixin):
__tablename__ = "task_items"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_task_item_idempotency",
),
Index("ix_task_items_tenant_status", "tenant_id", "status"),
Index("ix_task_items_tenant_due", "tenant_id", "due_at", "status"),
Index(
"ix_task_items_source",
"tenant_id",
"source_module",
"source_resource_type",
"source_resource_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(500), nullable=False)
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(30), nullable=False, default="open", index=True
)
priority: Mapped[str] = mapped_column(
String(20), nullable=False, default="normal", index=True
)
required_action: Mapped[str | None] = mapped_column(String(500), nullable=True)
action_url: Mapped[str | None] = mapped_column(String(1500), nullable=True)
due_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
deferred_until: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
source_module: Mapped[str | None] = mapped_column(
String(100), nullable=True, index=True
)
source_resource_type: Mapped[str | None] = mapped_column(
String(100), nullable=True, index=True
)
source_resource_id: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
sources: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
provenance: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSON, default=dict, nullable=False
)
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
idempotency_key: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
updated_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
assignments: Mapped[list["TaskAssignment"]] = relationship(
back_populates="task",
cascade="all, delete-orphan",
order_by="TaskAssignment.created_at",
)
class TaskAssignment(Base, TimestampMixin):
__tablename__ = "task_assignments"
__table_args__ = (
UniqueConstraint(
"task_id",
"assignment_kind",
"assignment_id",
name="uq_task_assignment_target",
),
Index(
"ix_task_assignment_lookup",
"tenant_id",
"assignment_kind",
"assignment_id",
"task_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
task_id: Mapped[str] = mapped_column(
ForeignKey("task_items.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
assignment_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
assignment_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
assignment_label: Mapped[str | None] = mapped_column(String(500), nullable=True)
task: Mapped[TaskItem] = relationship(back_populates="assignments")
__all__ = ["TaskAssignment", "TaskItem", "new_uuid"]
+341
View File
@@ -0,0 +1,341 @@
from __future__ import annotations
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,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.tasks import (
CAPABILITY_TASK_COMMANDS,
WorkItemProviderRegistration,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_tasks.backend.db import models as task_models
from govoplan_tasks.backend.service import SqlTaskService
MODULE_ID = "tasks"
MODULE_NAME = "Tasks"
MODULE_VERSION = "0.1.18"
READ_SCOPE = "tasks:item:read"
WRITE_SCOPE = "tasks:item:write"
ADMIN_SCOPE = "tasks:item:admin"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Tasks",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
def _router(context: ModuleContext):
from govoplan_tasks.backend.router import create_router
return create_router(context.registry)
def _service(context: ModuleContext) -> SqlTaskService:
return SqlTaskService(context.registry)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
total = (
session.query(task_models.TaskItem)
.filter(task_models.TaskItem.tenant_id == tenant_id)
.count()
)
open_items = (
session.query(task_models.TaskItem)
.filter(
task_models.TaskItem.tenant_id == tenant_id,
task_models.TaskItem.status.in_(
("open", "in_progress", "deferred", "blocked")
),
)
.count()
)
return {"tasks": total, "open_tasks": open_items}
PERMISSIONS = (
_permission(
READ_SCOPE,
"View assigned work",
"Read explicit and contributed work visible to the current account, group, role, or function.",
),
_permission(
WRITE_SCOPE,
"Manage assigned work",
"Create explicit tasks and advance visible task state.",
),
_permission(
ADMIN_SCOPE,
"Administer tenant work",
"Read and recover all explicit tasks in the tenant.",
),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="work_participant",
name="Work participant",
description="Read and advance assigned work and create explicit tasks.",
permissions=(READ_SCOPE, WRITE_SCOPE),
),
RoleTemplate(
slug="work_supervisor",
name="Work supervisor",
description="Inspect and recover tenant-wide work in addition to participating.",
permissions=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE),
),
)
DOCUMENTATION = (
DocumentationTopic(
id="tasks.work-inbox",
title="Unified work inbox",
summary="Resume explicit tasks and module-owned work requiring attention.",
body=(
"The Work inbox combines explicit Tasks with work contributed by enabled modules. "
"Each source keeps ownership of its commands and completion state. Tasks does not turn a "
"Workflow handoff, Postbox message, approval, or notification into a copied task. Filters, "
"due dates, priorities, and source links help the current actor resume work safely."
),
layer="configured",
documentation_types=("user", "admin"),
audience=("user", "operator", "tenant_admin", "module_admin"),
related_modules=(
"workflow_engine",
"notifications",
"postbox",
"approvals",
"views",
"dashboard",
),
links=(
DocumentationLink(
label="Tasks domain",
href="govoplan-tasks/docs/TASKS_DOMAIN.md",
kind="repository",
),
),
translations={
"de": {
"title": "Gemeinsamer Arbeitsvorrat",
"summary": "Explizite Aufgaben und Arbeitsvorgaenge anderer Module sicher fortsetzen.",
"body": (
"Der Arbeitsvorrat verbindet explizite Aufgaben mit Arbeitsobjekten aktivierter Module. "
"Jede Quelle behaelt die Verantwortung fuer Befehle und Abschlussstatus. Tasks kopiert "
"keine Workflow-Uebergabe, Postfachnachricht, Freigabe oder Benachrichtigung in einen "
"zweiten Fachzustand. Filter, Fristen, Prioritaeten und Quellverweise helfen beim sicheren Fortsetzen."
),
}
},
metadata={
"help_contexts": [
"tasks.route.work",
"tasks.page.inbox",
"tasks.page.detail",
"tasks.action.create",
"tasks.action.advance",
"tasks.field.assignment",
"tasks.field.due-at",
"tasks.field.priority",
]
},
),
)
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=("access",),
optional_dependencies=(
"idm",
"organizations",
"workflow_engine",
"workflow",
"notifications",
"postbox",
"approvals",
"views",
"dashboard",
"search",
),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_TASK_COMMANDS, version="1.0.0"),
ModuleInterfaceProvider(name="tasks.work_items", version="1.0.0"),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
route_factory=_router,
nav_items=(
NavItem(
path="/tasks",
label="Work",
icon="list-checks",
required_any=(READ_SCOPE,),
order=21,
surface_id="tasks.route.work",
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/tasks-webui",
routes=(
FrontendRoute(
path="/tasks",
component="TasksPage",
required_any=(READ_SCOPE,),
order=21,
surface_id="tasks.route.work",
),
),
view_surfaces=(
ViewSurface(
id="tasks.page.inbox",
module_id=MODULE_ID,
kind="section",
label="Work inbox",
parent_id="tasks.route.work",
order=20,
),
ViewSurface(
id="tasks.page.detail",
module_id=MODULE_ID,
kind="section",
label="Work details",
parent_id="tasks.route.work",
order=30,
),
ViewSurface(
id="tasks.action.create",
module_id=MODULE_ID,
kind="action",
label="Create task",
parent_id="tasks.page.inbox",
order=40,
),
ViewSurface(
id="tasks.action.advance",
module_id=MODULE_ID,
kind="action",
label="Advance task",
parent_id="tasks.page.detail",
order=50,
),
ViewSurface(
id="tasks.widget.open-work",
module_id=MODULE_ID,
kind="section",
label="Open work widget",
order=60,
),
),
),
tenant_summary_providers=(_tenant_summary,),
capability_factories={CAPABILITY_TASK_COMMANDS: _service},
capability_documentation={
CAPABILITY_TASK_COMMANDS: CapabilityDocumentation(
label="Task commands",
summary="Creates replay-safe explicit tasks without importing the Tasks implementation.",
contract_version="1.0.0",
)
},
work_item_providers=(
WorkItemProviderRegistration(id="tasks.explicit", factory=_service, order=10),
),
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(
task_models.TaskAssignment, task_models.TaskItem, label="Tasks"
),
retirement_notes="Destructive retirement removes explicit task state after a database snapshot; contributed work remains with its owner.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
task_models.TaskItem, task_models.TaskAssignment, label="Tasks"
),
),
documentation=DOCUMENTATION,
architecture=declared_module_architecture(
layer="human_work_procedure",
kind="domain",
maturity="vertical_slice",
documentation_ref="docs/TASKS_DOMAIN.md",
test_ref="tests/test_tasks.py",
known_limits=(
"Function assignment resolution depends on the optional IDM directory; source-owned inline commands remain deep links in this first slice.",
),
supported_authority_modes=("native_authoritative", "linked_reference"),
owned_concepts=("explicit task", "task assignment", "unified work inbox"),
non_owned_concepts=(
"workflow instance",
"notification",
"postbox message",
"approval request",
"domain object",
),
reference_packages=(
"product.service-to-decision",
"product.governed-communication",
"product.governed-data-assurance",
),
migration_docs=("docs/TASKS_DOMAIN.md",),
recovery_docs=("docs/TASKS_DOMAIN.md",),
security_docs=("docs/TASKS_DOMAIN.md",),
operations_docs=("docs/TASKS_DOMAIN.md",),
),
)
def get_manifest() -> ModuleManifest:
return manifest
__all__ = [
"ADMIN_SCOPE",
"MODULE_ID",
"MODULE_VERSION",
"READ_SCOPE",
"WRITE_SCOPE",
"get_manifest",
"manifest",
]
@@ -0,0 +1 @@
@@ -0,0 +1,110 @@
"""v0.1.18 Tasks kernel.
Revision ID: 7c4d9a2e1f30
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "7c4d9a2e1f30"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"task_items",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("summary", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("priority", sa.String(length=20), nullable=False),
sa.Column("required_action", sa.String(length=500), nullable=True),
sa.Column("action_url", sa.String(length=1500), nullable=True),
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deferred_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_module", sa.String(length=100), nullable=True),
sa.Column("source_resource_type", sa.String(length=100), nullable=True),
sa.Column("source_resource_id", sa.String(length=255), nullable=True),
sa.Column("source_revision", sa.String(length=255), nullable=True),
sa.Column("sources", sa.JSON(), nullable=False),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.String(length=255), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_by", sa.String(length=255), nullable=True),
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_task_item_idempotency"
),
)
for column in (
"tenant_id",
"status",
"priority",
"due_at",
"deferred_until",
"source_module",
"source_resource_type",
"source_resource_id",
"idempotency_key",
"created_by",
"updated_by",
):
op.create_index(f"ix_task_items_{column}", "task_items", [column])
op.create_index(
"ix_task_items_tenant_status", "task_items", ["tenant_id", "status"]
)
op.create_index(
"ix_task_items_tenant_due", "task_items", ["tenant_id", "due_at", "status"]
)
op.create_index(
"ix_task_items_source",
"task_items",
["tenant_id", "source_module", "source_resource_type", "source_resource_id"],
)
op.create_table(
"task_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("task_id", sa.String(length=36), nullable=False),
sa.Column("assignment_kind", sa.String(length=40), nullable=False),
sa.Column("assignment_id", sa.String(length=255), nullable=False),
sa.Column("assignment_label", sa.String(length=500), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["task_id"], ["task_items.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"task_id",
"assignment_kind",
"assignment_id",
name="uq_task_assignment_target",
),
)
for column in ("tenant_id", "task_id", "assignment_kind", "assignment_id"):
op.create_index(f"ix_task_assignments_{column}", "task_assignments", [column])
op.create_index(
"ix_task_assignment_lookup",
"task_assignments",
["tenant_id", "assignment_kind", "assignment_id", "task_id"],
)
def downgrade() -> None:
op.drop_table("task_assignments")
op.drop_table("task_items")
@@ -0,0 +1 @@
+343
View File
@@ -0,0 +1,343 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.concurrency import (
ConcurrencyError,
MissingPreconditionError,
RevisionConflictError,
assert_revision_precondition,
)
from govoplan_core.core.tasks import (
TaskCreateCommand,
WorkAssignmentRef,
WorkItem,
WorkItemQuery,
WorkSourceRef,
)
from govoplan_core.db.session import get_session
from govoplan_tasks.backend.aggregation import aggregate_work_items
from govoplan_tasks.backend.schemas import (
TaskActionPayload,
TaskCreatePayload,
WorkItemListResponse,
WorkItemResponse,
WorkSummaryResponse,
)
from govoplan_tasks.backend.service import (
ACTIVE_STATUSES,
SqlTaskService,
TaskConflict,
TaskError,
TaskForbidden,
TaskNotFound,
task_etag,
)
READ_SCOPE = "tasks:item:read"
WRITE_SCOPE = "tasks:item:write"
ADMIN_SCOPE = "tasks:item:admin"
def create_router(registry: object) -> APIRouter:
router = APIRouter(prefix="/tasks", tags=["tasks"])
explicit_tasks = SqlTaskService(registry)
@router.get("", response_model=WorkItemListResponse)
def list_work(
status_filter: list[str] | None = Query(default=None, alias="status"),
priority: list[str] | None = Query(default=None),
provider: list[str] | None = Query(default=None),
owner_module: list[str] | None = Query(default=None),
due_before: datetime | None = Query(default=None),
query_text: str = Query(default="", alias="q", max_length=500),
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkItemListResponse:
_require(principal, READ_SCOPE)
query = _query(
principal,
statuses=status_filter,
priorities=priority,
provider_ids=provider,
owner_modules=owner_module,
due_before=due_before,
text=query_text,
limit=limit,
)
aggregation = aggregate_work_items(
registry,
session,
principal,
query=query,
)
return WorkItemListResponse(
items=[_response(item) for item in aggregation.items],
total=aggregation.total,
truncated=aggregation.truncated,
diagnostics=list(aggregation.diagnostics),
)
@router.get("/summary", response_model=WorkSummaryResponse)
def work_summary(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkSummaryResponse:
_require(principal, READ_SCOPE)
query = _query(principal, limit=500)
aggregation = aggregate_work_items(
registry,
session,
principal,
query=query,
)
now = datetime.now(UTC)
return WorkSummaryResponse(
total=aggregation.total,
open=sum(item.status == "open" for item in aggregation.items),
in_progress=sum(item.status == "in_progress" for item in aggregation.items),
deferred=sum(item.status == "deferred" for item in aggregation.items),
blocked=sum(item.status == "blocked" for item in aggregation.items),
overdue=sum(
item.due_at is not None
and _aware(item.due_at) < now
and item.status in ACTIVE_STATUSES
for item in aggregation.items
),
urgent=sum(item.priority == "urgent" for item in aggregation.items),
truncated=aggregation.truncated,
diagnostics=list(aggregation.diagnostics),
)
@router.post(
"", response_model=WorkItemResponse, status_code=status.HTTP_201_CREATED
)
def create_task(
payload: TaskCreatePayload,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkItemResponse:
_require(principal, WRITE_SCOPE)
try:
item = explicit_tasks.create_task(
session,
principal,
command=TaskCreateCommand(
tenant_id=principal.tenant_id,
title=payload.title,
summary=payload.summary,
priority=payload.priority,
due_at=payload.due_at,
required_action=payload.required_action,
action_url=payload.action_url,
assignments=tuple(
WorkAssignmentRef(**value.model_dump())
for value in payload.assignments
),
sources=tuple(
WorkSourceRef(**value.model_dump()) for value in payload.sources
),
provenance=payload.provenance,
metadata=payload.metadata,
idempotency_key=payload.idempotency_key,
),
)
session.commit()
except (TaskError, ValueError) as exc:
session.rollback()
raise _task_error(exc) from exc
_set_etag(response, item)
return _response(item)
@router.get("/{task_id}", response_model=WorkItemResponse)
def get_task(
task_id: str,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkItemResponse:
_require(principal, READ_SCOPE)
try:
row = explicit_tasks.get_task(
session,
principal,
tenant_id=principal.tenant_id,
task_id=task_id,
)
item = explicit_tasks.to_item(row)
except TaskError as exc:
raise _task_error(exc) from exc
_set_etag(response, item)
return _response(item)
@router.post("/{task_id}/actions", response_model=WorkItemResponse)
def transition_task(
task_id: str,
payload: TaskActionPayload,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkItemResponse:
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
assert_revision_precondition(
if_match,
resource_type="task",
resource_id=task_id,
submitted_base_revision=payload.expected_revision,
)
item = explicit_tasks.transition_task(
session,
principal,
tenant_id=principal.tenant_id,
task_id=task_id,
action=payload.action,
expected_revision=payload.expected_revision,
deferred_until=payload.deferred_until,
comment=payload.comment,
)
session.commit()
except (TaskError, ConcurrencyError) as exc:
session.rollback()
raise _mutation_error(exc) from exc
_set_etag(response, item)
return _response(item)
return router
def _query(
principal: ApiPrincipal,
*,
statuses: list[str] | None = None,
priorities: list[str] | None = None,
provider_ids: list[str] | None = None,
owner_modules: list[str] | None = None,
due_before: datetime | None = None,
text: str = "",
limit: int = 100,
) -> WorkItemQuery:
allowed_statuses = {
"open",
"in_progress",
"deferred",
"blocked",
"completed",
"cancelled",
}
allowed_priorities = {"low", "normal", "high", "urgent"}
normalized_statuses = tuple(statuses or ACTIVE_STATUSES)
normalized_priorities = tuple(priorities or ())
if any(value not in allowed_statuses for value in normalized_statuses):
raise HTTPException(status_code=422, detail="Unsupported task status filter.")
if any(value not in allowed_priorities for value in normalized_priorities):
raise HTTPException(status_code=422, detail="Unsupported task priority filter.")
return WorkItemQuery(
tenant_id=principal.tenant_id,
statuses=normalized_statuses, # type: ignore[arg-type]
priorities=normalized_priorities, # type: ignore[arg-type]
provider_ids=tuple(provider_ids or ()),
owner_modules=tuple(owner_modules or ()),
due_before=due_before,
text=text,
limit=limit,
)
def _response(item: WorkItem) -> WorkItemResponse:
return WorkItemResponse(
id=item.id,
provider_id=item.provider_id,
owner_module=item.owner_module,
tenant_id=item.tenant_id,
title=item.title,
status=item.status,
priority=item.priority,
summary=item.summary,
required_action=item.required_action,
action_url=item.action_url,
due_at=item.due_at,
deferred_until=item.deferred_until,
assignments=[
{"kind": value.kind, "id": value.id, "label": value.label}
for value in item.assignments
],
sources=[
{
"module_id": value.module_id,
"resource_type": value.resource_type,
"resource_id": value.resource_id,
"revision": value.revision,
"url": value.url,
"label": value.label,
}
for value in item.sources
],
provenance=dict(item.provenance),
metadata=dict(item.metadata),
revision=item.revision,
etag=task_etag(item) if item.provider_id == "tasks.explicit" else None,
created_at=item.created_at,
updated_at=item.updated_at,
)
def _aware(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
def _require(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
def _require_any(principal: ApiPrincipal, *scopes: str) -> None:
if not any(has_scope(principal, scope) for scope in scopes):
raise HTTPException(
status_code=403, detail=f"Requires one of: {', '.join(scopes)}"
)
def _task_error(exc: TaskError | ValueError) -> HTTPException:
if isinstance(exc, TaskNotFound):
code = status.HTTP_404_NOT_FOUND
elif isinstance(exc, TaskForbidden):
code = status.HTTP_403_FORBIDDEN
elif isinstance(exc, TaskConflict):
code = status.HTTP_409_CONFLICT
else:
code = status.HTTP_422_UNPROCESSABLE_CONTENT
return HTTPException(
status_code=code,
detail={"code": getattr(exc, "code", "invalid_task"), "message": str(exc)},
)
def _mutation_error(exc: TaskError | ConcurrencyError) -> HTTPException:
if isinstance(exc, MissingPreconditionError):
return HTTPException(status_code=428, detail=exc.as_dict())
if isinstance(exc, RevisionConflictError):
return HTTPException(status_code=412, detail=exc.as_dict())
if isinstance(exc, ConcurrencyError):
return HTTPException(
status_code=400,
detail={"code": "invalid_precondition", "message": str(exc)},
)
return _task_error(exc)
def _set_etag(response: Response, item: WorkItem) -> None:
etag = task_etag(item)
if etag:
response.headers["ETag"] = etag
__all__ = ["create_router"]
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
class WorkAssignmentPayload(BaseModel):
kind: Literal[
"account",
"group",
"role",
"function",
"function_assignment",
"anyone",
]
id: str = Field(min_length=1, max_length=255)
label: str | None = Field(default=None, max_length=500)
class WorkSourcePayload(BaseModel):
module_id: str = Field(min_length=1, max_length=100)
resource_type: str = Field(min_length=1, max_length=100)
resource_id: str = Field(min_length=1, max_length=255)
revision: str | None = Field(default=None, max_length=255)
url: str | None = Field(default=None, max_length=1500)
label: str | None = Field(default=None, max_length=500)
class WorkItemResponse(BaseModel):
id: str
provider_id: str
owner_module: str
tenant_id: str
title: str
status: str
priority: str
summary: str | None = None
required_action: str | None = None
action_url: str | None = None
due_at: datetime | None = None
deferred_until: datetime | None = None
assignments: list[WorkAssignmentPayload] = Field(default_factory=list)
sources: list[WorkSourcePayload] = Field(default_factory=list)
provenance: dict[str, Any] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
revision: str
etag: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class WorkProviderDiagnostic(BaseModel):
provider_id: str
owner_module: str
code: str
message: str
class WorkItemListResponse(BaseModel):
items: list[WorkItemResponse]
total: int
truncated: bool = False
diagnostics: list[WorkProviderDiagnostic] = Field(default_factory=list)
class WorkSummaryResponse(BaseModel):
total: int
open: int
in_progress: int
deferred: int
blocked: int
overdue: int
urgent: int
truncated: bool = False
diagnostics: list[WorkProviderDiagnostic] = Field(default_factory=list)
class TaskCreatePayload(BaseModel):
title: str = Field(min_length=1, max_length=500)
summary: str | None = Field(default=None, max_length=4000)
priority: Literal["low", "normal", "high", "urgent"] = "normal"
due_at: datetime | None = None
required_action: str | None = Field(default=None, max_length=500)
action_url: str | None = Field(default=None, max_length=1500)
assignments: list[WorkAssignmentPayload] = Field(min_length=1, max_length=100)
sources: list[WorkSourcePayload] = Field(default_factory=list, max_length=100)
provenance: dict[str, Any] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
idempotency_key: str = Field(min_length=1, max_length=255)
class TaskActionPayload(BaseModel):
action: Literal["start", "complete", "defer", "reopen", "cancel"]
expected_revision: int = Field(ge=1)
deferred_until: datetime | None = None
comment: str | None = Field(default=None, max_length=4000)
@model_validator(mode="after")
def validate_action(self) -> "TaskActionPayload":
if self.action == "defer" and self.deferred_until is None:
raise ValueError("Deferring a task requires a date and time.")
if self.action != "defer" and self.deferred_until is not None:
raise ValueError("Only a defer action accepts deferred_until.")
return self
__all__ = [
"TaskActionPayload",
"TaskCreatePayload",
"WorkAssignmentPayload",
"WorkItemListResponse",
"WorkItemResponse",
"WorkProviderDiagnostic",
"WorkSourcePayload",
"WorkSummaryResponse",
]
+516
View File
@@ -0,0 +1,516 @@
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session, selectinload
from govoplan_core.core.change_sequence import record_change
from govoplan_core.core.concurrency import (
RevisionConflictError,
claim_revision,
strong_resource_etag,
)
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory
from govoplan_core.core.tasks import (
TaskCreateCommand,
WorkAssignmentRef,
WorkItem,
WorkItemPage,
WorkItemQuery,
WorkSourceRef,
)
from govoplan_tasks.backend.db.models import TaskAssignment, TaskItem
READ_SCOPE = "tasks:item:read"
WRITE_SCOPE = "tasks:item:write"
ADMIN_SCOPE = "tasks:item:admin"
PROVIDER_ID = "tasks.explicit"
ACTIVE_STATUSES = ("open", "in_progress", "deferred", "blocked")
class TaskError(RuntimeError):
code = "task_error"
class TaskNotFound(TaskError):
code = "task_not_found"
class TaskForbidden(TaskError):
code = "task_forbidden"
class TaskConflict(TaskError):
code = "task_conflict"
class SqlTaskService:
def __init__(self, registry: object | None = None) -> None:
self.registry = registry
def list_items(
self,
session: object,
principal: object,
*,
query: WorkItemQuery,
) -> WorkItemPage:
if not isinstance(session, Session):
raise TypeError("Tasks requires a SQLAlchemy session.")
self._require_tenant(principal, query.tenant_id)
if not _has(principal, READ_SCOPE) and not _has(principal, ADMIN_SCOPE):
raise TaskForbidden("The current principal may not read tasks.")
statement = self._visible_statement(principal, query.tenant_id)
if query.statuses:
statement = statement.where(TaskItem.status.in_(query.statuses))
if query.priorities:
statement = statement.where(TaskItem.priority.in_(query.priorities))
if query.due_before is not None:
statement = statement.where(TaskItem.due_at <= query.due_before)
if query.text:
pattern = f"%{_escape_like(query.text)}%"
statement = statement.where(
or_(
TaskItem.title.ilike(pattern, escape="\\"),
TaskItem.summary.ilike(pattern, escape="\\"),
TaskItem.required_action.ilike(pattern, escape="\\"),
)
)
count_statement = select(func.count()).select_from(
statement.order_by(None).subquery()
)
total = int(session.scalar(count_statement) or 0)
rows = list(
session.scalars(
statement.order_by(
_priority_rank(),
TaskItem.due_at.is_(None),
TaskItem.due_at.asc(),
TaskItem.updated_at.desc(),
TaskItem.id.desc(),
).limit(query.limit)
).unique()
)
return WorkItemPage(
items=tuple(self.to_item(row) for row in rows),
total=total,
truncated=total > len(rows),
)
def get_task(
self,
session: Session,
principal: object,
*,
tenant_id: str,
task_id: str,
for_update: bool = False,
) -> TaskItem:
self._require_tenant(principal, tenant_id)
statement = self._visible_statement(principal, tenant_id).where(
TaskItem.id == task_id
)
if for_update:
statement = statement.with_for_update()
task = session.scalar(statement)
if task is None:
raise TaskNotFound("Task not found or not visible.")
return task
def create_task(
self,
session: object,
principal: object,
*,
command: TaskCreateCommand,
) -> WorkItem:
if not isinstance(session, Session):
raise TypeError("Tasks requires a SQLAlchemy session.")
self._require_tenant(principal, command.tenant_id)
if not _has(principal, WRITE_SCOPE) and not _has(principal, ADMIN_SCOPE):
raise TaskForbidden("The current principal may not create tasks.")
digest = _command_digest(command)
existing = session.scalar(
select(TaskItem)
.where(
TaskItem.tenant_id == command.tenant_id,
TaskItem.idempotency_key == command.idempotency_key,
)
.options(selectinload(TaskItem.assignments))
)
if existing is not None:
if existing.request_sha256 != digest:
raise TaskConflict(
"The idempotency key already identifies a different task request."
)
return self.to_item(existing)
sources = [_source_dict(item) for item in command.sources]
primary = command.sources[0] if command.sources else None
actor_id = _account_id(principal)
task = TaskItem(
tenant_id=command.tenant_id,
title=command.title.strip(),
summary=_optional(command.summary),
status="open",
priority=command.priority,
due_at=command.due_at,
required_action=_optional(command.required_action),
action_url=_optional(command.action_url),
source_module=primary.module_id if primary else None,
source_resource_type=primary.resource_type if primary else None,
source_resource_id=primary.resource_id if primary else None,
source_revision=primary.revision if primary else None,
sources=sources,
provenance=dict(command.provenance),
metadata_=dict(command.metadata),
idempotency_key=command.idempotency_key.strip(),
request_sha256=digest,
created_by=actor_id,
updated_by=actor_id,
)
task.assignments = [
TaskAssignment(
tenant_id=command.tenant_id,
assignment_kind=item.kind,
assignment_id=item.id,
assignment_label=item.label,
)
for item in _deduplicate_assignments(command.assignments)
]
session.add(task)
session.flush()
record_change(
session,
module_id="tasks",
collection="work_items",
resource_type="task",
resource_id=task.id,
operation="created",
tenant_id=task.tenant_id,
actor_type="account" if actor_id else None,
actor_id=actor_id,
payload={
"status": task.status,
"priority": task.priority,
"assignment_count": len(task.assignments),
"source_module": task.source_module,
},
)
return self.to_item(task)
def transition_task(
self,
session: Session,
principal: object,
*,
tenant_id: str,
task_id: str,
action: str,
expected_revision: int,
deferred_until: datetime | None = None,
comment: str | None = None,
) -> WorkItem:
if not _has(principal, WRITE_SCOPE) and not _has(principal, ADMIN_SCOPE):
raise TaskForbidden("The current principal may not update tasks.")
task = self.get_task(
session,
principal,
tenant_id=tenant_id,
task_id=task_id,
for_update=True,
)
previous_status = task.status
next_status = _next_status(task.status, action)
if action == "defer":
if deferred_until is None or _utc(deferred_until) <= datetime.now(UTC):
raise TaskConflict("Deferred tasks require a future date and time.")
try:
next_revision = claim_revision(
session,
model=TaskItem,
filters=(TaskItem.id == task.id, TaskItem.tenant_id == tenant_id),
revision_attribute="revision",
expected_revision=expected_revision,
resource_type="task",
resource_id=task.id,
refresh_path=f"/api/v1/tasks/{task.id}",
)
except RevisionConflictError:
raise
session.refresh(task)
actor_id = _account_id(principal)
now = datetime.now(UTC)
task.revision = next_revision
task.status = next_status
task.updated_by = actor_id
task.deferred_until = _utc(deferred_until) if action == "defer" else None
if action == "complete":
task.completed_at = now
task.completed_by = actor_id
elif action == "reopen":
task.completed_at = None
task.completed_by = None
task.cancelled_at = None
elif action == "cancel":
task.cancelled_at = now
metadata = dict(task.metadata_ or {})
history = list(metadata.get("transition_history") or [])
history.append(
{
"action": action,
"from_status": previous_status,
"to_status": next_status,
"actor_id": actor_id,
"recorded_at": now.isoformat(),
"comment": _optional(comment),
}
)
metadata["transition_history"] = history[-100:]
task.metadata_ = metadata
session.flush()
record_change(
session,
module_id="tasks",
collection="work_items",
resource_type="task",
resource_id=task.id,
operation="updated",
tenant_id=tenant_id,
actor_type="account" if actor_id else None,
actor_id=actor_id,
payload={
"action": action,
"status": next_status,
"revision": next_revision,
},
)
return self.to_item(task)
def _visible_statement(self, principal: object, tenant_id: str):
statement = (
select(TaskItem)
.where(TaskItem.tenant_id == tenant_id)
.options(selectinload(TaskItem.assignments))
)
if _has(principal, ADMIN_SCOPE):
return statement
targets = self._assignment_targets(principal, tenant_id)
conditions = [
and_(
TaskAssignment.assignment_kind == kind,
TaskAssignment.assignment_id.in_(tuple(ids)),
)
for kind, ids in targets.items()
if ids
]
if not conditions:
return statement.where(False)
return statement.join(TaskAssignment).where(or_(*conditions)).distinct()
def _assignment_targets(
self, principal: object, tenant_id: str
) -> dict[str, set[str]]:
targets = {
"account": {_account_id(principal)} if _account_id(principal) else set(),
"group": set(getattr(principal, "group_ids", ()) or ()),
"role": set(getattr(principal, "role_ids", ()) or ()),
"function_assignment": set(
getattr(principal, "function_assignment_ids", ()) or ()
),
"function": set(),
"anyone": {"*"},
}
directory = self._idm_directory()
if directory is not None and _account_id(principal):
assignments = directory.organization_function_assignments_for_account(
_account_id(principal),
tenant_id=tenant_id,
)
targets["function"].update(
item.function_id
for item in assignments
if item.status == "active" and item.tenant_id == tenant_id
)
return targets
def _idm_directory(self) -> IdmDirectory | None:
registry = self.registry
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_IDM_DIRECTORY)
):
return None
provider = registry.capability(CAPABILITY_IDM_DIRECTORY)
return provider if isinstance(provider, IdmDirectory) else None
@staticmethod
def _require_tenant(principal: object, tenant_id: str) -> None:
if str(getattr(principal, "tenant_id", "") or "") != tenant_id:
raise TaskForbidden("Task access is limited to the active tenant.")
@staticmethod
def to_item(task: TaskItem) -> WorkItem:
return WorkItem(
id=task.id,
provider_id=PROVIDER_ID,
owner_module="tasks",
tenant_id=task.tenant_id,
title=task.title,
summary=task.summary,
status=task.status, # type: ignore[arg-type]
priority=task.priority, # type: ignore[arg-type]
required_action=task.required_action,
action_url=task.action_url,
due_at=task.due_at,
deferred_until=task.deferred_until,
assignments=tuple(
WorkAssignmentRef(
kind=item.assignment_kind, # type: ignore[arg-type]
id=item.assignment_id,
label=item.assignment_label,
)
for item in task.assignments
),
sources=tuple(WorkSourceRef(**item) for item in task.sources),
provenance=dict(task.provenance or {}),
metadata=dict(task.metadata_ or {}),
revision=str(task.revision),
created_at=task.created_at,
updated_at=task.updated_at,
)
def task_etag(task: WorkItem) -> str | None:
try:
return strong_resource_etag("task", task.id, int(task.revision))
except (TypeError, ValueError):
return None
def _has(principal: object, scope: str) -> bool:
checker = getattr(principal, "has", None)
return bool(callable(checker) and checker(scope))
def _account_id(principal: object) -> str:
return str(getattr(principal, "account_id", "") or "")
def _optional(value: str | None) -> str | None:
normalized = str(value or "").strip()
return normalized or None
def _utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def _source_dict(value: WorkSourceRef) -> dict[str, str | None]:
return {
"module_id": value.module_id,
"resource_type": value.resource_type,
"resource_id": value.resource_id,
"revision": value.revision,
"url": value.url,
"label": value.label,
}
def _deduplicate_assignments(
assignments: Sequence[WorkAssignmentRef],
) -> tuple[WorkAssignmentRef, ...]:
by_key: dict[tuple[str, str], WorkAssignmentRef] = {}
for item in assignments:
by_key.setdefault((item.kind, item.id), item)
return tuple(by_key.values())
def _command_digest(command: TaskCreateCommand) -> str:
payload = {
"tenant_id": command.tenant_id,
"title": command.title.strip(),
"summary": _optional(command.summary),
"priority": command.priority,
"due_at": command.due_at.isoformat() if command.due_at else None,
"required_action": _optional(command.required_action),
"action_url": _optional(command.action_url),
"assignments": [
{"kind": item.kind, "id": item.id, "label": item.label}
for item in _deduplicate_assignments(command.assignments)
],
"sources": [_source_dict(item) for item in command.sources],
"provenance": dict(command.provenance),
"metadata": dict(command.metadata),
}
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _next_status(current: str, action: str) -> str:
allowed: Mapping[str, Mapping[str, str]] = {
"open": {
"start": "in_progress",
"complete": "completed",
"defer": "deferred",
"cancel": "cancelled",
},
"in_progress": {
"complete": "completed",
"defer": "deferred",
"cancel": "cancelled",
},
"deferred": {
"start": "in_progress",
"complete": "completed",
"reopen": "open",
"cancel": "cancelled",
},
"blocked": {
"reopen": "open",
"cancel": "cancelled",
},
"completed": {"reopen": "open"},
"cancelled": {"reopen": "open"},
}
next_status = allowed.get(current, {}).get(action)
if next_status is None:
raise TaskConflict(
f"Action {action!r} is not available for a {current!r} task."
)
return next_status
def _escape_like(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _priority_rank():
from sqlalchemy import case
return case(
(TaskItem.priority == "urgent", 0),
(TaskItem.priority == "high", 1),
(TaskItem.priority == "normal", 2),
else_=3,
)
__all__ = [
"ACTIVE_STATUSES",
"PROVIDER_ID",
"SqlTaskService",
"TaskConflict",
"TaskError",
"TaskForbidden",
"TaskNotFound",
"task_etag",
]
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+41
View File
@@ -0,0 +1,41 @@
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_access.backend.manifest import get_manifest as get_access_manifest
from govoplan_core.db.migrations import migrate_database
from govoplan_tasks.backend.manifest import get_manifest as get_tasks_manifest
class TasksMigrationTests(unittest.TestCase):
def test_fresh_migration_creates_task_kernel_and_head(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-tasks-migration-") as directory:
url = f"sqlite:///{Path(directory) / 'tasks.db'}"
migrate_database(
database_url=url,
enabled_modules=("access", "tasks"),
manifest_factories=(get_access_manifest, get_tasks_manifest),
)
engine = create_engine(url)
try:
self.assertTrue(
{"task_items", "task_assignments"}.issubset(
inspect(engine).get_table_names()
)
)
with engine.connect() as connection:
self.assertIn(
"7c4d9a2e1f30",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+293
View File
@@ -0,0 +1,293 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.concurrency import RevisionConflictError
from govoplan_core.core.tasks import (
TaskCreateCommand,
WorkAssignmentRef,
WorkItemQuery,
WorkSourceRef,
)
from govoplan_core.db.base import Base
from govoplan_tasks.backend.db.models import TaskAssignment, TaskItem
from govoplan_tasks.backend.manifest import get_manifest
from govoplan_tasks.backend.service import (
ADMIN_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
SqlTaskService,
TaskConflict,
TaskForbidden,
TaskNotFound,
)
class TaskServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
self.tables = (
ChangeSequenceEntry.__table__,
TaskItem.__table__,
TaskAssignment.__table__,
)
Base.metadata.create_all(self.engine, tables=self.tables)
self.Session = sessionmaker(bind=self.engine)
self.service = SqlTaskService()
def tearDown(self) -> None:
Base.metadata.drop_all(self.engine, tables=reversed(self.tables))
self.engine.dispose()
@staticmethod
def principal(
account_id: str,
*,
tenant_id: str = "tenant-1",
scopes: set[str] | None = None,
group_ids: set[str] | None = None,
role_ids: set[str] | None = None,
function_assignment_ids: set[str] | None = None,
) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id=account_id,
membership_id=f"membership-{account_id}",
tenant_id=tenant_id,
scopes=frozenset(
{READ_SCOPE, WRITE_SCOPE} if scopes is None else scopes
),
group_ids=frozenset(group_ids or ()),
role_ids=frozenset(role_ids or ()),
function_assignment_ids=frozenset(function_assignment_ids or ()),
),
account=SimpleNamespace(id=account_id),
user=SimpleNamespace(id=f"membership-{account_id}"),
)
@staticmethod
def command(
*,
idempotency_key: str = "request-1",
title: str = "Review the submission",
assignments: tuple[WorkAssignmentRef, ...] | None = None,
) -> TaskCreateCommand:
return TaskCreateCommand(
tenant_id="tenant-1",
title=title,
summary="Resolve the governed handoff.",
priority="high",
due_at=datetime.now(UTC) + timedelta(days=2),
required_action="Review and decide",
action_url="/cases/case-1",
assignments=assignments
or (WorkAssignmentRef(kind="account", id="account-1"),),
sources=(
WorkSourceRef(
module_id="cases",
resource_type="case",
resource_id="case-1",
revision="3",
url="/cases/case-1",
),
),
provenance={"workflow_instance_id": "workflow-1"},
idempotency_key=idempotency_key,
)
def test_replayed_create_is_idempotent_and_mismatch_is_rejected(self) -> None:
principal = self.principal("account-1")
with self.Session() as session:
command = self.command()
first = self.service.create_task(session, principal, command=command)
replay = self.service.create_task(session, principal, command=command)
self.assertEqual(first.id, replay.id)
self.assertEqual(1, len(session.scalars(select(TaskItem)).all()))
self.assertEqual(
1,
len(session.scalars(select(ChangeSequenceEntry)).all()),
)
with self.assertRaises(TaskConflict):
self.service.create_task(
session,
principal,
command=self.command(title="A different task"),
)
def test_visibility_uses_typed_assignments_and_fails_closed(self) -> None:
creator = self.principal(
"account-1",
scopes={READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE},
)
with self.Session() as session:
account_task = self.service.create_task(
session,
creator,
command=self.command(idempotency_key="account"),
)
group_task = self.service.create_task(
session,
creator,
command=self.command(
idempotency_key="group",
assignments=(WorkAssignmentRef(kind="group", id="group-1"),),
),
)
function_assignment_task = self.service.create_task(
session,
creator,
command=self.command(
idempotency_key="function-assignment",
assignments=(
WorkAssignmentRef(
kind="function_assignment",
id="assignment-1",
),
),
),
)
session.flush()
group_reader = self.principal(
"account-2",
group_ids={"group-1"},
function_assignment_ids={"assignment-1"},
)
page = self.service.list_items(
session,
group_reader,
query=WorkItemQuery(tenant_id="tenant-1"),
)
self.assertEqual(
{group_task.id, function_assignment_task.id},
{item.id for item in page.items},
)
self.assertNotIn(account_task.id, {item.id for item in page.items})
hidden = self.principal("account-3")
empty = self.service.list_items(
session,
hidden,
query=WorkItemQuery(tenant_id="tenant-1"),
)
self.assertEqual(0, empty.total)
def test_admin_can_read_all_tenant_work_but_not_another_tenant(self) -> None:
admin = self.principal(
"admin",
scopes={READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE},
)
with self.Session() as session:
task = self.service.create_task(
session,
admin,
command=self.command(
assignments=(WorkAssignmentRef(kind="account", id="someone-else"),),
),
)
page = self.service.list_items(
session,
admin,
query=WorkItemQuery(tenant_id="tenant-1"),
)
self.assertEqual([task.id], [item.id for item in page.items])
with self.assertRaises(TaskForbidden):
self.service.list_items(
session,
admin,
query=WorkItemQuery(tenant_id="tenant-2"),
)
def test_capability_enforces_read_and_write_scopes(self) -> None:
without_scopes = self.principal("account-1", scopes=set())
with self.Session() as session:
with self.assertRaises(TaskForbidden):
self.service.create_task(
session,
without_scopes,
command=self.command(),
)
with self.assertRaises(TaskForbidden):
self.service.list_items(
session,
without_scopes,
query=WorkItemQuery(tenant_id="tenant-1"),
)
def test_transition_uses_revision_and_records_exact_history(self) -> None:
principal = self.principal("account-1")
with self.Session() as session:
created = self.service.create_task(
session,
principal,
command=self.command(),
)
started = self.service.transition_task(
session,
principal,
tenant_id="tenant-1",
task_id=created.id,
action="start",
expected_revision=1,
comment="Taking responsibility",
)
self.assertEqual("in_progress", started.status)
self.assertEqual("2", started.revision)
history = started.metadata["transition_history"]
self.assertEqual("open", history[0]["from_status"])
self.assertEqual("in_progress", history[0]["to_status"])
with self.assertRaises(RevisionConflictError):
self.service.transition_task(
session,
principal,
tenant_id="tenant-1",
task_id=created.id,
action="complete",
expected_revision=1,
)
def test_invalid_transition_and_hidden_task_are_indistinguishable(self) -> None:
owner = self.principal("account-1")
other = self.principal("account-2")
with self.Session() as session:
task = self.service.create_task(session, owner, command=self.command())
with self.assertRaises(TaskConflict):
self.service.transition_task(
session,
owner,
tenant_id="tenant-1",
task_id=task.id,
action="reopen",
expected_revision=1,
)
with self.assertRaises(TaskNotFound):
self.service.get_task(
session,
other,
tenant_id="tenant-1",
task_id=task.id,
)
class TaskManifestTests(unittest.TestCase):
def test_manifest_exposes_static_docs_and_work_provider(self) -> None:
manifest = get_manifest()
self.assertEqual("tasks", manifest.id)
self.assertEqual("tasks.explicit", manifest.work_item_providers[0].id)
self.assertTrue(manifest.documentation)
self.assertEqual("@govoplan/tasks-webui", manifest.frontend.package_name)
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@govoplan/tasks-webui",
"version": "0.1.18",
"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/tasks.css": "./src/styles/tasks.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
}
}
}
+146
View File
@@ -0,0 +1,146 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type WorkStatus =
| "open"
| "in_progress"
| "deferred"
| "blocked"
| "completed"
| "cancelled";
export type WorkPriority = "low" | "normal" | "high" | "urgent";
export type WorkAssignment = {
kind: "account" | "group" | "role" | "function" | "function_assignment" | "anyone";
id: string;
label?: string | null;
};
export type WorkSource = {
module_id: string;
resource_type: string;
resource_id: string;
revision?: string | null;
url?: string | null;
label?: string | null;
};
export type WorkItem = {
id: string;
provider_id: string;
owner_module: string;
tenant_id: string;
title: string;
status: WorkStatus;
priority: WorkPriority;
summary?: string | null;
required_action?: string | null;
action_url?: string | null;
due_at?: string | null;
deferred_until?: string | null;
assignments: WorkAssignment[];
sources: WorkSource[];
provenance: Record<string, unknown>;
metadata: Record<string, unknown>;
revision: string;
etag?: string | null;
created_at?: string | null;
updated_at?: string | null;
};
export type WorkProviderDiagnostic = {
provider_id: string;
owner_module: string;
code: string;
message: string;
};
export type WorkListResponse = {
items: WorkItem[];
total: number;
truncated: boolean;
diagnostics: WorkProviderDiagnostic[];
};
export type WorkSummary = {
total: number;
open: number;
in_progress: number;
deferred: number;
blocked: number;
overdue: number;
urgent: number;
truncated: boolean;
diagnostics: WorkProviderDiagnostic[];
};
export type TaskCreatePayload = {
title: string;
summary?: string | null;
priority: WorkPriority;
due_at?: string | null;
required_action?: string | null;
action_url?: string | null;
assignments: WorkAssignment[];
sources?: WorkSource[];
provenance?: Record<string, unknown>;
metadata?: Record<string, unknown>;
idempotency_key: string;
};
export function listWork(
settings: ApiSettings,
filters: {
statuses?: WorkStatus[];
priorities?: WorkPriority[];
providers?: string[];
modules?: string[];
q?: string;
limit?: number;
} = {}
): Promise<WorkListResponse> {
const params = new URLSearchParams();
filters.statuses?.forEach((value) => params.append("status", value));
filters.priorities?.forEach((value) => params.append("priority", value));
filters.providers?.forEach((value) => params.append("provider", value));
filters.modules?.forEach((value) => params.append("owner_module", value));
if (filters.q) params.set("q", filters.q);
if (filters.limit) params.set("limit", String(filters.limit));
const query = params.toString();
return apiFetch<WorkListResponse>(
settings,
`/api/v1/tasks${query ? `?${query}` : ""}`
);
}
export function loadWorkSummary(settings: ApiSettings): Promise<WorkSummary> {
return apiFetch<WorkSummary>(settings, "/api/v1/tasks/summary");
}
export function createTask(
settings: ApiSettings,
payload: TaskCreatePayload
): Promise<WorkItem> {
return apiFetch<WorkItem>(settings, "/api/v1/tasks", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function transitionTask(
settings: ApiSettings,
task: WorkItem,
action: "start" | "complete" | "defer" | "reopen" | "cancel",
deferredUntil?: string | null
): Promise<WorkItem> {
if (!task.etag) throw new Error("i18n:govoplan-tasks.reason.refresh_required");
return apiFetch<WorkItem>(settings, `/api/v1/tasks/${task.id}/actions`, {
method: "POST",
headers: { "If-Match": task.etag },
body: JSON.stringify({
action,
expected_revision: Number(task.revision),
deferred_until: deferredUntil || null
})
});
}
+385
View File
@@ -0,0 +1,385 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
CalendarClock,
Check,
CirclePlay,
ExternalLink,
ListChecks,
Plus,
RefreshCw,
RotateCcw,
Search,
XCircle
} from "lucide-react";
import { Link } from "react-router";
import {
AdminIconButton,
Button,
DateTimeField,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
FormField,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge,
hasScope,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
createTask,
listWork,
transitionTask,
type TaskCreatePayload,
type WorkItem,
type WorkPriority,
type WorkStatus
} from "../../api/tasks";
type StatusView = "active" | "completed" | "all";
const ACTIVE_STATUSES: WorkStatus[] = ["open", "in_progress", "deferred", "blocked"];
const CLOSED_STATUSES: WorkStatus[] = ["completed", "cancelled"];
const ALL_STATUSES: WorkStatus[] = [...ACTIVE_STATUSES, ...CLOSED_STATUSES];
const DOCUMENTATION = {
contextId: "tasks.page.inbox",
topicId: "tasks.work-inbox",
documentationType: "user" as const
};
export default function TasksPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const [items, setItems] = useState<WorkItem[]>([]);
const [selectedKey, setSelectedKey] = useState("");
const [statusView, setStatusView] = useState<StatusView>("active");
const [searchDraft, setSearchDraft] = useState("");
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [diagnostics, setDiagnostics] = useState<string[]>([]);
const [createOpen, setCreateOpen] = useState(false);
const [deferOpen, setDeferOpen] = useState(false);
const [deferredUntil, setDeferredUntil] = useState("");
const canWrite = hasScope(auth, "tasks:item:write");
const selected = useMemo(
() => items.find((item) => workKey(item) === selectedKey) ?? items[0] ?? null,
[items, selectedKey]
);
useEffect(() => {
void load();
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, statusView, query]);
async function load() {
setLoading(true);
setError("");
try {
const response = await listWork(settings, {
statuses: statusView === "active" ? ACTIVE_STATUSES : statusView === "completed" ? CLOSED_STATUSES : ALL_STATUSES,
q: query,
limit: 500
});
setItems(response.items);
setDiagnostics(response.diagnostics.map((item) => `${item.owner_module}: ${item.message}`));
setSelectedKey((current) => response.items.some((item) => workKey(item) === current) ? current : response.items[0] ? workKey(response.items[0]) : "");
} catch (reason) {
setError(errorMessage(reason));
} finally {
setLoading(false);
}
}
async function runAction(action: "start" | "complete" | "defer" | "reopen" | "cancel") {
if (!selected || selected.provider_id !== "tasks.explicit") return;
setBusy(true);
setError("");
try {
const next = await transitionTask(
settings,
selected,
action,
action === "defer" ? localDateTimeToIso(deferredUntil) : null
);
setItems((current) => current.map((item) => item.id === next.id ? next : item));
setDeferOpen(false);
setDeferredUntil("");
await load();
} catch (reason) {
setError(errorMessage(reason));
} finally {
setBusy(false);
}
}
function submitSearch(event: FormEvent) {
event.preventDefault();
setQuery(searchDraft.trim());
}
return (
<main className="tasks-page" data-help-context-id="tasks.page.inbox">
<div className="tasks-shell">
<aside className="tasks-sidebar">
<div className="tasks-sidebar-bar">
<span className="tasks-title"><ListChecks size={18} /><strong>i18n:govoplan-tasks.work</strong></span>
<span className="tasks-toolbar-actions">
<AdminIconButton
label="i18n:govoplan-tasks.refresh"
icon={<RefreshCw size={16} aria-hidden="true" />}
onClick={() => void load()}
disabled={loading || busy}
/>
{canWrite ? (
<AdminIconButton
label="i18n:govoplan-tasks.create_task"
icon={<Plus size={16} aria-hidden="true" />}
onClick={() => setCreateOpen(true)}
disabled={busy}
helpContextId="tasks.action.create"
/>
) : null}
</span>
</div>
<form className="tasks-search" onSubmit={submitSearch} role="search">
<Search size={15} aria-hidden="true" />
<input
value={searchDraft}
onChange={(event) => setSearchDraft(event.target.value)}
placeholder="i18n:govoplan-tasks.search_placeholder"
aria-label="i18n:govoplan-tasks.search"
/>
</form>
<SegmentedControl
className="tasks-status-filter"
options={[
{ id: "active", label: "i18n:govoplan-tasks.active" },
{ id: "completed", label: "i18n:govoplan-tasks.completed" },
{ id: "all", label: "i18n:govoplan-tasks.all" }
]}
value={statusView}
onChange={setStatusView}
ariaLabel="i18n:govoplan-tasks.status_filter"
width="fill"
/>
<div className="tasks-list">
{loading ? <p className="tasks-note">i18n:govoplan-tasks.loading</p> : null}
{!loading && items.length === 0 ? <p className="tasks-note">i18n:govoplan-tasks.empty</p> : null}
{items.length ? (
<SelectionList label="i18n:govoplan-tasks.work_items">
{items.map((item) => (
<SelectionListItem
key={`${item.provider_id}:${item.id}`}
selected={selected ? workKey(selected) === workKey(item) : false}
onClick={() => setSelectedKey(workKey(item))}
className="tasks-list-item"
>
<span className="tasks-list-heading"><strong>{item.title}</strong><StatusBadge status={item.status} label={statusLabel(item.status)} /></span>
<span className="tasks-list-meta"><span>{moduleLabel(item.owner_module)}</span><span>{dueLabel(item.due_at)}</span></span>
</SelectionListItem>
))}
</SelectionList>
) : null}
</div>
</aside>
<section className="tasks-workspace" data-help-context-id="tasks.page.detail">
<div className="tasks-topbar">
<span className="tasks-detail-title"><ListChecks size={18} /><strong>{selected?.title ?? "i18n:govoplan-tasks.work_details"}</strong></span>
<span className="tasks-toolbar-actions">
<DocumentationHelpLink reference={DOCUMENTATION} />
{selected?.provider_id === "tasks.explicit" && canWrite ? <TaskActions item={selected} busy={busy} onAction={(action) => action === "defer" ? setDeferOpen(true) : void runAction(action)} /> : null}
</span>
</div>
{error ? <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert> : null}
{diagnostics.map((message) => <DismissibleAlert key={message} tone="warning" compact resetKey={message}>{message}</DismissibleAlert>)}
{selected ? <TaskDetails item={selected} /> : (
<div className="tasks-empty-detail"><ListChecks size={24} /><h1>i18n:govoplan-tasks.work</h1><p>i18n:govoplan-tasks.select_help</p></div>
)}
</section>
</div>
<CreateTaskDialog
open={createOpen}
busy={busy}
settings={settings}
auth={auth}
onClose={() => setCreateOpen(false)}
onCreated={async (item) => {
setCreateOpen(false);
setSelectedKey(workKey(item));
await load();
}}
onError={setError}
setBusy={setBusy}
/>
<Dialog
open={deferOpen}
title="i18n:govoplan-tasks.defer_task"
onClose={() => setDeferOpen(false)}
closeDisabled={busy}
portal
helpContextId="tasks.action.advance"
footer={<><Button onClick={() => setDeferOpen(false)} disabled={busy}>i18n:govoplan-tasks.cancel</Button><Button variant="primary" onClick={() => void runAction("defer")} disabled={!deferredUntil || busy}>i18n:govoplan-tasks.defer</Button></>}
>
<FormField label="i18n:govoplan-tasks.resume_at" helpContextId="tasks.field.due-at">
<DateTimeField value={deferredUntil} onChange={setDeferredUntil} min={localDateTime(new Date())} />
</FormField>
</Dialog>
</main>
);
}
function TaskActions({ item, busy, onAction }: { item: WorkItem; busy: boolean; onAction: (action: "start" | "complete" | "defer" | "reopen" | "cancel") => void }) {
if (["completed", "cancelled"].includes(item.status)) {
return <Button onClick={() => onAction("reopen")} disabled={busy}><RotateCcw size={15} /> i18n:govoplan-tasks.reopen</Button>;
}
return (
<>
{["open", "deferred"].includes(item.status) ? <Button onClick={() => onAction("start")} disabled={busy}><CirclePlay size={15} /> i18n:govoplan-tasks.start</Button> : null}
<Button variant="primary" onClick={() => onAction("complete")} disabled={busy}><Check size={15} /> i18n:govoplan-tasks.complete</Button>
<Button onClick={() => onAction("defer")} disabled={busy}><CalendarClock size={15} /> i18n:govoplan-tasks.defer</Button>
<Button variant="danger" onClick={() => onAction("cancel")} disabled={busy}><XCircle size={15} /> i18n:govoplan-tasks.cancel_task</Button>
</>
);
}
function TaskDetails({ item }: { item: WorkItem }) {
const safeAction = safeActionUrl(item.action_url);
return (
<div className="tasks-detail">
<section className="tasks-detail-main">
<div className="tasks-detail-meta"><StatusBadge status={item.status} label={statusLabel(item.status)} /><StatusBadge status={item.priority} label={priorityLabel(item.priority)} /><span>{moduleLabel(item.owner_module)}</span>{item.due_at ? <span>{dueLabel(item.due_at)}</span> : null}</div>
<h1>{item.title}</h1>
{item.summary ? <p>{item.summary}</p> : null}
{item.required_action ? <div className="tasks-required-action"><strong>i18n:govoplan-tasks.required_action</strong><span>{item.required_action}</span></div> : null}
{safeAction ? <Link className="btn btn-primary tasks-open-action" to={safeAction}><ExternalLink size={16} /> i18n:govoplan-tasks.open_work</Link> : null}
</section>
<section className="tasks-properties">
<h2>i18n:govoplan-tasks.context</h2>
<dl>
<div><dt>i18n:govoplan-tasks.source_module</dt><dd>{moduleLabel(item.owner_module)}</dd></div>
<div><dt>i18n:govoplan-tasks.provider</dt><dd>{item.provider_id}</dd></div>
<div><dt>i18n:govoplan-tasks.assigned_to</dt><dd>{item.assignments.map(assignmentLabel).join(", ") || "i18n:govoplan-tasks.not_set"}</dd></div>
<div><dt>i18n:govoplan-tasks.updated</dt><dd>{formatDate(item.updated_at)}</dd></div>
</dl>
</section>
{item.sources.length ? <section className="tasks-sources"><h2>i18n:govoplan-tasks.sources</h2>{item.sources.map((source) => <div key={`${source.module_id}:${source.resource_type}:${source.resource_id}`} className="tasks-source"><strong>{source.label || `${source.resource_type} ${source.resource_id}`}</strong><span>{moduleLabel(source.module_id)}{source.revision ? ` · ${source.revision}` : ""}</span></div>)}</section> : null}
</div>
);
}
function CreateTaskDialog({ open, busy, settings, auth, onClose, onCreated, onError, setBusy }: { open: boolean; busy: boolean; settings: ApiSettings; auth: AuthInfo; onClose: () => void; onCreated: (item: WorkItem) => Promise<void>; onError: (message: string) => void; setBusy: (value: boolean) => void }) {
const [title, setTitle] = useState("");
const [summary, setSummary] = useState("");
const [requiredAction, setRequiredAction] = useState("");
const [priority, setPriority] = useState<WorkPriority>("normal");
const [dueAt, setDueAt] = useState("");
const accountId = auth.principal?.account_id || auth.user.account_id;
async function submit(event: FormEvent) {
event.preventDefault();
if (!title.trim() || !accountId) return;
setBusy(true);
onError("");
const payload: TaskCreatePayload = {
title: title.trim(),
summary: summary.trim() || null,
required_action: requiredAction.trim() || null,
priority,
due_at: dueAt ? localDateTimeToIso(dueAt) : null,
assignments: [{ kind: "account", id: accountId, label: auth.user.display_name || auth.user.email }],
idempotency_key: crypto.randomUUID()
};
try {
const item = await createTask(settings, payload);
setTitle("");
setSummary("");
setRequiredAction("");
setPriority("normal");
setDueAt("");
await onCreated(item);
} catch (reason) {
onError(errorMessage(reason));
} finally {
setBusy(false);
}
}
return (
<Dialog
open={open}
title="i18n:govoplan-tasks.create_task"
onClose={onClose}
closeDisabled={busy}
portal
helpContextId="tasks.action.create"
footer={<><Button onClick={onClose} disabled={busy}>i18n:govoplan-tasks.cancel</Button><Button type="submit" form="tasks-create-form" variant="primary" disabled={!title.trim() || !accountId || busy}>i18n:govoplan-tasks.create</Button></>}
>
<form id="tasks-create-form" className="tasks-create-form" onSubmit={submit}>
<FormField label="i18n:govoplan-tasks.title"><input value={title} onChange={(event) => setTitle(event.target.value)} maxLength={500} autoFocus required /></FormField>
<FormField label="i18n:govoplan-tasks.summary"><textarea value={summary} onChange={(event) => setSummary(event.target.value)} maxLength={4000} rows={4} /></FormField>
<div className="tasks-create-grid">
<FormField label="i18n:govoplan-tasks.priority" helpContextId="tasks.field.priority"><select value={priority} onChange={(event) => setPriority(event.target.value as WorkPriority)}><option value="low">i18n:govoplan-tasks.priority.low</option><option value="normal">i18n:govoplan-tasks.priority.normal</option><option value="high">i18n:govoplan-tasks.priority.high</option><option value="urgent">i18n:govoplan-tasks.priority.urgent</option></select></FormField>
<FormField label="i18n:govoplan-tasks.due_at" helpContextId="tasks.field.due-at"><DateTimeField value={dueAt} onChange={setDueAt} min={localDateTime(new Date())} /></FormField>
</div>
<FormField label="i18n:govoplan-tasks.required_action"><input value={requiredAction} onChange={(event) => setRequiredAction(event.target.value)} maxLength={500} /></FormField>
<p className="tasks-assignment-note">i18n:govoplan-tasks.assigned_to_you</p>
</form>
</Dialog>
);
}
function safeActionUrl(value?: string | null): string | null {
if (!value || !value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return null;
return value;
}
function workKey(item: WorkItem): string {
return `${item.provider_id}:${item.id}`;
}
function assignmentLabel(value: WorkItem["assignments"][number]): string {
return value.label || `${value.kind}: ${value.id}`;
}
function statusLabel(value: string): string {
return `i18n:govoplan-tasks.status.${value}`;
}
function priorityLabel(value: string): string {
return `i18n:govoplan-tasks.priority.${value}`;
}
function moduleLabel(value: string): string {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function dueLabel(value?: string | null): string {
if (!value) return "i18n:govoplan-tasks.no_due_date";
return formatDate(value);
}
function formatDate(value?: string | null): string {
if (!value) return "i18n:govoplan-tasks.not_set";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
}
function localDateTime(value: Date): string {
const shifted = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
return shifted.toISOString().slice(0, 16);
}
function localDateTimeToIso(value: string): string {
return new Date(value).toISOString();
}
function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : "i18n:govoplan-tasks.request_failed";
}
@@ -0,0 +1,46 @@
import { useEffect, useState } from "react";
import { Link } from "react-router";
import {
DismissibleAlert,
LoadingFrame,
MetricCard,
type ApiSettings
} from "@govoplan/core-webui";
import { loadWorkSummary, type WorkSummary } from "../../api/tasks";
export default function TasksSummaryWidget({ settings, refreshKey }: { settings: ApiSettings; refreshKey: number }) {
const [summary, setSummary] = useState<WorkSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
setLoading(true);
void loadWorkSummary(settings)
.then((value) => {
if (active) {
setSummary(value);
setError("");
}
})
.catch((reason: unknown) => {
if (active) setError(reason instanceof Error ? reason.message : "i18n:govoplan-tasks.request_failed");
})
.finally(() => {
if (active) setLoading(false);
});
return () => { active = false; };
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, refreshKey]);
return (
<LoadingFrame loading={loading} label="i18n:govoplan-tasks.loading_summary">
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
<div className="metric-grid inside dashboard-widget-metrics">
<MetricCard label="i18n:govoplan-tasks.open" value={(summary?.open ?? 0) + (summary?.in_progress ?? 0)} tone="info" detail="i18n:govoplan-tasks.actionable_work" />
<MetricCard label="i18n:govoplan-tasks.overdue" value={summary?.overdue ?? 0} tone={summary?.overdue ? "danger" : "good"} detail="i18n:govoplan-tasks.due_date_passed" />
<MetricCard label="i18n:govoplan-tasks.blocked" value={summary?.blocked ?? 0} tone={summary?.blocked ? "warning" : "good"} detail="i18n:govoplan-tasks.needs_resolution" />
</div>
<div className="tasks-widget-actions"><Link className="btn btn-secondary" to="/tasks">i18n:govoplan-tasks.open_work_inbox</Link></div>
</LoadingFrame>
);
}
+134
View File
@@ -0,0 +1,134 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
de: {
"i18n:govoplan-tasks.work": "Arbeit",
"i18n:govoplan-tasks.work_inbox": "Arbeitsvorrat",
"i18n:govoplan-tasks.work_details": "Arbeitsdetails",
"i18n:govoplan-tasks.work_items": "Arbeitsobjekte",
"i18n:govoplan-tasks.work_category": "Arbeit und Verfahren",
"i18n:govoplan-tasks.widget": "Widget für offene Arbeit",
"i18n:govoplan-tasks.widget_description": "Zugewiesene, überfällige und blockierte Arbeit aus aktivierten Modulen.",
"i18n:govoplan-tasks.refresh": "Arbeitsvorrat aktualisieren",
"i18n:govoplan-tasks.create_task": "Aufgabe erstellen",
"i18n:govoplan-tasks.create": "Erstellen",
"i18n:govoplan-tasks.search": "Arbeitsvorrat durchsuchen",
"i18n:govoplan-tasks.search_placeholder": "Titel, Beschreibung oder erforderliche Aktion",
"i18n:govoplan-tasks.status_filter": "Statusfilter",
"i18n:govoplan-tasks.active": "Aktiv",
"i18n:govoplan-tasks.completed": "Abgeschlossen",
"i18n:govoplan-tasks.all": "Alle",
"i18n:govoplan-tasks.loading": "Arbeitsvorrat wird geladen.",
"i18n:govoplan-tasks.empty": "In dieser Ansicht ist keine Arbeit vorhanden.",
"i18n:govoplan-tasks.select_help": "Wählen Sie ein Arbeitsobjekt, um Kontext, Zuständigkeit und nächste Aktion zu prüfen.",
"i18n:govoplan-tasks.start": "Beginnen",
"i18n:govoplan-tasks.complete": "Abschließen",
"i18n:govoplan-tasks.defer": "Zurückstellen",
"i18n:govoplan-tasks.defer_task": "Aufgabe zurückstellen",
"i18n:govoplan-tasks.reopen": "Wieder öffnen",
"i18n:govoplan-tasks.cancel": "Abbrechen",
"i18n:govoplan-tasks.cancel_task": "Aufgabe abbrechen",
"i18n:govoplan-tasks.advance_task": "Aufgabenstatus fortschreiben",
"i18n:govoplan-tasks.resume_at": "Wieder vorlegen am",
"i18n:govoplan-tasks.title": "Titel",
"i18n:govoplan-tasks.summary": "Beschreibung",
"i18n:govoplan-tasks.priority": "Priorität",
"i18n:govoplan-tasks.due_at": "Fällig am",
"i18n:govoplan-tasks.required_action": "Erforderliche Aktion",
"i18n:govoplan-tasks.assigned_to_you": "Die Aufgabe wird Ihnen zugewiesen. Weitere Zuständigkeitsarten stehen über angebundene Verfahren und die API zur Verfügung.",
"i18n:govoplan-tasks.open_work": "Arbeit fortsetzen",
"i18n:govoplan-tasks.context": "Kontext und Zuständigkeit",
"i18n:govoplan-tasks.source_module": "Quellmodul",
"i18n:govoplan-tasks.provider": "Quelle des Arbeitsobjekts",
"i18n:govoplan-tasks.assigned_to": "Zugewiesen an",
"i18n:govoplan-tasks.updated": "Aktualisiert",
"i18n:govoplan-tasks.sources": "Verknüpfte Quellen",
"i18n:govoplan-tasks.not_set": "Nicht festgelegt",
"i18n:govoplan-tasks.no_due_date": "Ohne Frist",
"i18n:govoplan-tasks.request_failed": "Der Arbeitsvorrat konnte nicht verarbeitet werden.",
"i18n:govoplan-tasks.reason.refresh_required": "Die Aufgabe muss vor der Änderung aktualisiert werden.",
"i18n:govoplan-tasks.loading_summary": "Arbeitsübersicht wird geladen",
"i18n:govoplan-tasks.open": "Offen",
"i18n:govoplan-tasks.overdue": "Überfällig",
"i18n:govoplan-tasks.blocked": "Blockiert",
"i18n:govoplan-tasks.actionable_work": "Offen oder in Bearbeitung",
"i18n:govoplan-tasks.due_date_passed": "Frist ist überschritten",
"i18n:govoplan-tasks.needs_resolution": "Hindernis muss geklärt werden",
"i18n:govoplan-tasks.open_work_inbox": "Arbeitsvorrat öffnen",
"i18n:govoplan-tasks.status.open": "Offen",
"i18n:govoplan-tasks.status.in_progress": "In Bearbeitung",
"i18n:govoplan-tasks.status.deferred": "Zurückgestellt",
"i18n:govoplan-tasks.status.blocked": "Blockiert",
"i18n:govoplan-tasks.status.completed": "Abgeschlossen",
"i18n:govoplan-tasks.status.cancelled": "Abgebrochen",
"i18n:govoplan-tasks.priority.low": "Niedrig",
"i18n:govoplan-tasks.priority.normal": "Normal",
"i18n:govoplan-tasks.priority.high": "Hoch",
"i18n:govoplan-tasks.priority.urgent": "Dringend"
},
en: {
"i18n:govoplan-tasks.work": "Work",
"i18n:govoplan-tasks.work_inbox": "Work inbox",
"i18n:govoplan-tasks.work_details": "Work details",
"i18n:govoplan-tasks.work_items": "Work items",
"i18n:govoplan-tasks.work_category": "Work and procedures",
"i18n:govoplan-tasks.widget": "Open work widget",
"i18n:govoplan-tasks.widget_description": "Assigned, overdue, and blocked work from enabled modules.",
"i18n:govoplan-tasks.refresh": "Refresh work inbox",
"i18n:govoplan-tasks.create_task": "Create task",
"i18n:govoplan-tasks.create": "Create",
"i18n:govoplan-tasks.search": "Search work inbox",
"i18n:govoplan-tasks.search_placeholder": "Title, description, or required action",
"i18n:govoplan-tasks.status_filter": "Status filter",
"i18n:govoplan-tasks.active": "Active",
"i18n:govoplan-tasks.completed": "Completed",
"i18n:govoplan-tasks.all": "All",
"i18n:govoplan-tasks.loading": "Loading work.",
"i18n:govoplan-tasks.empty": "There is no work in this view.",
"i18n:govoplan-tasks.select_help": "Select a work item to inspect its context, responsibility, and next action.",
"i18n:govoplan-tasks.start": "Start",
"i18n:govoplan-tasks.complete": "Complete",
"i18n:govoplan-tasks.defer": "Defer",
"i18n:govoplan-tasks.defer_task": "Defer task",
"i18n:govoplan-tasks.reopen": "Reopen",
"i18n:govoplan-tasks.cancel": "Cancel",
"i18n:govoplan-tasks.cancel_task": "Cancel task",
"i18n:govoplan-tasks.advance_task": "Advance task state",
"i18n:govoplan-tasks.resume_at": "Resume at",
"i18n:govoplan-tasks.title": "Title",
"i18n:govoplan-tasks.summary": "Summary",
"i18n:govoplan-tasks.priority": "Priority",
"i18n:govoplan-tasks.due_at": "Due at",
"i18n:govoplan-tasks.required_action": "Required action",
"i18n:govoplan-tasks.assigned_to_you": "The task is assigned to you. Connected procedures and the API support further responsibility types.",
"i18n:govoplan-tasks.open_work": "Continue work",
"i18n:govoplan-tasks.context": "Context and responsibility",
"i18n:govoplan-tasks.source_module": "Source module",
"i18n:govoplan-tasks.provider": "Work source",
"i18n:govoplan-tasks.assigned_to": "Assigned to",
"i18n:govoplan-tasks.updated": "Updated",
"i18n:govoplan-tasks.sources": "Linked sources",
"i18n:govoplan-tasks.not_set": "Not set",
"i18n:govoplan-tasks.no_due_date": "No due date",
"i18n:govoplan-tasks.request_failed": "The work request failed.",
"i18n:govoplan-tasks.reason.refresh_required": "Refresh the task before changing it.",
"i18n:govoplan-tasks.loading_summary": "Loading work summary",
"i18n:govoplan-tasks.open": "Open",
"i18n:govoplan-tasks.overdue": "Overdue",
"i18n:govoplan-tasks.blocked": "Blocked",
"i18n:govoplan-tasks.actionable_work": "Open or in progress",
"i18n:govoplan-tasks.due_date_passed": "Due date has passed",
"i18n:govoplan-tasks.needs_resolution": "A blocker needs resolution",
"i18n:govoplan-tasks.open_work_inbox": "Open work inbox",
"i18n:govoplan-tasks.status.open": "Open",
"i18n:govoplan-tasks.status.in_progress": "In progress",
"i18n:govoplan-tasks.status.deferred": "Deferred",
"i18n:govoplan-tasks.status.blocked": "Blocked",
"i18n:govoplan-tasks.status.completed": "Completed",
"i18n:govoplan-tasks.status.cancelled": "Cancelled",
"i18n:govoplan-tasks.priority.low": "Low",
"i18n:govoplan-tasks.priority.normal": "Normal",
"i18n:govoplan-tasks.priority.high": "High",
"i18n:govoplan-tasks.priority.urgent": "Urgent"
}
};
+2
View File
@@ -0,0 +1,2 @@
export { tasksModule as default, tasksModule } from "./module";
export { default as TasksPage } from "./features/tasks/TasksPage";
+71
View File
@@ -0,0 +1,71 @@
import { createElement, lazy } from "react";
import type {
DashboardWidgetsUiCapability,
PlatformWebModule
} from "@govoplan/core-webui";
import TasksSummaryWidget from "./features/tasks/TasksSummaryWidget";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/tasks.css";
const TasksPage = lazy(() => import("./features/tasks/TasksPage"));
const readScope = ["tasks:item:read"];
const dashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "tasks.open-work",
surfaceId: "tasks.widget.open-work",
title: "i18n:govoplan-tasks.work",
description: "i18n:govoplan-tasks.widget_description",
moduleId: "tasks",
category: "i18n:govoplan-tasks.work_category",
order: 20,
defaultVisible: true,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: readScope,
refreshIntervalMs: 30_000,
render: ({ settings, refreshKey }) => createElement(TasksSummaryWidget, { settings, refreshKey })
}
]
};
export const tasksModule: PlatformWebModule = {
id: "tasks",
label: "i18n:govoplan-tasks.work",
version: "0.1.18",
dependencies: ["access"],
optionalDependencies: ["idm", "organizations", "workflow_engine", "workflow", "notifications", "postbox", "approvals", "views", "dashboard", "search"],
translations: generatedTranslations,
navItems: [
{
to: "/tasks",
label: "i18n:govoplan-tasks.work",
iconName: "list-checks",
anyOf: readScope,
order: 21,
surfaceId: "tasks.route.work"
}
],
routes: [
{
path: "/tasks",
anyOf: readScope,
order: 21,
surfaceId: "tasks.route.work",
render: ({ settings, auth }) => createElement(TasksPage, { settings, auth })
}
],
viewSurfaces: [
{ id: "tasks.page.inbox", moduleId: "tasks", kind: "section", label: "i18n:govoplan-tasks.work_inbox", parentId: "tasks.route.work", order: 20 },
{ id: "tasks.page.detail", moduleId: "tasks", kind: "section", label: "i18n:govoplan-tasks.work_details", parentId: "tasks.route.work", order: 30 },
{ id: "tasks.action.create", moduleId: "tasks", kind: "action", label: "i18n:govoplan-tasks.create_task", parentId: "tasks.page.inbox", order: 40 },
{ id: "tasks.action.advance", moduleId: "tasks", kind: "action", label: "i18n:govoplan-tasks.advance_task", parentId: "tasks.page.detail", order: 50 },
{ id: "tasks.widget.open-work", moduleId: "tasks", kind: "section", label: "i18n:govoplan-tasks.widget", order: 60 }
],
uiCapabilities: {
"dashboard.widgets": dashboardWidgets
}
};
export default tasksModule;
+308
View File
@@ -0,0 +1,308 @@
.tasks-page {
box-sizing: border-box;
height: calc(100vh - 115px);
min-height: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.tasks-page *,
.tasks-page *::before,
.tasks-page *::after {
box-sizing: border-box;
}
.tasks-shell {
height: 100%;
min-height: 0;
display: grid;
grid-template-columns: minmax(285px, 350px) minmax(0, 1fr);
border: var(--border-line);
background: var(--panel);
overflow: hidden;
}
.tasks-sidebar,
.tasks-workspace {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tasks-sidebar {
border-right: var(--border-line);
background: var(--panel-soft);
}
.tasks-sidebar-bar,
.tasks-topbar,
.tasks-title,
.tasks-detail-title,
.tasks-toolbar-actions,
.tasks-list-heading,
.tasks-list-meta,
.tasks-detail-meta,
.tasks-open-action {
display: flex;
align-items: center;
gap: 8px;
}
.tasks-sidebar-bar,
.tasks-topbar {
min-height: 54px;
justify-content: space-between;
border-bottom: var(--border-line);
background: var(--panel-header);
padding: 9px 12px;
}
.tasks-detail-title {
min-width: 0;
}
.tasks-detail-title strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tasks-toolbar-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.tasks-toolbar-actions .btn {
min-height: 32px;
gap: 6px;
}
.tasks-search {
height: 36px;
display: flex;
align-items: center;
gap: 7px;
border: var(--border-line);
border-radius: 5px;
background: var(--surface);
margin: 8px 8px 0;
padding: 0 9px;
}
.tasks-search:focus-within {
border-color: var(--accent);
}
.tasks-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
padding: 0;
}
.tasks-status-filter {
width: calc(100% - 16px);
margin: 8px;
}
.tasks-list {
min-height: 0;
overflow: auto;
padding: 0 8px 8px;
}
.tasks-list-item {
min-height: 64px;
display: grid;
gap: 6px;
}
.tasks-list-heading,
.tasks-list-meta {
min-width: 0;
justify-content: space-between;
}
.tasks-list-heading strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tasks-list-meta,
.tasks-note {
color: var(--muted);
font-size: 12px;
}
.tasks-workspace > .alert {
margin: 8px 12px 0;
}
.tasks-detail {
min-height: 0;
overflow: auto;
padding: 18px;
}
.tasks-detail-main,
.tasks-properties,
.tasks-sources {
border-bottom: var(--border-line);
margin-bottom: 18px;
padding-bottom: 18px;
}
.tasks-detail-main h1 {
margin: 10px 0 8px;
color: var(--text-strong);
font-size: 24px;
letter-spacing: 0;
}
.tasks-detail-main > p {
max-width: 850px;
line-height: 1.55;
white-space: pre-line;
}
.tasks-detail-meta {
color: var(--muted);
font-size: 12px;
}
.tasks-required-action {
max-width: 850px;
display: grid;
gap: 4px;
border-left: 3px solid var(--accent);
background: var(--info-soft);
margin: 14px 0;
padding: 10px 12px;
}
.tasks-open-action {
width: max-content;
max-width: 100%;
text-decoration: none;
}
.tasks-properties h2,
.tasks-sources h2 {
margin: 0 0 12px;
color: var(--text-strong);
font-size: 15px;
}
.tasks-properties dl {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 12px 20px;
margin: 0;
}
.tasks-properties dt {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.tasks-properties dd {
margin: 3px 0 0;
overflow-wrap: anywhere;
}
.tasks-source {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
border-top: var(--border-line);
padding: 10px 0;
}
.tasks-source span {
color: var(--muted);
}
.tasks-empty-detail {
min-height: 100%;
display: grid;
place-content: center;
justify-items: center;
color: var(--muted);
text-align: center;
padding: 24px;
}
.tasks-empty-detail h1 {
margin: 10px 0 0;
color: var(--text-strong);
font-size: 20px;
}
.tasks-create-form {
width: min(620px, 75vw);
display: grid;
gap: 14px;
}
.tasks-create-form textarea {
resize: vertical;
}
.tasks-create-grid {
display: grid;
grid-template-columns: minmax(150px, .75fr) minmax(260px, 1.25fr);
gap: 12px;
}
.tasks-assignment-note {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.tasks-widget-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
@media (max-width: 820px) {
.tasks-shell {
grid-template-columns: minmax(230px, 42%) minmax(0, 1fr);
}
.tasks-topbar {
align-items: flex-start;
}
.tasks-properties dl,
.tasks-create-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {
.tasks-shell {
grid-template-columns: 1fr;
grid-template-rows: minmax(250px, 44%) minmax(0, 1fr);
}
.tasks-sidebar {
border-right: 0;
border-bottom: var(--border-line);
}
.tasks-create-form {
width: min(100%, 88vw);
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference path="../../../govoplan-core/webui/src/vite-env.d.ts" />
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"preserveSymlinks": true,
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"],
"react-router": ["../../govoplan-core/webui/node_modules/react-router/dist/development/index.d.ts"]
}
},
"include": ["src"]
}