Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
239664092a | ||
|
|
843540b14d | ||
|
|
0731951aef | ||
|
|
375425b174 | ||
|
|
f0e1032ea9 | ||
|
|
bb59342b24 | ||
|
|
3c71422b90 | ||
|
|
ad6a31f68b | ||
|
|
3b1a87b3e2 | ||
|
|
f9afe9570d | ||
|
|
099725a85b | ||
|
|
a7ad5b99fb | ||
|
|
8153a1de45 | ||
|
|
e32ba3663b | ||
|
|
992d2ca533 | ||
|
|
9c2dc3efbf | ||
|
|
0e62e6df8f | ||
|
|
ae144a13e0 | ||
|
|
92fb409973 | ||
|
|
6163c8f733 | ||
|
|
3444a4920f |
@@ -0,0 +1,270 @@
|
||||
name: Module Package Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing protected version tag to publish
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish-packages:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Select and validate protected release tag
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||
case "$tag" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||
esac
|
||||
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||
echo "Release tag is not contained in main" >&2
|
||||
exit 1
|
||||
}
|
||||
git checkout --detach "$tag"
|
||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||
- name: Validate package versions
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
expected = tag.removeprefix("v")
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
if project.get("version") != expected:
|
||||
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||
webui = Path("webui/package.json")
|
||||
if webui.is_file():
|
||||
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||
if package.get("version") != expected:
|
||||
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||
release = Path("webui/package.release.json")
|
||||
if release.is_file():
|
||||
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||
if (
|
||||
release_package.get("name") != package.get("name")
|
||||
or release_package.get("version") != expected
|
||||
):
|
||||
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||
PY
|
||||
- name: Build immutable package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||
rm -rf dist .package-webui
|
||||
python -m build --wheel --outdir dist
|
||||
python -m twine check dist/*.whl
|
||||
if [[ -f webui/package.json ]]; then
|
||||
mkdir .package-webui
|
||||
cp -a webui/. .package-webui/
|
||||
rm -rf .package-webui/node_modules .package-webui/dist
|
||||
if [[ -f .package-webui/package.release.json ]]; then
|
||||
cp .package-webui/package.release.json .package-webui/package.json
|
||||
fi
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = ".package-webui/package.json";
|
||||
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||
for (const group of groups) {
|
||||
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||
if (!name.startsWith("@govoplan/")) continue;
|
||||
if (typeof specifier !== "string") {
|
||||
throw new Error(`${group}.${name} must use a string version`);
|
||||
}
|
||||
const packageSlug = name.slice("@govoplan/".length);
|
||||
if (!packageSlug.endsWith("-webui")) {
|
||||
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||
}
|
||||
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const gitTag = specifier.match(
|
||||
new RegExp(
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
),
|
||||
);
|
||||
if (gitTag) {
|
||||
packageJson[group][name] = gitTag[1];
|
||||
continue;
|
||||
}
|
||||
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||
throw new Error(
|
||||
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
delete packageJson.private;
|
||||
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
npm pkg delete private --prefix .package-webui
|
||||
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||
fi
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
artifacts = []
|
||||
for path in sorted(Path("dist").iterdir()):
|
||||
if path.suffix not in {".whl", ".tgz"}:
|
||||
continue
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||
payload = {
|
||||
"schema_version": "1",
|
||||
"repository": os.environ["GITEA_REPOSITORY"],
|
||||
"tag": os.environ["RELEASE_TAG"],
|
||||
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
Path("dist/package-artifacts.json").write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
- name: Retain package hash evidence
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||
with:
|
||||
name: module-packages-${{ gitea.ref_name }}
|
||||
path: dist/package-artifacts.json
|
||||
- name: Check immutable registry state
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||
token = os.environ["PACKAGE_TOKEN"]
|
||||
|
||||
def should_publish(kind, name, version, path):
|
||||
package_url = "/".join(
|
||||
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||
)
|
||||
request = Request(
|
||||
package_url,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
files = json.load(response)
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print(f"{kind} package {name}=={version} is not published yet")
|
||||
return True
|
||||
raise
|
||||
if not isinstance(files, list) or len(files) != 1:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||
)
|
||||
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if files[0].get("sha256") != expected_sha256:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||
)
|
||||
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||
return False
|
||||
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
wheels = tuple(Path("dist").glob("*.whl"))
|
||||
if len(wheels) != 1:
|
||||
raise SystemExit("release build must contain exactly one wheel")
|
||||
publish_pypi = should_publish(
|
||||
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||
)
|
||||
|
||||
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||
if len(tarballs) > 1:
|
||||
raise SystemExit("release build must contain at most one npm package")
|
||||
publish_npm = False
|
||||
if tarballs:
|
||||
webui = json.loads(
|
||||
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||
)
|
||||
publish_npm = should_publish(
|
||||
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||
)
|
||||
|
||||
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||
PY
|
||||
- name: Publish wheel and WebUI package
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_USERNAME"
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||
python -m twine upload --non-interactive \
|
||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||
dist/*.whl
|
||||
else
|
||||
echo "Exact wheel is already present; skipping immutable retry."
|
||||
fi
|
||||
shopt -s nullglob
|
||||
webui_packages=(dist/*.tgz)
|
||||
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||
npmrc="$(mktemp)"
|
||||
trap 'rm -f "$npmrc"' EXIT
|
||||
chmod 600 "$npmrc"
|
||||
printf '%s\n' \
|
||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||
> "$npmrc"
|
||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||
--ignore-scripts --access public \
|
||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||
elif (( ${#webui_packages[@]} )); then
|
||||
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||
fi
|
||||
@@ -0,0 +1,16 @@
|
||||
# GovOPlaN Notifications Codex Guide
|
||||
|
||||
## Scope
|
||||
|
||||
This repository owns durable in-product notifications, preferences, delivery attempts, the notification center, and optional channel dispatch.
|
||||
|
||||
## Documentation Contract
|
||||
|
||||
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||
- Keep feature content here; `govoplan-docs` projects it without importing Notifications internals.
|
||||
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Producing modules own notification meaning; Notifications owns durable delivery state.
|
||||
- Use optional Mail and Portal capabilities without importing their internals.
|
||||
@@ -0,0 +1,5 @@
|
||||
# govoplan-notifications
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (platform).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
@@ -0,0 +1,24 @@
|
||||
# Email Notification Delivery
|
||||
|
||||
In-app notifications are the canonical notification channel. They remain
|
||||
available when no email implementation is installed.
|
||||
|
||||
Production email delivery is an optional integration with the
|
||||
`mail.notificationDelivery` capability:
|
||||
|
||||
- Notifications owns recipient intent, notification preferences, content, and
|
||||
notification status.
|
||||
- Mail owns server profiles, credentials, queue persistence, retry policy, and
|
||||
transport outcomes.
|
||||
- An accepted handoff is recorded as `accepted`; it does not claim that the
|
||||
remote SMTP server has delivered the message.
|
||||
- If Mail or a usable profile is unavailable, delivery is recorded as
|
||||
`paused`. The notification is retained and can be retried after the
|
||||
capability becomes available.
|
||||
- Transport failures are recorded as `failed` with an attempt record and do
|
||||
not remove the in-app notification.
|
||||
- File-based EML delivery is development-only and must not be used as a
|
||||
production fallback.
|
||||
|
||||
The capability contract deliberately passes references to recipient, tenant,
|
||||
and profile context without exposing or duplicating Mail credentials.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Notifications interface pattern migration
|
||||
|
||||
Notifications uses the platform inbox/list-detail pattern for recipient work and the governed-operation pattern for delivery.
|
||||
|
||||
## Surfaces
|
||||
|
||||
- `notifications.route.notifications` remains the route identifier so existing Views keep working.
|
||||
- `notifications.page.inbox` owns status filtering and notification selection.
|
||||
- `notifications.page.detail` owns message, source, recipient, and read/acknowledgement state.
|
||||
- `notifications.page.delivery` owns sanitized delivery-attempt evidence.
|
||||
- `notifications.action.mark-read`, `notifications.action.acknowledge`, and `notifications.action.cancel` describe recipient actions on the selected record.
|
||||
- `notifications.action.dispatch` describes the privileged bounded delivery operation.
|
||||
- `notifications.settings.preferences` and `notifications.widget.summary` remain composed Settings and Dashboard surfaces.
|
||||
|
||||
The backend and WebUI manifests publish the same identifiers and hierarchy. Notifications does not introduce a navigation entry because the title-bar bell is the platform entry point.
|
||||
|
||||
## Consequences and recovery
|
||||
|
||||
Read and acknowledgement actions record durable recipient state. Cancellation is confirmed and is available only before an outcome becomes provider-accepted or otherwise terminal; it stops eligible local work and is not presented as remote recall. Dispatch is confirmed separately and processes at most 50 eligible tenant notifications per request. Accepted outcomes are not retried blindly.
|
||||
|
||||
Unavailable actions remain visible with explicit selection, permission, busy, terminal-state, and optional-Mail explanations. Personal preference drafts use the shared unsaved-change guard. The settings surface explains that in-product notifications continue when Mail is absent and that production email depends on the optional Mail capability.
|
||||
|
||||
Contextual help resolves through `govoplan-docs` when installed and through the hosted fallback otherwise. Delivery evidence remains sanitized and never exposes provider credentials or private sibling-module state.
|
||||
|
||||
## Optional boundaries
|
||||
|
||||
Producing modules continue to own notification meaning. Notifications owns durable recipient and delivery state and calls optional Mail, Portal, Tasks, Calendar, Scheduling, and Workflow Engine integrations only through declared capabilities and references.
|
||||
@@ -0,0 +1,23 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-notifications"
|
||||
version = "0.1.17"
|
||||
description = "GovOPlaN notification inbox and delivery module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.17",
|
||||
]
|
||||
|
||||
[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"
|
||||
@@ -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,274 @@
|
||||
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 (
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_notifications.backend.db import models as notification_models # noqa: F401 - populate Notifications ORM metadata
|
||||
|
||||
|
||||
MODULE_ID = "notifications"
|
||||
MODULE_NAME = "Notifications"
|
||||
MODULE_VERSION = "0.1.17"
|
||||
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
|
||||
|
||||
|
||||
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"),),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
route_factory=_notifications_router,
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="notifications.center-and-preferences",
|
||||
title="Use the notification center",
|
||||
summary="The title-bar badge and notification center collect durable notices that require attention outside an immediate request.",
|
||||
body="Open the notification center to read, acknowledge, or follow notifications from enabled modules. Preferences control eligible delivery channels and categories. Disabling an optional external channel does not remove the in-product notification unless the originating module's retention policy does so.",
|
||||
documentation_types=("user",),
|
||||
audience=("user",),
|
||||
related_modules=("mail", "calendar", "scheduling", "workflow_engine"),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"notifications.route.notifications",
|
||||
"notifications.page.inbox",
|
||||
"notifications.page.detail",
|
||||
"notifications.settings.preferences",
|
||||
"notifications.widget.summary",
|
||||
"notifications.state.read-only",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"mark_read": "record the notification as read for the current recipient",
|
||||
"acknowledge": "record explicit recipient acknowledgement in addition to read state",
|
||||
"cancel": "stop an eligible notification before provider acceptance without claiming remote recall",
|
||||
"update_preferences": "replace the current user's badge, channel, digest, and source-muting preferences",
|
||||
},
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="notifications.delivery-operations",
|
||||
title="Operate notification delivery",
|
||||
summary="Notifications persists message intent and bounded per-channel attempts before workers dispatch optional delivery channels.",
|
||||
body="Producing modules emit notifications through the dispatch capability and do not own delivery credentials. In-product delivery is the baseline. Production email delivery is available only through an enabled Mail capability; file delivery remains development-only. Operators can inspect pending and failed attempts and retry only outcomes that are safe to repeat. Tenant module entitlement is checked before enqueue and again before worker delivery; disabling Notifications preserves accepted messages and exposes an operator action instead of silently consuming them.",
|
||||
documentation_types=("admin",),
|
||||
audience=("tenant_admin", "operator", "module_admin"),
|
||||
related_modules=("mail", "audit", "ops"),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"notifications.page.delivery",
|
||||
"notifications.action.dispatch",
|
||||
"notifications.state.delivery-unavailable",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"dispatch_pending": "attempt delivery for up to the requested number of eligible tenant notifications",
|
||||
"inspect_attempts": "read sanitized provider, status, timing, and error evidence for the selected notification",
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/notifications-webui",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/notifications",
|
||||
component="NotificationCenterPage",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=59,
|
||||
surface_id="notifications.route.notifications",
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="notifications.page.inbox",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Notification inbox",
|
||||
parent_id="notifications.route.notifications",
|
||||
order=20,
|
||||
),
|
||||
ViewSurface(
|
||||
id="notifications.page.detail",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Notification details",
|
||||
parent_id="notifications.route.notifications",
|
||||
order=30,
|
||||
),
|
||||
ViewSurface(
|
||||
id="notifications.page.delivery",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Notification delivery evidence",
|
||||
parent_id="notifications.page.detail",
|
||||
order=40,
|
||||
),
|
||||
ViewSurface(
|
||||
id="notifications.action.mark-read",
|
||||
module_id=MODULE_ID,
|
||||
kind="action",
|
||||
label="Mark notification as read",
|
||||
parent_id="notifications.page.detail",
|
||||
order=50,
|
||||
),
|
||||
ViewSurface(
|
||||
id="notifications.action.acknowledge",
|
||||
module_id=MODULE_ID,
|
||||
kind="action",
|
||||
label="Acknowledge notification",
|
||||
parent_id="notifications.page.detail",
|
||||
order=60,
|
||||
),
|
||||
ViewSurface(
|
||||
id="notifications.action.cancel",
|
||||
module_id=MODULE_ID,
|
||||
kind="action",
|
||||
label="Cancel notification delivery",
|
||||
parent_id="notifications.page.delivery",
|
||||
order=70,
|
||||
),
|
||||
ViewSurface(
|
||||
id="notifications.action.dispatch",
|
||||
module_id=MODULE_ID,
|
||||
kind="action",
|
||||
label="Dispatch pending notifications",
|
||||
parent_id="notifications.page.delivery",
|
||||
order=80,
|
||||
),
|
||||
ViewSurface(
|
||||
id="notifications.widget.summary",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Notification summary widget",
|
||||
order=90,
|
||||
),
|
||||
ViewSurface(
|
||||
id="notifications.settings.preferences",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Notification preferences",
|
||||
order=100,
|
||||
),
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
metadata=Base.metadata,
|
||||
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),
|
||||
},
|
||||
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
|
||||
|
||||
+110
@@ -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")
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
"""Add notification summary covering index.
|
||||
|
||||
Revision ID: 6e2f91ab4c70
|
||||
Revises: 6f7a8b9c0d1e
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "6e2f91ab4c70"
|
||||
down_revision = "6f7a8b9c0d1e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index(
|
||||
"ix_notification_messages_summary",
|
||||
"notification_messages",
|
||||
["tenant_id", "recipient_id", "deleted_at", "status", "read_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_notification_messages_summary",
|
||||
table_name="notification_messages",
|
||||
)
|
||||
+41
@@ -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,261 @@
|
||||
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")
|
||||
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,
|
||||
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,
|
||||
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,696 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
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,
|
||||
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)
|
||||
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]:
|
||||
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))
|
||||
active = NotificationMessage.status.notin_(["cancelled", "skipped"])
|
||||
total, unread, pending, failed = (
|
||||
session.query(
|
||||
func.count(NotificationMessage.id),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
and_(
|
||||
active,
|
||||
NotificationMessage.read_at.is_(None),
|
||||
),
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
and_(
|
||||
active,
|
||||
NotificationMessage.status.in_(
|
||||
sorted(SUMMARY_PENDING_STATUSES)
|
||||
),
|
||||
),
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
and_(
|
||||
active,
|
||||
NotificationMessage.status == "failed",
|
||||
),
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.filter(*filters)
|
||||
.one()
|
||||
)
|
||||
show_unread_badge = True
|
||||
if user_id:
|
||||
show_unread_badge = get_notification_preferences(session, tenant_id=tenant_id, user_id=user_id).show_unread_badge
|
||||
return {
|
||||
"total": 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),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from govoplan_notifications.backend.manifest import get_manifest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class NotificationsInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
|
||||
frontend = get_manifest().frontend
|
||||
self.assertIsNotNone(frontend)
|
||||
surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr]
|
||||
expected = {
|
||||
"notifications.page.inbox",
|
||||
"notifications.page.detail",
|
||||
"notifications.page.delivery",
|
||||
"notifications.action.mark-read",
|
||||
"notifications.action.acknowledge",
|
||||
"notifications.action.cancel",
|
||||
"notifications.action.dispatch",
|
||||
"notifications.settings.preferences",
|
||||
"notifications.widget.summary",
|
||||
}
|
||||
self.assertEqual(expected, set(surfaces))
|
||||
self.assertEqual(
|
||||
"notifications.route.notifications",
|
||||
frontend.routes[0].surface_id, # type: ignore[union-attr]
|
||||
)
|
||||
self.assertEqual(
|
||||
"notifications.route.notifications",
|
||||
surfaces["notifications.page.inbox"].parent_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
"notifications.route.notifications",
|
||||
surfaces["notifications.page.detail"].parent_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
"notifications.page.detail",
|
||||
surfaces["notifications.page.delivery"].parent_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
"notifications.page.delivery",
|
||||
surfaces["notifications.action.dispatch"].parent_id,
|
||||
)
|
||||
|
||||
def test_help_and_consequence_metadata_remain_published(self) -> None:
|
||||
topics = {topic.id: topic for topic in get_manifest().documentation}
|
||||
center = topics["notifications.center-and-preferences"]
|
||||
delivery = topics["notifications.delivery-operations"]
|
||||
|
||||
self.assertIn("notifications.page.detail", center.metadata["help_contexts"])
|
||||
self.assertIn("notifications.settings.preferences", center.metadata["help_contexts"])
|
||||
self.assertIn("acknowledge", center.metadata["consequence_classes"])
|
||||
self.assertIn("cancel", center.metadata["consequence_classes"])
|
||||
self.assertIn("notifications.action.dispatch", delivery.metadata["help_contexts"])
|
||||
self.assertIn("dispatch_pending", delivery.metadata["consequence_classes"])
|
||||
|
||||
def test_webui_uses_shared_consequence_and_draft_patterns(self) -> None:
|
||||
center = (REPO_ROOT / "webui/src/features/notifications/NotificationCenterPage.tsx").read_text(encoding="utf-8")
|
||||
settings = (REPO_ROOT / "webui/src/features/notifications/NotificationSettingsPanel.tsx").read_text(encoding="utf-8")
|
||||
widget = (REPO_ROOT / "webui/src/features/notifications/NotificationSummaryWidget.tsx").read_text(encoding="utf-8")
|
||||
|
||||
for component in (
|
||||
"ActionBlockerHint",
|
||||
"ConfirmDialog",
|
||||
"DocumentationHelpLink",
|
||||
"SelectionList",
|
||||
):
|
||||
self.assertIn(component, center)
|
||||
for component in (
|
||||
"ActionBlockerHint",
|
||||
"DocumentationHelpLink",
|
||||
"ReferenceMultiSelect",
|
||||
"useUnsavedDraftGuard",
|
||||
):
|
||||
self.assertIn(component, settings)
|
||||
self.assertIn("DocumentationHelpLink", widget)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,556 @@
|
||||
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"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@govoplan/notifications-webui",
|
||||
"version": "0.1.17",
|
||||
"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.17",
|
||||
"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.");
|
||||
@@ -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,375 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Bell, Check, ExternalLink, RefreshCw, Send, XCircle } from "lucide-react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
AdminIconButton,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
SegmentedControl,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
StatusBadge,
|
||||
hasScope,
|
||||
i18nMessage,
|
||||
type ApiSettings,
|
||||
type AuthInfo
|
||||
} from "@govoplan/core-webui";
|
||||
import { deliverPendingNotifications, listNotifications, updateNotification, type NotificationMessage } from "../../api/notifications";
|
||||
import { safeNotificationActionUrl } from "../../security/actionUrl";
|
||||
import {
|
||||
NOTIFICATIONS_BLOCKER_LABELS,
|
||||
NOTIFICATIONS_DELIVERY_DOCUMENTATION,
|
||||
NOTIFICATIONS_DOCUMENTATION,
|
||||
NOTIFICATIONS_I18N
|
||||
} from "./interfacePatterns";
|
||||
|
||||
type StatusFilter =
|
||||
| "all"
|
||||
| "pending"
|
||||
| "queued"
|
||||
| "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 (
|
||||
<main className="notifications-page">
|
||||
<div className="notifications-permission-state">
|
||||
<ActionBlockerHint
|
||||
tone="warning"
|
||||
reason={{
|
||||
summary: "i18n:govoplan-notifications.read_blocked_summary",
|
||||
details: NOTIFICATIONS_I18N.readPermissionRequired,
|
||||
requiredAction: "i18n:govoplan-notifications.permission_action",
|
||||
actor: "i18n:govoplan-notifications.permission_actor",
|
||||
target: "i18n:govoplan-notifications.permission_target"
|
||||
}}
|
||||
labels={NOTIFICATIONS_BLOCKER_LABELS}
|
||||
documentation={NOTIFICATIONS_DOCUMENTATION}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="notifications-page">
|
||||
<div className="notifications-shell">
|
||||
<aside className="notifications-sidebar">
|
||||
<div className="notifications-sidebar-bar">
|
||||
<div className="notifications-title">
|
||||
<Bell size={17} />
|
||||
<strong>i18n:govoplan-notifications.notifications</strong>
|
||||
{unreadCount > 0 ? <span className="notifications-count">{unreadCount}</span> : null}
|
||||
</div>
|
||||
<AdminIconButton
|
||||
label="i18n:govoplan-notifications.refresh"
|
||||
icon={<RefreshCw size={16} aria-hidden="true" />}
|
||||
onClick={() => void load()}
|
||||
disabled={loading || busy}
|
||||
disabledReason={loading ? NOTIFICATIONS_I18N.loading : busy ? NOTIFICATIONS_I18N.actionActive : undefined}
|
||||
/>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
className="notifications-status-filter"
|
||||
options={statusFilters.map((status) => ({ id: status, label: statusLabel(status) }))}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
ariaLabel="i18n:govoplan-notifications.notification_status"
|
||||
width="fill"
|
||||
/>
|
||||
<div className="notifications-list">
|
||||
{loading ? <div className="notifications-note">i18n:govoplan-notifications.loading_notifications</div> : null}
|
||||
{!loading && notifications.length === 0 ? <div className="notifications-note">i18n:govoplan-notifications.no_notifications</div> : null}
|
||||
{notifications.length > 0 ? (
|
||||
<SelectionList label="i18n:govoplan-notifications.notifications" className="notifications-selection-list">
|
||||
{notifications.map((notification) => (
|
||||
<SelectionListItem
|
||||
key={notification.id}
|
||||
selected={selected?.id === notification.id}
|
||||
className={`notifications-list-item ${notification.read_at ? "is-read" : ""}`}
|
||||
onClick={() => setSelectedId(notification.id)}
|
||||
>
|
||||
<span className="notifications-list-heading">
|
||||
<strong>{notification.subject || notification.event_kind}</strong>
|
||||
<small>{formatStatus(notification.status)}</small>
|
||||
</span>
|
||||
<span className="notifications-list-meta">
|
||||
<span>{notification.source_module}</span>
|
||||
<span>{formatDate(notification.created_at)}</span>
|
||||
</span>
|
||||
</SelectionListItem>
|
||||
))}
|
||||
</SelectionList>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
<section className="notifications-workspace">
|
||||
<div className="notifications-topbar">
|
||||
<div className="notifications-title-line">
|
||||
<Bell size={18} />
|
||||
<strong>{selected?.subject || selected?.event_kind || "i18n:govoplan-notifications.surface.center"}</strong>
|
||||
</div>
|
||||
<div className="notifications-actions">
|
||||
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
|
||||
<Button onClick={() => void markSelected("read")} disabled={Boolean(markReadDisabledReason)} disabledReason={markReadDisabledReason}>
|
||||
<Check size={16} /> i18n:govoplan-notifications.mark_read
|
||||
</Button>
|
||||
<Button onClick={() => void markSelected("acknowledged")} disabled={Boolean(acknowledgeDisabledReason)} disabledReason={acknowledgeDisabledReason}>
|
||||
<Check size={16} /> i18n:govoplan-notifications.acknowledge
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setConfirmingAction("cancel")} disabled={Boolean(cancelDisabledReason)} disabledReason={cancelDisabledReason}>
|
||||
<XCircle size={16} /> i18n:govoplan-notifications.cancel_delivery
|
||||
</Button>
|
||||
<Button onClick={() => setConfirmingAction("dispatch")} disabled={Boolean(dispatchDisabledReason)} disabledReason={dispatchDisabledReason}>
|
||||
<Send size={16} /> i18n:govoplan-notifications.dispatch_pending
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
|
||||
{selected && !canWrite ? (
|
||||
<ActionBlockerHint
|
||||
tone="info"
|
||||
reason={{
|
||||
summary: "i18n:govoplan-notifications.actions_read_only_summary",
|
||||
details: NOTIFICATIONS_I18N.writePermissionRequired,
|
||||
requiredAction: "i18n:govoplan-notifications.permission_action",
|
||||
actor: "i18n:govoplan-notifications.permission_actor",
|
||||
target: "i18n:govoplan-notifications.permission_target"
|
||||
}}
|
||||
labels={NOTIFICATIONS_BLOCKER_LABELS}
|
||||
documentation={NOTIFICATIONS_DOCUMENTATION}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selected ? <NotificationDetails notification={selected} /> : (
|
||||
<div className="notifications-empty-state">
|
||||
<Bell size={22} />
|
||||
<h1>i18n:govoplan-notifications.notifications</h1>
|
||||
<p>i18n:govoplan-notifications.select_notification_help</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={confirmingAction === "cancel"}
|
||||
title="i18n:govoplan-notifications.cancel_delivery_title"
|
||||
message={i18nMessage("i18n:govoplan-notifications.cancel_delivery_message", { value0: selected?.subject || selected?.event_kind || "i18n:govoplan-notifications.selected_notification" })}
|
||||
confirmLabel="i18n:govoplan-notifications.cancel_delivery"
|
||||
tone="danger"
|
||||
busy={busy}
|
||||
onCancel={() => setConfirmingAction(null)}
|
||||
onConfirm={() => void confirmAction()}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={confirmingAction === "dispatch"}
|
||||
title="i18n:govoplan-notifications.dispatch_pending_title"
|
||||
message="i18n:govoplan-notifications.dispatch_pending_message"
|
||||
confirmLabel="i18n:govoplan-notifications.dispatch_pending"
|
||||
busy={busy}
|
||||
onCancel={() => setConfirmingAction(null)}
|
||||
onConfirm={() => void confirmAction()}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="notifications-section-heading">
|
||||
<h2>i18n:govoplan-notifications.delivery_attempts</h2>
|
||||
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
|
||||
</div>
|
||||
{notification.attempts.length === 0 ? <p className="muted">i18n:govoplan-notifications.no_delivery_attempt</p> : null}
|
||||
{notification.attempts.map((attempt) => (
|
||||
<div className="notifications-attempt" key={attempt.id}>
|
||||
<strong>{attempt.provider || attempt.channel}</strong>
|
||||
<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 {
|
||||
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 (
|
||||
<div className="dashboard-grid settings-dashboard-grid 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">
|
||||
<div className="form-grid">
|
||||
<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}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="i18n:govoplan-notifications.delivery">
|
||||
<div className="form-grid">
|
||||
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
|
||||
<div className="notifications-settings-inline-title">
|
||||
<Mail size={16} />
|
||||
<strong>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 }))}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
LoadingFrame,
|
||||
MetricCard,
|
||||
adminErrorMessage,
|
||||
i18nMessage,
|
||||
type ApiSettings,
|
||||
type DashboardWidgetConfiguration,
|
||||
useSharedNotificationSummary
|
||||
} from "@govoplan/core-webui";
|
||||
import { NOTIFICATIONS_DOCUMENTATION } from "./interfacePatterns";
|
||||
|
||||
export default function NotificationSummaryWidget({
|
||||
settings,
|
||||
refreshKey,
|
||||
configuration,
|
||||
showCenterLink
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
refreshKey: number;
|
||||
configuration: DashboardWidgetConfiguration;
|
||||
showCenterLink: boolean;
|
||||
}) {
|
||||
const showDeliveryState = configuration.showDeliveryState !== false;
|
||||
const state = useSharedNotificationSummary(settings, {
|
||||
enabled: true,
|
||||
scopeKey: "current-session",
|
||||
intervalMs: 30_000,
|
||||
refreshKey
|
||||
});
|
||||
const summary = state.summary;
|
||||
const error = state.error ? adminErrorMessage(state.error) : "";
|
||||
|
||||
return (
|
||||
<LoadingFrame loading={state.loading} label="i18n:govoplan-notifications.loading_summary">
|
||||
{error && (
|
||||
<DismissibleAlert tone="warning" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
<div className="metric-grid inside dashboard-widget-metrics">
|
||||
<MetricCard
|
||||
label="i18n:govoplan-notifications.unread"
|
||||
value={summary?.unread ?? 0}
|
||||
tone={summary?.unread ? "info" : "good"}
|
||||
detail={i18nMessage("i18n:govoplan-notifications.value_total", { value0: summary?.total ?? 0 })}
|
||||
/>
|
||||
{showDeliveryState && (
|
||||
<MetricCard
|
||||
label="i18n:govoplan-notifications.pending"
|
||||
value={summary?.pending ?? 0}
|
||||
tone={summary?.pending ? "warning" : "good"}
|
||||
detail="i18n:govoplan-notifications.awaiting_delivery"
|
||||
/>
|
||||
)}
|
||||
{showDeliveryState && (
|
||||
<MetricCard
|
||||
label="i18n:govoplan-notifications.failed"
|
||||
value={summary?.failed ?? 0}
|
||||
tone={summary?.failed ? "danger" : "good"}
|
||||
detail="i18n:govoplan-notifications.delivery_failures"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="notifications-widget-actions">
|
||||
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
|
||||
{showCenterLink && (
|
||||
<Link className="btn btn-secondary" to="/notifications">
|
||||
i18n:govoplan-notifications.open_center
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||
|
||||
export const NOTIFICATIONS_DOCUMENTATION = {
|
||||
topicId: "notifications.center-and-preferences",
|
||||
documentationType: "user"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const NOTIFICATIONS_DELIVERY_DOCUMENTATION = {
|
||||
topicId: "notifications.delivery-operations",
|
||||
documentationType: "admin"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const NOTIFICATIONS_I18N = {
|
||||
loading: "i18n:govoplan-notifications.reason.loading",
|
||||
saving: "i18n:govoplan-notifications.reason.saving",
|
||||
actionActive: "i18n:govoplan-notifications.reason.action_active",
|
||||
selectionRequired: "i18n:govoplan-notifications.reason.selection_required",
|
||||
readPermissionRequired: "i18n:govoplan-notifications.reason.read_permission_required",
|
||||
writePermissionRequired: "i18n:govoplan-notifications.reason.write_permission_required",
|
||||
dispatchPermissionRequired: "i18n:govoplan-notifications.reason.dispatch_permission_required",
|
||||
alreadyRead: "i18n:govoplan-notifications.reason.already_read",
|
||||
alreadyAcknowledged: "i18n:govoplan-notifications.reason.already_acknowledged",
|
||||
notCancellable: "i18n:govoplan-notifications.reason.not_cancellable",
|
||||
noChanges: "i18n:govoplan-notifications.reason.no_changes",
|
||||
mailUnavailable: "i18n:govoplan-notifications.reason.mail_unavailable"
|
||||
} as const;
|
||||
|
||||
export const NOTIFICATIONS_BLOCKER_LABELS = {
|
||||
requiredAction: "i18n:govoplan-notifications.blocker.required_action",
|
||||
actor: "i18n:govoplan-notifications.blocker.actor",
|
||||
target: "i18n:govoplan-notifications.blocker.target"
|
||||
} as const;
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { notificationsModule as default } from "./module";
|
||||
export { notificationsModule } from "./module";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
.notifications-page {
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - 115px);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.notifications-page *,
|
||||
.notifications-page *::before,
|
||||
.notifications-page *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.notifications-widget-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.notifications-shell {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(270px, 340px) minmax(0, 1fr);
|
||||
border: var(--border-line);
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notifications-sidebar {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.notifications-sidebar-bar,
|
||||
.notifications-title,
|
||||
.notifications-topbar,
|
||||
.notifications-title-line,
|
||||
.notifications-actions,
|
||||
.notifications-message-meta,
|
||||
.notifications-action-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notifications-sidebar-bar,
|
||||
.notifications-topbar {
|
||||
min-height: 54px;
|
||||
justify-content: space-between;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.notifications-count {
|
||||
min-width: 21px;
|
||||
height: 21px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.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: 840px;
|
||||
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-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.notifications-section-heading h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notifications-properties dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||
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: 6px;
|
||||
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: 6px;
|
||||
background: var(--surface);
|
||||
margin-bottom: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.notifications-attempt small,
|
||||
.notifications-attempt p {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.notifications-empty-state {
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notifications-permission-state {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px;
|
||||
}
|
||||
|
||||
.notifications-empty-state h1 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.notifications-empty-state p {
|
||||
max-width: 520px;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.notifications-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.notifications-sidebar {
|
||||
min-height: 220px;
|
||||
border-right: 0;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.notifications-topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notifications-properties dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user