9 Commits
v0.1.6 ... main

31 changed files with 3206 additions and 0 deletions

348
.gitignore vendored Normal file
View File

@@ -0,0 +1,348 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Local WebUI test/build scratch directories
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
*.db
# GovOPlaN local runtime state
runtime/
# GovOPlaN WebUI test output
webui/.module-test-build/
webui/.component-test-build/

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# govoplan-notifications
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->

View File

@@ -0,0 +1,29 @@
# Notification And Inbox Boundary
`govoplan-notifications` should own delivery and notification state, but it
should not become the owner of tasks, postbox messages, workflow exceptions, or
approval records.
Notifications can contribute lightweight inbox items when they require attention
or acknowledgement. Domain modules remain responsible for the underlying
business object and action commands.
## Responsibilities
Notifications owns:
- notification channels and delivery preferences
- notification templates and rendered delivery records
- acknowledgement or dismissal state for notification-owned items
- delivery attempts, failures, and audit events
Notifications does not own:
- task completion
- postbox message read/write state
- workflow transition decisions
- approval semantics
- case or record ownership
The unified inbox should aggregate notification contributions through DTOs and
capabilities shared with tasks, postbox, workflow, and portal.

23
pyproject.toml Normal file
View File

@@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-notifications"
version = "0.1.8"
description = "GovOPlaN notification inbox and delivery module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.8",
]
[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"

View File

@@ -0,0 +1,2 @@
from __future__ import annotations

View File

@@ -0,0 +1,2 @@
from __future__ import annotations

View File

@@ -0,0 +1,39 @@
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
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) # 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) # type: ignore[arg-type]
def dispatch_capability(context: ModuleContext) -> SqlNotificationDispatchProvider:
return SqlNotificationDispatchProvider(context)

View File

@@ -0,0 +1,5 @@
from __future__ import annotations
from govoplan_notifications.backend.db.models import NotificationDeliveryAttempt, NotificationMessage, NotificationPreference
__all__ = ["NotificationDeliveryAttempt", "NotificationMessage", "NotificationPreference"]

View File

@@ -0,0 +1,104 @@
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_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"]

View File

@@ -0,0 +1,147 @@
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 (
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_notifications.backend.db import models as notification_models # noqa: F401 - populate Notifications ORM metadata
MODULE_ID = "notifications"
MODULE_NAME = "Notifications"
MODULE_VERSION = "0.1.8"
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", "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,),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/notifications-webui",
routes=(
FrontendRoute(
path="/notifications",
component="NotificationCenterPage",
required_any=(READ_SCOPE,),
order=59,
),
),
view_surfaces=(
ViewSurface(
id="notifications.settings.preferences",
module_id=MODULE_ID,
kind="section",
label="Notification preferences",
order=40,
),
),
),
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),
},
)
def get_manifest() -> ModuleManifest:
return manifest

View File

@@ -0,0 +1,2 @@
from __future__ import annotations

View File

@@ -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")

View File

@@ -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")

View File

@@ -0,0 +1,2 @@
from __future__ import annotations

View File

@@ -0,0 +1,244 @@
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.db.session import get_session
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)
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)
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
return NotificationDeliveryResultResponse(
notification=_response(notification),
processed=1,
sent=sent,
failed=failed,
skipped=skipped,
errors=[notification.last_error] if notification.last_error else [],
)

View File

@@ -0,0 +1,159 @@
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", "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
failed: int = 0
skipped: int = 0
errors: list[str] = Field(default_factory=list)

View File

@@ -0,0 +1,538 @@
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 event, or_
from sqlalchemy.orm import Session
from govoplan_core.core.notifications import NotificationDispatchRequest
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"}
_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]:
base_query = session.query(NotificationMessage).filter(
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.deleted_at.is_(None),
)
if recipient_ids is not None:
base_query = base_query.filter(NotificationMessage.recipient_id.in_(recipient_ids))
active_query = base_query.filter(NotificationMessage.status.notin_(["cancelled", "skipped"]))
show_unread_badge = True
if user_id:
show_unread_badge = get_notification_preferences(session, tenant_id=tenant_id, user_id=user_id).show_unread_badge
return {
"total": base_query.count(),
"unread": active_query.filter(NotificationMessage.read_at.is_(None)).count(),
"pending": active_query.filter(NotificationMessage.status.in_(sorted(PENDING_STATUSES))).count(),
"failed": active_query.filter(NotificationMessage.status == "failed").count(),
"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":
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,
) -> 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 {"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(notification, settings=settings)
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"), 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,
) -> 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, "failed": 0, "skipped": 0, "errors": []}
for notification in notifications:
result["processed"] += 1
delivered = deliver_notification(session, notification_id=notification.id, settings=settings)
if delivered.status == "sent":
result["sent"] += 1
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(notification: NotificationMessage, *, settings: object | None) -> dict[str, Any]:
if notification.channel == "inbox":
return {"status": "sent", "provider": "inbox", "external_message_id": notification.id}
if notification.channel == "mail":
if not notification.recipient:
return {"status": "skipped", "provider": "mail", "error": "Mail notification has no recipient address"}
path = _write_local_mail(notification, settings=settings)
return {"status": "sent", "provider": "local_file_mail", "external_message_id": str(path), "path": str(path)}
return {"status": "skipped", "provider": notification.channel, "error": f"No delivery adapter configured for channel {notification.channel!r}"}
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),
}

