diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7e3d6e5 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/src/govoplan_notifications/__init__.py b/src/govoplan_notifications/__init__.py new file mode 100644 index 0000000..3ce55a6 --- /dev/null +++ b/src/govoplan_notifications/__init__.py @@ -0,0 +1,2 @@ +from __future__ import annotations + diff --git a/src/govoplan_notifications/backend/__init__.py b/src/govoplan_notifications/backend/__init__.py new file mode 100644 index 0000000..3ce55a6 --- /dev/null +++ b/src/govoplan_notifications/backend/__init__.py @@ -0,0 +1,2 @@ +from __future__ import annotations + diff --git a/src/govoplan_notifications/backend/capabilities.py b/src/govoplan_notifications/backend/capabilities.py new file mode 100644 index 0000000..ab142ee --- /dev/null +++ b/src/govoplan_notifications/backend/capabilities.py @@ -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) diff --git a/src/govoplan_notifications/backend/db/__init__.py b/src/govoplan_notifications/backend/db/__init__.py new file mode 100644 index 0000000..95e3089 --- /dev/null +++ b/src/govoplan_notifications/backend/db/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from govoplan_notifications.backend.db.models import NotificationDeliveryAttempt, NotificationMessage, NotificationPreference + +__all__ = ["NotificationDeliveryAttempt", "NotificationMessage", "NotificationPreference"] diff --git a/src/govoplan_notifications/backend/db/models.py b/src/govoplan_notifications/backend/db/models.py new file mode 100644 index 0000000..800bc30 --- /dev/null +++ b/src/govoplan_notifications/backend/db/models.py @@ -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"] diff --git a/src/govoplan_notifications/backend/manifest.py b/src/govoplan_notifications/backend/manifest.py new file mode 100644 index 0000000..99404dd --- /dev/null +++ b/src/govoplan_notifications/backend/manifest.py @@ -0,0 +1,116 @@ +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 MigrationSpec, ModuleContext, ModuleInterfaceProvider, ModuleManifest, PermissionDefinition, RoleTemplate +from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH +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 notification inbox, outbox, 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", "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,), + 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 diff --git a/src/govoplan_notifications/backend/migrations/__init__.py b/src/govoplan_notifications/backend/migrations/__init__.py new file mode 100644 index 0000000..3ce55a6 --- /dev/null +++ b/src/govoplan_notifications/backend/migrations/__init__.py @@ -0,0 +1,2 @@ +from __future__ import annotations + diff --git a/src/govoplan_notifications/backend/migrations/versions/5e6f7a8b9c0d_v018_notifications_baseline.py b/src/govoplan_notifications/backend/migrations/versions/5e6f7a8b9c0d_v018_notifications_baseline.py new file mode 100644 index 0000000..7178694 --- /dev/null +++ b/src/govoplan_notifications/backend/migrations/versions/5e6f7a8b9c0d_v018_notifications_baseline.py @@ -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") diff --git a/src/govoplan_notifications/backend/migrations/versions/6f7a8b9c0d1e_v019_notification_preferences.py b/src/govoplan_notifications/backend/migrations/versions/6f7a8b9c0d1e_v019_notification_preferences.py new file mode 100644 index 0000000..25dc61e --- /dev/null +++ b/src/govoplan_notifications/backend/migrations/versions/6f7a8b9c0d1e_v019_notification_preferences.py @@ -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") diff --git a/src/govoplan_notifications/backend/migrations/versions/__init__.py b/src/govoplan_notifications/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..3ce55a6 --- /dev/null +++ b/src/govoplan_notifications/backend/migrations/versions/__init__.py @@ -0,0 +1,2 @@ +from __future__ import annotations + diff --git a/src/govoplan_notifications/backend/router.py b/src/govoplan_notifications/backend/router.py new file mode 100644 index 0000000..ecd98fe --- /dev/null +++ b/src/govoplan_notifications/backend/router.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +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)) + + +@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, + 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) + notifications = list_notifications( + session, + tenant_id=principal.tenant_id, + status=status_filter, + channel=channel, + source_module=source_module, + recipient_id=recipient_id, + limit=limit, + ) + return NotificationListResponse(notifications=[_response(notification) for notification in notifications]) + + +@router.get("/summary", response_model=NotificationSummaryResponse) +def api_notification_summary( + 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)) + + +@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, + 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)) + 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, + 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) + 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: + 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 [], + ) diff --git a/src/govoplan_notifications/backend/schemas.py b/src/govoplan_notifications/backend/schemas.py new file mode 100644 index 0000000..5b1958d --- /dev/null +++ b/src/govoplan_notifications/backend/schemas.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +NotificationStatus = Literal["pending", "queued", "sending", "sent", "failed", "skipped", "cancelled"] + + +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) + + +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) diff --git a/src/govoplan_notifications/backend/service.py b/src/govoplan_notifications/backend/service.py new file mode 100644 index 0000000..88f1e33 --- /dev/null +++ b/src/govoplan_notifications/backend/service.py @@ -0,0 +1,466 @@ +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 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 + + +class NotificationError(ValueError): + pass + + +PENDING_STATUSES = {"pending", "queued", "failed"} + + +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 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=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_celery_delivery(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, + 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_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) -> dict[str, int | bool]: + base_query = session.query(NotificationMessage).filter( + NotificationMessage.tenant_id == tenant_id, + NotificationMessage.deleted_at.is_(None), + ) + 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) -> NotificationMessage: + notification = ( + session.query(NotificationMessage) + .filter( + NotificationMessage.tenant_id == tenant_id, + NotificationMessage.id == notification_id, + NotificationMessage.deleted_at.is_(None), + ) + .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, +) -> NotificationMessage: + notification = get_notification(session, tenant_id=tenant_id, notification_id=notification_id) + 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: + 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 + except Exception: + return + if not getattr(settings, "celery_enabled", False): + return + celery.send_task("govoplan.notifications.deliver", args=[notification_id], queue="notifications") + + +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": 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), + } diff --git a/src/govoplan_notifications/py.typed b/src/govoplan_notifications/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_notifications/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000..3c56b60 --- /dev/null +++ b/tests/test_notifications.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import tempfile +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +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.schemas import NotificationCreateRequest, NotificationPreferencesUpdateRequest +from govoplan_notifications.backend.service import create_notification, deliver_pending, notification_preferences_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 test_create_and_deliver_inbox_notification(self) -> None: + 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) + + 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() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..4226e8c --- /dev/null +++ b/webui/package.json @@ -0,0 +1,31 @@ +{ + "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" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.8", + "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 + } + } +} diff --git a/webui/src/api/notifications.ts b/webui/src/api/notifications.ts new file mode 100644 index 0000000..b6bdc66 --- /dev/null +++ b/webui/src/api/notifications.ts @@ -0,0 +1,123 @@ +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; + 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; + metadata: Record; + 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; +}; + +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; limit?: number } = {}): Promise { + 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.limit) params.set("limit", String(filters.limit)); + const query = params.toString(); + return apiFetch(settings, `/api/v1/notifications${query ? `?${query}` : ""}`); +} + +export function notificationSummary(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/notifications/summary"); +} + +export function getNotificationPreferences(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/notifications/preferences/me"); +} + +export function updateNotificationPreferences(settings: ApiSettings, payload: Partial>): Promise { + return apiFetch(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 | null }): Promise { + return apiFetch(settings, `/api/v1/notifications/${notificationId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function deliverPendingNotifications(settings: ApiSettings, limit = 50): Promise { + return apiFetch(settings, "/api/v1/notifications/deliver-pending", { + method: "POST", + body: JSON.stringify({ limit }) + }); +} diff --git a/webui/src/features/notifications/NotificationCenterPage.tsx b/webui/src/features/notifications/NotificationCenterPage.tsx new file mode 100644 index 0000000..acf4ea8 --- /dev/null +++ b/webui/src/features/notifications/NotificationCenterPage.tsx @@ -0,0 +1,242 @@ +import { useEffect, useMemo, useState } from "react"; +import { Bell, Check, ExternalLink, RefreshCw, Send, XCircle } from "lucide-react"; +import { Button, DismissibleAlert, hasScope, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; +import { deliverPendingNotifications, listNotifications, updateNotification, type NotificationMessage } from "../../api/notifications"; + +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([]); + const [selectedId, setSelectedId] = useState(""); + const [statusFilter, setStatusFilter] = useState("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 ( +
+
+ +

Notifications

+

You do not have permission to read notifications in this tenant.

+
+
+ ); + } + + return ( +
+
+ +
+
+
+ + {selected?.subject || selected?.event_kind || "Notification center"} +
+
+ + + + {canDispatch ? ( + + ) : null} +
+
+ + {error ? {error} : null} + + {selected ? : ( +
+ +

Notifications

+

Select a notification to inspect delivery state, source context, and content.

+
+ )} +
+
+
+ ); +} + +function NotificationDetails({ notification }: { notification: NotificationMessage }) { + return ( +
+
+
+ {formatStatus(notification.status)} + {notification.channel} + {formatDate(notification.created_at)} +
+

{notification.subject || notification.event_kind}

+ {notification.body_text ?

{notification.body_text}

:

No message body was provided.

} + {notification.action_url ? ( + + Open related item + + ) : null} +
+ +
+

Source and delivery

+
+
Source
{notification.source_module} / {notification.source_resource_type}
+
Resource
{notification.source_resource_id || "None"}
+
Recipient
{notification.recipient_label || notification.recipient || notification.recipient_id || "None"}
+
Priority
{notification.priority}
+
Queued
{formatDate(notification.queued_at)}
+
Sent
{formatDate(notification.sent_at)}
+
Read
{formatDate(notification.read_at)}
+
Attempts
{notification.attempt_count}
+
+ {notification.last_error ?

{notification.last_error}

: null} +
+ +
+

Delivery attempts

+ {notification.attempts.length === 0 ?

No delivery attempt has been recorded yet.

: null} + {notification.attempts.map((attempt) => ( +
+ {attempt.provider || attempt.channel} + {formatStatus(attempt.status)} + {formatDate(attempt.started_at)} - {formatDate(attempt.finished_at)} + {attempt.error ?

{attempt.error}

: null} +
+ ))} +
+
+ ); +} + +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")); +} diff --git a/webui/src/features/notifications/NotificationSettingsPanel.tsx b/webui/src/features/notifications/NotificationSettingsPanel.tsx new file mode 100644 index 0000000..020cd3c --- /dev/null +++ b/webui/src/features/notifications/NotificationSettingsPanel.tsx @@ -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 & { + 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(null); + const [draft, setDraft] = useState(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 ( +
+ +
+ setDraft((current) => ({ ...current, show_unread_badge: value }))} + /> + + setDraft((current) => ({ ...current, muted_source_modules_text: event.target.value }))} + placeholder="calendar, campaign" + disabled={loading} + /> + +
+ +
+ {message ? {message} : null} +
+
+ +
+
+ + Email notifications +
+ setDraft((current) => ({ ...current, email_enabled: value, email_digest_enabled: value ? current.email_digest_enabled : false }))} + /> + setDraft((current) => ({ ...current, email_digest_enabled: value }))} + /> +
+
+
+ ); +} + +function normalizeMutedModules(value: string): string[] { + const seen = new Set(); + return value + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter((item) => { + if (!item || seen.has(item)) return false; + seen.add(item); + return true; + }); +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts new file mode 100644 index 0000000..b2d1925 --- /dev/null +++ b/webui/src/i18n/generatedTranslations.ts @@ -0,0 +1,4 @@ +export const generatedTranslations = { + en: {}, + de: {} +}; diff --git a/webui/src/index.ts b/webui/src/index.ts new file mode 100644 index 0000000..4d6c547 --- /dev/null +++ b/webui/src/index.ts @@ -0,0 +1,2 @@ +export { notificationsModule as default } from "./module"; +export { notificationsModule } from "./module"; diff --git a/webui/src/module.ts b/webui/src/module.ts new file mode 100644 index 0000000..96c4276 --- /dev/null +++ b/webui/src/module.ts @@ -0,0 +1,39 @@ +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", + 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, + routes: [ + { path: "/notifications", anyOf: notificationRead, order: 59, render: ({ settings, auth }) => createElement(NotificationCenterPage, { settings, auth }) } + ], + uiCapabilities: { + "settings.sections": notificationSettingsSections + } +}; + +export default notificationsModule; diff --git a/webui/src/styles/notifications.css b/webui/src/styles/notifications.css new file mode 100644 index 0000000..9fc9d51 --- /dev/null +++ b/webui/src/styles/notifications.css @@ -0,0 +1,379 @@ +.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-icon-button.btn { + width: 34px; + min-width: 34px; + height: 34px; + justify-content: center; + padding: 0; +} + +.notifications-filter-row { + display: flex; + gap: 5px; + padding: 8px; + border-bottom: var(--border-line); + overflow-x: auto; +} + +.notifications-filter-row button { + min-height: 28px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--muted); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 700; + padding: 0 8px; + text-transform: capitalize; +} + +.notifications-filter-row button:hover, +.notifications-filter-row button.is-active { + background: var(--sidebar-hover-bg); + color: var(--text-strong); +} + +.notifications-list { + min-height: 0; + display: grid; + align-content: start; + gap: 4px; + overflow: auto; + padding: 8px; +} + +.notifications-list-item { + width: 100%; + display: grid; + gap: 4px; + min-height: 58px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text); + cursor: pointer; + padding: 9px; + text-align: left; +} + +.notifications-list-item:hover, +.notifications-list-item.is-selected { + background: var(--sidebar-hover-bg); +} + +.notifications-list-item.is-selected { + box-shadow: inset 3px 0 var(--accent); +} + +.notifications-list-item.is-read { + 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-status { + border-radius: 999px; + background: var(--panel-soft); + color: var(--text); + padding: 3px 8px; +} + +.notifications-status.status-failed { + background: var(--danger-soft); + color: var(--danger-text); +} + +.notifications-status.status-sent { + background: var(--success-soft); + color: var(--green); +} + +.notifications-status.status-queued, +.notifications-status.status-pending { + background: var(--warning-bg); + color: var(--warning-text); +} + +.notifications-action-link { + width: max-content; + max-width: 100%; + 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; + } +}