15 Commits
Author SHA1 Message Date
zemion 239664092a Release v0.1.17
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 20:34:13 +02:00
zemion 843540b14d Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:29 +02:00
zemion 0731951aef Release v0.1.15
Module Package Release / publish-packages (push) Successful in 10s
2026-08-04 15:10:27 +02:00
zemion 375425b174 Make package publication retries hash-safe 2026-08-04 14:32:20 +02:00
zemion f0e1032ea9 Harden module package publication 2026-08-04 14:02:40 +02:00
zemion bb59342b24 Resolve notification worker items to tenants 2026-08-04 09:29:36 +02:00
zemion 3c71422b90 Add protected package release workflow 2026-08-04 04:14:06 +02:00
zemion ad6a31f68b Migrate Notifications interface patterns 2026-08-03 15:33:49 +02:00
zemion 3b1a87b3e2 Assign Notifications a unique migration revision 2026-08-03 15:05:03 +02:00
zemion f9afe9570d docs: declare institutional architecture boundary 2026-08-01 17:48:37 +02:00
zemion 099725a85b refactor: target workflow engine runtime 2026-07-31 16:59:22 +02:00
zemion a7ad5b99fb Align notification WebUI runtime dependencies 2026-07-31 02:48:56 +02:00
zemion 8153a1de45 feat: route email notifications through mail capability 2026-07-30 17:42:07 +02:00
zemion e32ba3663b perf: add indexed notification summaries and widget 2026-07-29 14:16:29 +02:00
zemion 992d2ca533 Declare notification View surfaces 2026-07-28 21:04:54 +02:00
24 changed files with 1813 additions and 136 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
+16
View File
@@ -0,0 +1,16 @@
# GovOPlaN Notifications Codex Guide
## Scope
This repository owns durable in-product notifications, preferences, delivery attempts, the notification center, and optional channel dispatch.
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Notifications internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Boundaries
- Producing modules own notification meaning; Notifications owns durable delivery state.
- Use optional Mail and Portal capabilities without importing their internals.
+24
View File
@@ -0,0 +1,24 @@
# Email Notification Delivery
In-app notifications are the canonical notification channel. They remain
available when no email implementation is installed.
Production email delivery is an optional integration with the
`mail.notificationDelivery` capability:
- Notifications owns recipient intent, notification preferences, content, and
notification status.
- Mail owns server profiles, credentials, queue persistence, retry policy, and
transport outcomes.
- An accepted handoff is recorded as `accepted`; it does not claim that the
remote SMTP server has delivered the message.
- If Mail or a usable profile is unavailable, delivery is recorded as
`paused`. The notification is retained and can be retried after the
capability becomes available.
- Transport failures are recorded as `failed` with an attempt record and do
not remove the in-app notification.
- File-based EML delivery is development-only and must not be used as a
production fallback.
The capability contract deliberately passes references to recipient, tenant,
and profile context without exposing or duplicating Mail credentials.
+27
View File
@@ -0,0 +1,27 @@
# Notifications interface pattern migration
Notifications uses the platform inbox/list-detail pattern for recipient work and the governed-operation pattern for delivery.
## Surfaces
- `notifications.route.notifications` remains the route identifier so existing Views keep working.
- `notifications.page.inbox` owns status filtering and notification selection.
- `notifications.page.detail` owns message, source, recipient, and read/acknowledgement state.
- `notifications.page.delivery` owns sanitized delivery-attempt evidence.
- `notifications.action.mark-read`, `notifications.action.acknowledge`, and `notifications.action.cancel` describe recipient actions on the selected record.
- `notifications.action.dispatch` describes the privileged bounded delivery operation.
- `notifications.settings.preferences` and `notifications.widget.summary` remain composed Settings and Dashboard surfaces.
The backend and WebUI manifests publish the same identifiers and hierarchy. Notifications does not introduce a navigation entry because the title-bar bell is the platform entry point.
## Consequences and recovery
Read and acknowledgement actions record durable recipient state. Cancellation is confirmed and is available only before an outcome becomes provider-accepted or otherwise terminal; it stops eligible local work and is not presented as remote recall. Dispatch is confirmed separately and processes at most 50 eligible tenant notifications per request. Accepted outcomes are not retried blindly.
Unavailable actions remain visible with explicit selection, permission, busy, terminal-state, and optional-Mail explanations. Personal preference drafts use the shared unsaved-change guard. The settings surface explains that in-product notifications continue when Mail is absent and that production email depends on the optional Mail capability.
Contextual help resolves through `govoplan-docs` when installed and through the hosted fallback otherwise. Delivery evidence remains sanitized and never exposes provider credentials or private sibling-module state.
## Optional boundaries
Producing modules continue to own notification meaning. Notifications owns durable recipient and delivery state and calls optional Mail, Portal, Tasks, Calendar, Scheduling, and Workflow Engine integrations only through declared capabilities and references.
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-notifications"
version = "0.1.8"
version = "0.1.17"
description = "GovOPlaN notification inbox and delivery module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.8",
"govoplan-core>=0.1.17",
]
[tool.setuptools.packages.find]
@@ -10,6 +10,18 @@ from govoplan_notifications.backend.service import deliver_notification, deliver
class SqlNotificationDispatchProvider(NotificationDispatchProvider):
def __init__(self, context: ModuleContext) -> None:
self._settings = context.settings
self._registry = context.registry
def tenant_id_for_notification(
self,
session: object,
*,
notification_id: str,
) -> str | None:
from govoplan_notifications.backend.db.models import NotificationMessage
notification = session.get(NotificationMessage, notification_id) # type: ignore[attr-defined]
return notification.tenant_id if notification is not None else None
def enqueue_notification(
self,
@@ -22,7 +34,12 @@ class SqlNotificationDispatchProvider(NotificationDispatchProvider):
return notification_response(notification)
def deliver_notification(self, session: object, *, notification_id: str) -> Mapping[str, object]:
notification = deliver_notification(session, notification_id=notification_id, settings=self._settings) # type: ignore[arg-type]
notification = deliver_notification(
session,
notification_id=notification_id,
settings=self._settings,
registry=self._registry,
) # type: ignore[arg-type]
return notification_response(notification)
def deliver_pending(
@@ -32,7 +49,13 @@ class SqlNotificationDispatchProvider(NotificationDispatchProvider):
tenant_id: str | None = None,
limit: int = 50,
) -> Mapping[str, object]:
return deliver_pending(session, tenant_id=tenant_id, limit=limit, settings=self._settings) # type: ignore[arg-type]
return deliver_pending(
session,
tenant_id=tenant_id,
limit=limit,
settings=self._settings,
registry=self._registry,
) # type: ignore[arg-type]
def dispatch_capability(context: ModuleContext) -> SqlNotificationDispatchProvider:
@@ -19,6 +19,14 @@ class NotificationMessage(Base, TimestampMixin):
__table_args__ = (
Index("ix_notification_messages_tenant_status", "tenant_id", "status"),
Index("ix_notification_messages_tenant_recipient", "tenant_id", "recipient_type", "recipient_id"),
Index(
"ix_notification_messages_summary",
"tenant_id",
"recipient_id",
"deleted_at",
"status",
"read_at",
),
Index("ix_notification_messages_source", "tenant_id", "source_module", "source_resource_type", "source_resource_id"),
Index("ix_notification_messages_due", "tenant_id", "status", "not_before_at"),
)
+161 -3
View File
@@ -4,15 +4,27 @@ 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 MigrationSpec, ModuleContext, ModuleInterfaceProvider, ModuleManifest, PermissionDefinition, RoleTemplate
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_notifications.backend.db import models as notification_models # noqa: F401 - populate Notifications ORM metadata
MODULE_ID = "notifications"
MODULE_NAME = "Notifications"
MODULE_VERSION = "0.1.8"
MODULE_VERSION = "0.1.17"
READ_SCOPE = "notifications:notification:read"
WRITE_SCOPE = "notifications:notification:write"
DISPATCH_SCOPE = "notifications:delivery:dispatch"
@@ -76,12 +88,146 @@ manifest = ModuleManifest(
name=MODULE_NAME,
version=MODULE_VERSION,
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("mail", "tasks", "portal", "workflow", "calendar", "scheduling"),
optional_dependencies=("mail", "tasks", "portal", "workflow_engine", "calendar", "scheduling"),
provides_interfaces=(ModuleInterfaceProvider(name="notifications.dispatch", version="0.1.8"),),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
route_factory=_notifications_router,
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
id="notifications.center-and-preferences",
title="Use the notification center",
summary="The title-bar badge and notification center collect durable notices that require attention outside an immediate request.",
body="Open the notification center to read, acknowledge, or follow notifications from enabled modules. Preferences control eligible delivery channels and categories. Disabling an optional external channel does not remove the in-product notification unless the originating module's retention policy does so.",
documentation_types=("user",),
audience=("user",),
related_modules=("mail", "calendar", "scheduling", "workflow_engine"),
metadata={
"kind": "reference",
"help_contexts": [
"notifications.route.notifications",
"notifications.page.inbox",
"notifications.page.detail",
"notifications.settings.preferences",
"notifications.widget.summary",
"notifications.state.read-only",
],
"consequence_classes": {
"mark_read": "record the notification as read for the current recipient",
"acknowledge": "record explicit recipient acknowledgement in addition to read state",
"cancel": "stop an eligible notification before provider acceptance without claiming remote recall",
"update_preferences": "replace the current user's badge, channel, digest, and source-muting preferences",
},
},
),
DocumentationTopic(
id="notifications.delivery-operations",
title="Operate notification delivery",
summary="Notifications persists message intent and bounded per-channel attempts before workers dispatch optional delivery channels.",
body="Producing modules emit notifications through the dispatch capability and do not own delivery credentials. In-product delivery is the baseline. Production email delivery is available only through an enabled Mail capability; file delivery remains development-only. Operators can inspect pending and failed attempts and retry only outcomes that are safe to repeat. Tenant module entitlement is checked before enqueue and again before worker delivery; disabling Notifications preserves accepted messages and exposes an operator action instead of silently consuming them.",
documentation_types=("admin",),
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("mail", "audit", "ops"),
metadata={
"kind": "reference",
"help_contexts": [
"notifications.page.delivery",
"notifications.action.dispatch",
"notifications.state.delivery-unavailable",
],
"consequence_classes": {
"dispatch_pending": "attempt delivery for up to the requested number of eligible tenant notifications",
"inspect_attempts": "read sanitized provider, status, timing, and error evidence for the selected notification",
},
},
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/notifications-webui",
routes=(
FrontendRoute(
path="/notifications",
component="NotificationCenterPage",
required_any=(READ_SCOPE,),
order=59,
surface_id="notifications.route.notifications",
),
),
view_surfaces=(
ViewSurface(
id="notifications.page.inbox",
module_id=MODULE_ID,
kind="section",
label="Notification inbox",
parent_id="notifications.route.notifications",
order=20,
),
ViewSurface(
id="notifications.page.detail",
module_id=MODULE_ID,
kind="section",
label="Notification details",
parent_id="notifications.route.notifications",
order=30,
),
ViewSurface(
id="notifications.page.delivery",
module_id=MODULE_ID,
kind="section",
label="Notification delivery evidence",
parent_id="notifications.page.detail",
order=40,
),
ViewSurface(
id="notifications.action.mark-read",
module_id=MODULE_ID,
kind="action",
label="Mark notification as read",
parent_id="notifications.page.detail",
order=50,
),
ViewSurface(
id="notifications.action.acknowledge",
module_id=MODULE_ID,
kind="action",
label="Acknowledge notification",
parent_id="notifications.page.detail",
order=60,
),
ViewSurface(
id="notifications.action.cancel",
module_id=MODULE_ID,
kind="action",
label="Cancel notification delivery",
parent_id="notifications.page.delivery",
order=70,
),
ViewSurface(
id="notifications.action.dispatch",
module_id=MODULE_ID,
kind="action",
label="Dispatch pending notifications",
parent_id="notifications.page.delivery",
order=80,
),
ViewSurface(
id="notifications.widget.summary",
module_id=MODULE_ID,
kind="section",
label="Notification summary widget",
order=90,
),
ViewSurface(
id="notifications.settings.preferences",
module_id=MODULE_ID,
kind="section",
label="Notification preferences",
order=100,
),
),
),
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
@@ -109,6 +255,18 @@ manifest = ModuleManifest(
fromlist=["dispatch_capability"],
).dispatch_capability(context),
},
architecture=declared_module_architecture(
layer="communication_participation",
kind="runtime",
maturity="vertical_slice",
documentation_ref="docs/NOTIFICATION_INBOX_BOUNDARY.md",
test_ref="tests/test_notifications.py",
known_limits=("Production email delivery depends on the optional Mail capability and does not provide an independent transport.",),
owned_concepts=("notification", "notification preference", "notification delivery attempt"),
non_owned_concepts=("mail transport", "domain event", "portal message"),
recovery_docs=("docs/EMAIL_DELIVERY.md",),
operations_docs=("docs/EMAIL_DELIVERY.md",),
),
)
@@ -0,0 +1,29 @@
"""Add notification summary covering index.
Revision ID: 6e2f91ab4c70
Revises: 6f7a8b9c0d1e
"""
from alembic import op
revision = "6e2f91ab4c70"
down_revision = "6f7a8b9c0d1e"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"ix_notification_messages_summary",
"notification_messages",
["tenant_id", "recipient_id", "deleted_at", "status", "read_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
"ix_notification_messages_summary",
table_name="notification_messages",
)
+19 -2
View File
@@ -6,7 +6,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.runtime import get_registry
from govoplan_core.db.session import get_session
from govoplan_core.settings import settings as core_settings
from govoplan_notifications.backend.manifest import ADMIN_SCOPE, DISPATCH_SCOPE, READ_SCOPE, WRITE_SCOPE
from govoplan_notifications.backend.schemas import (
NotificationCreateRequest,
@@ -169,7 +171,13 @@ def api_deliver_pending(
_require_scope(principal, DISPATCH_SCOPE)
if payload.tenant_id is not None:
_require_scope(principal, ADMIN_SCOPE)
result = deliver_pending(session, tenant_id=payload.tenant_id or principal.tenant_id, limit=payload.limit)
result = deliver_pending(
session,
tenant_id=payload.tenant_id or principal.tenant_id,
limit=payload.limit,
settings=core_settings,
registry=get_registry(),
)
session.commit()
return NotificationDeliveryResultResponse.model_validate(result)
@@ -227,17 +235,26 @@ def api_deliver_notification(
_require_scope(principal, DISPATCH_SCOPE)
try:
get_notification(session, tenant_id=principal.tenant_id, notification_id=notification_id)
notification = deliver_notification(session, notification_id=notification_id)
notification = deliver_notification(
session,
notification_id=notification_id,
settings=core_settings,
registry=get_registry(),
)
except NotificationError as exc:
raise _notification_http_error(exc) from exc
session.commit()
sent = 1 if notification.status == "sent" else 0
failed = 1 if notification.status == "failed" else 0
skipped = 1 if notification.status == "skipped" else 0
accepted = 1 if notification.status == "accepted" else 0
paused = 1 if notification.status == "paused" else 0
return NotificationDeliveryResultResponse(
notification=_response(notification),
processed=1,
sent=sent,
accepted=accepted,
paused=paused,
failed=failed,
skipped=skipped,
errors=[notification.last_error] if notification.last_error else [],
+13 -1
View File
@@ -6,7 +6,17 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
NotificationStatus = Literal["pending", "queued", "sending", "sent", "failed", "skipped", "cancelled"]
NotificationStatus = Literal[
"pending",
"queued",
"sending",
"accepted",
"paused",
"sent",
"failed",
"skipped",
"cancelled",
]
def normalize_notification_action_url(value: str | None) -> str | None:
@@ -154,6 +164,8 @@ class NotificationDeliveryResultResponse(BaseModel):
notification: NotificationResponse | None = None
processed: int = 0
sent: int = 0
accepted: int = 0
paused: int = 0
failed: int = 0
skipped: int = 0
errors: list[str] = Field(default_factory=list)
+175 -17
View File
@@ -6,10 +6,15 @@ from email.message import EmailMessage
from pathlib import Path
from typing import Any
from sqlalchemy import event, or_
from sqlalchemy import and_, case, event, func, or_
from sqlalchemy.orm import Session
from govoplan_core.core.mail import (
NotificationMailDeliveryRequest,
notification_mail_delivery_provider,
)
from govoplan_core.core.notifications import NotificationDispatchRequest
from govoplan_core.core.runtime import get_registry
from govoplan_core.db.base import utcnow
from govoplan_notifications.backend.db.models import NotificationDeliveryAttempt, NotificationMessage, NotificationPreference
from govoplan_notifications.backend.schemas import (
@@ -25,6 +30,7 @@ class NotificationError(ValueError):
PENDING_STATUSES = {"pending", "queued", "failed"}
SUMMARY_PENDING_STATUSES = PENDING_STATUSES | {"accepted", "paused", "sending"}
_AFTER_COMMIT_DELIVERY_IDS = "govoplan_notifications_after_commit_delivery_ids"
@@ -200,21 +206,75 @@ def notification_summary(
user_id: str | None = None,
recipient_ids: tuple[str, ...] | None = None,
) -> dict[str, int | bool]:
base_query = session.query(NotificationMessage).filter(
filters = [
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.deleted_at.is_(None),
)
]
if recipient_ids is not None:
base_query = base_query.filter(NotificationMessage.recipient_id.in_(recipient_ids))
active_query = base_query.filter(NotificationMessage.status.notin_(["cancelled", "skipped"]))
filters.append(NotificationMessage.recipient_id.in_(recipient_ids))
active = NotificationMessage.status.notin_(["cancelled", "skipped"])
total, unread, pending, failed = (
session.query(
func.count(NotificationMessage.id),
func.coalesce(
func.sum(
case(
(
and_(
active,
NotificationMessage.read_at.is_(None),
),
1,
),
else_=0,
)
),
0,
),
func.coalesce(
func.sum(
case(
(
and_(
active,
NotificationMessage.status.in_(
sorted(SUMMARY_PENDING_STATUSES)
),
),
1,
),
else_=0,
)
),
0,
),
func.coalesce(
func.sum(
case(
(
and_(
active,
NotificationMessage.status == "failed",
),
1,
),
else_=0,
)
),
0,
),
)
.filter(*filters)
.one()
)
show_unread_badge = True
if user_id:
show_unread_badge = get_notification_preferences(session, tenant_id=tenant_id, user_id=user_id).show_unread_badge
return {
"total": base_query.count(),
"unread": active_query.filter(NotificationMessage.read_at.is_(None)).count(),
"pending": active_query.filter(NotificationMessage.status.in_(sorted(PENDING_STATUSES))).count(),
"failed": active_query.filter(NotificationMessage.status == "failed").count(),
"total": int(total or 0),
"unread": int(unread or 0),
"pending": int(pending or 0),
"failed": int(failed or 0),
"show_unread_badge": show_unread_badge,
}
@@ -262,6 +322,10 @@ def update_notification(
notification.read_at = notification.read_at or now
notification.acknowledged_at = notification.acknowledged_at or now
elif payload.status == "cancelled":
if notification.status not in PENDING_STATUSES | {"paused"}:
raise NotificationError(
f"Notification cannot be cancelled from status {notification.status}"
)
notification.status = "cancelled"
notification.cancelled_at = notification.cancelled_at or now
if payload.metadata is not None:
@@ -275,6 +339,7 @@ def deliver_notification(
*,
notification_id: str,
settings: object | None = None,
registry: object | None = None,
) -> NotificationMessage:
# An explicit same-transaction delivery supersedes the deferred Celery
# handoff registered when the row was created.
@@ -282,7 +347,7 @@ def deliver_notification(
notification = session.get(NotificationMessage, notification_id)
if notification is None or notification.deleted_at is not None:
raise NotificationError("Notification not found")
if notification.status in {"sent", "skipped", "cancelled"}:
if notification.status in {"accepted", "sent", "skipped", "cancelled"}:
return notification
if notification.not_before_at is not None and response_datetime(notification.not_before_at) > _now():
notification.status = "queued"
@@ -291,7 +356,12 @@ def deliver_notification(
attempt = _start_attempt(notification)
try:
result = _deliver_by_channel(notification, settings=settings)
result = _deliver_by_channel(
session,
notification,
settings=settings,
registry=registry if registry is not None else get_registry(),
)
except Exception as exc: # noqa: BLE001 - persisted as delivery failure.
_finish_attempt(attempt, status="failed", error=str(exc))
notification.status = "failed"
@@ -299,7 +369,13 @@ def deliver_notification(
notification.last_error = str(exc)
session.flush()
return notification
_finish_attempt(attempt, status=result["status"], provider=result.get("provider"), details=result)
_finish_attempt(
attempt,
status=result["status"],
provider=result.get("provider"),
error=result.get("error"),
details=result,
)
notification.status = result["status"]
notification.sent_at = _now() if result["status"] == "sent" else notification.sent_at
notification.failed_at = _now() if result["status"] == "failed" else notification.failed_at
@@ -315,6 +391,7 @@ def deliver_pending(
tenant_id: str | None = None,
limit: int = 50,
settings: object | None = None,
registry: object | None = None,
) -> dict[str, Any]:
now = _now()
query = session.query(NotificationMessage).filter(
@@ -325,12 +402,31 @@ def deliver_pending(
if tenant_id:
query = query.filter(NotificationMessage.tenant_id == tenant_id)
notifications = query.order_by(NotificationMessage.priority.desc(), NotificationMessage.created_at.asc()).limit(limit).all()
result = {"processed": 0, "sent": 0, "failed": 0, "skipped": 0, "errors": []}
result = {
"processed": 0,
"sent": 0,
"accepted": 0,
"paused": 0,
"failed": 0,
"skipped": 0,
"errors": [],
}
for notification in notifications:
result["processed"] += 1
delivered = deliver_notification(session, notification_id=notification.id, settings=settings)
delivered = deliver_notification(
session,
notification_id=notification.id,
settings=settings,
registry=registry,
)
if delivered.status == "sent":
result["sent"] += 1
elif delivered.status == "accepted":
result["accepted"] += 1
elif delivered.status == "paused":
result["paused"] += 1
if delivered.last_error:
result["errors"].append(delivered.last_error)
elif delivered.status == "skipped":
result["skipped"] += 1
elif delivered.status == "failed":
@@ -442,17 +538,79 @@ def _finish_attempt(
attempt.external_message_id = str(details["external_message_id"])
def _deliver_by_channel(notification: NotificationMessage, *, settings: object | None) -> dict[str, Any]:
def _deliver_by_channel(
session: Session,
notification: NotificationMessage,
*,
settings: object | None,
registry: object | None,
) -> dict[str, Any]:
if notification.channel == "inbox":
return {"status": "sent", "provider": "inbox", "external_message_id": notification.id}
if notification.channel == "mail":
if not notification.recipient:
return {"status": "skipped", "provider": "mail", "error": "Mail notification has no recipient address"}
path = _write_local_mail(notification, settings=settings)
return {"status": "sent", "provider": "local_file_mail", "external_message_id": str(path), "path": str(path)}
provider = notification_mail_delivery_provider(registry)
if provider is not None:
mail_settings = _notification_mail_settings(notification)
return dict(
provider.submit_notification_mail(
session,
NotificationMailDeliveryRequest(
tenant_id=notification.tenant_id,
notification_id=notification.id,
recipient=notification.recipient,
subject=notification.subject or f"Notification: {notification.event_kind}",
body_text=notification.body_text or notification.subject or notification.event_kind,
body_html=notification.body_html,
action_url=notification.action_url,
mail_profile_id=_optional_text(mail_settings.get("mail_profile_id")),
from_address=_optional_text(mail_settings.get("from_address")),
smtp_server_id=_optional_text(mail_settings.get("smtp_server_id")),
smtp_credential_id=_optional_text(mail_settings.get("smtp_credential_id")),
metadata={
"source_module": notification.source_module,
"event_kind": notification.event_kind,
},
),
)
)
if _development_file_mail_enabled(settings):
path = _write_local_mail(notification, settings=settings)
return {
"status": "sent",
"provider": "local_file_mail",
"external_message_id": str(path),
"path": str(path),
}
return {
"status": "paused",
"provider": "mail",
"error": "Mail-backed notification delivery is unavailable.",
}
return {"status": "skipped", "provider": notification.channel, "error": f"No delivery adapter configured for channel {notification.channel!r}"}
def _notification_mail_settings(
notification: NotificationMessage,
) -> dict[str, object]:
metadata = notification.metadata_ if isinstance(notification.metadata_, dict) else {}
value = metadata.get("mail_delivery")
return dict(value) if isinstance(value, dict) else {}
def _optional_text(value: object) -> str | None:
text = str(value or "").strip()
return text or None
def _development_file_mail_enabled(settings: object | None) -> bool:
if settings is None:
return False
app_env = str(getattr(settings, "app_env", "")).strip().lower()
return app_env in {"dev", "development", "test"}
def _write_local_mail(notification: NotificationMessage, *, settings: object | None) -> Path:
root = Path(str(getattr(settings, "mock_mailbox_dir", "runtime/mock-mailbox"))) / "notifications"
root.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,85 @@
from __future__ import annotations
from pathlib import Path
import unittest
from govoplan_notifications.backend.manifest import get_manifest
REPO_ROOT = Path(__file__).resolve().parents[1]
class NotificationsInterfaceDocumentationContractTests(unittest.TestCase):
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
frontend = get_manifest().frontend
self.assertIsNotNone(frontend)
surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr]
expected = {
"notifications.page.inbox",
"notifications.page.detail",
"notifications.page.delivery",
"notifications.action.mark-read",
"notifications.action.acknowledge",
"notifications.action.cancel",
"notifications.action.dispatch",
"notifications.settings.preferences",
"notifications.widget.summary",
}
self.assertEqual(expected, set(surfaces))
self.assertEqual(
"notifications.route.notifications",
frontend.routes[0].surface_id, # type: ignore[union-attr]
)
self.assertEqual(
"notifications.route.notifications",
surfaces["notifications.page.inbox"].parent_id,
)
self.assertEqual(
"notifications.route.notifications",
surfaces["notifications.page.detail"].parent_id,
)
self.assertEqual(
"notifications.page.detail",
surfaces["notifications.page.delivery"].parent_id,
)
self.assertEqual(
"notifications.page.delivery",
surfaces["notifications.action.dispatch"].parent_id,
)
def test_help_and_consequence_metadata_remain_published(self) -> None:
topics = {topic.id: topic for topic in get_manifest().documentation}
center = topics["notifications.center-and-preferences"]
delivery = topics["notifications.delivery-operations"]
self.assertIn("notifications.page.detail", center.metadata["help_contexts"])
self.assertIn("notifications.settings.preferences", center.metadata["help_contexts"])
self.assertIn("acknowledge", center.metadata["consequence_classes"])
self.assertIn("cancel", center.metadata["consequence_classes"])
self.assertIn("notifications.action.dispatch", delivery.metadata["help_contexts"])
self.assertIn("dispatch_pending", delivery.metadata["consequence_classes"])
def test_webui_uses_shared_consequence_and_draft_patterns(self) -> None:
center = (REPO_ROOT / "webui/src/features/notifications/NotificationCenterPage.tsx").read_text(encoding="utf-8")
settings = (REPO_ROOT / "webui/src/features/notifications/NotificationSettingsPanel.tsx").read_text(encoding="utf-8")
widget = (REPO_ROOT / "webui/src/features/notifications/NotificationSummaryWidget.tsx").read_text(encoding="utf-8")
for component in (
"ActionBlockerHint",
"ConfirmDialog",
"DocumentationHelpLink",
"SelectionList",
):
self.assertIn(component, center)
for component in (
"ActionBlockerHint",
"DocumentationHelpLink",
"ReferenceMultiSelect",
"useUnsavedDraftGuard",
):
self.assertIn(component, settings)
self.assertIn("DocumentationHelpLink", widget)
if __name__ == "__main__":
unittest.main()
+152 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import tempfile
import unittest
from unittest.mock import patch
from pathlib import Path
from types import SimpleNamespace
from fastapi import HTTPException
@@ -11,6 +12,7 @@ from sqlalchemy.orm import sessionmaker
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.mail import CAPABILITY_MAIL_NOTIFICATION_DELIVERY
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import NotificationDispatchRequest
from govoplan_core.db.base import Base
@@ -19,15 +21,43 @@ from govoplan_notifications.backend.db.models import NotificationDeliveryAttempt
from govoplan_notifications.backend.router import api_get_notification, api_list_notifications, api_notification_summary, api_update_notification
from govoplan_notifications.backend.schemas import NotificationCreateRequest, NotificationPreferencesUpdateRequest, NotificationUpdateRequest
from govoplan_notifications.backend.service import (
NotificationError,
create_notification,
deliver_pending,
notification_preferences_response,
notification_response,
notification_summary,
update_notification_preferences,
update_notification,
)
class _NotificationMailProvider:
def __init__(self) -> None:
self.request = None
def submit_notification_mail(self, session, request):
self.request = request
return {
"status": "accepted",
"provider": "mail.delivery_outbox",
"external_message_id": "mail-command-1",
}
class _CapabilityRegistry:
def __init__(self, provider: object) -> None:
self.provider = provider
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_MAIL_NOTIFICATION_DELIVERY
def require_capability(self, name: str) -> object:
if not self.has_capability(name):
raise LookupError(name)
return self.provider
class NotificationServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
@@ -194,6 +224,37 @@ class NotificationServiceTests(unittest.TestCase):
session.commit()
enqueue.assert_not_called()
def test_cancellation_is_limited_to_locally_controllable_delivery_states(self) -> None:
with self.Session() as session:
notification = create_notification(
session,
tenant_id="tenant-1",
payload=self._inbox_payload(recipient_id="user-1", subject="Cancellation boundary"),
)
notification.status = "sent"
session.flush()
with self.assertRaisesRegex(
NotificationError,
"cannot be cancelled from status sent",
):
update_notification(
session,
tenant_id="tenant-1",
notification_id=notification.id,
payload=NotificationUpdateRequest(status="cancelled"),
)
notification.status = "queued"
cancelled = update_notification(
session,
tenant_id="tenant-1",
notification_id=notification.id,
payload=NotificationUpdateRequest(status="cancelled"),
)
self.assertEqual("cancelled", cancelled.status)
self.assertIsNotNone(cancelled.cancelled_at)
def test_action_url_accepts_only_application_relative_paths(self) -> None:
payload = self._inbox_payload(recipient_id="user-1", subject="Safe action")
safe = payload.model_copy(update={"action_url": "/calendar?event=event-1#details"})
@@ -290,7 +351,11 @@ class NotificationServiceTests(unittest.TestCase):
def test_mail_delivery_uses_local_file_transport(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir, self.Session() as session:
settings = type("Settings", (), {"mock_mailbox_dir": tmpdir})()
settings = type(
"Settings",
(),
{"app_env": "dev", "mock_mailbox_dir": tmpdir},
)()
notification = create_notification(
session,
tenant_id="tenant-1",
@@ -310,6 +375,79 @@ class NotificationServiceTests(unittest.TestCase):
self.assertEqual(notification.status, "sent")
self.assertTrue(notification.external_message_id.endswith(".eml"))
def test_production_mail_delivery_pauses_without_mail_capability(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir, self.Session() as session:
notification = create_notification(
session,
tenant_id="tenant-1",
payload=NotificationCreateRequest(
source_module="test",
source_resource_type="thing",
event_kind="created",
channel="mail",
recipient="person@example.test",
subject="Created",
),
)
result = deliver_pending(
session,
tenant_id="tenant-1",
settings=SimpleNamespace(
app_env="production",
mock_mailbox_dir=tmpdir,
),
registry=object(),
)
self.assertEqual(result["paused"], 1)
self.assertEqual(notification.status, "paused")
self.assertEqual(
notification.last_error,
"Mail-backed notification delivery is unavailable.",
)
self.assertEqual(list(Path(tmpdir).rglob("*.eml")), [])
def test_mail_delivery_uses_optional_mail_capability(self) -> None:
provider = _NotificationMailProvider()
registry = _CapabilityRegistry(provider)
with self.Session() as session:
notification = create_notification(
session,
tenant_id="tenant-1",
payload=NotificationCreateRequest(
source_module="test",
source_resource_type="thing",
event_kind="created",
channel="mail",
recipient="person@example.test",
subject="Created",
body_text="Hello",
metadata={
"mail_delivery": {
"mail_profile_id": "profile-1",
"from_address": "notifications@example.test",
}
},
),
)
result = deliver_pending(
session,
tenant_id="tenant-1",
settings=SimpleNamespace(app_env="production"),
registry=registry,
)
self.assertEqual(result["accepted"], 1)
self.assertEqual(notification.status, "accepted")
self.assertEqual(notification.external_message_id, "mail-command-1")
self.assertEqual(provider.request.mail_profile_id, "profile-1")
self.assertEqual(
provider.request.from_address,
"notifications@example.test",
)
def test_dispatch_capability_enqueues_notification(self) -> None:
with self.Session() as session:
capability = dispatch_capability(ModuleContext(registry=object(), settings=object()))
@@ -329,6 +467,19 @@ class NotificationServiceTests(unittest.TestCase):
)
self.assertEqual(payload["source_module"], "scheduling")
self.assertEqual(payload["status"], "pending")
self.assertEqual(
"tenant-1",
capability.tenant_id_for_notification(
session,
notification_id=str(payload["id"]),
),
)
self.assertIsNone(
capability.tenant_id_for_notification(
session,
notification_id="missing",
)
)
def test_summary_counts_unread_active_notifications(self) -> None:
with self.Session() as session:
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/notifications-webui",
"version": "0.1.8",
"version": "0.1.17",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -18,14 +18,14 @@
"test:ui-structure": "node scripts/test-notification-page-structure.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.9",
"@govoplan/core-webui": "^0.1.17",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"@vitejs/plugin-react": "^4.3.4",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
"vite": "^7.3.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -8,11 +8,14 @@ const page = readFileSync(pagePath, "utf8");
const styles = readFileSync(stylesPath, "utf8");
assert.match(page, /SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
assert.match(page, /<SelectionList label="Notifications" className="notifications-selection-list">/);
assert.match(page, /<SelectionList label="i18n:govoplan-notifications\.notifications" className="notifications-selection-list">/);
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selected\?\.id === notification\.id\}/);
assert.match(page, /className=\{`notifications-list-item \$\{notification\.read_at \? "is-read" : ""\}`\}/);
assert.doesNotMatch(page, /<button[\s\S]{0,160}notifications-list-item/);
assert.doesNotMatch(styles, /\.notifications-list-item:(?:hover|focus-visible)/);
assert.doesNotMatch(styles, /\.notifications-list-item\.is-selected/);
assert.match(page, /<ConfirmDialog[\s\S]*open=\{confirmingAction === "cancel"\}/);
assert.match(page, /<ConfirmDialog[\s\S]*open=\{confirmingAction === "dispatch"\}/);
assert.match(page, /disabledReason=\{cancelDisabledReason\}/);
console.log("Notification rows use the central selection-list contract.");
console.log("Notification center uses central selection, disabled-action, and confirmation contracts.");
+2
View File
@@ -77,6 +77,8 @@ export type NotificationDeliveryResult = {
notification?: NotificationMessage | null;
processed: number;
sent: number;
accepted: number;
paused: number;
failed: number;
skipped: number;
errors: string[];
@@ -1,12 +1,56 @@
import { useEffect, useMemo, useState } from "react";
import { Bell, Check, ExternalLink, RefreshCw, Send, XCircle } from "lucide-react";
import { AdminIconButton, Button, DismissibleAlert, SegmentedControl, SelectionList, SelectionListItem, StatusBadge, hasScope, type ApiSettings, type AuthInfo } from "@govoplan/core-webui";
import {
ActionBlockerHint,
AdminIconButton,
Button,
ConfirmDialog,
DismissibleAlert,
DocumentationHelpLink,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge,
hasScope,
i18nMessage,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import { deliverPendingNotifications, listNotifications, updateNotification, type NotificationMessage } from "../../api/notifications";
import { safeNotificationActionUrl } from "../../security/actionUrl";
import {
NOTIFICATIONS_BLOCKER_LABELS,
NOTIFICATIONS_DELIVERY_DOCUMENTATION,
NOTIFICATIONS_DOCUMENTATION,
NOTIFICATIONS_I18N
} from "./interfacePatterns";
type StatusFilter = "all" | "pending" | "queued" | "sent" | "failed" | "skipped" | "cancelled";
type StatusFilter =
| "all"
| "pending"
| "queued"
| "sending"
| "accepted"
| "paused"
| "sent"
| "failed"
| "skipped"
| "cancelled";
const statusFilters: StatusFilter[] = ["all", "pending", "queued", "sent", "failed", "skipped", "cancelled"];
const statusFilters: StatusFilter[] = [
"all",
"pending",
"queued",
"sending",
"accepted",
"paused",
"sent",
"failed",
"skipped",
"cancelled"
];
const cancellableStatuses = new Set(["pending", "queued", "paused", "failed"]);
export default function NotificationCenterPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const [notifications, setNotifications] = useState<NotificationMessage[]>([]);
@@ -15,12 +59,28 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirmingAction, setConfirmingAction] = useState<"cancel" | "dispatch" | null>(null);
const canRead = hasScope(auth, "notifications:notification:read");
const canWrite = hasScope(auth, "notifications:notification:write");
const canDispatch = hasScope(auth, "notifications:delivery:dispatch");
const selected = useMemo(() => notifications.find((item) => item.id === selectedId) || notifications[0] || null, [notifications, selectedId]);
const unreadCount = notifications.filter((item) => !item.read_at && !["cancelled", "skipped"].includes(item.status)).length;
const commonSelectionReason = busy
? NOTIFICATIONS_I18N.actionActive
: !selected
? NOTIFICATIONS_I18N.selectionRequired
: !canWrite
? NOTIFICATIONS_I18N.writePermissionRequired
: undefined;
const markReadDisabledReason = commonSelectionReason ?? (selected?.read_at ? NOTIFICATIONS_I18N.alreadyRead : undefined);
const acknowledgeDisabledReason = commonSelectionReason ?? (selected?.acknowledged_at ? NOTIFICATIONS_I18N.alreadyAcknowledged : undefined);
const cancelDisabledReason = commonSelectionReason ?? (selected && !cancellableStatuses.has(selected.status) ? NOTIFICATIONS_I18N.notCancellable : undefined);
const dispatchDisabledReason = busy
? NOTIFICATIONS_I18N.actionActive
: !canDispatch
? NOTIFICATIONS_I18N.dispatchPermissionRequired
: undefined;
useEffect(() => {
if (!canRead) {
@@ -47,43 +107,65 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
}
}
async function markSelected(status: "read" | "acknowledged" | "cancelled") {
if (!selected || !canWrite) return;
async function markSelected(status: "read" | "acknowledged" | "cancelled"): Promise<boolean> {
if (!selected || !canWrite) return false;
setBusy(true);
setError("");
try {
const next = await updateNotification(settings, selected.id, { status });
setNotifications((items) => items.map((item) => item.id === next.id ? next : item));
notifyNotificationsChanged();
return true;
} catch (err) {
setError(errorMessage(err));
return false;
} finally {
setBusy(false);
}
}
async function runDelivery() {
if (!canDispatch) return;
async function runDelivery(): Promise<boolean> {
if (!canDispatch) return false;
setBusy(true);
setError("");
try {
await deliverPendingNotifications(settings, 50);
await load();
notifyNotificationsChanged();
return true;
} catch (err) {
setError(errorMessage(err));
return false;
} finally {
setBusy(false);
}
}
async function confirmAction() {
const succeeded = confirmingAction === "cancel"
? await markSelected("cancelled")
: confirmingAction === "dispatch"
? await runDelivery()
: false;
if (succeeded) setConfirmingAction(null);
}
if (!canRead) {
return (
<main className="notifications-page">
<div className="notifications-empty-state">
<Bell size={22} />
<h1>Notifications</h1>
<p>You do not have permission to read notifications in this tenant.</p>
<div className="notifications-permission-state">
<ActionBlockerHint
tone="warning"
reason={{
summary: "i18n:govoplan-notifications.read_blocked_summary",
details: NOTIFICATIONS_I18N.readPermissionRequired,
requiredAction: "i18n:govoplan-notifications.permission_action",
actor: "i18n:govoplan-notifications.permission_actor",
target: "i18n:govoplan-notifications.permission_target"
}}
labels={NOTIFICATIONS_BLOCKER_LABELS}
documentation={NOTIFICATIONS_DOCUMENTATION}
/>
</div>
</main>
);
@@ -96,24 +178,30 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
<div className="notifications-sidebar-bar">
<div className="notifications-title">
<Bell size={17} />
<strong>Notifications</strong>
<strong>i18n:govoplan-notifications.notifications</strong>
{unreadCount > 0 ? <span className="notifications-count">{unreadCount}</span> : null}
</div>
<AdminIconButton label="Refresh" icon={<RefreshCw size={16} aria-hidden="true" />} onClick={() => void load()} disabled={loading || busy} />
<AdminIconButton
label="i18n:govoplan-notifications.refresh"
icon={<RefreshCw size={16} aria-hidden="true" />}
onClick={() => void load()}
disabled={loading || busy}
disabledReason={loading ? NOTIFICATIONS_I18N.loading : busy ? NOTIFICATIONS_I18N.actionActive : undefined}
/>
</div>
<SegmentedControl
className="notifications-status-filter"
options={statusFilters.map((status) => ({ id: status, label: status }))}
options={statusFilters.map((status) => ({ id: status, label: statusLabel(status) }))}
value={statusFilter}
onChange={setStatusFilter}
ariaLabel="Notification status"
ariaLabel="i18n:govoplan-notifications.notification_status"
width="fill"
/>
<div className="notifications-list">
{loading ? <div className="notifications-note">Loading notifications</div> : null}
{!loading && notifications.length === 0 ? <div className="notifications-note">No notifications in this view.</div> : null}
{loading ? <div className="notifications-note">i18n:govoplan-notifications.loading_notifications</div> : null}
{!loading && notifications.length === 0 ? <div className="notifications-note">i18n:govoplan-notifications.no_notifications</div> : null}
{notifications.length > 0 ? (
<SelectionList label="Notifications" className="notifications-selection-list">
<SelectionList label="i18n:govoplan-notifications.notifications" className="notifications-selection-list">
{notifications.map((notification) => (
<SelectionListItem
key={notification.id}
@@ -139,37 +227,70 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
<div className="notifications-topbar">
<div className="notifications-title-line">
<Bell size={18} />
<strong>{selected?.subject || selected?.event_kind || "Notification center"}</strong>
<strong>{selected?.subject || selected?.event_kind || "i18n:govoplan-notifications.surface.center"}</strong>
</div>
<div className="notifications-actions">
<Button onClick={() => void markSelected("read")} disabled={!selected || busy || !canWrite}>
<Check size={16} /> Mark read
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
<Button onClick={() => void markSelected("read")} disabled={Boolean(markReadDisabledReason)} disabledReason={markReadDisabledReason}>
<Check size={16} /> i18n:govoplan-notifications.mark_read
</Button>
<Button onClick={() => void markSelected("acknowledged")} disabled={!selected || busy || !canWrite}>
<Check size={16} /> Acknowledge
<Button onClick={() => void markSelected("acknowledged")} disabled={Boolean(acknowledgeDisabledReason)} disabledReason={acknowledgeDisabledReason}>
<Check size={16} /> i18n:govoplan-notifications.acknowledge
</Button>
<Button onClick={() => void markSelected("cancelled")} disabled={!selected || busy || !canWrite}>
<XCircle size={16} /> Cancel
<Button variant="danger" onClick={() => setConfirmingAction("cancel")} disabled={Boolean(cancelDisabledReason)} disabledReason={cancelDisabledReason}>
<XCircle size={16} /> i18n:govoplan-notifications.cancel_delivery
</Button>
<Button onClick={() => setConfirmingAction("dispatch")} disabled={Boolean(dispatchDisabledReason)} disabledReason={dispatchDisabledReason}>
<Send size={16} /> i18n:govoplan-notifications.dispatch_pending
</Button>
{canDispatch ? (
<Button onClick={() => void runDelivery()} disabled={busy}>
<Send size={16} /> Dispatch pending
</Button>
) : null}
</div>
</div>
{error ? <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert> : null}
{selected && !canWrite ? (
<ActionBlockerHint
tone="info"
reason={{
summary: "i18n:govoplan-notifications.actions_read_only_summary",
details: NOTIFICATIONS_I18N.writePermissionRequired,
requiredAction: "i18n:govoplan-notifications.permission_action",
actor: "i18n:govoplan-notifications.permission_actor",
target: "i18n:govoplan-notifications.permission_target"
}}
labels={NOTIFICATIONS_BLOCKER_LABELS}
documentation={NOTIFICATIONS_DOCUMENTATION}
/>
) : null}
{selected ? <NotificationDetails notification={selected} /> : (
<div className="notifications-empty-state">
<Bell size={22} />
<h1>Notifications</h1>
<p>Select a notification to inspect delivery state, source context, and content.</p>
<h1>i18n:govoplan-notifications.notifications</h1>
<p>i18n:govoplan-notifications.select_notification_help</p>
</div>
)}
</section>
</div>
<ConfirmDialog
open={confirmingAction === "cancel"}
title="i18n:govoplan-notifications.cancel_delivery_title"
message={i18nMessage("i18n:govoplan-notifications.cancel_delivery_message", { value0: selected?.subject || selected?.event_kind || "i18n:govoplan-notifications.selected_notification" })}
confirmLabel="i18n:govoplan-notifications.cancel_delivery"
tone="danger"
busy={busy}
onCancel={() => setConfirmingAction(null)}
onConfirm={() => void confirmAction()}
/>
<ConfirmDialog
open={confirmingAction === "dispatch"}
title="i18n:govoplan-notifications.dispatch_pending_title"
message="i18n:govoplan-notifications.dispatch_pending_message"
confirmLabel="i18n:govoplan-notifications.dispatch_pending"
busy={busy}
onCancel={() => setConfirmingAction(null)}
onConfirm={() => void confirmAction()}
/>
</main>
);
}
@@ -185,32 +306,35 @@ function NotificationDetails({ notification }: { notification: NotificationMessa
<span>{formatDate(notification.created_at)}</span>
</div>
<h1>{notification.subject || notification.event_kind}</h1>
{notification.body_text ? <p>{notification.body_text}</p> : <p className="muted">No message body was provided.</p>}
{notification.body_text ? <p>{notification.body_text}</p> : <p className="muted">i18n:govoplan-notifications.no_message_body</p>}
{actionUrl ? (
<a className="notifications-action-link" href={actionUrl}>
<ExternalLink size={16} /> Open related item
<ExternalLink size={16} /> i18n:govoplan-notifications.open_related_item
</a>
) : null}
</section>
<section className="notifications-properties">
<h2>Source and delivery</h2>
<h2>i18n:govoplan-notifications.source_and_delivery</h2>
<dl>
<div><dt>Source</dt><dd>{notification.source_module} / {notification.source_resource_type}</dd></div>
<div><dt>Resource</dt><dd>{notification.source_resource_id || "None"}</dd></div>
<div><dt>Recipient</dt><dd>{notification.recipient_label || notification.recipient || notification.recipient_id || "None"}</dd></div>
<div><dt>Priority</dt><dd>{notification.priority}</dd></div>
<div><dt>Queued</dt><dd>{formatDate(notification.queued_at)}</dd></div>
<div><dt>Sent</dt><dd>{formatDate(notification.sent_at)}</dd></div>
<div><dt>Read</dt><dd>{formatDate(notification.read_at)}</dd></div>
<div><dt>Attempts</dt><dd>{notification.attempt_count}</dd></div>
<div><dt>i18n:govoplan-notifications.source</dt><dd>{notification.source_module} / {notification.source_resource_type}</dd></div>
<div><dt>i18n:govoplan-notifications.resource</dt><dd>{notification.source_resource_id || "i18n:govoplan-notifications.none"}</dd></div>
<div><dt>i18n:govoplan-notifications.recipient</dt><dd>{notification.recipient_label || notification.recipient || notification.recipient_id || "i18n:govoplan-notifications.none"}</dd></div>
<div><dt>i18n:govoplan-notifications.priority</dt><dd>{notification.priority}</dd></div>
<div><dt>i18n:govoplan-notifications.queued</dt><dd>{formatDate(notification.queued_at)}</dd></div>
<div><dt>i18n:govoplan-notifications.sent</dt><dd>{formatDate(notification.sent_at)}</dd></div>
<div><dt>i18n:govoplan-notifications.read</dt><dd>{formatDate(notification.read_at)}</dd></div>
<div><dt>i18n:govoplan-notifications.attempts</dt><dd>{notification.attempt_count}</dd></div>
</dl>
{notification.last_error ? <p className="notifications-error">{notification.last_error}</p> : null}
</section>
<section className="notifications-attempts">
<h2>Delivery attempts</h2>
{notification.attempts.length === 0 ? <p className="muted">No delivery attempt has been recorded yet.</p> : null}
<div className="notifications-section-heading">
<h2>i18n:govoplan-notifications.delivery_attempts</h2>
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
</div>
{notification.attempts.length === 0 ? <p className="muted">i18n:govoplan-notifications.no_delivery_attempt</p> : null}
{notification.attempts.map((attempt) => (
<div className="notifications-attempt" key={attempt.id}>
<strong>{attempt.provider || attempt.channel}</strong>
@@ -225,11 +349,15 @@ function NotificationDetails({ notification }: { notification: NotificationMessa
}
function formatStatus(value: string): string {
return value.replace(/_/g, " ");
return statusLabel(value);
}
function statusLabel(value: string): string {
return `i18n:govoplan-notifications.status.${value.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}`;
}
function formatDate(value?: string | null): string {
if (!value) return "Not set";
if (!value) return "i18n:govoplan-notifications.not_set";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
@@ -239,7 +367,7 @@ function formatDate(value?: string | null): string {
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Request failed";
return error instanceof Error ? error.message : "i18n:govoplan-notifications.request_failed";
}
function notifyNotificationsChanged(): void {
@@ -1,26 +1,60 @@
import { useEffect, useMemo, useState } from "react";
import { Mail, Save } from "lucide-react";
import { Button, Card, DismissibleAlert, FormField, ToggleSwitch, type ApiSettings, type AuthInfo } from "@govoplan/core-webui";
import {
ActionBlockerHint,
Button,
Card,
DismissibleAlert,
DocumentationHelpLink,
FormField,
ReferenceMultiSelect,
ToggleSwitch,
hasScope,
i18nMessage,
isViewSurfaceVisible,
moduleViewSurfaceId,
platformModuleReferenceProvider,
useEffectiveView,
usePlatformLanguage,
usePlatformModules,
useUnsavedDraftGuard,
useViewSurfaces,
type ApiSettings,
type AuthInfo,
type ReferenceOption
} from "@govoplan/core-webui";
import { getNotificationPreferences, updateNotificationPreferences, type NotificationPreferences } from "../../api/notifications";
import {
NOTIFICATIONS_BLOCKER_LABELS,
NOTIFICATIONS_DELIVERY_DOCUMENTATION,
NOTIFICATIONS_DOCUMENTATION,
NOTIFICATIONS_I18N
} from "./interfacePatterns";
type Draft = Pick<NotificationPreferences, "show_unread_badge" | "email_enabled" | "email_digest_enabled"> & {
muted_source_modules_text: string;
muted_source_modules: string[];
};
const DEFAULT_DRAFT: Draft = {
show_unread_badge: true,
email_enabled: false,
email_digest_enabled: false,
muted_source_modules_text: ""
muted_source_modules: []
};
export default function NotificationSettingsPanel({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const modules = usePlatformModules();
const effectiveView = useEffectiveView();
const viewSurfaces = useViewSurfaces();
const { translateText } = usePlatformLanguage();
const [loaded, setLoaded] = useState<NotificationPreferences | null>(null);
const [draft, setDraft] = useState<Draft>(DEFAULT_DRAFT);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [messageTone, setMessageTone] = useState<"success" | "warning">("success");
const canWrite = hasScope(auth, "notifications:notification:write");
const mailAvailable = modules.some((module) => module.id === "mail");
const dirty = useMemo(() => {
if (!loaded) return false;
@@ -28,9 +62,56 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
draft.show_unread_badge !== loaded.show_unread_badge ||
draft.email_enabled !== loaded.email_enabled ||
draft.email_digest_enabled !== loaded.email_digest_enabled ||
normalizeMutedModules(draft.muted_source_modules_text).join(",") !== loaded.muted_source_modules.join(",")
draft.muted_source_modules.join(",") !== loaded.muted_source_modules.join(",")
);
}, [draft, loaded]);
const moduleOptions = useMemo<ReferenceOption[]>(
() => modules.map((module) => {
const visible = isViewSurfaceVisible(
effectiveView,
moduleViewSurfaceId(module.id),
viewSurfaces
);
return {
value: module.id,
label: translateText(module.label),
description: [
module.id,
i18nMessage("i18n:govoplan-notifications.version_value", { value0: module.version }),
visible ? null : translateText("i18n:govoplan-notifications.hidden_by_view")
].filter(Boolean).join(" · "),
kind: "module",
availability: visible ? "available" : "unavailable",
disabled: !visible,
sourceModule: "core",
provenance: {
version: module.version,
visibleInActiveView: visible
}
};
}),
[effectiveView, modules, translateText, viewSurfaces]
);
const moduleProvider = useMemo(
() => platformModuleReferenceProvider(settings, moduleOptions),
[moduleOptions, settings]
);
function resetDraft() {
if (!loaded) return;
setDraft({
show_unread_badge: loaded.show_unread_badge,
email_enabled: loaded.email_enabled,
email_digest_enabled: loaded.email_digest_enabled,
muted_source_modules: loaded.muted_source_modules
});
}
useUnsavedDraftGuard({
dirty,
onSave: savePreferences,
onDiscard: resetDraft
});
useEffect(() => {
void loadPreferences();
@@ -46,17 +127,18 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
show_unread_badge: preferences.show_unread_badge,
email_enabled: preferences.email_enabled,
email_digest_enabled: preferences.email_digest_enabled,
muted_source_modules_text: preferences.muted_source_modules.join(", ")
muted_source_modules: preferences.muted_source_modules
});
} catch (error) {
setMessageTone("warning");
setMessage(error instanceof Error ? error.message : "Loading notification preferences failed");
setMessage(error instanceof Error ? error.message : translateText("i18n:govoplan-notifications.preferences_load_failed"));
} finally {
setLoading(false);
}
}
async function savePreferences() {
async function savePreferences(): Promise<boolean> {
if (!canWrite) return false;
setSaving(true);
setMessage("");
try {
@@ -64,71 +146,123 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
show_unread_badge: draft.show_unread_badge,
email_enabled: draft.email_enabled,
email_digest_enabled: draft.email_digest_enabled,
muted_source_modules: normalizeMutedModules(draft.muted_source_modules_text)
muted_source_modules: draft.muted_source_modules
});
setLoaded(next);
setDraft({
show_unread_badge: next.show_unread_badge,
email_enabled: next.email_enabled,
email_digest_enabled: next.email_digest_enabled,
muted_source_modules_text: next.muted_source_modules.join(", ")
muted_source_modules: next.muted_source_modules
});
window.dispatchEvent(new CustomEvent("govoplan:notifications-changed"));
setMessageTone("success");
setMessage("Notification preferences saved.");
setMessage("i18n:govoplan-notifications.preferences_saved");
return true;
} catch (error) {
setMessageTone("warning");
setMessage(error instanceof Error ? error.message : "Saving notification preferences failed");
setMessage(error instanceof Error ? error.message : translateText("i18n:govoplan-notifications.preferences_save_failed"));
return false;
} finally {
setSaving(false);
}
}
const saveDisabledReason = loading
? NOTIFICATIONS_I18N.loading
: saving
? NOTIFICATIONS_I18N.saving
: !canWrite
? NOTIFICATIONS_I18N.writePermissionRequired
: !dirty
? NOTIFICATIONS_I18N.noChanges
: undefined;
const preferenceControlsDisabled = loading || saving || !canWrite;
const emailToggleDisabled = preferenceControlsDisabled || (!mailAvailable && !draft.email_enabled);
const digestToggleDisabled = preferenceControlsDisabled || !mailAvailable || !draft.email_enabled;
return (
<div className="dashboard-grid settings-dashboard-grid notifications-settings-panel">
<Card title="Notifications">
<div className="notifications-settings-documentation">
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
</div>
{!canWrite ? (
<ActionBlockerHint
tone="info"
reason={{
summary: "i18n:govoplan-notifications.preferences_read_only_summary",
details: NOTIFICATIONS_I18N.writePermissionRequired,
requiredAction: "i18n:govoplan-notifications.permission_action",
actor: "i18n:govoplan-notifications.permission_actor",
target: "i18n:govoplan-notifications.permission_target"
}}
labels={NOTIFICATIONS_BLOCKER_LABELS}
documentation={NOTIFICATIONS_DOCUMENTATION}
/>
) : null}
<Card title="i18n:govoplan-notifications.notifications">
<div className="form-grid">
<ToggleSwitch
label="Unread badge"
help="Show unread notification counts in the top bar."
label="i18n:govoplan-notifications.unread_badge"
help="i18n:govoplan-notifications.unread_badge_help"
checked={draft.show_unread_badge}
disabled={loading}
disabled={preferenceControlsDisabled}
onChange={(value) => setDraft((current) => ({ ...current, show_unread_badge: value }))}
/>
<FormField label="Muted source modules" help="Comma-separated module IDs. Filtering will be applied by later delivery rules.">
<input
value={draft.muted_source_modules_text}
onChange={(event) => setDraft((current) => ({ ...current, muted_source_modules_text: event.target.value }))}
placeholder="calendar, campaign"
disabled={loading}
<FormField label="i18n:govoplan-notifications.muted_sources" help="i18n:govoplan-notifications.muted_sources_help">
<ReferenceMultiSelect
values={draft.muted_source_modules}
onChange={(muted_source_modules) =>
setDraft((current) => ({ ...current, muted_source_modules }))
}
provider={moduleProvider}
aria-label="i18n:govoplan-notifications.muted_sources"
placeholder="i18n:govoplan-notifications.add_source_module"
emptyText="i18n:govoplan-notifications.no_visible_modules"
disabled={preferenceControlsDisabled}
/>
</FormField>
<div className="button-row compact-actions">
<Button variant="primary" onClick={() => void savePreferences()} disabled={loading || saving || !dirty}>
<Save size={16} /> {saving ? "Saving" : "Save preferences"}
<Button variant="primary" onClick={() => void savePreferences()} disabled={Boolean(saveDisabledReason)} disabledReason={saveDisabledReason}>
<Save size={16} /> {saving ? "i18n:govoplan-notifications.saving" : "i18n:govoplan-notifications.save_preferences"}
</Button>
</div>
{message ? <DismissibleAlert tone={messageTone} resetKey={message} floating>{message}</DismissibleAlert> : null}
</div>
</Card>
<Card title="Delivery">
<Card title="i18n:govoplan-notifications.delivery">
<div className="form-grid">
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
<div className="notifications-settings-inline-title">
<Mail size={16} />
<strong>Email notifications</strong>
<strong>i18n:govoplan-notifications.email_notifications</strong>
</div>
{!mailAvailable ? (
<ActionBlockerHint
tone="info"
reason={{
summary: "i18n:govoplan-notifications.mail_unavailable_summary",
details: NOTIFICATIONS_I18N.mailUnavailable,
requiredAction: "i18n:govoplan-notifications.mail_unavailable_action",
actor: "i18n:govoplan-notifications.mail_unavailable_actor",
target: "i18n:govoplan-notifications.mail_unavailable_target"
}}
labels={NOTIFICATIONS_BLOCKER_LABELS}
documentation={NOTIFICATIONS_DELIVERY_DOCUMENTATION}
/>
) : null}
<ToggleSwitch
label="Email notifications"
help="Prepared for mail-backed notification delivery."
label="i18n:govoplan-notifications.email_notifications"
help="i18n:govoplan-notifications.email_notifications_help"
checked={draft.email_enabled}
disabled={loading}
disabled={emailToggleDisabled}
onChange={(value) => setDraft((current) => ({ ...current, email_enabled: value, email_digest_enabled: value ? current.email_digest_enabled : false }))}
/>
<ToggleSwitch
label="Email digest"
help="Batch eligible notifications into summary messages when delivery rules support it."
label="i18n:govoplan-notifications.email_digest"
help="i18n:govoplan-notifications.email_digest_help"
checked={draft.email_digest_enabled}
disabled={loading || !draft.email_enabled}
disabled={digestToggleDisabled}
onChange={(value) => setDraft((current) => ({ ...current, email_digest_enabled: value }))}
/>
</div>
@@ -136,15 +270,3 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
</div>
);
}
function normalizeMutedModules(value: string): string[] {
const seen = new Set<string>();
return value
.split(",")
.map((item) => item.trim().toLowerCase())
.filter((item) => {
if (!item || seen.has(item)) return false;
seen.add(item);
return true;
});
}
@@ -0,0 +1,77 @@
import { Link } from "react-router";
import {
DismissibleAlert,
DocumentationHelpLink,
LoadingFrame,
MetricCard,
adminErrorMessage,
i18nMessage,
type ApiSettings,
type DashboardWidgetConfiguration,
useSharedNotificationSummary
} from "@govoplan/core-webui";
import { NOTIFICATIONS_DOCUMENTATION } from "./interfacePatterns";
export default function NotificationSummaryWidget({
settings,
refreshKey,
configuration,
showCenterLink
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
showCenterLink: boolean;
}) {
const showDeliveryState = configuration.showDeliveryState !== false;
const state = useSharedNotificationSummary(settings, {
enabled: true,
scopeKey: "current-session",
intervalMs: 30_000,
refreshKey
});
const summary = state.summary;
const error = state.error ? adminErrorMessage(state.error) : "";
return (
<LoadingFrame loading={state.loading} label="i18n:govoplan-notifications.loading_summary">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<div className="metric-grid inside dashboard-widget-metrics">
<MetricCard
label="i18n:govoplan-notifications.unread"
value={summary?.unread ?? 0}
tone={summary?.unread ? "info" : "good"}
detail={i18nMessage("i18n:govoplan-notifications.value_total", { value0: summary?.total ?? 0 })}
/>
{showDeliveryState && (
<MetricCard
label="i18n:govoplan-notifications.pending"
value={summary?.pending ?? 0}
tone={summary?.pending ? "warning" : "good"}
detail="i18n:govoplan-notifications.awaiting_delivery"
/>
)}
{showDeliveryState && (
<MetricCard
label="i18n:govoplan-notifications.failed"
value={summary?.failed ?? 0}
tone={summary?.failed ? "danger" : "good"}
detail="i18n:govoplan-notifications.delivery_failures"
/>
)}
</div>
<div className="notifications-widget-actions">
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
{showCenterLink && (
<Link className="btn btn-secondary" to="/notifications">
i18n:govoplan-notifications.open_center
</Link>
)}
</div>
</LoadingFrame>
);
}
@@ -0,0 +1,32 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const NOTIFICATIONS_DOCUMENTATION = {
topicId: "notifications.center-and-preferences",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const NOTIFICATIONS_DELIVERY_DOCUMENTATION = {
topicId: "notifications.delivery-operations",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const NOTIFICATIONS_I18N = {
loading: "i18n:govoplan-notifications.reason.loading",
saving: "i18n:govoplan-notifications.reason.saving",
actionActive: "i18n:govoplan-notifications.reason.action_active",
selectionRequired: "i18n:govoplan-notifications.reason.selection_required",
readPermissionRequired: "i18n:govoplan-notifications.reason.read_permission_required",
writePermissionRequired: "i18n:govoplan-notifications.reason.write_permission_required",
dispatchPermissionRequired: "i18n:govoplan-notifications.reason.dispatch_permission_required",
alreadyRead: "i18n:govoplan-notifications.reason.already_read",
alreadyAcknowledged: "i18n:govoplan-notifications.reason.already_acknowledged",
notCancellable: "i18n:govoplan-notifications.reason.not_cancellable",
noChanges: "i18n:govoplan-notifications.reason.no_changes",
mailUnavailable: "i18n:govoplan-notifications.reason.mail_unavailable"
} as const;
export const NOTIFICATIONS_BLOCKER_LABELS = {
requiredAction: "i18n:govoplan-notifications.blocker.required_action",
actor: "i18n:govoplan-notifications.blocker.actor",
target: "i18n:govoplan-notifications.blocker.target"
} as const;
+223 -3
View File
@@ -1,4 +1,224 @@
export const generatedTranslations = {
en: {},
de: {}
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
en: {
"i18n:govoplan-notifications.surface.center": "Notification center",
"i18n:govoplan-notifications.surface.inbox": "Notification inbox",
"i18n:govoplan-notifications.surface.detail": "Notification details",
"i18n:govoplan-notifications.surface.delivery": "Notification delivery evidence",
"i18n:govoplan-notifications.surface.mark_read": "Mark notification as read",
"i18n:govoplan-notifications.surface.acknowledge": "Acknowledge notification",
"i18n:govoplan-notifications.surface.cancel": "Cancel notification delivery",
"i18n:govoplan-notifications.surface.dispatch": "Dispatch pending notifications",
"i18n:govoplan-notifications.surface.widget_summary": "Notification summary widget",
"i18n:govoplan-notifications.surface.preferences": "Notification preferences",
"i18n:govoplan-notifications.widget_description": "Unread notifications and delivery state.",
"i18n:govoplan-notifications.communication": "Communication",
"i18n:govoplan-notifications.show_delivery_state": "Show delivery state",
"i18n:govoplan-notifications.show_delivery_state_help": "Include pending and failed delivery counts.",
"i18n:govoplan-notifications.reason.loading": "Notifications are loading.",
"i18n:govoplan-notifications.reason.saving": "Notification preferences are being saved.",
"i18n:govoplan-notifications.reason.action_active": "A notification action is already running.",
"i18n:govoplan-notifications.reason.selection_required": "Select a notification first.",
"i18n:govoplan-notifications.reason.read_permission_required": "Notification-read permission is required.",
"i18n:govoplan-notifications.reason.write_permission_required": "Notification-write permission is required.",
"i18n:govoplan-notifications.reason.dispatch_permission_required": "Notification-dispatch permission is required.",
"i18n:govoplan-notifications.reason.already_read": "This notification is already marked as read.",
"i18n:govoplan-notifications.reason.already_acknowledged": "This notification is already acknowledged.",
"i18n:govoplan-notifications.reason.not_cancellable": "Only pending, queued, paused, or failed delivery can be cancelled safely.",
"i18n:govoplan-notifications.reason.no_changes": "No preference changes need to be saved.",
"i18n:govoplan-notifications.reason.mail_unavailable": "The optional Mail module is not enabled. In-product notifications remain available.",
"i18n:govoplan-notifications.blocker.required_action": "Required action",
"i18n:govoplan-notifications.blocker.actor": "Who can fix it",
"i18n:govoplan-notifications.blocker.target": "Where to go",
"i18n:govoplan-notifications.read_blocked_summary": "The notification center is unavailable.",
"i18n:govoplan-notifications.actions_read_only_summary": "Notification actions are read-only.",
"i18n:govoplan-notifications.preferences_read_only_summary": "Notification preferences are read-only.",
"i18n:govoplan-notifications.permission_action": "Ask a tenant administrator to grant the required Notifications permission.",
"i18n:govoplan-notifications.permission_actor": "Tenant administrator or access manager",
"i18n:govoplan-notifications.permission_target": "Administration > Access > Roles",
"i18n:govoplan-notifications.mail_unavailable_summary": "Email delivery is unavailable.",
"i18n:govoplan-notifications.mail_unavailable_action": "Enable and configure Mail before turning on production email notifications.",
"i18n:govoplan-notifications.mail_unavailable_actor": "System or tenant administrator",
"i18n:govoplan-notifications.mail_unavailable_target": "Administration > Modules and Mail settings",
"i18n:govoplan-notifications.refresh": "Refresh notifications",
"i18n:govoplan-notifications.notifications": "Notifications",
"i18n:govoplan-notifications.notification_status": "Notification status",
"i18n:govoplan-notifications.loading_notifications": "Loading notifications",
"i18n:govoplan-notifications.no_notifications": "No notifications in this view.",
"i18n:govoplan-notifications.mark_read": "Mark read",
"i18n:govoplan-notifications.acknowledge": "Acknowledge",
"i18n:govoplan-notifications.cancel_delivery": "Cancel delivery",
"i18n:govoplan-notifications.dispatch_pending": "Dispatch pending",
"i18n:govoplan-notifications.cancel_delivery_title": "Cancel notification delivery",
"i18n:govoplan-notifications.cancel_delivery_message": "Cancel delivery of {value0}. This stops eligible local delivery work but cannot recall provider-accepted messages.",
"i18n:govoplan-notifications.dispatch_pending_title": "Dispatch pending notifications",
"i18n:govoplan-notifications.dispatch_pending_message": "Attempt delivery for up to 50 eligible notifications in this tenant. Provider-accepted outcomes will not be repeated automatically.",
"i18n:govoplan-notifications.selected_notification": "the selected notification",
"i18n:govoplan-notifications.delivery_attempts": "Delivery attempts",
"i18n:govoplan-notifications.select_notification_help": "Select a notification to inspect delivery state, source context, and content.",
"i18n:govoplan-notifications.no_message_body": "No message body was provided.",
"i18n:govoplan-notifications.open_related_item": "Open related item",
"i18n:govoplan-notifications.source_and_delivery": "Source and delivery",
"i18n:govoplan-notifications.source": "Source",
"i18n:govoplan-notifications.resource": "Resource",
"i18n:govoplan-notifications.recipient": "Recipient",
"i18n:govoplan-notifications.priority": "Priority",
"i18n:govoplan-notifications.queued": "Queued",
"i18n:govoplan-notifications.sent": "Sent",
"i18n:govoplan-notifications.read": "Read",
"i18n:govoplan-notifications.attempts": "Attempts",
"i18n:govoplan-notifications.none": "None",
"i18n:govoplan-notifications.no_delivery_attempt": "No delivery attempt has been recorded yet.",
"i18n:govoplan-notifications.not_set": "Not set",
"i18n:govoplan-notifications.request_failed": "Request failed",
"i18n:govoplan-notifications.preferences_saved": "Notification preferences saved.",
"i18n:govoplan-notifications.preferences_load_failed": "Loading notification preferences failed.",
"i18n:govoplan-notifications.preferences_save_failed": "Saving notification preferences failed.",
"i18n:govoplan-notifications.save_preferences": "Save preferences",
"i18n:govoplan-notifications.saving": "Saving",
"i18n:govoplan-notifications.version_value": "version {value0}",
"i18n:govoplan-notifications.hidden_by_view": "Hidden by the active view",
"i18n:govoplan-notifications.unread_badge": "Unread badge",
"i18n:govoplan-notifications.unread_badge_help": "Show unread notification counts in the top bar.",
"i18n:govoplan-notifications.muted_sources": "Muted source modules",
"i18n:govoplan-notifications.muted_sources_help": "Choose active modules. Saved modules hidden by the current view remain visible here and are not discarded.",
"i18n:govoplan-notifications.add_source_module": "Add a source module",
"i18n:govoplan-notifications.no_visible_modules": "No visible modules match.",
"i18n:govoplan-notifications.delivery": "Delivery",
"i18n:govoplan-notifications.email_notifications": "Email notifications",
"i18n:govoplan-notifications.email_notifications_help": "Deliver production email through the optional Mail module. In-product notifications continue to work when Mail is unavailable.",
"i18n:govoplan-notifications.email_digest": "Email digest",
"i18n:govoplan-notifications.email_digest_help": "Batch eligible notifications into summary messages when delivery rules support it.",
"i18n:govoplan-notifications.loading_summary": "Loading notification summary",
"i18n:govoplan-notifications.unread": "Unread",
"i18n:govoplan-notifications.pending": "Pending",
"i18n:govoplan-notifications.failed": "Failed",
"i18n:govoplan-notifications.value_total": "{value0} total",
"i18n:govoplan-notifications.awaiting_delivery": "Awaiting delivery",
"i18n:govoplan-notifications.delivery_failures": "Delivery failures",
"i18n:govoplan-notifications.open_center": "Open notification center",
"i18n:govoplan-notifications.status.all": "All",
"i18n:govoplan-notifications.status.pending": "Pending",
"i18n:govoplan-notifications.status.queued": "Queued",
"i18n:govoplan-notifications.status.sending": "Sending",
"i18n:govoplan-notifications.status.accepted": "Accepted",
"i18n:govoplan-notifications.status.paused": "Paused",
"i18n:govoplan-notifications.status.sent": "Sent",
"i18n:govoplan-notifications.status.failed": "Failed",
"i18n:govoplan-notifications.status.skipped": "Skipped",
"i18n:govoplan-notifications.status.cancelled": "Cancelled",
"i18n:govoplan-notifications.status.read": "Read",
"i18n:govoplan-notifications.status.acknowledged": "Acknowledged"
},
de: {
"i18n:govoplan-notifications.surface.center": "Benachrichtigungszentrale",
"i18n:govoplan-notifications.surface.inbox": "Benachrichtigungseingang",
"i18n:govoplan-notifications.surface.detail": "Benachrichtigungsdetails",
"i18n:govoplan-notifications.surface.delivery": "Zustellnachweise für Benachrichtigungen",
"i18n:govoplan-notifications.surface.mark_read": "Benachrichtigung als gelesen markieren",
"i18n:govoplan-notifications.surface.acknowledge": "Benachrichtigung bestätigen",
"i18n:govoplan-notifications.surface.cancel": "Zustellung der Benachrichtigung abbrechen",
"i18n:govoplan-notifications.surface.dispatch": "Ausstehende Benachrichtigungen zustellen",
"i18n:govoplan-notifications.surface.widget_summary": "Übersicht der Benachrichtigungen",
"i18n:govoplan-notifications.surface.preferences": "Benachrichtigungseinstellungen",
"i18n:govoplan-notifications.widget_description": "Ungelesene Benachrichtigungen und Zustellstatus.",
"i18n:govoplan-notifications.communication": "Kommunikation",
"i18n:govoplan-notifications.show_delivery_state": "Zustellstatus anzeigen",
"i18n:govoplan-notifications.show_delivery_state_help": "Ausstehende und fehlgeschlagene Zustellungen einbeziehen.",
"i18n:govoplan-notifications.reason.loading": "Benachrichtigungen werden geladen.",
"i18n:govoplan-notifications.reason.saving": "Benachrichtigungseinstellungen werden gespeichert.",
"i18n:govoplan-notifications.reason.action_active": "Eine Benachrichtigungsaktion wird bereits ausgeführt.",
"i18n:govoplan-notifications.reason.selection_required": "Wählen Sie zuerst eine Benachrichtigung aus.",
"i18n:govoplan-notifications.reason.read_permission_required": "Die Berechtigung zum Lesen von Benachrichtigungen ist erforderlich.",
"i18n:govoplan-notifications.reason.write_permission_required": "Die Berechtigung zum Bearbeiten von Benachrichtigungen ist erforderlich.",
"i18n:govoplan-notifications.reason.dispatch_permission_required": "Die Berechtigung zum Zustellen von Benachrichtigungen ist erforderlich.",
"i18n:govoplan-notifications.reason.already_read": "Diese Benachrichtigung ist bereits als gelesen markiert.",
"i18n:govoplan-notifications.reason.already_acknowledged": "Diese Benachrichtigung wurde bereits bestätigt.",
"i18n:govoplan-notifications.reason.not_cancellable": "Nur ausstehende, eingereihte, pausierte oder fehlgeschlagene Zustellungen können sicher abgebrochen werden.",
"i18n:govoplan-notifications.reason.no_changes": "Es müssen keine geänderten Einstellungen gespeichert werden.",
"i18n:govoplan-notifications.reason.mail_unavailable": "Das optionale Mail-Modul ist nicht aktiviert. Benachrichtigungen in GovOPlaN bleiben verfügbar.",
"i18n:govoplan-notifications.blocker.required_action": "Erforderliche Aktion",
"i18n:govoplan-notifications.blocker.actor": "Zuständige Stelle",
"i18n:govoplan-notifications.blocker.target": "Ziel",
"i18n:govoplan-notifications.read_blocked_summary": "Die Benachrichtigungszentrale ist nicht verfügbar.",
"i18n:govoplan-notifications.actions_read_only_summary": "Benachrichtigungsaktionen sind schreibgeschützt.",
"i18n:govoplan-notifications.preferences_read_only_summary": "Benachrichtigungseinstellungen sind schreibgeschützt.",
"i18n:govoplan-notifications.permission_action": "Bitten Sie eine mandantenverwaltende Person, die erforderliche Benachrichtigungsberechtigung zu erteilen.",
"i18n:govoplan-notifications.permission_actor": "Mandantenadministration oder Zugriffsverwaltung",
"i18n:govoplan-notifications.permission_target": "Administration > Zugriff > Rollen",
"i18n:govoplan-notifications.mail_unavailable_summary": "E-Mail-Zustellung ist nicht verfügbar.",
"i18n:govoplan-notifications.mail_unavailable_action": "Aktivieren und konfigurieren Sie Mail, bevor produktive E-Mail-Benachrichtigungen eingeschaltet werden.",
"i18n:govoplan-notifications.mail_unavailable_actor": "System- oder Mandantenadministration",
"i18n:govoplan-notifications.mail_unavailable_target": "Administration > Module und Mail-Einstellungen",
"i18n:govoplan-notifications.refresh": "Benachrichtigungen aktualisieren",
"i18n:govoplan-notifications.notifications": "Benachrichtigungen",
"i18n:govoplan-notifications.notification_status": "Benachrichtigungsstatus",
"i18n:govoplan-notifications.loading_notifications": "Benachrichtigungen werden geladen",
"i18n:govoplan-notifications.no_notifications": "In dieser Ansicht gibt es keine Benachrichtigungen.",
"i18n:govoplan-notifications.mark_read": "Als gelesen markieren",
"i18n:govoplan-notifications.acknowledge": "Bestätigen",
"i18n:govoplan-notifications.cancel_delivery": "Zustellung abbrechen",
"i18n:govoplan-notifications.dispatch_pending": "Ausstehende zustellen",
"i18n:govoplan-notifications.cancel_delivery_title": "Zustellung der Benachrichtigung abbrechen",
"i18n:govoplan-notifications.cancel_delivery_message": "Zustellung von {value0} abbrechen. Dies stoppt geeignete lokale Zustellarbeit, kann aber von einem Anbieter angenommene Nachrichten nicht zurückrufen.",
"i18n:govoplan-notifications.dispatch_pending_title": "Ausstehende Benachrichtigungen zustellen",
"i18n:govoplan-notifications.dispatch_pending_message": "Versuchen, bis zu 50 geeignete Benachrichtigungen in diesem Mandanten zuzustellen. Von einem Anbieter angenommene Ergebnisse werden nicht automatisch wiederholt.",
"i18n:govoplan-notifications.selected_notification": "die ausgewählte Benachrichtigung",
"i18n:govoplan-notifications.delivery_attempts": "Zustellversuche",
"i18n:govoplan-notifications.select_notification_help": "Wählen Sie eine Benachrichtigung aus, um Zustellstatus, Quellkontext und Inhalt zu prüfen.",
"i18n:govoplan-notifications.no_message_body": "Es wurde kein Nachrichtentext angegeben.",
"i18n:govoplan-notifications.open_related_item": "Zugehöriges Element öffnen",
"i18n:govoplan-notifications.source_and_delivery": "Quelle und Zustellung",
"i18n:govoplan-notifications.source": "Quelle",
"i18n:govoplan-notifications.resource": "Ressource",
"i18n:govoplan-notifications.recipient": "Empfänger",
"i18n:govoplan-notifications.priority": "Priorität",
"i18n:govoplan-notifications.queued": "Eingereiht",
"i18n:govoplan-notifications.sent": "Gesendet",
"i18n:govoplan-notifications.read": "Gelesen",
"i18n:govoplan-notifications.attempts": "Versuche",
"i18n:govoplan-notifications.none": "Keine Angabe",
"i18n:govoplan-notifications.no_delivery_attempt": "Es wurde noch kein Zustellversuch aufgezeichnet.",
"i18n:govoplan-notifications.not_set": "Nicht gesetzt",
"i18n:govoplan-notifications.request_failed": "Anfrage fehlgeschlagen",
"i18n:govoplan-notifications.preferences_saved": "Benachrichtigungseinstellungen gespeichert.",
"i18n:govoplan-notifications.preferences_load_failed": "Benachrichtigungseinstellungen konnten nicht geladen werden.",
"i18n:govoplan-notifications.preferences_save_failed": "Benachrichtigungseinstellungen konnten nicht gespeichert werden.",
"i18n:govoplan-notifications.save_preferences": "Einstellungen speichern",
"i18n:govoplan-notifications.saving": "Speichern",
"i18n:govoplan-notifications.version_value": "Version {value0}",
"i18n:govoplan-notifications.hidden_by_view": "Durch die aktive Ansicht ausgeblendet",
"i18n:govoplan-notifications.unread_badge": "Ungelesen-Markierung",
"i18n:govoplan-notifications.unread_badge_help": "Anzahl ungelesener Benachrichtigungen in der Titelleiste anzeigen.",
"i18n:govoplan-notifications.muted_sources": "Stummgeschaltete Quellmodule",
"i18n:govoplan-notifications.muted_sources_help": "Wählen Sie aktive Module. Gespeicherte, durch die aktuelle Ansicht ausgeblendete Module bleiben hier sichtbar und werden nicht verworfen.",
"i18n:govoplan-notifications.add_source_module": "Quellmodul hinzufügen",
"i18n:govoplan-notifications.no_visible_modules": "Keine sichtbaren Module passen.",
"i18n:govoplan-notifications.delivery": "Zustellung",
"i18n:govoplan-notifications.email_notifications": "E-Mail-Benachrichtigungen",
"i18n:govoplan-notifications.email_notifications_help": "Produktive E-Mails über das optionale Mail-Modul zustellen. Benachrichtigungen in GovOPlaN funktionieren weiter, wenn Mail nicht verfügbar ist.",
"i18n:govoplan-notifications.email_digest": "E-Mail-Zusammenfassung",
"i18n:govoplan-notifications.email_digest_help": "Geeignete Benachrichtigungen zu Zusammenfassungen bündeln, wenn die Zustellregeln dies unterstützen.",
"i18n:govoplan-notifications.loading_summary": "Benachrichtigungsübersicht wird geladen",
"i18n:govoplan-notifications.unread": "Ungelesen",
"i18n:govoplan-notifications.pending": "Ausstehend",
"i18n:govoplan-notifications.failed": "Fehlgeschlagen",
"i18n:govoplan-notifications.value_total": "{value0} insgesamt",
"i18n:govoplan-notifications.awaiting_delivery": "Wartet auf Zustellung",
"i18n:govoplan-notifications.delivery_failures": "Zustellfehler",
"i18n:govoplan-notifications.open_center": "Benachrichtigungszentrale öffnen",
"i18n:govoplan-notifications.status.all": "Alle",
"i18n:govoplan-notifications.status.pending": "Ausstehend",
"i18n:govoplan-notifications.status.queued": "Eingereiht",
"i18n:govoplan-notifications.status.sending": "Wird gesendet",
"i18n:govoplan-notifications.status.accepted": "Angenommen",
"i18n:govoplan-notifications.status.paused": "Pausiert",
"i18n:govoplan-notifications.status.sent": "Gesendet",
"i18n:govoplan-notifications.status.failed": "Fehlgeschlagen",
"i18n:govoplan-notifications.status.skipped": "Übersprungen",
"i18n:govoplan-notifications.status.cancelled": "Abgebrochen",
"i18n:govoplan-notifications.status.read": "Gelesen",
"i18n:govoplan-notifications.status.acknowledged": "Bestätigt"
}
};
+76 -5
View File
@@ -1,5 +1,10 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule, SettingsSectionsUiCapability } from "@govoplan/core-webui";
import type {
DashboardWidgetsUiCapability,
PlatformWebModule,
SettingsSectionsUiCapability
} from "@govoplan/core-webui";
import NotificationSummaryWidget from "./features/notifications/NotificationSummaryWidget";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/notifications.css";
@@ -8,11 +13,53 @@ const NotificationSettingsPanel = lazy(() => import("./features/notifications/No
const notificationRead = ["notifications:notification:read"];
const notificationDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "notifications.summary",
surfaceId: "notifications.widget.summary",
title: "i18n:govoplan-notifications.surface.center",
description: "i18n:govoplan-notifications.widget_description",
moduleId: "notifications",
category: "i18n:govoplan-notifications.communication",
order: 60,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: notificationRead,
refreshIntervalMs: 30_000,
defaultConfiguration: {
showDeliveryState: true
},
configurationFields: [
{
id: "showDeliveryState",
label: "i18n:govoplan-notifications.show_delivery_state",
description: "i18n:govoplan-notifications.show_delivery_state_help",
kind: "boolean"
}
],
render: ({ settings, effectiveView, refreshKey, configuration }) =>
createElement(NotificationSummaryWidget, {
settings,
refreshKey,
configuration,
showCenterLink: (
!effectiveView?.activeViewId
|| effectiveView.visibleSurfaceIds.includes(
"notifications.route.notifications"
)
)
})
}
]
};
const notificationSettingsSections: SettingsSectionsUiCapability = {
sections: [
{
id: "notifications",
label: "Notifications",
surfaceId: "notifications.settings.preferences",
label: "i18n:govoplan-notifications.surface.center",
group: "ui",
order: 40,
anyOf: notificationRead,
@@ -23,16 +70,40 @@ const notificationSettingsSections: SettingsSectionsUiCapability = {
export const notificationsModule: PlatformWebModule = {
id: "notifications",
label: "Notifications",
label: "i18n:govoplan-notifications.surface.center",
version: "1.0.0",
dependencies: [],
optionalDependencies: ["mail", "tasks", "portal", "workflow", "calendar", "scheduling"],
translations: generatedTranslations,
viewSurfaces: [
{ id: "notifications.page.inbox", moduleId: "notifications", kind: "section", label: "i18n:govoplan-notifications.surface.inbox", parentId: "notifications.route.notifications", order: 20 },
{ id: "notifications.page.detail", moduleId: "notifications", kind: "section", label: "i18n:govoplan-notifications.surface.detail", parentId: "notifications.route.notifications", order: 30 },
{ id: "notifications.page.delivery", moduleId: "notifications", kind: "section", label: "i18n:govoplan-notifications.surface.delivery", parentId: "notifications.page.detail", order: 40 },
{ id: "notifications.action.mark-read", moduleId: "notifications", kind: "action", label: "i18n:govoplan-notifications.surface.mark_read", parentId: "notifications.page.detail", order: 50 },
{ id: "notifications.action.acknowledge", moduleId: "notifications", kind: "action", label: "i18n:govoplan-notifications.surface.acknowledge", parentId: "notifications.page.detail", order: 60 },
{ id: "notifications.action.cancel", moduleId: "notifications", kind: "action", label: "i18n:govoplan-notifications.surface.cancel", parentId: "notifications.page.delivery", order: 70 },
{ id: "notifications.action.dispatch", moduleId: "notifications", kind: "action", label: "i18n:govoplan-notifications.surface.dispatch", parentId: "notifications.page.delivery", order: 80 },
{
id: "notifications.widget.summary",
moduleId: "notifications",
kind: "section",
label: "i18n:govoplan-notifications.surface.widget_summary",
order: 90
},
{
id: "notifications.settings.preferences",
moduleId: "notifications",
kind: "section",
label: "i18n:govoplan-notifications.surface.preferences",
order: 100
}
],
routes: [
{ path: "/notifications", anyOf: notificationRead, order: 59, render: ({ settings, auth }) => createElement(NotificationCenterPage, { settings, auth }) }
{ path: "/notifications", anyOf: notificationRead, order: 59, surfaceId: "notifications.route.notifications", render: ({ settings, auth }) => createElement(NotificationCenterPage, { settings, auth }) }
],
uiCapabilities: {
"settings.sections": notificationSettingsSections
"settings.sections": notificationSettingsSections,
"dashboard.widgets": notificationDashboardWidgets
}
};
+44
View File
@@ -13,6 +13,14 @@
box-sizing: border-box;
}
.notifications-widget-actions {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
margin-top: 14px;
}
.notifications-shell {
height: 100%;
min-height: 0;
@@ -68,6 +76,11 @@
.notifications-status-filter {
width: calc(100% - 16px);
margin: 8px;
overflow-x: auto;
}
.notifications-status-filter .segmented-control-option {
flex: 0 0 auto;
}
.notifications-list {
@@ -152,6 +165,10 @@
margin: 8px 12px 0;
}
.notifications-workspace > .action-blocker-hint {
margin: 8px 12px 0;
}
.notifications-detail {
min-height: 0;
overflow: auto;
@@ -202,6 +219,16 @@
align-items: start;
}
.notifications-settings-documentation,
.notifications-settings-panel > .action-blocker-hint {
grid-column: 1 / -1;
}
.notifications-settings-documentation {
display: flex;
justify-content: flex-end;
}
.notifications-settings-inline-title {
display: flex;
align-items: center;
@@ -217,6 +244,17 @@
font-size: 15px;
}
.notifications-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.notifications-section-heading h2 {
margin: 0;
}
.notifications-properties dl {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
@@ -279,6 +317,12 @@
text-align: center;
}
.notifications-permission-state {
max-width: 860px;
margin: 0 auto;
padding: 32px 20px;
}
.notifications-empty-state h1 {
margin: 0;
color: var(--text-strong);