View File

@@ -0,0 +1 @@

405
tests/test_notifications.py Normal file
View File

@@ -0,0 +1,405 @@
from __future__ import annotations
import tempfile
import unittest
from unittest.mock import patch
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.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 (
create_notification,
deliver_pending,
notification_preferences_response,
notification_response,
notification_summary,
update_notification_preferences,
)
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_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", (), {"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_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")
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()

35
webui/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "@govoplan/notifications-webui",
"version": "0.1.8",
"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.9",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

View File

@@ -0,0 +1,18 @@
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="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/);
console.log("Notification rows use the central selection-list contract.");

View File

@@ -0,0 +1,124 @@
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;
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 })
});
}

View File

@@ -0,0 +1,247 @@
import { useEffect, useMemo, useState } from "react";
import { Bell, Check, ExternalLink, RefreshCw, Send, XCircle } from "lucide-react";
import { AdminIconButton, Button, DismissibleAlert, SegmentedControl, SelectionList, SelectionListItem, StatusBadge, hasScope, type ApiSettings, type AuthInfo } from "@govoplan/core-webui";
import { deliverPendingNotifications, listNotifications, updateNotification, type NotificationMessage } from "../../api/notifications";
import { safeNotificationActionUrl } from "../../security/actionUrl";
type StatusFilter = "all" | "pending" | "queued" | "sent" | "failed" | "skipped" | "cancelled";
const statusFilters: StatusFilter[] = ["all", "pending", "queued", "sent", "failed", "skipped", "cancelled"];
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 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;
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") {
if (!selected || !canWrite) return;
setBusy(true);
setError("");
try {
const next = await updateNotification(settings, selected.id, { status });
setNotifications((items) => items.map((item) => item.id === next.id ? next : item));
notifyNotificationsChanged();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
async function runDelivery() {
if (!canDispatch) return;
setBusy(true);
setError("");
try {
await deliverPendingNotifications(settings, 50);
await load();
notifyNotificationsChanged();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
if (!canRead) {
return (
<main className="notifications-page">
<div className="notifications-empty-state">
<Bell size={22} />
<h1>Notifications</h1>
<p>You do not have permission to read notifications in this tenant.</p>
</div>
</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>Notifications</strong>
{unreadCount > 0 ? <span className="notifications-count">{unreadCount}</span> : null}
</div>
<AdminIconButton label="Refresh" icon={<RefreshCw size={16} aria-hidden="true" />} onClick={() => void load()} disabled={loading || busy} />
</div>
<SegmentedControl
className="notifications-status-filter"
options={statusFilters.map((status) => ({ id: status, label: status }))}
value={statusFilter}
onChange={setStatusFilter}
ariaLabel="Notification status"
width="fill"
/>
<div className="notifications-list">
{loading ? <div className="notifications-note">Loading notifications</div> : null}
{!loading && notifications.length === 0 ? <div className="notifications-note">No notifications in this view.</div> : null}
{notifications.length > 0 ? (
<SelectionList label="Notifications" className="notifications-selection-list">
{notifications.map((notification) => (
<SelectionListItem
key={notification.id}
selected={selected?.id === notification.id}
className={`notifications-list-item ${notification.read_at ? "is-read" : ""}`}
onClick={() => setSelectedId(notification.id)}
>
<span className="notifications-list-heading">
<strong>{notification.subject || notification.event_kind}</strong>
<small>{formatStatus(notification.status)}</small>
</span>
<span className="notifications-list-meta">
<span>{notification.source_module}</span>
<span>{formatDate(notification.created_at)}</span>
</span>
</SelectionListItem>
))}
</SelectionList>
) : null}
</div>
</aside>
<section className="notifications-workspace">
<div className="notifications-topbar">
<div className="notifications-title-line">
<Bell size={18} />
<strong>{selected?.subject || selected?.event_kind || "Notification center"}</strong>
</div>
<div className="notifications-actions">
<Button onClick={() => void markSelected("read")} disabled={!selected || busy || !canWrite}>
<Check size={16} /> Mark read
</Button>
<Button onClick={() => void markSelected("acknowledged")} disabled={!selected || busy || !canWrite}>
<Check size={16} /> Acknowledge
</Button>
<Button onClick={() => void markSelected("cancelled")} disabled={!selected || busy || !canWrite}>
<XCircle size={16} /> Cancel
</Button>
{canDispatch ? (
<Button onClick={() => void runDelivery()} disabled={busy}>
<Send size={16} /> Dispatch pending
</Button>
) : null}
</div>
</div>
{error ? <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert> : null}
{selected ? <NotificationDetails notification={selected} /> : (
<div className="notifications-empty-state">
<Bell size={22} />
<h1>Notifications</h1>
<p>Select a notification to inspect delivery state, source context, and content.</p>
</div>
)}
</section>
</div>
</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">No message body was provided.</p>}
{actionUrl ? (
<a className="notifications-action-link" href={actionUrl}>
<ExternalLink size={16} /> Open related item
</a>
) : null}
</section>
<section className="notifications-properties">
<h2>Source and delivery</h2>
<dl>
<div><dt>Source</dt><dd>{notification.source_module} / {notification.source_resource_type}</dd></div>
<div><dt>Resource</dt><dd>{notification.source_resource_id || "None"}</dd></div>
<div><dt>Recipient</dt><dd>{notification.recipient_label || notification.recipient || notification.recipient_id || "None"}</dd></div>
<div><dt>Priority</dt><dd>{notification.priority}</dd></div>
<div><dt>Queued</dt><dd>{formatDate(notification.queued_at)}</dd></div>
<div><dt>Sent</dt><dd>{formatDate(notification.sent_at)}</dd></div>
<div><dt>Read</dt><dd>{formatDate(notification.read_at)}</dd></div>
<div><dt>Attempts</dt><dd>{notification.attempt_count}</dd></div>
</dl>
{notification.last_error ? <p className="notifications-error">{notification.last_error}</p> : null}
</section>
<section className="notifications-attempts">
<h2>Delivery attempts</h2>
{notification.attempts.length === 0 ? <p className="muted">No delivery attempt has been recorded yet.</p> : null}
{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 value.replace(/_/g, " ");
}
function formatDate(value?: string | null): string {
if (!value) return "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 : "Request failed";
}
function notifyNotificationsChanged(): void {
window.dispatchEvent(new CustomEvent("govoplan:notifications-changed"));
}

View File

@@ -0,0 +1,150 @@
import { useEffect, useMemo, useState } from "react";
import { Mail, Save } from "lucide-react";
import { Button, Card, DismissibleAlert, FormField, ToggleSwitch, type ApiSettings, type AuthInfo } from "@govoplan/core-webui";
import { getNotificationPreferences, updateNotificationPreferences, type NotificationPreferences } from "../../api/notifications";
type Draft = Pick<NotificationPreferences, "show_unread_badge" | "email_enabled" | "email_digest_enabled"> & {
muted_source_modules_text: string;
};
const DEFAULT_DRAFT: Draft = {
show_unread_badge: true,
email_enabled: false,
email_digest_enabled: false,
muted_source_modules_text: ""
};
export default function NotificationSettingsPanel({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
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 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 ||
normalizeMutedModules(draft.muted_source_modules_text).join(",") !== loaded.muted_source_modules.join(",")
);
}, [draft, loaded]);
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_text: preferences.muted_source_modules.join(", ")
});
} catch (error) {
setMessageTone("warning");
setMessage(error instanceof Error ? error.message : "Loading notification preferences failed");
} finally {
setLoading(false);
}
}
async function savePreferences() {
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: normalizeMutedModules(draft.muted_source_modules_text)
});
setLoaded(next);
setDraft({
show_unread_badge: next.show_unread_badge,
email_enabled: next.email_enabled,
email_digest_enabled: next.email_digest_enabled,
muted_source_modules_text: next.muted_source_modules.join(", ")
});
window.dispatchEvent(new CustomEvent("govoplan:notifications-changed"));
setMessageTone("success");
setMessage("Notification preferences saved.");
} catch (error) {
setMessageTone("warning");
setMessage(error instanceof Error ? error.message : "Saving notification preferences failed");
} finally {
setSaving(false);
}
}
return (
<div className="dashboard-grid settings-dashboard-grid notifications-settings-panel">
<Card title="Notifications">
<div className="form-grid">
<ToggleSwitch
label="Unread badge"
help="Show unread notification counts in the top bar."
checked={draft.show_unread_badge}
disabled={loading}
onChange={(value) => setDraft((current) => ({ ...current, show_unread_badge: value }))}
/>
<FormField label="Muted source modules" help="Comma-separated module IDs. Filtering will be applied by later delivery rules.">
<input
value={draft.muted_source_modules_text}
onChange={(event) => setDraft((current) => ({ ...current, muted_source_modules_text: event.target.value }))}
placeholder="calendar, campaign"
disabled={loading}
/>
</FormField>
<div className="button-row compact-actions">
<Button variant="primary" onClick={() => void savePreferences()} disabled={loading || saving || !dirty}>
<Save size={16} /> {saving ? "Saving" : "Save preferences"}
</Button>
</div>
{message ? <DismissibleAlert tone={messageTone} resetKey={message} floating>{message}</DismissibleAlert> : null}
</div>
</Card>
<Card title="Delivery">
<div className="form-grid">
<div className="notifications-settings-inline-title">
<Mail size={16} />
<strong>Email notifications</strong>
</div>
<ToggleSwitch
label="Email notifications"
help="Prepared for mail-backed notification delivery."
checked={draft.email_enabled}
disabled={loading}
onChange={(value) => setDraft((current) => ({ ...current, email_enabled: value, email_digest_enabled: value ? current.email_digest_enabled : false }))}
/>
<ToggleSwitch
label="Email digest"
help="Batch eligible notifications into summary messages when delivery rules support it."
checked={draft.email_digest_enabled}
disabled={loading || !draft.email_enabled}
onChange={(value) => setDraft((current) => ({ ...current, email_digest_enabled: value }))}
/>
</div>
</Card>
</div>
);
}
function normalizeMutedModules(value: string): string[] {
const seen = new Set<string>();
return value
.split(",")
.map((item) => item.trim().toLowerCase())
.filter((item) => {
if (!item || seen.has(item)) return false;
seen.add(item);
return true;
});
}

