30 Commits
Author SHA1 Message Date
zemion 713f2d3c63 docs(notifications): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 11s
2026-08-23 19:31:22 +02:00
zemion 975d90b012 feat(notifications): add governed DSAR coverage 2026-08-21 04:08:21 +02:00
zemion 5c28ac5094 fix(notifications): enforce personal source mutes 2026-08-20 04:58:34 +02:00
zemion f9f7ab75d3 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:45 +02:00
zemion da0960967d feat: align notifications with shared UI foundations 2026-08-18 21:32:42 +02:00
zemion e4d6dff1d7 Adopt shared WebUI structural primitives 2026-08-18 13:17:31 +02:00
zemion 178888c7ac Adopt shared WebUI layout primitives 2026-08-18 11:30:40 +02:00
zemion bf0ebe1541 Adopt shared WebUI layout primitives 2026-08-18 10:42:52 +02:00
zemion 09205374f9 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:52 +02:00
zemion 239664092a Release v0.1.17
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 20:34:13 +02:00
zemion 843540b14d Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:29 +02:00
zemion 0731951aef Release v0.1.15
Module Package Release / publish-packages (push) Successful in 10s
2026-08-04 15:10:27 +02:00
zemion 375425b174 Make package publication retries hash-safe 2026-08-04 14:32:20 +02:00
zemion f0e1032ea9 Harden module package publication 2026-08-04 14:02:40 +02:00
zemion bb59342b24 Resolve notification worker items to tenants 2026-08-04 09:29:36 +02:00
zemion 3c71422b90 Add protected package release workflow 2026-08-04 04:14:06 +02:00
zemion ad6a31f68b Migrate Notifications interface patterns 2026-08-03 15:33:49 +02:00
zemion 3b1a87b3e2 Assign Notifications a unique migration revision 2026-08-03 15:05:03 +02:00
zemion f9afe9570d docs: declare institutional architecture boundary 2026-08-01 17:48:37 +02:00
zemion 099725a85b refactor: target workflow engine runtime 2026-07-31 16:59:22 +02:00
zemion a7ad5b99fb Align notification WebUI runtime dependencies 2026-07-31 02:48:56 +02:00
zemion 8153a1de45 feat: route email notifications through mail capability 2026-07-30 17:42:07 +02:00
zemion e32ba3663b perf: add indexed notification summaries and widget 2026-07-29 14:16:29 +02:00
zemion 992d2ca533 Declare notification View surfaces 2026-07-28 21:04:54 +02:00
zemion 9c2dc3efbf refactor(webui): centralize notification selection 2026-07-21 13:36:04 +02:00
zemion 0e62e6df8f refactor(webui): use central status and action components 2026-07-21 12:01:24 +02:00
zemion ae144a13e0 Restrict notification action links to application paths 2026-07-21 03:16:51 +02:00
zemion 92fb409973 fix(notifications): secure inbox and post-commit delivery 2026-07-20 20:03:11 +02:00
zemion 6163c8f733 intermittent commit 2026-07-14 13:22:11 +02:00
zemion 3444a4920f Release v0.1.8 2026-07-11 16:49:04 +02:00
39 changed files with 5901 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+16
View File
@@ -0,0 +1,16 @@
# GovOPlaN Notifications Codex Guide
## Scope
This repository owns durable in-product notifications, preferences, delivery attempts, the notification center, and optional channel dispatch.
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Notifications internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Boundaries
- Producing modules own notification meaning; Notifications owns durable delivery state.
- Use optional Mail and Portal capabilities without importing their internals.
+5
View File
@@ -0,0 +1,5 @@
# govoplan-notifications
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
+24
View File
@@ -0,0 +1,24 @@
# Email Notification Delivery
In-app notifications are the canonical notification channel. They remain
available when no email implementation is installed.
Production email delivery is an optional integration with the
`mail.notificationDelivery` capability:
- Notifications owns recipient intent, notification preferences, content, and
notification status.
- Mail owns server profiles, credentials, queue persistence, retry policy, and
transport outcomes.
- An accepted handoff is recorded as `accepted`; it does not claim that the
remote SMTP server has delivered the message.
- If Mail or a usable profile is unavailable, delivery is recorded as
`paused`. The notification is retained and can be retried after the
capability becomes available.
- Transport failures are recorded as `failed` with an attempt record and do
not remove the in-app notification.
- File-based EML delivery is development-only and must not be used as a
production fallback.
The capability contract deliberately passes references to recipient, tenant,
and profile context without exposing or duplicating Mail credentials.
+27
View File
@@ -0,0 +1,27 @@
# Notifications interface pattern migration
Notifications uses the platform inbox/list-detail pattern for recipient work and the governed-operation pattern for delivery.
## Surfaces
- `notifications.route.notifications` remains the route identifier so existing Views keep working.
- `notifications.page.inbox` owns status filtering and notification selection.
- `notifications.page.detail` owns message, source, recipient, and read/acknowledgement state.
- `notifications.page.delivery` owns sanitized delivery-attempt evidence.
- `notifications.action.mark-read`, `notifications.action.acknowledge`, and `notifications.action.cancel` describe recipient actions on the selected record.
- `notifications.action.dispatch` describes the privileged bounded delivery operation.
- `notifications.settings.preferences` and `notifications.widget.summary` remain composed Settings and Dashboard surfaces.
The backend and WebUI manifests publish the same identifiers and hierarchy. Notifications does not introduce a navigation entry because the title-bar bell is the platform entry point.
## Consequences and recovery
Read and acknowledgement actions record durable recipient state. Cancellation is confirmed and is available only before an outcome becomes provider-accepted or otherwise terminal; it stops eligible local work and is not presented as remote recall. Dispatch is confirmed separately and processes at most 50 eligible tenant notifications per request. Accepted outcomes are not retried blindly.
Unavailable actions remain visible with explicit selection, permission, busy, terminal-state, and optional-Mail explanations. Personal preference drafts use the shared unsaved-change guard. The settings surface explains that in-product notifications continue when Mail is absent and that production email depends on the optional Mail capability.
Contextual help resolves through `govoplan-docs` when installed and through the hosted fallback otherwise. Delivery evidence remains sanitized and never exposes provider credentials or private sibling-module state.
## Optional boundaries
Producing modules continue to own notification meaning. Notifications owns durable recipient and delivery state and calls optional Mail, Portal, Tasks, Calendar, Scheduling, and Workflow Engine integrations only through declared capabilities and references.
+23
View File
@@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-notifications"
version = "0.1.19"
description = "GovOPlaN notification inbox and delivery module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.18",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_notifications = ["py.typed"]
[project.entry-points."govoplan.modules"]
notifications = "govoplan_notifications.backend.manifest:get_manifest"
+2
View File
@@ -0,0 +1,2 @@
from __future__ import annotations
@@ -0,0 +1,2 @@
from __future__ import annotations
@@ -0,0 +1,62 @@
from __future__ import annotations
from collections.abc import Mapping
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import NotificationDispatchProvider, NotificationDispatchRequest
from govoplan_notifications.backend.service import deliver_notification, deliver_pending, enqueue_dispatch_request, notification_response
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,
session: object,
request: NotificationDispatchRequest,
*,
enqueue_delivery: bool = True,
) -> Mapping[str, object]:
notification = enqueue_dispatch_request(session, request, enqueue_delivery=enqueue_delivery) # type: ignore[arg-type]
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,
registry=self._registry,
) # type: ignore[arg-type]
return notification_response(notification)
def deliver_pending(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 50,
) -> Mapping[str, object]:
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:
return SqlNotificationDispatchProvider(context)
@@ -0,0 +1,5 @@
from __future__ import annotations
from govoplan_notifications.backend.db.models import NotificationDeliveryAttempt, NotificationMessage, NotificationPreference
__all__ = ["NotificationDeliveryAttempt", "NotificationMessage", "NotificationPreference"]
@@ -0,0 +1,112 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class NotificationMessage(Base, TimestampMixin):
__tablename__ = "notification_messages"
__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"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
source_module: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
source_resource_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
source_resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
event_kind: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
channel: Mapped[str] = mapped_column(String(40), default="inbox", nullable=False, index=True)
recipient: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True)
recipient_type: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
recipient_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
recipient_label: Mapped[str | None] = mapped_column(String(500), nullable=True)
subject: Mapped[str | None] = mapped_column(String(500), nullable=True)
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
body_html: Mapped[str | None] = mapped_column(Text, nullable=True)
action_url: Mapped[str | None] = mapped_column(String(1000), nullable=True)
priority: Mapped[int] = mapped_column(Integer, default=0, nullable=False, index=True)
status: Mapped[str] = mapped_column(String(40), default="pending", nullable=False, index=True)
not_before_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
failed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
external_message_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
attempts: Mapped[list["NotificationDeliveryAttempt"]] = relationship(
back_populates="notification",
cascade="all, delete-orphan",
order_by="NotificationDeliveryAttempt.created_at",
)
class NotificationDeliveryAttempt(Base, TimestampMixin):
__tablename__ = "notification_delivery_attempts"
__table_args__ = (
Index("ix_notification_attempts_notification", "notification_id", "attempt_no"),
Index("ix_notification_attempts_tenant_status", "tenant_id", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
notification_id: Mapped[str] = mapped_column(ForeignKey("notification_messages.id", ondelete="CASCADE"), nullable=False, index=True)
attempt_no: Mapped[int] = mapped_column(Integer, nullable=False)
channel: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
provider: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
external_message_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
details: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
notification: Mapped[NotificationMessage] = relationship(back_populates="attempts")
class NotificationPreference(Base, TimestampMixin):
__tablename__ = "notification_preferences"
__table_args__ = (
UniqueConstraint("tenant_id", "user_id", name="uq_notification_preferences_tenant_user"),
Index("ix_notification_preferences_tenant_user", "tenant_id", "user_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
show_unread_badge: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
email_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
email_digest_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
muted_source_modules: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
__all__ = ["NotificationDeliveryAttempt", "NotificationMessage", "NotificationPreference", "new_uuid"]
@@ -0,0 +1,655 @@
from __future__ import annotations
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_notifications.backend.db.models import (
NotificationMessage,
NotificationPreference,
)
NOTIFICATIONS_DSAR_CAPABILITY = dsar_capability_name("notifications")
_MAX_RECORDS = 1_000
_MAX_ATTEMPTS_PER_MESSAGE = 100
_MAX_BODY_CHARS = 100_000
_MAX_JSON_NODES = 10_000
_MAX_JSON_CHARS = 100_000
_CONFLICT = object()
_SENSITIVE_KEY_PARTS = (
"authorization",
"cookie",
"credential",
"password",
"secret",
"token",
"api_key",
"apikey",
)
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
actor_ids: tuple[str, ...]
email: str | None
notification_id: str | None
preference_id: str | None
@dataclass(slots=True)
class _JsonBudget:
nodes: int = 0
characters: int = 0
class NotificationsDsarProvider:
provider_id = "notifications"
module_id = "notifications"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
records: list[DsarRecordRef] = []
if selectors.preference_id is None:
messages = db.query(NotificationMessage).filter(
NotificationMessage.tenant_id == tenant_id,
_recipient_filter(selectors),
)
if selectors.notification_id:
messages = messages.filter(
NotificationMessage.id == selectors.notification_id
)
rows = (
messages.order_by(
NotificationMessage.created_at,
NotificationMessage.id,
)
.limit(_MAX_RECORDS + 1)
.all()
)
if len(rows) > _MAX_RECORDS:
raise ValueError(
"Notifications DSAR result limit exceeded; narrow the selectors."
)
records.extend(_message_record(row) for row in rows)
if selectors.notification_id is None and selectors.actor_ids:
preferences = db.query(NotificationPreference).filter(
NotificationPreference.tenant_id == tenant_id,
NotificationPreference.user_id.in_(selectors.actor_ids),
)
if selectors.preference_id:
preferences = preferences.filter(
NotificationPreference.id == selectors.preference_id
)
rows = (
preferences.order_by(NotificationPreference.id)
.limit(_MAX_RECORDS + 1)
.all()
)
if len(rows) > _MAX_RECORDS:
raise ValueError(
"Notification preference DSAR result limit exceeded; narrow the selectors."
)
records.extend(_preference_record(row) for row in rows)
if len(records) > _MAX_RECORDS:
raise ValueError(
"Notifications DSAR combined result limit exceeded; narrow the selectors."
)
order = {"notification_preference": 10, "notification_message": 20}
return tuple(
sorted(
records,
key=lambda item: (order[item.resource_type], item.resource_id),
)
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
raise ValueError("Notifications DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
if record.resource_type == "notification_preference":
preference = (
db.query(NotificationPreference)
.filter(
NotificationPreference.tenant_id == tenant_id,
NotificationPreference.id == record.resource_id,
)
.one_or_none()
)
if (
preference is None
or preference.user_id not in selectors.actor_ids
or (
selectors.preference_id
and preference.id != selectors.preference_id
)
):
actions.append(
_manual_action(record, "preference ownership mismatch")
)
continue
actions.append(_delete_action(record, preference.updated_at))
continue
notification = (
db.query(NotificationMessage)
.filter(
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.id == record.resource_id,
_recipient_filter(selectors),
)
.one_or_none()
)
if notification is None or (
selectors.notification_id
and notification.id != selectors.notification_id
):
actions.append(_manual_action(record, "recipient or selector mismatch"))
elif notification.channel == "inbox":
actions.append(_delete_action(record, notification.updated_at))
else:
actions.append(
_manual_action(
record,
"external-channel delivery and provider evidence require retention review",
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
raise ValueError("Notifications DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if not action.executable or action.kind != "delete":
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"External notification delivery evidence requires an "
"authorized retention review."
),
)
)
continue
if action.resource_type == "notification_preference":
row = (
db.query(NotificationPreference)
.filter(
NotificationPreference.tenant_id == tenant_id,
NotificationPreference.id == action.resource_id,
)
.one_or_none()
)
if row is None:
results.append(_unchanged(action, request_id, "preference"))
continue
if (
row.user_id not in selectors.actor_ids
or str(action.metadata.get("owner_id") or "") != row.user_id
or (selectors.preference_id and row.id != selectors.preference_id)
):
results.append(_blocked(action, "preference ownership mismatch"))
continue
elif action.resource_type == "notification_message":
row = (
db.query(NotificationMessage)
.filter(
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.id == action.resource_id,
)
.one_or_none()
)
if row is None:
results.append(_unchanged(action, request_id, "notification"))
continue
if (
row.channel != "inbox"
or not _recipient_matches(row, selectors)
or (
selectors.notification_id
and row.id != selectors.notification_id
)
):
results.append(_blocked(action, "recipient or channel mismatch"))
continue
else:
raise ValueError("Unsupported executable Notifications DSAR action.")
if action.metadata.get("updated_at") != _iso(row.updated_at):
results.append(_blocked(action, "the record changed after planning"))
continue
resource_id = str(row.id)
db.delete(row)
db.flush()
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="executed",
summary=(
"Deleted the personal Notifications record without "
"traversing or changing its source-module data."
),
evidence={
"request_id": request_id,
"resource_type": action.resource_type,
"resource_id": resource_id,
},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("notifications.account"),
references.get("access.account"),
),
"membership_id": _coalesce(
subject.membership_id,
references.get("notifications.membership"),
references.get("tenancy.membership"),
),
"identity_id": _coalesce(
subject.identity_id,
references.get("notifications.identity"),
references.get("identity.id"),
),
"user_id": _coalesce(
references.get("notifications.user"),
references.get("idm.user"),
),
"email": _coalesce(
_normalized_email(subject.email),
_normalized_email(references.get("notifications.email")),
),
"notification_id": _coalesce(
references.get("notifications.notification"),
references.get("notifications.message"),
),
"preference_id": _coalesce(
references.get("notifications.preference"),
references.get("notifications.preference_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
actor_ids = tuple(
dict.fromkeys(
value
for key in ("account_id", "membership_id", "identity_id", "user_id")
if (value := _optional_string(values[key]))
)
)
email = _optional_string(values["email"])
if not actor_ids and email is None:
return None
return _SubjectSelectors(
actor_ids=actor_ids,
email=email,
notification_id=_optional_string(values["notification_id"]),
preference_id=_optional_string(values["preference_id"]),
)
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _normalized_email(value: str | None) -> str | None:
normalized = (value or "").strip().casefold()
return normalized or None
def _recipient_filter(selectors: _SubjectSelectors):
filters = []
if selectors.actor_ids:
filters.append(NotificationMessage.recipient_id.in_(selectors.actor_ids))
if selectors.email:
filters.extend(
(
func.lower(NotificationMessage.recipient) == selectors.email,
func.lower(NotificationMessage.recipient_id) == selectors.email,
)
)
return or_(*filters)
def _recipient_matches(
notification: NotificationMessage,
selectors: _SubjectSelectors,
) -> bool:
if notification.recipient_id in selectors.actor_ids:
return True
return bool(
selectors.email
and selectors.email
in {
_normalized_email(notification.recipient),
_normalized_email(notification.recipient_id),
}
)
def _message_record(notification: NotificationMessage) -> DsarRecordRef:
attempts = list(notification.attempts)
if len(attempts) > _MAX_ATTEMPTS_PER_MESSAGE:
raise ValueError(
"Notifications DSAR delivery-attempt limit exceeded; narrow the selectors."
)
return DsarRecordRef(
provider_id="notifications",
module_id="notifications",
resource_type="notification_message",
resource_id=notification.id,
category="direct_notification",
title=f"Notification: {(notification.subject or notification.event_kind)[:200]}",
data={
"source_module": notification.source_module,
"source_resource_type": notification.source_resource_type,
"source_resource_id": notification.source_resource_id,
"event_kind": notification.event_kind,
"channel": notification.channel,
"recipient": notification.recipient,
"recipient_type": notification.recipient_type,
"recipient_id": notification.recipient_id,
"recipient_label": notification.recipient_label,
"subject": notification.subject,
"body_text": _bounded_text(notification.body_text, "body text"),
"body_html": _bounded_text(notification.body_html, "body HTML"),
"action_url": notification.action_url,
"priority": notification.priority,
"status": notification.status,
"not_before_at": _iso(notification.not_before_at),
"queued_at": _iso(notification.queued_at),
"sent_at": _iso(notification.sent_at),
"failed_at": _iso(notification.failed_at),
"read_at": _iso(notification.read_at),
"acknowledged_at": _iso(notification.acknowledged_at),
"cancelled_at": _iso(notification.cancelled_at),
"attempt_count": notification.attempt_count,
"last_error": _bounded_text(notification.last_error, "last error"),
"external_message_id": notification.external_message_id,
"payload": _bounded_json(notification.payload, label="payload"),
"metadata": _bounded_json(notification.metadata_, label="metadata"),
"deleted_at": _iso(notification.deleted_at),
"attempts": [
{
"id": attempt.id,
"attempt_no": attempt.attempt_no,
"channel": attempt.channel,
"provider": attempt.provider,
"status": attempt.status,
"started_at": _iso(attempt.started_at),
"finished_at": _iso(attempt.finished_at),
"external_message_id": attempt.external_message_id,
"error": _bounded_text(attempt.error, "attempt error"),
"details": _bounded_json(
attempt.details,
label="attempt details",
),
}
for attempt in attempts
],
},
observed_at=_aware(notification.updated_at),
retention_reason=(
"External-channel messages require delivery and source-record retention review."
if notification.channel != "inbox"
else None
),
)
def _preference_record(preference: NotificationPreference) -> DsarRecordRef:
muted = preference.muted_source_modules
if not isinstance(muted, list) or len(muted) > 500:
raise ValueError("Notification muted-source preference exceeds the DSAR bound.")
return DsarRecordRef(
provider_id="notifications",
module_id="notifications",
resource_type="notification_preference",
resource_id=preference.id,
category="personal_communication_preference",
title="Personal notification preferences",
data={
"user_id": preference.user_id,
"show_unread_badge": preference.show_unread_badge,
"email_enabled": preference.email_enabled,
"email_digest_enabled": preference.email_digest_enabled,
"muted_source_modules": [str(value)[:100] for value in muted],
"metadata": _bounded_json(
preference.metadata_,
label="preference metadata",
),
},
observed_at=_aware(preference.updated_at),
)
def _bounded_text(value: str | None, label: str) -> str | None:
if value is None:
return None
if len(value) > _MAX_BODY_CHARS:
raise ValueError(
f"Notification {label} exceeds the DSAR export bound; narrow and review the record."
)
return value
def _bounded_json(value: object, *, label: str) -> object:
budget = _JsonBudget()
def project(item: object, depth: int) -> object:
budget.nodes += 1
if budget.nodes > _MAX_JSON_NODES or depth > 12:
raise ValueError(f"Notification {label} exceeds the DSAR structure bound.")
if item is None or isinstance(item, (bool, int)):
return item
if isinstance(item, float):
if not math.isfinite(item):
raise ValueError(f"Notification {label} contains a non-finite number.")
return item
if isinstance(item, str):
budget.characters += len(item)
if budget.characters > _MAX_JSON_CHARS:
raise ValueError(
f"Notification {label} exceeds the DSAR character bound."
)
return item
if isinstance(item, Mapping):
if len(item) > 1_000:
raise ValueError(f"Notification {label} contains too many fields.")
projected: dict[str, object] = {}
for raw_key, raw_value in item.items():
key = str(raw_key)
if len(key) > 500:
raise ValueError(f"Notification {label} contains an oversized key.")
budget.characters += len(key)
if budget.characters > _MAX_JSON_CHARS:
raise ValueError(
f"Notification {label} exceeds the DSAR character bound."
)
projected[key] = (
"[redacted]"
if _is_sensitive_key(key)
else project(raw_value, depth + 1)
)
return projected
if isinstance(item, (list, tuple)):
if len(item) > 1_000:
raise ValueError(f"Notification {label} contains too many items.")
return [project(entry, depth + 1) for entry in item]
raise ValueError(f"Notification {label} contains an unsupported value.")
return project(value, 0)
def _is_sensitive_key(value: str) -> bool:
normalized = value.strip().casefold().replace("-", "_")
return any(part in normalized for part in _SENSITIVE_KEY_PARTS)
def _delete_action(
record: DsarRecordRef,
updated_at: datetime | None,
) -> DsarErasureActionRef:
metadata: dict[str, object] = {"updated_at": _iso(updated_at)}
if record.resource_type == "notification_preference":
metadata["owner_id"] = str(record.data.get("user_id") or "")
return DsarErasureActionRef(
action_id=f"notifications:delete:{record.resource_type}:{record.resource_id}",
provider_id="notifications",
module_id="notifications",
kind="delete",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Delete {record.title}",
rationale=(
"The record is a subject-owned preference or derived in-product "
"notification copy; source-module data remains unchanged."
),
executable=True,
irreversible=True,
metadata=metadata,
)
def _manual_action(record: DsarRecordRef, reason: str) -> DsarErasureActionRef:
return DsarErasureActionRef(
action_id=(
f"notifications:manual_review:{record.resource_type}:{record.resource_id}"
),
provider_id="notifications",
module_id="notifications",
kind="manual_review",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Review {record.title}",
rationale=f"Automatic deletion stopped because {reason}.",
executable=False,
)
def _unchanged(
action: DsarErasureActionRef,
request_id: str,
label: str,
) -> DsarExecutionResultRef:
return DsarExecutionResultRef(
action_id=action.action_id,
status="unchanged",
summary=f"The personal notification {label} was already absent.",
evidence={"request_id": request_id},
)
def _blocked(
action: DsarErasureActionRef,
reason: str,
) -> DsarExecutionResultRef:
return DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=f"Notifications erasure stopped because of {reason}.",
)
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Notifications DSAR requires a SQLAlchemy Session.")
return value
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "notifications" or record.module_id != "notifications":
raise ValueError("Notifications DSAR cannot plan a foreign provider record.")
if (
record.resource_type
not in {
"notification_message",
"notification_preference",
}
or not record.resource_id
):
raise ValueError("Notifications DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "notifications" or action.module_id != "notifications":
raise ValueError("Notifications DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("notifications:"):
raise ValueError("Notifications DSAR action identity is invalid.")
__all__ = ["NOTIFICATIONS_DSAR_CAPABILITY", "NotificationsDsarProvider"]
@@ -0,0 +1,518 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
PermissionDefinition,
ProductAreaContribution,
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
from govoplan_notifications.backend.dsar_provider import (
NOTIFICATIONS_DSAR_CAPABILITY,
NotificationsDsarProvider,
)
MODULE_ID = "notifications"
MODULE_NAME = "Notifications"
MODULE_VERSION = "0.1.19"
READ_SCOPE = "notifications:notification:read"
WRITE_SCOPE = "notifications:notification:write"
DISPATCH_SCOPE = "notifications:delivery:dispatch"
ADMIN_SCOPE = "notifications:notification:admin"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Notifications",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
PERMISSIONS = (
_permission(
READ_SCOPE,
"View notifications",
"Read the authenticated actor's notification inbox and delivery state.",
),
_permission(
WRITE_SCOPE,
"Manage notifications",
"Create, update, cancel, read, and acknowledge notifications.",
),
_permission(
DISPATCH_SCOPE,
"Dispatch notifications",
"Run notification delivery attempts and delivery workers.",
),
_permission(
ADMIN_SCOPE,
"Administer notifications",
"Explicitly read and manage tenant-wide notification delivery state.",
),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="notification_manager",
name="Notification manager",
description="Manage and dispatch tenant notifications.",
permissions=(READ_SCOPE, WRITE_SCOPE, DISPATCH_SCOPE),
),
RoleTemplate(
slug="notification_viewer",
name="Notification viewer",
description="Read notification state.",
permissions=(READ_SCOPE,),
),
)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_notifications.backend.db.models import NotificationMessage
return {
"notifications": session.query(NotificationMessage)
.filter(
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.deleted_at.is_(None),
)
.count(),
"pending_notifications": session.query(NotificationMessage)
.filter(
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.status.in_(("pending", "queued", "failed")),
NotificationMessage.deleted_at.is_(None),
)
.count(),
}
def _notifications_router(_context: ModuleContext):
from govoplan_notifications.backend.router import router
return router
def _dsar_provider(_context: ModuleContext) -> NotificationsDsarProvider:
return NotificationsDsarProvider()
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
optional_dependencies=(
"mail",
"tasks",
"portal",
"workflow_engine",
"calendar",
"scheduling",
),
provides_interfaces=(
ModuleInterfaceProvider(name="notifications.dispatch", version="0.1.8"),
ModuleInterfaceProvider(
name=NOTIFICATIONS_DSAR_CAPABILITY,
version="0.1.0",
),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
route_factory=_notifications_router,
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
id="notifications.data-subject-requests",
title="Notification data-subject requests",
summary=(
"Export subject-addressed notifications and preferences, delete "
"personal inbox copies, and review external delivery evidence."
),
body=(
"Notifications correlates verified account, membership, identity, "
"user, and email selectors only inside the active tenant. The access "
"package contains bounded message content, recipient fields, lifecycle "
"timestamps, source references, payload and metadata, and sanitized "
"delivery attempts. Personal badge, email, digest, and source-muting "
"preferences are also included. Explicit notification or preference "
"references can narrow a request and must still match the subject. "
"In-product inbox messages are derived copies, so erasure can delete "
"them after recipient and change checks without following the source "
"reference into its owning module. Personal preferences can also be "
"deleted and then return to defaults. Mail and other external-channel "
"records require manual retention review because provider acceptance "
"and delivery evidence may remain outside Notifications. Repeated "
"execution is unchanged; unrelated tenants and recipients are never "
"included."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "operator", "auditor"),
related_modules=("core", "mail", "tasks", "workflow_engine"),
metadata={
"help_contexts": [
"notifications.page.inbox",
"notifications.page.detail",
"notifications.page.delivery",
"notifications.settings.preferences",
"privacy.data-subject-requests",
],
"consequence_classes": {
"delete_inbox_copy": (
"Removes the derived in-product notification and attempts, "
"not the source-module record."
),
"delete_preferences": (
"Removes personal overrides; notification defaults apply again."
),
"review_external_delivery": (
"Requires an authorized retention review before changing "
"provider-delivery evidence."
),
},
},
translations={
"de": {
"title": "Datenschutzanfragen zu Benachrichtigungen",
"summary": (
"An eine betroffene Person adressierte Benachrichtigungen und Einstellungen ausgeben, "
"persönliche Posteingangskopien löschen und externe Zustellnachweise prüfen."
),
"body": (
"Notifications gleicht verifizierte Konto-, Mitgliedschafts-, Identitäts-, Benutzer- und "
"E-Mail-Selektoren ausschließlich innerhalb des aktiven Mandanten ab. Das Auskunftspaket "
"enthält begrenzte Nachrichteninhalte, Empfängerfelder, Lebenszykluszeitpunkte, Quellverweise, "
"Nutzdaten und Metadaten sowie bereinigte Zustellversuche. Persönliche Einstellungen für "
"Kennzeichen, E-Mail, Zusammenfassungen und stummgeschaltete Quellmodule werden ebenfalls "
"einbezogen. Ausdrückliche Benachrichtigungs- oder Einstellungsverweise dürfen eine Anfrage nur "
"einschränken und müssen weiterhin zur betroffenen Person gehören. Nachrichten im internen "
"Posteingang sind abgeleitete Kopien und können deshalb nach Empfänger- und Änderungsprüfung "
"gelöscht werden, ohne dem Quellverweis in das Eigentümermodul zu folgen. Persönliche "
"Einstellungen können ebenfalls gelöscht werden; anschließend gelten wieder die Standardwerte. "
"Mail- und andere externe Kanalnachweise erfordern eine manuelle Aufbewahrungsprüfung, weil beim "
"Anbieter Annahme- und Zustellnachweise verbleiben können. Eine wiederholte Ausführung bleibt "
"unverändert; fremde Mandanten und Empfänger werden nie einbezogen."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"delete_inbox_copy": (
"Entfernt die abgeleitete interne Benachrichtigung und ihre Versuche, nicht den Datensatz des Quellmoduls."
),
"delete_preferences": (
"Entfernt persönliche Abweichungen; anschließend gelten wieder die Benachrichtigungsstandardwerte."
),
"review_external_delivery": (
"Erfordert eine autorisierte Aufbewahrungsprüfung, bevor externe Zustellnachweise verändert werden."
),
}
}
},
),
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. Personal source-muting preferences remove matching entries from the personal list and badge counts even when a producer addressed the actor through an account, membership, or identity identifier; tenant-administrator evidence views remain complete. Preferences also control eligible delivery channels and categories. Disabling an optional external channel does not remove an unmuted in-product notification unless the originating module's retention policy does so.",
documentation_types=("user",),
audience=("user",),
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
related_modules=("mail", "calendar", "scheduling", "workflow_engine"),
metadata={
"kind": "workflow",
"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",
},
},
translations={
"de": {
"title": "Benachrichtigungszentrale verwenden",
"summary": (
"Das Kennzeichen in der Titelleiste und die Benachrichtigungszentrale bündeln dauerhafte Hinweise, "
"die außerhalb einer unmittelbaren Anfrage Aufmerksamkeit erfordern."
),
"body": (
"Die Benachrichtigungszentrale zeigt Hinweise aktivierter Module zum Lesen, Bestätigen oder "
"Weiterverfolgen. Persönlich stummgeschaltete Quellmodule entfernen passende Einträge aus der "
"persönlichen Liste und der Kennzahl, auch wenn ein Erzeugermodul die Person über Konto, "
"Mitgliedschaft oder Identität adressiert hat; Nachweisansichten für Mandantenadministratoren "
"bleiben vollständig. Die Einstellungen steuern außerdem zulässige Zustellkanäle und Kategorien. "
"Das Abschalten eines optionalen externen Kanals entfernt keine nicht stummgeschaltete interne "
"Benachrichtigung, sofern dies nicht die Aufbewahrungsregel des Ursprungsmoduls vorsieht."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"mark_read": "Markiert die Benachrichtigung für den aktuellen Empfänger als gelesen.",
"acknowledge": "Hält zusätzlich zum Lesestatus eine ausdrückliche Bestätigung des Empfängers fest.",
"cancel": (
"Stoppt eine geeignete Benachrichtigung vor der Anbieterannahme, ohne einen Fernrückruf zu behaupten."
),
"update_preferences": (
"Ersetzt die Einstellungen des aktuellen Benutzers für Kennzeichen, Kanäle, Zusammenfassungen und stummgeschaltete Quellen."
),
}
}
},
),
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",
},
},
translations={
"de": {
"title": "Benachrichtigungszustellung betreiben",
"summary": (
"Notifications speichert Nachrichtenabsicht und begrenzte kanalbezogene Versuche, bevor Worker optionale Zustellkanäle ausführen."
),
"body": (
"Erzeugende Module übergeben Benachrichtigungen über die Dispatch-Fähigkeit und verwalten keine "
"Zugangsdaten für die Zustellung. Die interne Zustellung ist die Grundlage. Produktive "
"E-Mail-Zustellung steht nur über eine aktivierte Mail-Fähigkeit bereit; Dateizustellung bleibt auf "
"Entwicklung beschränkt. Betreiber können ausstehende und fehlgeschlagene Versuche prüfen und nur "
"Ergebnisse erneut versuchen, deren Wiederholung sicher ist. Die Modulberechtigung des Mandanten "
"wird vor dem Einreihen und erneut vor der Worker-Zustellung geprüft. Das Deaktivieren von "
"Notifications bewahrt angenommene Nachrichten und bietet eine Betreiberaktion an, statt sie "
"unbemerkt zu verarbeiten."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"dispatch_pending": (
"Versucht die Zustellung für höchstens die angeforderte Anzahl geeigneter Mandantenbenachrichtigungen."
),
"inspect_attempts": (
"Liest bereinigte Anbieter-, Status-, Zeit- und Fehlernachweise der ausgewählten Benachrichtigung."
),
}
}
},
),
),
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",
),
),
product_areas=(
ProductAreaContribution(
id="communication",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.communication",
icon="mail",
description="i18n:govoplan-core.product_area.communication_description",
surface_ids=("notifications.route.notifications",),
order=40,
),
),
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,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
notification_models.NotificationMessage,
notification_models.NotificationDeliveryAttempt,
notification_models.NotificationPreference,
label="Notifications",
),
retirement_notes="Destructive retirement drops notification-owned database tables after the installer captures a database snapshot.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
notification_models.NotificationMessage,
notification_models.NotificationDeliveryAttempt,
notification_models.NotificationPreference,
label="Notifications",
),
),
capability_factories={
CAPABILITY_NOTIFICATIONS_DISPATCH: lambda context: __import__(
"govoplan_notifications.backend.capabilities",
fromlist=["dispatch_capability"],
).dispatch_capability(context),
NOTIFICATIONS_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
NOTIFICATIONS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Notifications data-subject request provider",
summary=(
"Exports subject-addressed notification data, deletes personal "
"inbox copies and preferences, and isolates external delivery evidence."
),
contract_version="0.1.0",
),
},
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",),
),
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -0,0 +1,2 @@
from __future__ import annotations
@@ -0,0 +1,110 @@
"""v0.1.8 notifications baseline
Revision ID: 5e6f7a8b9c0d
Revises: None
Create Date: 2026-07-13 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "5e6f7a8b9c0d"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"notification_messages",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("source_module", sa.String(length=100), nullable=False),
sa.Column("source_resource_type", sa.String(length=100), nullable=False),
sa.Column("source_resource_id", sa.String(length=255), nullable=True),
sa.Column("event_kind", sa.String(length=100), nullable=False),
sa.Column("channel", sa.String(length=40), nullable=False),
sa.Column("recipient", sa.String(length=500), nullable=True),
sa.Column("recipient_type", sa.String(length=40), nullable=True),
sa.Column("recipient_id", sa.String(length=255), nullable=True),
sa.Column("recipient_label", sa.String(length=500), nullable=True),
sa.Column("subject", sa.String(length=500), nullable=True),
sa.Column("body_text", sa.Text(), nullable=True),
sa.Column("body_html", sa.Text(), nullable=True),
sa.Column("action_url", sa.String(length=1000), nullable=True),
sa.Column("priority", sa.Integer(), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("not_before_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("queued_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("failed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("acknowledged_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("attempt_count", sa.Integer(), nullable=False),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("external_message_id", sa.String(length=255), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_notification_messages")),
)
op.create_index(op.f("ix_notification_messages_channel"), "notification_messages", ["channel"], unique=False)
op.create_index(op.f("ix_notification_messages_deleted_at"), "notification_messages", ["deleted_at"], unique=False)
op.create_index(op.f("ix_notification_messages_event_kind"), "notification_messages", ["event_kind"], unique=False)
op.create_index(op.f("ix_notification_messages_external_message_id"), "notification_messages", ["external_message_id"], unique=False)
op.create_index(op.f("ix_notification_messages_not_before_at"), "notification_messages", ["not_before_at"], unique=False)
op.create_index(op.f("ix_notification_messages_priority"), "notification_messages", ["priority"], unique=False)
op.create_index(op.f("ix_notification_messages_recipient"), "notification_messages", ["recipient"], unique=False)
op.create_index(op.f("ix_notification_messages_recipient_id"), "notification_messages", ["recipient_id"], unique=False)
op.create_index(op.f("ix_notification_messages_recipient_type"), "notification_messages", ["recipient_type"], unique=False)
op.create_index(op.f("ix_notification_messages_source_module"), "notification_messages", ["source_module"], unique=False)
op.create_index(op.f("ix_notification_messages_source_resource_id"), "notification_messages", ["source_resource_id"], unique=False)
op.create_index(op.f("ix_notification_messages_source_resource_type"), "notification_messages", ["source_resource_type"], unique=False)
op.create_index(op.f("ix_notification_messages_status"), "notification_messages", ["status"], unique=False)
op.create_index(op.f("ix_notification_messages_tenant_id"), "notification_messages", ["tenant_id"], unique=False)
op.create_index("ix_notification_messages_due", "notification_messages", ["tenant_id", "status", "not_before_at"], unique=False)
op.create_index("ix_notification_messages_source", "notification_messages", ["tenant_id", "source_module", "source_resource_type", "source_resource_id"], unique=False)
op.create_index("ix_notification_messages_tenant_recipient", "notification_messages", ["tenant_id", "recipient_type", "recipient_id"], unique=False)
op.create_index("ix_notification_messages_tenant_status", "notification_messages", ["tenant_id", "status"], unique=False)
op.create_table(
"notification_delivery_attempts",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("notification_id", sa.String(length=36), nullable=False),
sa.Column("attempt_no", sa.Integer(), nullable=False),
sa.Column("channel", sa.String(length=40), nullable=False),
sa.Column("provider", sa.String(length=100), nullable=True),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("external_message_id", sa.String(length=255), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("details", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["notification_id"],
["notification_messages.id"],
name=op.f("fk_notification_delivery_attempts_notification_id_notification_messages"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_notification_delivery_attempts")),
)
op.create_index(op.f("ix_notification_delivery_attempts_channel"), "notification_delivery_attempts", ["channel"], unique=False)
op.create_index(op.f("ix_notification_delivery_attempts_notification_id"), "notification_delivery_attempts", ["notification_id"], unique=False)
op.create_index(op.f("ix_notification_delivery_attempts_provider"), "notification_delivery_attempts", ["provider"], unique=False)
op.create_index(op.f("ix_notification_delivery_attempts_status"), "notification_delivery_attempts", ["status"], unique=False)
op.create_index(op.f("ix_notification_delivery_attempts_tenant_id"), "notification_delivery_attempts", ["tenant_id"], unique=False)
op.create_index("ix_notification_attempts_notification", "notification_delivery_attempts", ["notification_id", "attempt_no"], unique=False)
op.create_index("ix_notification_attempts_tenant_status", "notification_delivery_attempts", ["tenant_id", "status"], unique=False)
def downgrade() -> None:
op.drop_table("notification_delivery_attempts")
op.drop_table("notification_messages")
@@ -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",
)
@@ -0,0 +1,41 @@
"""notification preferences
Revision ID: 6f7a8b9c0d1e
Revises: 5e6f7a8b9c0d
Create Date: 2026-07-13 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "6f7a8b9c0d1e"
down_revision = "5e6f7a8b9c0d"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"notification_preferences",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("show_unread_badge", sa.Boolean(), nullable=False),
sa.Column("email_enabled", sa.Boolean(), nullable=False),
sa.Column("email_digest_enabled", sa.Boolean(), nullable=False),
sa.Column("muted_source_modules", sa.JSON(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_notification_preferences")),
sa.UniqueConstraint("tenant_id", "user_id", name="uq_notification_preferences_tenant_user"),
)
op.create_index(op.f("ix_notification_preferences_tenant_id"), "notification_preferences", ["tenant_id"], unique=False)
op.create_index("ix_notification_preferences_tenant_user", "notification_preferences", ["tenant_id", "user_id"], unique=False)
op.create_index(op.f("ix_notification_preferences_user_id"), "notification_preferences", ["user_id"], unique=False)
def downgrade() -> None:
op.drop_table("notification_preferences")
@@ -0,0 +1,2 @@
from __future__ import annotations
@@ -0,0 +1,270 @@
from __future__ import annotations
from typing import Literal
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,
NotificationDeliverPendingRequest,
NotificationDeliveryResultResponse,
NotificationListResponse,
NotificationPreferencesResponse,
NotificationPreferencesUpdateRequest,
NotificationResponse,
NotificationSummaryResponse,
NotificationUpdateRequest,
)
from govoplan_notifications.backend.service import (
NotificationError,
create_notification,
deliver_notification,
deliver_pending,
get_notification,
get_notification_preferences,
list_notifications,
notification_preferences_response,
notification_response,
notification_summary,
update_notification_preferences,
update_notification,
)
router = APIRouter(prefix="/notifications", tags=["notifications"])
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _notification_http_error(exc: NotificationError) -> HTTPException:
if str(exc) == "Notification not found":
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
def _response(notification) -> NotificationResponse:
return NotificationResponse.model_validate(notification_response(notification))
def _principal_recipient_ids(principal: ApiPrincipal) -> tuple[str, ...]:
"""Return the stable actor identifiers accepted for personal notifications.
Existing producers use both tenant membership/user ids and account ids. Both
identify the same authenticated actor; identity/service-account ids are
included when present so producers can address those stable identities too.
"""
candidates = (
getattr(principal.user, "id", None),
principal.membership_id,
principal.account_id,
principal.identity_id,
principal.principal.service_account_id,
)
return tuple(dict.fromkeys(str(value) for value in candidates if value))
def _recipient_ids_for_view(principal: ApiPrincipal, view: Literal["personal", "tenant"]) -> tuple[str, ...] | None:
if view == "tenant":
_require_scope(principal, ADMIN_SCOPE)
return None
return _principal_recipient_ids(principal)
@router.get("", response_model=NotificationListResponse)
def api_list_notifications(
status_filter: str | None = Query(default=None, alias="status"),
channel: str | None = None,
source_module: str | None = None,
recipient_id: str | None = None,
view: Literal["personal", "tenant"] = Query(default="personal"),
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> NotificationListResponse:
_require_scope(principal, READ_SCOPE)
recipient_ids = _recipient_ids_for_view(principal, view)
if recipient_id is not None and recipient_ids is not None and recipient_id not in recipient_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot read another recipient's notifications")
muted_source_modules: tuple[str, ...] = ()
if view == "personal":
preference = get_notification_preferences(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
)
muted_source_modules = tuple(preference.muted_source_modules or ())
notifications = list_notifications(
session,
tenant_id=principal.tenant_id,
status=status_filter,
channel=channel,
source_module=source_module,
recipient_id=recipient_id,
recipient_ids=recipient_ids,
muted_source_modules=muted_source_modules,
limit=limit,
)
return NotificationListResponse(notifications=[_response(notification) for notification in notifications])
@router.get("/summary", response_model=NotificationSummaryResponse)
def api_notification_summary(
view: Literal["personal", "tenant"] = Query(default="personal"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> NotificationSummaryResponse:
_require_scope(principal, READ_SCOPE)
return NotificationSummaryResponse.model_validate(
notification_summary(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id if view == "personal" else None,
recipient_ids=_recipient_ids_for_view(principal, view),
)
)
@router.get("/preferences/me", response_model=NotificationPreferencesResponse)
def api_get_my_notification_preferences(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> NotificationPreferencesResponse:
_require_scope(principal, READ_SCOPE)
preference = get_notification_preferences(session, tenant_id=principal.tenant_id, user_id=principal.user.id)
return NotificationPreferencesResponse.model_validate(notification_preferences_response(preference))
@router.put("/preferences/me", response_model=NotificationPreferencesResponse)
def api_update_my_notification_preferences(
payload: NotificationPreferencesUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> NotificationPreferencesResponse:
_require_scope(principal, WRITE_SCOPE)
preference = update_notification_preferences(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
session.commit()
return NotificationPreferencesResponse.model_validate(notification_preferences_response(preference))
@router.post("", response_model=NotificationResponse, status_code=status.HTTP_201_CREATED)
def api_create_notification(
payload: NotificationCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> NotificationResponse:
_require_scope(principal, WRITE_SCOPE)
try:
notification = create_notification(session, tenant_id=principal.tenant_id, payload=payload)
except NotificationError as exc:
raise _notification_http_error(exc) from exc
session.commit()
return _response(notification)
@router.post("/deliver-pending", response_model=NotificationDeliveryResultResponse)
def api_deliver_pending(
payload: NotificationDeliverPendingRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> NotificationDeliveryResultResponse:
_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,
settings=core_settings,
registry=get_registry(),
)
session.commit()
return NotificationDeliveryResultResponse.model_validate(result)
@router.get("/{notification_id}", response_model=NotificationResponse)
def api_get_notification(
notification_id: str,
view: Literal["personal", "tenant"] = Query(default="personal"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> NotificationResponse:
_require_scope(principal, READ_SCOPE)
try:
return _response(
get_notification(
session,
tenant_id=principal.tenant_id,
notification_id=notification_id,
recipient_ids=_recipient_ids_for_view(principal, view),
)
)
except NotificationError as exc:
raise _notification_http_error(exc) from exc
@router.patch("/{notification_id}", response_model=NotificationResponse)
def api_update_notification(
notification_id: str,
payload: NotificationUpdateRequest,
view: Literal["personal", "tenant"] = Query(default="personal"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> NotificationResponse:
_require_scope(principal, WRITE_SCOPE)
try:
notification = update_notification(
session,
tenant_id=principal.tenant_id,
notification_id=notification_id,
payload=payload,
recipient_ids=_recipient_ids_for_view(principal, view),
)
except NotificationError as exc:
raise _notification_http_error(exc) from exc
session.commit()
return _response(notification)
@router.post("/{notification_id}/deliver", response_model=NotificationDeliveryResultResponse)
def api_deliver_notification(
notification_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> NotificationDeliveryResultResponse:
_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,
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 [],
)
@@ -0,0 +1,171 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
NotificationStatus = Literal[
"pending",
"queued",
"sending",
"accepted",
"paused",
"sent",
"failed",
"skipped",
"cancelled",
]
def normalize_notification_action_url(value: str | None) -> str | None:
if value is None:
return None
candidate = value.strip()
if not candidate:
return None
if (
not candidate.startswith("/")
or candidate.startswith("//")
or "\\" in candidate
or any(ord(character) < 32 or ord(character) == 127 for character in candidate)
):
raise ValueError("Notification action URL must be an application-relative path")
return candidate
class NotificationCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source_module: str = Field(min_length=1, max_length=100)
source_resource_type: str = Field(min_length=1, max_length=100)
source_resource_id: str | None = Field(default=None, max_length=255)
event_kind: str = Field(min_length=1, max_length=100)
channel: str = Field(default="inbox", max_length=40)
recipient: str | None = Field(default=None, max_length=500)
recipient_type: str | None = Field(default=None, max_length=40)
recipient_id: str | None = Field(default=None, max_length=255)
recipient_label: str | None = Field(default=None, max_length=500)
subject: str | None = Field(default=None, max_length=500)
body_text: str | None = None
body_html: str | None = None
action_url: str | None = Field(default=None, max_length=1000)
priority: int = 0
not_before_at: datetime | None = None
enqueue_delivery: bool = True
payload: dict[str, Any] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
@field_validator("action_url")
@classmethod
def validate_action_url(cls, value: str | None) -> str | None:
return normalize_notification_action_url(value)
class NotificationUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
status: Literal["read", "acknowledged", "cancelled"] | None = None
metadata: dict[str, Any] | None = None
class NotificationDeliverPendingRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_id: str | None = Field(default=None, max_length=36)
limit: int = Field(default=50, ge=1, le=500)
class NotificationPreferencesUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
show_unread_badge: bool | None = None
email_enabled: bool | None = None
email_digest_enabled: bool | None = None
muted_source_modules: list[str] | None = None
metadata: dict[str, Any] | None = None
class NotificationPreferencesResponse(BaseModel):
tenant_id: str
user_id: str
show_unread_badge: bool = True
email_enabled: bool = False
email_digest_enabled: bool = False
muted_source_modules: list[str] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
class NotificationDeliveryAttemptResponse(BaseModel):
id: str
notification_id: str
attempt_no: int
channel: str
provider: str | None = None
status: str
started_at: datetime | None = None
finished_at: datetime | None = None
external_message_id: str | None = None
error: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
class NotificationResponse(BaseModel):
id: str
tenant_id: str
source_module: str
source_resource_type: str
source_resource_id: str | None = None
event_kind: str
channel: str
recipient: str | None = None
recipient_type: str | None = None
recipient_id: str | None = None
recipient_label: str | None = None
subject: str | None = None
body_text: str | None = None
body_html: str | None = None
action_url: str | None = None
priority: int
status: str
not_before_at: datetime | None = None
queued_at: datetime | None = None
sent_at: datetime | None = None
failed_at: datetime | None = None
read_at: datetime | None = None
acknowledged_at: datetime | None = None
cancelled_at: datetime | None = None
attempt_count: int
last_error: str | None = None
external_message_id: str | None = None
payload: dict[str, Any] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
attempts: list[NotificationDeliveryAttemptResponse] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class NotificationListResponse(BaseModel):
notifications: list[NotificationResponse] = Field(default_factory=list)
class NotificationSummaryResponse(BaseModel):
total: int = 0
unread: int = 0
pending: int = 0
failed: int = 0
show_unread_badge: bool = True
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)
@@ -0,0 +1,716 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from email.message import EmailMessage
from pathlib import Path
from typing import Any
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 (
NotificationCreateRequest,
NotificationPreferencesUpdateRequest,
NotificationUpdateRequest,
normalize_notification_action_url,
)
class NotificationError(ValueError):
pass
PENDING_STATUSES = {"pending", "queued", "failed"}
SUMMARY_PENDING_STATUSES = PENDING_STATUSES | {"accepted", "paused", "sending"}
_AFTER_COMMIT_DELIVERY_IDS = "govoplan_notifications_after_commit_delivery_ids"
@event.listens_for(Session, "after_commit")
def _enqueue_committed_notifications(session: Session) -> None:
notification_ids = tuple(session.info.pop(_AFTER_COMMIT_DELIVERY_IDS, ()))
for notification_id in notification_ids:
_enqueue_celery_delivery(notification_id)
@event.listens_for(Session, "after_rollback")
def _discard_rolled_back_notifications(session: Session) -> None:
session.info.pop(_AFTER_COMMIT_DELIVERY_IDS, None)
def _enqueue_notification_after_commit(session: Session, notification_id: str) -> None:
pending = session.info.setdefault(_AFTER_COMMIT_DELIVERY_IDS, [])
if notification_id not in pending:
pending.append(notification_id)
def _discard_notification_after_commit(session: Session, notification_id: str) -> None:
pending = session.info.get(_AFTER_COMMIT_DELIVERY_IDS)
if not isinstance(pending, list) or notification_id not in pending:
return
pending.remove(notification_id)
if not pending:
session.info.pop(_AFTER_COMMIT_DELIVERY_IDS, None)
def response_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _now() -> datetime:
return utcnow()
def _clean_channel(value: str) -> str:
channel = value.strip().lower()
if not channel:
raise NotificationError("Notification channel is required")
return channel
def _clean_source_modules(values: list[str]) -> list[str]:
cleaned: list[str] = []
seen: set[str] = set()
for value in values:
item = str(value).strip().lower()
if not item or item in seen:
continue
cleaned.append(item[:100])
seen.add(item)
return cleaned
def _safe_notification_action_url(value: str | None) -> str | None:
try:
return normalize_notification_action_url(value)
except ValueError:
# Legacy rows predate action URL validation. Never project an unsafe
# stored value back into a clickable browser link.
return None
def create_notification(
session: Session,
*,
tenant_id: str,
payload: NotificationCreateRequest,
) -> NotificationMessage:
notification = NotificationMessage(
tenant_id=tenant_id,
source_module=payload.source_module,
source_resource_type=payload.source_resource_type,
source_resource_id=payload.source_resource_id,
event_kind=payload.event_kind,
channel=_clean_channel(payload.channel),
recipient=payload.recipient,
recipient_type=payload.recipient_type,
recipient_id=payload.recipient_id,
recipient_label=payload.recipient_label,
subject=payload.subject,
body_text=payload.body_text,
body_html=payload.body_html,
action_url=normalize_notification_action_url(payload.action_url),
priority=payload.priority,
not_before_at=payload.not_before_at,
status="queued" if payload.enqueue_delivery else "pending",
queued_at=_now() if payload.enqueue_delivery else None,
payload=payload.payload,
metadata_=payload.metadata,
)
if notification.channel == "mail" and not notification.recipient:
notification.status = "skipped"
notification.last_error = "Mail notification has no recipient address"
session.add(notification)
session.flush()
if payload.enqueue_delivery and notification.status == "queued":
_enqueue_notification_after_commit(session, notification.id)
return notification
def enqueue_dispatch_request(
session: Session,
request: NotificationDispatchRequest,
*,
enqueue_delivery: bool = True,
) -> NotificationMessage:
return create_notification(
session,
tenant_id=request.tenant_id,
payload=NotificationCreateRequest(
source_module=request.source_module,
source_resource_type=request.source_resource_type,
source_resource_id=request.source_resource_id,
event_kind=request.event_kind,
channel=request.channel,
recipient=request.recipient,
recipient_type=request.recipient_type,
recipient_id=request.recipient_id,
recipient_label=request.recipient_label,
subject=request.subject,
body_text=request.body_text,
body_html=request.body_html,
action_url=request.action_url,
priority=request.priority,
not_before_at=request.not_before_at,
enqueue_delivery=enqueue_delivery,
payload=dict(request.payload),
metadata=dict(request.metadata),
),
)
def list_notifications(
session: Session,
*,
tenant_id: str,
status: str | None = None,
channel: str | None = None,
source_module: str | None = None,
recipient_id: str | None = None,
recipient_ids: tuple[str, ...] | None = None,
muted_source_modules: Sequence[str] = (),
limit: int = 100,
) -> list[NotificationMessage]:
query = session.query(NotificationMessage).filter(
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.deleted_at.is_(None),
)
if status:
query = query.filter(NotificationMessage.status == status)
if channel:
query = query.filter(NotificationMessage.channel == _clean_channel(channel))
if source_module:
query = query.filter(NotificationMessage.source_module == source_module)
if recipient_ids is not None:
query = query.filter(NotificationMessage.recipient_id.in_(recipient_ids))
if recipient_id:
query = query.filter(NotificationMessage.recipient_id == recipient_id)
muted_sources = _clean_source_modules(list(muted_source_modules))
if muted_sources:
query = query.filter(NotificationMessage.source_module.notin_(muted_sources))
return query.order_by(NotificationMessage.created_at.desc(), NotificationMessage.id.asc()).limit(limit).all()
def notification_summary(
session: Session,
*,
tenant_id: str,
user_id: str | None = None,
recipient_ids: tuple[str, ...] | None = None,
) -> dict[str, int | bool]:
preference = (
get_notification_preferences(
session,
tenant_id=tenant_id,
user_id=user_id,
)
if user_id
else None
)
filters = [
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.deleted_at.is_(None),
]
if recipient_ids is not None:
filters.append(NotificationMessage.recipient_id.in_(recipient_ids))
muted_sources = _clean_source_modules(
list(preference.muted_source_modules or [])
if preference is not None
else []
)
if muted_sources:
filters.append(NotificationMessage.source_module.notin_(muted_sources))
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 = (
preference.show_unread_badge if preference is not None else True
)
return {
"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,
}
def get_notification(
session: Session,
*,
tenant_id: str,
notification_id: str,
recipient_ids: tuple[str, ...] | None = None,
) -> NotificationMessage:
query = session.query(NotificationMessage).filter(
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.id == notification_id,
NotificationMessage.deleted_at.is_(None),
)
if recipient_ids is not None:
query = query.filter(NotificationMessage.recipient_id.in_(recipient_ids))
notification = (
query.first()
)
if notification is None:
raise NotificationError("Notification not found")
return notification
def update_notification(
session: Session,
*,
tenant_id: str,
notification_id: str,
payload: NotificationUpdateRequest,
recipient_ids: tuple[str, ...] | None = None,
) -> NotificationMessage:
notification = get_notification(
session,
tenant_id=tenant_id,
notification_id=notification_id,
recipient_ids=recipient_ids,
)
now = _now()
if payload.status == "read":
notification.read_at = notification.read_at or now
elif payload.status == "acknowledged":
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:
notification.metadata_ = payload.metadata
session.flush()
return notification
def deliver_notification(
session: Session,
*,
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.
_discard_notification_after_commit(session, notification_id)
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 {"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"
session.flush()
return notification
attempt = _start_attempt(notification)
try:
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"
notification.failed_at = _now()
notification.last_error = str(exc)
session.flush()
return notification
_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
notification.last_error = result.get("error")
notification.external_message_id = result.get("external_message_id")
session.flush()
return notification
def deliver_pending(
session: Session,
*,
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(
NotificationMessage.deleted_at.is_(None),
NotificationMessage.status.in_(sorted(PENDING_STATUSES)),
or_(NotificationMessage.not_before_at.is_(None), NotificationMessage.not_before_at <= now),
)
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,
"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,
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":
result["failed"] += 1
if delivered.last_error:
result["errors"].append(delivered.last_error)
return result
def get_notification_preferences(session: Session, *, tenant_id: str, user_id: str, create: bool = False) -> NotificationPreference:
preference = (
session.query(NotificationPreference)
.filter(
NotificationPreference.tenant_id == tenant_id,
NotificationPreference.user_id == user_id,
)
.first()
)
if preference is not None:
return preference
if not create:
return NotificationPreference(
tenant_id=tenant_id,
user_id=user_id,
show_unread_badge=True,
email_enabled=False,
email_digest_enabled=False,
muted_source_modules=[],
metadata_={},
)
preference = NotificationPreference(
tenant_id=tenant_id,
user_id=user_id,
show_unread_badge=True,
email_enabled=False,
email_digest_enabled=False,
muted_source_modules=[],
metadata_={},
)
session.add(preference)
session.flush()
return preference
def update_notification_preferences(
session: Session,
*,
tenant_id: str,
user_id: str,
payload: NotificationPreferencesUpdateRequest,
) -> NotificationPreference:
preference = get_notification_preferences(session, tenant_id=tenant_id, user_id=user_id, create=True)
if payload.show_unread_badge is not None:
preference.show_unread_badge = payload.show_unread_badge
if payload.email_enabled is not None:
preference.email_enabled = payload.email_enabled
if payload.email_digest_enabled is not None:
preference.email_digest_enabled = payload.email_digest_enabled
if payload.muted_source_modules is not None:
preference.muted_source_modules = _clean_source_modules(payload.muted_source_modules)
if payload.metadata is not None:
preference.metadata_ = payload.metadata
session.flush()
return preference
def notification_preferences_response(preference: NotificationPreference) -> dict[str, Any]:
return {
"tenant_id": preference.tenant_id,
"user_id": preference.user_id,
"show_unread_badge": preference.show_unread_badge,
"email_enabled": preference.email_enabled,
"email_digest_enabled": preference.email_digest_enabled,
"muted_source_modules": _clean_source_modules(preference.muted_source_modules or []),
"metadata": preference.metadata_ or {},
}
def _start_attempt(notification: NotificationMessage) -> NotificationDeliveryAttempt:
notification.status = "sending"
notification.attempt_count += 1
attempt = NotificationDeliveryAttempt(
tenant_id=notification.tenant_id,
notification_id=notification.id,
attempt_no=notification.attempt_count,
channel=notification.channel,
status="sending",
started_at=_now(),
details={},
)
notification.attempts.append(attempt)
return attempt
def _finish_attempt(
attempt: NotificationDeliveryAttempt,
*,
status: str,
provider: str | None = None,
error: str | None = None,
details: Mapping[str, object] | None = None,
) -> None:
attempt.status = status
attempt.provider = provider
attempt.error = error
attempt.details = dict(details or {})
attempt.finished_at = _now()
if details and isinstance(details.get("external_message_id"), str):
attempt.external_message_id = str(details["external_message_id"])
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"}
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)
message = EmailMessage()
message["Subject"] = notification.subject or f"Notification: {notification.event_kind}"
message["From"] = "notifications@govoplan.local"
message["To"] = notification.recipient or "undisclosed-recipients:;"
message.set_content(notification.body_text or notification.subject or notification.event_kind)
if notification.body_html:
message.add_alternative(notification.body_html, subtype="html")
filename = f"{notification.created_at.strftime('%Y%m%d%H%M%S')}-{notification.id}.eml"
path = root / filename
path.write_bytes(bytes(message))
return path
def _enqueue_celery_delivery(notification_id: str) -> None:
try:
from govoplan_core.celery_app import celery
from govoplan_core.settings import settings
if not getattr(settings, "celery_enabled", False):
return
celery.send_task("govoplan.notifications.deliver", args=[notification_id], queue="notifications")
except Exception:
# The committed queued row is the durable source of truth. A broker or
# import failure must not turn a successful database commit into an API
# failure; operators/workers can recover it through deliver_pending.
return
def notification_response(notification: NotificationMessage) -> dict[str, Any]:
return {
"id": notification.id,
"tenant_id": notification.tenant_id,
"source_module": notification.source_module,
"source_resource_type": notification.source_resource_type,
"source_resource_id": notification.source_resource_id,
"event_kind": notification.event_kind,
"channel": notification.channel,
"recipient": notification.recipient,
"recipient_type": notification.recipient_type,
"recipient_id": notification.recipient_id,
"recipient_label": notification.recipient_label,
"subject": notification.subject,
"body_text": notification.body_text,
"body_html": notification.body_html,
"action_url": _safe_notification_action_url(notification.action_url),
"priority": notification.priority,
"status": notification.status,
"not_before_at": response_datetime(notification.not_before_at),
"queued_at": response_datetime(notification.queued_at),
"sent_at": response_datetime(notification.sent_at),
"failed_at": response_datetime(notification.failed_at),
"read_at": response_datetime(notification.read_at),
"acknowledged_at": response_datetime(notification.acknowledged_at),
"cancelled_at": response_datetime(notification.cancelled_at),
"attempt_count": notification.attempt_count,
"last_error": notification.last_error,
"external_message_id": notification.external_message_id,
"payload": notification.payload or {},
"metadata": notification.metadata_ or {},
"created_at": response_datetime(notification.created_at),
"updated_at": response_datetime(notification.updated_at),
"attempts": [notification_attempt_response(attempt) for attempt in notification.attempts],
}
def notification_attempt_response(attempt: NotificationDeliveryAttempt) -> dict[str, Any]:
return {
"id": attempt.id,
"notification_id": attempt.notification_id,
"attempt_no": attempt.attempt_no,
"channel": attempt.channel,
"provider": attempt.provider,
"status": attempt.status,
"started_at": response_datetime(attempt.started_at),
"finished_at": response_datetime(attempt.finished_at),
"external_message_id": attempt.external_message_id,
"error": attempt.error,
"details": attempt.details or {},
"created_at": response_datetime(attempt.created_at),
"updated_at": response_datetime(attempt.updated_at),
}
+1
View File
@@ -0,0 +1 @@
+476
View File
@@ -0,0 +1,476 @@
from __future__ import annotations
import json
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_notifications.backend.db.models import (
NotificationDeliveryAttempt,
NotificationMessage,
NotificationPreference,
)
from govoplan_notifications.backend.dsar_provider import (
NOTIFICATIONS_DSAR_CAPABILITY,
NotificationsDsarProvider,
)
from govoplan_notifications.backend.manifest import manifest
class _Registry:
def __init__(
self,
provider: NotificationsDsarProvider,
*,
active: bool = True,
) -> None:
self.provider = provider
self.active = active
def capability_names(self):
return (NOTIFICATIONS_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "notifications"
def tenant_entitlement_resolver(self):
active = self.active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("notifications",) if active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "notifications"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != NOTIFICATIONS_DSAR_CAPABILITY:
raise KeyError(name)
class NotificationsDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = NotificationsDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _message(
self,
notification_id: str,
*,
tenant_id: str = "tenant-1",
channel: str = "inbox",
recipient: str | None = None,
recipient_id: str | None = "membership-1",
subject: str,
) -> NotificationMessage:
return NotificationMessage(
id=notification_id,
tenant_id=tenant_id,
source_module="tasks",
source_resource_type="task",
source_resource_id=f"task-{notification_id}",
event_kind="task_due",
channel=channel,
recipient=recipient,
recipient_type="email" if recipient else "membership",
recipient_id=recipient_id,
recipient_label="Resident Example",
subject=subject,
body_text=f"Private body for {notification_id}",
body_html=f"<p>Private HTML for {notification_id}</p>",
action_url="/tasks",
priority=2,
status="sent",
attempt_count=0,
payload={
"case_reference": f"CASE-{notification_id}",
"access_token": "provider-secret-do-not-export",
},
metadata_={"classification": "personal"},
)
def _seed(self) -> None:
inbox = self._message(
"notification-inbox",
subject="Personal inbox notice",
)
mail = self._message(
"notification-mail",
channel="mail",
recipient="Resident@Example.test",
recipient_id=None,
subject="Personal mail notice",
)
mail.attempt_count = 1
mail.attempts.append(
NotificationDeliveryAttempt(
id="attempt-mail",
tenant_id="tenant-1",
attempt_no=1,
channel="mail",
provider="mail.delivery_outbox",
status="accepted",
external_message_id="mail-command-1",
details={
"provider_state": "accepted",
"authorization": "Bearer provider-secret-do-not-export",
},
)
)
self.session.add_all(
(
inbox,
mail,
self._message(
"notification-other-recipient",
recipient_id="membership-other",
subject="Other recipient private notice",
),
self._message(
"notification-other-tenant",
tenant_id="tenant-2",
subject="Other tenant private notice",
),
NotificationPreference(
id="preference-personal",
tenant_id="tenant-1",
user_id="membership-1",
show_unread_badge=False,
email_enabled=True,
email_digest_enabled=True,
muted_source_modules=["campaign", "calendar"],
metadata_={"digest_hour": 8},
),
NotificationPreference(
id="preference-other",
tenant_id="tenant-1",
user_id="membership-other",
show_unread_badge=True,
email_enabled=False,
email_digest_enabled=False,
muted_source_modules=[],
metadata_={},
),
)
)
@staticmethod
def _subject() -> DsarSubjectRef:
return DsarSubjectRef(
account_id="account-1",
membership_id="membership-1",
identity_id="identity-1",
email="resident@example.test",
)
def test_search_is_tenant_recipient_scoped_and_exports_bounded_content(
self,
) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
)
self.assertEqual(
[
"preference-personal",
"notification-inbox",
"notification-mail",
],
[record.resource_id for record in records],
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("Private body for notification-inbox", exported)
self.assertIn("CASE-notification-mail", exported)
self.assertIn("mail-command-1", exported)
self.assertIn("digest_hour", exported)
self.assertIn("[redacted]", exported)
self.assertNotIn("provider-secret-do-not-export", exported)
self.assertNotIn("notification-other-recipient", exported)
self.assertNotIn("notification-other-tenant", exported)
def test_resource_references_narrow_and_alias_conflicts_fail_closed(self) -> None:
notification = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
membership_id="membership-1",
external_references={
"notifications.notification": "notification-inbox"
},
),
)
preference = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
membership_id="membership-1",
external_references={"notifications.preference": "preference-personal"},
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"notifications.account": "account-other"},
),
)
reference_only = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"notifications.notification": "notification-inbox"}
),
)
self.assertEqual(
["notification-inbox"], [item.resource_id for item in notification]
)
self.assertEqual(
["preference-personal"], [item.resource_id for item in preference]
)
self.assertEqual((), conflict)
self.assertEqual((), reference_only)
def test_erasure_deletes_preferences_and_inbox_but_reviews_mail(self) -> None:
subject = self._subject()
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertEqual(
["delete", "delete", "manual_review"],
[action.kind for action in actions],
)
first = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
second = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
self.assertEqual(
["executed", "executed", "blocked"],
[result.status for result in first],
)
self.assertEqual(
["unchanged", "unchanged", "blocked"],
[result.status for result in second],
)
self.assertIsNone(
self.session.get(NotificationPreference, "preference-personal")
)
self.assertIsNone(self.session.get(NotificationMessage, "notification-inbox"))
self.assertIsNotNone(self.session.get(NotificationMessage, "notification-mail"))
self.assertIsNotNone(
self.session.get(NotificationMessage, "notification-other-recipient")
)
def test_changed_and_foreign_resources_are_blocked(self) -> None:
subject = self._subject()
record = next(
item
for item in self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
if item.resource_id == "notification-inbox"
)
action = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(record,),
)[0]
notification = self.session.get(NotificationMessage, "notification-inbox")
notification.subject = "Changed after planning"
self.session.flush()
changed = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(action,),
request_id="dsar-1",
)
self.assertEqual("blocked", changed[0].status)
with self.assertRaisesRegex(ValueError, "foreign provider record"):
self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(
DsarRecordRef(
provider_id="mail",
module_id="mail",
resource_type="notification_message",
resource_id="notification-inbox",
category="message",
title="Foreign message",
),
),
)
with self.assertRaisesRegex(ValueError, "foreign provider action"):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(
DsarErasureActionRef(
action_id="mail:delete:notification:notification-inbox",
provider_id="mail",
module_id="mail",
kind="delete",
resource_type="notification_message",
resource_id="notification-inbox",
title="Delete notification",
rationale="Foreign action",
executable=True,
),
),
request_id="dsar-1",
)
def test_oversized_content_fails_closed(self) -> None:
notification = self.session.get(
NotificationMessage,
"notification-inbox",
)
notification.body_text = "x" * 100_001
self.session.flush()
with self.assertRaisesRegex(ValueError, "body text exceeds"):
self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
membership_id="membership-1",
external_references={
"notifications.notification": "notification-inbox"
},
),
)
def test_core_workflow_and_manifest_register_provider(self) -> None:
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-NOTIFICATIONS-1",
request_kind="access",
subject=self._subject(),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=1,
)
self.assertEqual(
[NOTIFICATIONS_DSAR_CAPABILITY],
row.coverage["provider_capabilities"],
)
self.assertEqual(3, row.search_result["record_count"])
inactive = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-NOTIFICATIONS-2",
request_kind="access",
subject=self._subject(),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider, active=False),
row=inactive,
expected_revision=1,
)
self.assertEqual(
[NOTIFICATIONS_DSAR_CAPABILITY],
inactive.coverage["inactive_provider_capabilities"],
)
self.assertIn(NOTIFICATIONS_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(
NOTIFICATIONS_DSAR_CAPABILITY,
manifest.capability_documentation,
)
self.assertIn(
NOTIFICATIONS_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertTrue(
any(
topic.id == "notifications.data-subject-requests"
and {"admin", "user"}.issubset(topic.documentation_types)
for topic in manifest.documentation
)
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,108 @@
from __future__ import annotations
from pathlib import Path
import unittest
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
localizable_documentation_metadata_keys,
)
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_german_reference_documentation_is_complete(self) -> None:
topics = get_manifest().documentation
self.assertEqual(3, len(topics))
for topic in topics:
translation = topic.translations.get("de", {})
self.assertTrue(translation.get("title"), topic.id)
self.assertTrue(translation.get("summary"), topic.id)
self.assertTrue(translation.get("body"), topic.id)
if localizable_documentation_metadata_keys(topic):
self.assertEqual("1", topic.structured_translation_version, topic.id)
self.assertIn("de", topic.structured_translations, topic.id)
self.assertEqual((), documentation_structured_translation_issues(topic))
center = next(topic for topic in topics if topic.id == "notifications.center-and-preferences")
delivery = next(topic for topic in topics if topic.id == "notifications.delivery-operations")
self.assertEqual("workflow", center.metadata["kind"])
self.assertTrue(center.conditions)
self.assertEqual("reference", delivery.metadata["kind"])
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()
+643
View File
@@ -0,0 +1,643 @@
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
from sqlalchemy import create_engine
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
from govoplan_notifications.backend.capabilities import dispatch_capability
from govoplan_notifications.backend.db.models import NotificationDeliveryAttempt, NotificationMessage, NotificationPreference
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:")
Base.metadata.create_all(self.engine, tables=[NotificationMessage.__table__, NotificationDeliveryAttempt.__table__, NotificationPreference.__table__])
self.Session = sessionmaker(bind=self.engine)
def tearDown(self) -> None:
Base.metadata.drop_all(
self.engine,
tables=[NotificationDeliveryAttempt.__table__, NotificationMessage.__table__, NotificationPreference.__table__],
)
self.engine.dispose()
@staticmethod
def _principal(*, tenant_id: str, user_id: str, account_id: str, scopes: set[str]) -> ApiPrincipal:
user = SimpleNamespace(id=user_id)
return ApiPrincipal(
principal=PrincipalRef(
account_id=account_id,
membership_id=user_id,
tenant_id=tenant_id,
scopes=frozenset(scopes),
),
account=SimpleNamespace(id=account_id),
user=user,
)
@staticmethod
def _inbox_payload(*, recipient_id: str, subject: str) -> NotificationCreateRequest:
return NotificationCreateRequest(
source_module="test",
source_resource_type="thing",
event_kind="created",
channel="inbox",
recipient_type="user",
recipient_id=recipient_id,
subject=subject,
enqueue_delivery=False,
)
def test_personal_inbox_cannot_read_or_update_another_recipient(self) -> None:
with self.Session() as session:
own_membership = create_notification(
session,
tenant_id="tenant-1",
payload=self._inbox_payload(recipient_id="user-1", subject="Membership addressed"),
)
own_account = create_notification(
session,
tenant_id="tenant-1",
payload=self._inbox_payload(recipient_id="account-1", subject="Account addressed"),
)
another_user = create_notification(
session,
tenant_id="tenant-1",
payload=self._inbox_payload(recipient_id="user-2", subject="Private for another user"),
)
other_tenant = create_notification(
session,
tenant_id="tenant-2",
payload=self._inbox_payload(recipient_id="user-1", subject="Other tenant"),
)
principal = self._principal(
tenant_id="tenant-1",
user_id="user-1",
account_id="account-1",
scopes={"notifications:notification:read", "notifications:notification:write"},
)
result = api_list_notifications(
status_filter=None,
channel=None,
source_module=None,
recipient_id=None,
view="personal",
limit=100,
session=session,
principal=principal,
)
self.assertEqual({own_membership.id, own_account.id}, {item.id for item in result.notifications})
summary = api_notification_summary(view="personal", session=session, principal=principal)
self.assertEqual(summary.total, 2)
with self.assertRaises(HTTPException) as hidden_read:
api_get_notification(another_user.id, view="personal", session=session, principal=principal)
self.assertEqual(hidden_read.exception.status_code, 404)
with self.assertRaises(HTTPException) as recipient_impersonation:
api_list_notifications(
status_filter=None,
channel=None,
source_module=None,
recipient_id="user-2",
view="personal",
limit=100,
session=session,
principal=principal,
)
self.assertEqual(recipient_impersonation.exception.status_code, 403)
with self.assertRaises(HTTPException) as hidden_update:
api_update_notification(
another_user.id,
NotificationUpdateRequest(status="read"),
view="personal",
session=session,
principal=principal,
)
self.assertEqual(hidden_update.exception.status_code, 404)
self.assertIsNone(another_user.read_at)
with self.assertRaises(HTTPException) as cross_tenant:
api_get_notification(other_tenant.id, view="personal", session=session, principal=principal)
self.assertEqual(cross_tenant.exception.status_code, 404)
def test_tenant_notification_view_is_an_explicit_admin_operation(self) -> None:
with self.Session() as session:
another_user = create_notification(
session,
tenant_id="tenant-1",
payload=self._inbox_payload(recipient_id="user-2", subject="Administrative outbox entry"),
)
reader = self._principal(
tenant_id="tenant-1",
user_id="user-1",
account_id="account-1",
scopes={"notifications:notification:read"},
)
admin = self._principal(
tenant_id="tenant-1",
user_id="user-admin",
account_id="account-admin",
scopes={"notifications:notification:read", "notifications:notification:admin"},
)
with self.assertRaises(HTTPException) as forbidden:
api_get_notification(another_user.id, view="tenant", session=session, principal=reader)
self.assertEqual(forbidden.exception.status_code, 403)
visible = api_get_notification(another_user.id, view="tenant", session=session, principal=admin)
self.assertEqual(visible.id, another_user.id)
def test_create_and_deliver_inbox_notification(self) -> None:
with patch("govoplan_notifications.backend.service._enqueue_celery_delivery") as enqueue:
with self.Session() as session:
notification = create_notification(
session,
tenant_id="tenant-1",
payload=NotificationCreateRequest(
source_module="test",
source_resource_type="thing",
source_resource_id="thing-1",
event_kind="created",
channel="inbox",
recipient_id="user-1",
subject="Created",
),
)
result = deliver_pending(session, tenant_id="tenant-1")
self.assertEqual(result["sent"], 1)
self.assertEqual(notification.status, "sent")
self.assertEqual(notification.attempt_count, 1)
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"})
self.assertEqual(
NotificationCreateRequest.model_validate(safe.model_dump()).action_url,
"/calendar?event=event-1#details",
)
for action_url in (
"javascript:alert(1)",
"data:text/html,unsafe",
"file:///etc/passwd",
"custom:unsafe",
"https://attacker.example.test/collect",
"//attacker.example.test/collect",
"/\\attacker.example.test/collect",
"/calendar\nmalformed",
):
with self.subTest(action_url=action_url):
with self.assertRaisesRegex(ValueError, "application-relative path"):
NotificationCreateRequest.model_validate(
{**payload.model_dump(), "action_url": action_url}
)
bypassed_validation = NotificationCreateRequest.model_construct(
**{**payload.model_dump(), "action_url": "javascript:alert(1)"}
)
with self.Session() as session:
with self.assertRaisesRegex(ValueError, "application-relative path"):
create_notification(
session,
tenant_id="tenant-1",
payload=bypassed_validation,
)
def test_legacy_unsafe_action_url_is_not_projected_as_a_link(self) -> None:
with self.Session() as session:
notification = create_notification(
session,
tenant_id="tenant-1",
payload=self._inbox_payload(recipient_id="user-1", subject="Legacy action"),
)
notification.action_url = "javascript:alert(1)"
session.flush()
self.assertIsNone(notification_response(notification)["action_url"])
def test_delivery_task_is_enqueued_only_after_notification_commit(self) -> None:
with patch("govoplan_notifications.backend.service._enqueue_celery_delivery") as enqueue:
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="inbox",
recipient_id="user-1",
subject="Committed",
),
)
notification_id = notification.id
enqueue.assert_not_called()
session.commit()
enqueue.assert_called_once_with(notification_id)
with self.Session() as verification_session:
self.assertIsNotNone(verification_session.get(NotificationMessage, notification_id))
def test_rolled_back_notification_never_enqueues_a_delivery_task(self) -> None:
with patch("govoplan_notifications.backend.service._enqueue_celery_delivery") as enqueue:
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="inbox",
recipient_id="user-1",
subject="Rolled back",
),
)
notification_id = notification.id
session.rollback()
session.commit()
enqueue.assert_not_called()
with self.Session() as verification_session:
self.assertIsNone(verification_session.get(NotificationMessage, notification_id))
def test_mail_delivery_uses_local_file_transport(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir, self.Session() as session:
settings = type(
"Settings",
(),
{"app_env": "dev", "mock_mailbox_dir": tmpdir},
)()
notification = create_notification(
session,
tenant_id="tenant-1",
payload=NotificationCreateRequest(
source_module="test",
source_resource_type="thing",
source_resource_id="thing-1",
event_kind="created",
channel="mail",
recipient="person@example.test",
subject="Created",
body_text="Hello",
),
)
result = deliver_pending(session, tenant_id="tenant-1", settings=settings)
self.assertEqual(result["sent"], 1)
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()))
payload = capability.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id="tenant-1",
source_module="scheduling",
source_resource_type="request",
source_resource_id="req-1",
event_kind="invitation",
channel="inbox",
recipient_id="user-1",
subject="Invite",
),
enqueue_delivery=False,
)
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:
unread = create_notification(
session,
tenant_id="tenant-1",
payload=NotificationCreateRequest(
source_module="test",
source_resource_type="thing",
event_kind="created",
channel="inbox",
subject="Unread",
enqueue_delivery=False,
),
)
read = create_notification(
session,
tenant_id="tenant-1",
payload=NotificationCreateRequest(
source_module="test",
source_resource_type="thing",
event_kind="read",
channel="inbox",
subject="Read",
enqueue_delivery=False,
),
)
cancelled = create_notification(
session,
tenant_id="tenant-1",
payload=NotificationCreateRequest(
source_module="test",
source_resource_type="thing",
event_kind="cancelled",
channel="inbox",
subject="Cancelled",
enqueue_delivery=False,
),
)
read.read_at = read.created_at
cancelled.status = "cancelled"
session.flush()
summary = notification_summary(session, tenant_id="tenant-1")
self.assertEqual(summary["total"], 3)
self.assertEqual(summary["unread"], 1)
self.assertEqual(summary["pending"], 2)
self.assertTrue(summary["show_unread_badge"])
self.assertEqual(unread.status, "pending")
def test_preferences_update_controls_summary_badge_flag(self) -> None:
with self.Session() as session:
preference = update_notification_preferences(
session,
tenant_id="tenant-1",
user_id="user-1",
payload=NotificationPreferencesUpdateRequest(
show_unread_badge=False,
email_enabled=True,
muted_source_modules=["Calendar", "calendar", " campaign "],
),
)
response = notification_preferences_response(preference)
summary = notification_summary(session, tenant_id="tenant-1", user_id="user-1")
self.assertFalse(response["show_unread_badge"])
self.assertTrue(response["email_enabled"])
self.assertEqual(["calendar", "campaign"], response["muted_source_modules"])
self.assertFalse(summary["show_unread_badge"])
def test_personal_source_mutes_apply_across_recipient_identifiers(self) -> None:
with self.Session() as session:
postbox = create_notification(
session,
tenant_id="tenant-1",
payload=NotificationCreateRequest(
source_module="postbox",
source_resource_type="postbox",
event_kind="postbox.assignment.newly_visible.v1",
channel="inbox",
recipient_type="account",
recipient_id="account-1",
subject="Postbox responsibility changed",
enqueue_delivery=False,
),
)
calendar = create_notification(
session,
tenant_id="tenant-1",
payload=NotificationCreateRequest(
source_module="calendar",
source_resource_type="calendar_event",
event_kind="calendar.reminder.v1",
channel="inbox",
recipient_type="account",
recipient_id="account-1",
subject="Calendar reminder",
enqueue_delivery=False,
),
)
update_notification_preferences(
session,
tenant_id="tenant-1",
user_id="user-1",
payload=NotificationPreferencesUpdateRequest(
muted_source_modules=["postbox"],
),
)
principal = self._principal(
tenant_id="tenant-1",
user_id="user-1",
account_id="account-1",
scopes={"notifications:notification:read"},
)
personal = api_list_notifications(
status_filter=None,
channel=None,
source_module=None,
recipient_id=None,
view="personal",
limit=100,
session=session,
principal=principal,
)
personal_summary = api_notification_summary(
view="personal",
session=session,
principal=principal,
)
tenant_admin = self._principal(
tenant_id="tenant-1",
user_id="user-1",
account_id="account-1",
scopes={
"notifications:notification:read",
"notifications:notification:admin",
},
)
tenant_view = api_list_notifications(
status_filter=None,
channel=None,
source_module=None,
recipient_id=None,
view="tenant",
limit=100,
session=session,
principal=tenant_admin,
)
self.assertEqual([calendar.id], [item.id for item in personal.notifications])
self.assertEqual(1, personal_summary.total)
self.assertEqual(
{postbox.id, calendar.id},
{item.id for item in tenant_view.notifications},
)
if __name__ == "__main__":
unittest.main()
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@govoplan/notifications-webui",
"version": "0.1.19",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/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:ui-structure": "node scripts/test-notification-page-structure.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "^5.7.2",
"vite": "^7.3.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
@@ -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.");
+126
View File
@@ -0,0 +1,126 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type NotificationDeliveryAttempt = {
id: string;
notification_id: string;
attempt_no: number;
channel: string;
provider?: string | null;
status: string;
started_at?: string | null;
finished_at?: string | null;
external_message_id?: string | null;
error?: string | null;
details: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type NotificationMessage = {
id: string;
tenant_id: string;
source_module: string;
source_resource_type: string;
source_resource_id?: string | null;
event_kind: string;
channel: string;
recipient?: string | null;
recipient_type?: string | null;
recipient_id?: string | null;
recipient_label?: string | null;
subject?: string | null;
body_text?: string | null;
body_html?: string | null;
action_url?: string | null;
priority: number;
status: string;
not_before_at?: string | null;
queued_at?: string | null;
sent_at?: string | null;
failed_at?: string | null;
read_at?: string | null;
acknowledged_at?: string | null;
cancelled_at?: string | null;
attempt_count: number;
last_error?: string | null;
external_message_id?: string | null;
payload: Record<string, unknown>;
metadata: Record<string, unknown>;
attempts: NotificationDeliveryAttempt[];
created_at: string;
updated_at: string;
};
export type NotificationListResponse = {
notifications: NotificationMessage[];
};
export type NotificationSummary = {
total: number;
unread: number;
pending: number;
failed: number;
show_unread_badge?: boolean;
};
export type NotificationPreferences = {
tenant_id: string;
user_id: string;
show_unread_badge: boolean;
email_enabled: boolean;
email_digest_enabled: boolean;
muted_source_modules: string[];
metadata: Record<string, unknown>;
};
export type NotificationDeliveryResult = {
notification?: NotificationMessage | null;
processed: number;
sent: number;
accepted: number;
paused: number;
failed: number;
skipped: number;
errors: string[];
};
export function listNotifications(settings: ApiSettings, filters: { status?: string; channel?: string; source_module?: string; recipient_id?: string; view?: "personal" | "tenant"; limit?: number } = {}): Promise<NotificationListResponse> {
const params = new URLSearchParams();
if (filters.status) params.set("status", filters.status);
if (filters.channel) params.set("channel", filters.channel);
if (filters.source_module) params.set("source_module", filters.source_module);
if (filters.recipient_id) params.set("recipient_id", filters.recipient_id);
if (filters.view) params.set("view", filters.view);
if (filters.limit) params.set("limit", String(filters.limit));
const query = params.toString();
return apiFetch<NotificationListResponse>(settings, `/api/v1/notifications${query ? `?${query}` : ""}`);
}
export function notificationSummary(settings: ApiSettings): Promise<NotificationSummary> {
return apiFetch<NotificationSummary>(settings, "/api/v1/notifications/summary");
}
export function getNotificationPreferences(settings: ApiSettings): Promise<NotificationPreferences> {
return apiFetch<NotificationPreferences>(settings, "/api/v1/notifications/preferences/me");
}
export function updateNotificationPreferences(settings: ApiSettings, payload: Partial<Pick<NotificationPreferences, "show_unread_badge" | "email_enabled" | "email_digest_enabled" | "muted_source_modules" | "metadata">>): Promise<NotificationPreferences> {
return apiFetch<NotificationPreferences>(settings, "/api/v1/notifications/preferences/me", {
method: "PUT",
body: JSON.stringify(payload)
});
}
export function updateNotification(settings: ApiSettings, notificationId: string, payload: { status?: "read" | "acknowledged" | "cancelled"; metadata?: Record<string, unknown> | null }): Promise<NotificationMessage> {
return apiFetch<NotificationMessage>(settings, `/api/v1/notifications/${notificationId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deliverPendingNotifications(settings: ApiSettings, limit = 50): Promise<NotificationDeliveryResult> {
return apiFetch<NotificationDeliveryResult>(settings, "/api/v1/notifications/deliver-pending", {
method: "POST",
body: JSON.stringify({ limit })
});
}
@@ -0,0 +1,388 @@
import { useEffect, useMemo, useState } from "react";
import { Bell, Check, ExternalLink, Send, XCircle } from "lucide-react";
import {
ActionBlockerHint,
ActionToolbar,
Button,
ConfirmDialog,
CountBadge,
DismissibleAlert,
DocumentationHelpLink,
SegmentedControl,
SelectionList,
SelectionListItem,
StatePanel,
StatusBadge,
WorkspaceActionBar,
WorkspaceFrame,
WorkspaceLayout,
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"
| "sending"
| "accepted"
| "paused"
| "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[]>([]);
const [selectedId, setSelectedId] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
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) {
setLoading(false);
return;
}
void load();
}, [canRead, settings.apiBaseUrl, settings.apiKey, settings.accessToken, statusFilter]);
async function load() {
setLoading(true);
setError("");
try {
const response = await listNotifications(settings, {
status: statusFilter === "all" ? undefined : statusFilter,
limit: 200
});
setNotifications(response.notifications);
setSelectedId((current) => current && response.notifications.some((item) => item.id === current) ? current : response.notifications[0]?.id ?? "");
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}
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(): 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 (
<WorkspaceFrame as="main" height="viewport" surface="plain" className="notifications-page" label="Notification center">
<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>
</WorkspaceFrame>
);
}
return (
<WorkspaceFrame as="main" height="viewport" surface="plain" className="notifications-page" label="Notification center">
<WorkspaceLayout
variant="split"
primarySize="default"
primaryScrollable={false}
contentScrollable={false}
surface="contained"
primaryClassName="notifications-sidebar"
contentClassName="notifications-workspace"
primaryLabel="i18n:govoplan-notifications.notifications"
contentLabel="i18n:govoplan-notifications.surface.center"
interfaceId="notifications.center.workspace"
helpContextId="notifications.page.center"
helpModuleId="notifications"
primary={<>
<WorkspaceActionBar
scope="collection-pane"
variant="collection"
refreshable
reloadAction={{ onReload: () => void load(), loading: loading || busy, label: "i18n:govoplan-notifications.refresh" }}
className="notifications-sidebar-bar"
contextActions={<div className="notifications-title">
<Bell size={17} />
<strong>i18n:govoplan-notifications.notifications</strong>
{unreadCount > 0 ? <CountBadge>{unreadCount}</CountBadge> : null}
</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">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 variant="navigation" 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>
</>}
>
<WorkspaceActionBar
scope="detail-pane"
variant="detail"
className="notifications-topbar"
contextActions={<div className="notifications-title-line">
<Bell size={18} />
<strong>{selected?.subject || selected?.event_kind || "i18n:govoplan-notifications.surface.center"}</strong>
</div>}
helpAction={<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />}
primaryActions={<div className="notifications-actions">
<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={Boolean(acknowledgeDisabledReason)} disabledReason={acknowledgeDisabledReason}>
<Check size={16} /> i18n:govoplan-notifications.acknowledge
</Button>
<Button onClick={() => setConfirmingAction("dispatch")} disabled={Boolean(dispatchDisabledReason)} disabledReason={dispatchDisabledReason}>
<Send size={16} /> i18n:govoplan-notifications.dispatch_pending
</Button>
</div>}
destructiveActions={<Button variant="danger" onClick={() => setConfirmingAction("cancel")} disabled={Boolean(cancelDisabledReason)} disabledReason={cancelDisabledReason}>
<XCircle size={16} /> i18n:govoplan-notifications.cancel_delivery
</Button>}
/>
{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} /> : (
<StatePanel size="fill" icon={<Bell size={22} />} title="i18n:govoplan-notifications.notifications" description="i18n:govoplan-notifications.select_notification_help" />
)}
</WorkspaceLayout>
<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()}
/>
</WorkspaceFrame>
);
}
function NotificationDetails({ notification }: { notification: NotificationMessage }) {
const actionUrl = safeNotificationActionUrl(notification.action_url);
return (
<div className="notifications-detail">
<section className="notifications-message">
<div className="notifications-message-meta">
<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">i18n:govoplan-notifications.no_message_body</p>}
{actionUrl ? (
<a className="notifications-action-link" href={actionUrl}>
<ExternalLink size={16} /> i18n:govoplan-notifications.open_related_item
</a>
) : null}
</section>
<section className="notifications-properties">
<h2>i18n:govoplan-notifications.source_and_delivery</h2>
<dl>
<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">
<ActionToolbar surface="section-header" className="notifications-section-heading">
<h2>i18n:govoplan-notifications.delivery_attempts</h2>
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
</ActionToolbar>
{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>
<span>{formatStatus(attempt.status)}</span>
<small>{formatDate(attempt.started_at)} - {formatDate(attempt.finished_at)}</small>
{attempt.error ? <p>{attempt.error}</p> : null}
</div>
))}
</section>
</div>
);
}
function formatStatus(value: string): string {
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 "i18n:govoplan-notifications.not_set";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(date);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "i18n:govoplan-notifications.request_failed";
}
function notifyNotificationsChanged(): void {
window.dispatchEvent(new CustomEvent("govoplan:notifications-changed"));
}
@@ -0,0 +1,272 @@
import { useEffect, useMemo, useState } from "react";
import { Mail, Save } from "lucide-react";
import { FormGrid, ContentGrid,
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: string[];
};
const DEFAULT_DRAFT: Draft = {
show_unread_badge: true,
email_enabled: false,
email_digest_enabled: false,
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;
return (
draft.show_unread_badge !== loaded.show_unread_badge ||
draft.email_enabled !== loaded.email_enabled ||
draft.email_digest_enabled !== loaded.email_digest_enabled ||
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();
}, [auth.user.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
async function loadPreferences() {
setLoading(true);
setMessage("");
try {
const preferences = await getNotificationPreferences(settings);
setLoaded(preferences);
setDraft({
show_unread_badge: preferences.show_unread_badge,
email_enabled: preferences.email_enabled,
email_digest_enabled: preferences.email_digest_enabled,
muted_source_modules: preferences.muted_source_modules
});
} catch (error) {
setMessageTone("warning");
setMessage(error instanceof Error ? error.message : translateText("i18n:govoplan-notifications.preferences_load_failed"));
} finally {
setLoading(false);
}
}
async function savePreferences(): Promise<boolean> {
if (!canWrite) return false;
setSaving(true);
setMessage("");
try {
const next = await updateNotificationPreferences(settings, {
show_unread_badge: draft.show_unread_badge,
email_enabled: draft.email_enabled,
email_digest_enabled: draft.email_digest_enabled,
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: next.muted_source_modules
});
window.dispatchEvent(new CustomEvent("govoplan:notifications-changed"));
setMessageTone("success");
setMessage("i18n:govoplan-notifications.preferences_saved");
return true;
} catch (error) {
setMessageTone("warning");
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 (
<ContentGrid columns={2} collapseAt="workspace" className="notifications-settings-panel">
<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">
<FormGrid columns={1} collapseAt="standard" className="">
<ToggleSwitch
label="i18n:govoplan-notifications.unread_badge"
help="i18n:govoplan-notifications.unread_badge_help"
checked={draft.show_unread_badge}
disabled={preferenceControlsDisabled}
onChange={(value) => setDraft((current) => ({ ...current, show_unread_badge: value }))}
/>
<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={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}
</FormGrid>
</Card>
<Card title="i18n:govoplan-notifications.delivery">
<FormGrid columns={1} collapseAt="standard" className="">
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
<div className="notifications-settings-inline-title">
<Mail size={16} />
<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="i18n:govoplan-notifications.email_notifications"
help="i18n:govoplan-notifications.email_notifications_help"
checked={draft.email_enabled}
disabled={emailToggleDisabled}
onChange={(value) => setDraft((current) => ({ ...current, email_enabled: value, email_digest_enabled: value ? current.email_digest_enabled : false }))}
/>
<ToggleSwitch
label="i18n:govoplan-notifications.email_digest"
help="i18n:govoplan-notifications.email_digest_help"
checked={draft.email_digest_enabled}
disabled={digestToggleDisabled}
onChange={(value) => setDraft((current) => ({ ...current, email_digest_enabled: value }))}
/>
</FormGrid>
</Card>
</ContentGrid>
);
}
@@ -0,0 +1,78 @@
import { MetricGrid } from "@govoplan/core-webui";
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>
)}
<MetricGrid columns={3} spacing="none">
<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"
/>
)}
</MetricGrid>
<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;
+224
View File
@@ -0,0 +1,224 @@
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"
}
};
+2
View File
@@ -0,0 +1,2 @@
export { notificationsModule as default } from "./module";
export { notificationsModule } from "./module";
+110
View File
@@ -0,0 +1,110 @@
import { createElement, lazy } from "react";
import type {
DashboardWidgetsUiCapability,
PlatformWebModule,
SettingsSectionsUiCapability
} from "@govoplan/core-webui";
import NotificationSummaryWidget from "./features/notifications/NotificationSummaryWidget";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/notifications.css";
const NotificationCenterPage = lazy(() => import("./features/notifications/NotificationCenterPage"));
const NotificationSettingsPanel = lazy(() => import("./features/notifications/NotificationSettingsPanel"));
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",
surfaceId: "notifications.settings.preferences",
label: "i18n:govoplan-notifications.surface.center",
group: "ui",
order: 40,
anyOf: notificationRead,
render: ({ settings, auth }) => createElement(NotificationSettingsPanel, { settings, auth })
}
]
};
export const notificationsModule: PlatformWebModule = {
id: "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, surfaceId: "notifications.route.notifications", render: ({ settings, auth }) => createElement(NotificationCenterPage, { settings, auth }) }
],
uiCapabilities: {
"settings.sections": notificationSettingsSections,
"dashboard.widgets": notificationDashboardWidgets
}
};
export default notificationsModule;
+16
View File
@@ -0,0 +1,16 @@
export function safeNotificationActionUrl(value: string | null | undefined): string | null {
const candidate = value?.trim();
if (
!candidate
|| !candidate.startsWith("/")
|| candidate.startsWith("//")
|| candidate.includes("\\")
|| [...candidate].some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint < 32 || codePoint === 127;
})
) {
return null;
}
return candidate;
}
+264
View File
@@ -0,0 +1,264 @@
.notifications-widget-actions {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
margin-top: 14px;
}
.notifications-sidebar {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.notifications-title,
.notifications-title-line,
.notifications-actions,
.notifications-message-meta,
.notifications-action-link {
display: flex;
align-items: center;
gap: 8px;
}
.notifications-status-filter {
width: calc(100% - 16px);
margin: 8px;
overflow-x: auto;
}
.notifications-status-filter .segmented-control-option {
flex: 0 0 auto;
}
.notifications-list {
min-height: 0;
display: grid;
align-content: start;
gap: 4px;
overflow: auto;
padding: 8px;
}
.notifications-selection-list {
gap: 4px;
}
.notifications-list-item {
display: grid;
gap: 4px;
min-height: 58px;
}
.notifications-list-item.is-read {
color: var(--muted);
}
.notifications-list-heading,
.notifications-list-meta {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.notifications-list-heading strong {
min-width: 0;
overflow: hidden;
color: var(--text-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.notifications-list-heading small,
.notifications-list-meta,
.notifications-note {
color: var(--muted);
font-size: 12px;
}
.notifications-workspace {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.notifications-title-line {
min-width: 160px;
}
.notifications-title-line strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notifications-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.notifications-actions .btn {
min-height: 32px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.notifications-workspace > .alert {
margin: 8px 12px 0;
}
.notifications-workspace > .action-blocker-hint {
margin: 8px 12px 0;
}
.notifications-detail {
min-height: 0;
overflow: auto;
padding: 18px;
}
.notifications-message,
.notifications-properties,
.notifications-attempts {
border-bottom: var(--border-line);
margin-bottom: 18px;
padding-bottom: 18px;
}
.notifications-message h1 {
margin: 10px 0 8px;
color: var(--text-strong);
font-size: 24px;
font-weight: 650;
}
.notifications-message p {
max-width: 900px;
margin: 0 0 14px;
line-height: 1.6;
white-space: pre-line;
}
.notifications-message-meta {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.notifications-action-link {
width: max-content;
max-width: 100%;
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.notifications-action-link:hover {
color: var(--text-strong);
}
.notifications-settings-panel {
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;
gap: 8px;
color: var(--text-strong);
font-size: 13px;
}
.notifications-properties h2,
.notifications-attempts h2 {
margin: 0 0 12px;
color: var(--text-strong);
font-size: 15px;
}
.notifications-properties dl {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 10px 18px;
margin: 0;
}
.notifications-properties div {
min-width: 0;
}
.notifications-properties dt {
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.notifications-properties dd {
min-width: 0;
margin: 3px 0 0;
overflow-wrap: anywhere;
}
.notifications-error {
width: max-content;
max-width: 100%;
margin: 14px 0 0;
border-radius: var(--radius-compact);
background: var(--danger-soft);
color: var(--danger-text);
padding: 8px 10px;
}
.notifications-attempt {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 3px 12px;
border: var(--border-line);
border-radius: var(--radius-compact);
background: var(--surface);
margin-bottom: 8px;
padding: 10px;
}
.notifications-attempt small,
.notifications-attempt p {
grid-column: 1 / -1;
margin: 0;
color: var(--muted);
}
.notifications-permission-state {
max-width: 900px;
margin: 0 auto;
padding: 32px 20px;
}
@media (max-width: 900px) {
.notifications-topbar {
align-items: flex-start;
flex-direction: column;
}
.notifications-properties dl {
grid-template-columns: 1fr;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { safeNotificationActionUrl } from "../src/security/actionUrl";
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
}
assertEqual(
safeNotificationActionUrl(" /calendar?event=event-1#details "),
"/calendar?event=event-1#details",
"application-relative links remain available"
);
for (const unsafe of [
"javascript:alert(1)",
"data:text/html,unsafe",
"https://attacker.example.test/collect",
"//attacker.example.test/collect",
"/\\attacker.example.test/collect",
"/calendar\nmalformed",
"/calendar\u007fmalformed"
]) {
assertEqual(safeNotificationActionUrl(unsafe), null, `unsafe link is suppressed: ${unsafe}`);
}
assertEqual(safeNotificationActionUrl(null), null, "missing links remain absent");
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": ".component-test-build",
"rootDir": "."
},
"include": [
"src/security/actionUrl.ts",
"tests/action-url.test.ts"
]
}