Compare commits
15
Commits
ae144a13e0
...
v0.1.15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0731951aef | ||
|
|
375425b174 | ||
|
|
f0e1032ea9 | ||
|
|
bb59342b24 | ||
|
|
3c71422b90 | ||
|
|
ad6a31f68b | ||
|
|
3b1a87b3e2 | ||
|
|
f9afe9570d | ||
|
|
099725a85b | ||
|
|
a7ad5b99fb | ||
|
|
8153a1de45 | ||
|
|
e32ba3663b | ||
|
|
992d2ca533 | ||
|
|
9c2dc3efbf | ||
|
|
0e62e6df8f |
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-notifications"
|
||||
version = "0.1.8"
|
||||
version = "0.1.15"
|
||||
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.15",
|
||||
]
|
||||
|
||||
[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"),
|
||||
)
|
||||
|
||||
@@ -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.15"
|
||||
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",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+29
@@ -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",
|
||||
)
|
||||
@@ -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 [],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
+9
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/notifications-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -14,17 +14,18 @@
|
||||
"./styles/notifications.css": "./src/styles/notifications.css"
|
||||
},
|
||||
"scripts": {
|
||||
"test:action-url": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.tests.json && node .component-test-build/tests/action-url.test.js"
|
||||
"test:action-url": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.tests.json && node .component-test-build/tests/action-url.test.js",
|
||||
"test:ui-structure": "node scripts/test-notification-page-structure.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"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": {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const pagePath = fileURLToPath(new URL("../src/features/notifications/NotificationCenterPage.tsx", import.meta.url));
|
||||
const stylesPath = fileURLToPath(new URL("../src/styles/notifications.css", import.meta.url));
|
||||
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="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 center uses central selection, disabled-action, and confirmation contracts.");
|
||||
@@ -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 { Button, DismissibleAlert, 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,77 +178,119 @@ 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>
|
||||
<Button className="notifications-icon-button" onClick={() => void load()} title="Refresh" disabled={loading || busy}>
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="notifications-filter-row" role="tablist" aria-label="Notification status">
|
||||
{statusFilters.map((status) => (
|
||||
<button key={status} className={status === statusFilter ? "is-active" : ""} type="button" onClick={() => setStatusFilter(status)} role="tab" aria-selected={status === statusFilter}>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
<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: statusLabel(status) }))}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
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}
|
||||
{notifications.map((notification) => (
|
||||
<button
|
||||
key={notification.id}
|
||||
type="button"
|
||||
className={`notifications-list-item ${selected?.id === notification.id ? "is-selected" : ""} ${notification.read_at ? "is-read" : ""}`}
|
||||
onClick={() => setSelectedId(notification.id)}
|
||||
>
|
||||
<span className="notifications-list-heading">
|
||||
<strong>{notification.subject || notification.event_kind}</strong>
|
||||
<small>{formatStatus(notification.status)}</small>
|
||||
</span>
|
||||
<span className="notifications-list-meta">
|
||||
<span>{notification.source_module}</span>
|
||||
<span>{formatDate(notification.created_at)}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{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="i18n:govoplan-notifications.notifications" className="notifications-selection-list">
|
||||
{notifications.map((notification) => (
|
||||
<SelectionListItem
|
||||
key={notification.id}
|
||||
selected={selected?.id === notification.id}
|
||||
className={`notifications-list-item ${notification.read_at ? "is-read" : ""}`}
|
||||
onClick={() => setSelectedId(notification.id)}
|
||||
>
|
||||
<span className="notifications-list-heading">
|
||||
<strong>{notification.subject || notification.event_kind}</strong>
|
||||
<small>{formatStatus(notification.status)}</small>
|
||||
</span>
|
||||
<span className="notifications-list-meta">
|
||||
<span>{notification.source_module}</span>
|
||||
<span>{formatDate(notification.created_at)}</span>
|
||||
</span>
|
||||
</SelectionListItem>
|
||||
))}
|
||||
</SelectionList>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
<section className="notifications-workspace">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -177,37 +301,40 @@ function NotificationDetails({ notification }: { notification: NotificationMessa
|
||||
<div className="notifications-detail">
|
||||
<section className="notifications-message">
|
||||
<div className="notifications-message-meta">
|
||||
<span className={`notifications-status status-${notification.status}`}>{formatStatus(notification.status)}</span>
|
||||
<StatusBadge status={notification.status} label={formatStatus(notification.status)} />
|
||||
<span>{notification.channel}</span>
|
||||
<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>
|
||||
@@ -222,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, {
|
||||
@@ -236,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;
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -65,40 +73,14 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.notifications-icon-button.btn {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notifications-filter-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
padding: 8px;
|
||||
border-bottom: var(--border-line);
|
||||
.notifications-status-filter {
|
||||
width: calc(100% - 16px);
|
||||
margin: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.notifications-filter-row button {
|
||||
min-height: 28px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 0 8px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.notifications-filter-row button:hover,
|
||||
.notifications-filter-row button.is-active {
|
||||
background: var(--sidebar-hover-bg);
|
||||
color: var(--text-strong);
|
||||
.notifications-status-filter .segmented-control-option {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.notifications-list {
|
||||
@@ -110,27 +92,14 @@
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.notifications-selection-list {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.notifications-list-item {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-height: 58px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
padding: 9px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.notifications-list-item:hover,
|
||||
.notifications-list-item.is-selected {
|
||||
background: var(--sidebar-hover-bg);
|
||||
}
|
||||
|
||||
.notifications-list-item.is-selected {
|
||||
box-shadow: inset 3px 0 var(--accent);
|
||||
}
|
||||
|
||||
.notifications-list-item.is-read {
|
||||
@@ -196,6 +165,10 @@
|
||||
margin: 8px 12px 0;
|
||||
}
|
||||
|
||||
.notifications-workspace > .action-blocker-hint {
|
||||
margin: 8px 12px 0;
|
||||
}
|
||||
|
||||
.notifications-detail {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
@@ -230,29 +203,6 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notifications-status {
|
||||
border-radius: 999px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.notifications-status.status-failed {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.notifications-status.status-sent {
|
||||
background: var(--success-soft);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.notifications-status.status-queued,
|
||||
.notifications-status.status-pending {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.notifications-action-link {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
@@ -269,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;
|
||||
@@ -284,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));
|
||||
@@ -346,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);
|
||||
|
||||
Reference in New Issue
Block a user