View File

@@ -0,0 +1,4 @@
export const generatedTranslations = {
en: {},
de: {}
};

2
webui/src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { notificationsModule as default } from "./module";
export { notificationsModule } from "./module";

49
webui/src/module.ts Normal file
View File

@@ -0,0 +1,49 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule, SettingsSectionsUiCapability } from "@govoplan/core-webui";
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 notificationSettingsSections: SettingsSectionsUiCapability = {
sections: [
{
id: "notifications",
surfaceId: "notifications.settings.preferences",
label: "Notifications",
group: "ui",
order: 40,
anyOf: notificationRead,
render: ({ settings, auth }) => createElement(NotificationSettingsPanel, { settings, auth })
}
]
};
export const notificationsModule: PlatformWebModule = {
id: "notifications",
label: "Notifications",
version: "1.0.0",
dependencies: [],
optionalDependencies: ["mail", "tasks", "portal", "workflow", "calendar", "scheduling"],
translations: generatedTranslations,
viewSurfaces: [
{
id: "notifications.settings.preferences",
moduleId: "notifications",
kind: "section",
label: "Notification preferences",
order: 40
}
],
routes: [
{ path: "/notifications", anyOf: notificationRead, order: 59, render: ({ settings, auth }) => createElement(NotificationCenterPage, { settings, auth }) }
],
uiCapabilities: {
"settings.sections": notificationSettingsSections
}
};
export default notificationsModule;

View File

@@ -0,0 +1,16 @@
export function safeNotificationActionUrl(value: string | null | undefined): string | null {
const candidate = value?.trim();
if (
!candidate
|| !candidate.startsWith("/")
|| candidate.startsWith("//")
|| candidate.includes("\\")
|| [...candidate].some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint < 32 || codePoint === 127;
})
) {
return null;
}
return candidate;
}

View File

@@ -0,0 +1,312 @@
.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-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;
}
.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-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-inline-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-strong);
font-size: 13px;
}
.notifications-properties h2,
.notifications-attempts h2 {
margin: 0 0 12px;
color: var(--text-strong);
font-size: 15px;
}
.notifications-properties dl {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 10px 18px;
margin: 0;
}
.notifications-properties div {
min-width: 0;
}
.notifications-properties dt {
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.notifications-properties dd {
min-width: 0;
margin: 3px 0 0;
overflow-wrap: anywhere;
}
.notifications-error {
width: max-content;
max-width: 100%;
margin: 14px 0 0;
border-radius: 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-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;
}
}

View File

@@ -0,0 +1,27 @@
import { safeNotificationActionUrl } from "../src/security/actionUrl";
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
}
assertEqual(
safeNotificationActionUrl(" /calendar?event=event-1#details "),
"/calendar?event=event-1#details",
"application-relative links remain available"
);
for (const unsafe of [
"javascript:alert(1)",
"data:text/html,unsafe",
"https://attacker.example.test/collect",
"//attacker.example.test/collect",
"/\\attacker.example.test/collect",
"/calendar\nmalformed",
"/calendar\u007fmalformed"
]) {
assertEqual(safeNotificationActionUrl(unsafe), null, `unsafe link is suppressed: ${unsafe}`);
}
assertEqual(safeNotificationActionUrl(null), null, "missing links remain absent");

16
webui/tsconfig.tests.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": ".component-test-build",
"rootDir": "."
},
"include": [
"src/security/actionUrl.ts",
"tests/action-url.test.ts"
]
}