feat: notification preferences system with plugin registry

- Add NotificationType and NotificationPreference models
- Add get_notification_types() to BasePlugin for plugin registration
- Add sync_notification_types() to PluginRegistry
- Update create_notification() to check user preferences (returns Notification|None)
- Add API endpoints: GET /types, GET /preferences, PATCH /preferences/{type_key}
- Add NotificationPreferenceUpdate schema
- Mail plugin registers 10 notification types with metadata
- Add Alembic migration 0017 for new tables + seed data
- Frontend: SettingsNotifications page with toggle switches
- Frontend: Settings tab, route, hooks for notification preferences
- i18n: notification settings keys (de/en)
This commit is contained in:
Agent Zero
2026-07-15 21:00:32 +02:00
parent c4d0ec6f7f
commit b0e987f790
15 changed files with 652 additions and 11 deletions
+37 -4
View File
@@ -6,10 +6,14 @@ import uuid
from datetime import UTC
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification import Notification
from app.models.notification import (
Notification,
NotificationPreference,
NotificationType,
)
async def create_notification(
@@ -19,8 +23,37 @@ async def create_notification(
type: str,
title: str,
body: str | None = None,
) -> Notification:
"""Create a new notification for a user."""
) -> Notification | None:
"""Create a new notification for a user if they have not disabled this type.
Returns None if the user has opted out of this notification type.
"""
# Check user preference
pref = await db.execute(
select(NotificationPreference).where(
and_(
NotificationPreference.user_id == user_id,
NotificationPreference.type_key == type,
NotificationPreference.tenant_id == tenant_id,
)
)
)
pref_row = pref.scalar_one_or_none()
# If preference exists and is disabled, skip
if pref_row and not pref_row.is_enabled:
return None
# If no preference, check if type is enabled by default
if not pref_row:
type_def = await db.execute(
select(NotificationType).where(NotificationType.type_key == type)
)
type_row = type_def.scalar_one_or_none()
if type_row and not type_row.is_enabled_by_default:
return None
# Create notification
notif = Notification(
tenant_id=tenant_id,
user_id=user_id,
+3 -1
View File
@@ -8,7 +8,7 @@ from app.models.auth import ApiToken, PasswordResetToken
from app.models.company import Company
from app.models.contact import CompanyContact, Contact
from app.models.currency import Currency
from app.models.notification import Notification
from app.models.notification import Notification, NotificationPreference, NotificationType
from app.models.plugin import Plugin, PluginMigration
from app.models.role import Role
from app.models.sequence import Sequence
@@ -28,6 +28,8 @@ __all__ = [
"AuditLog",
"DeletionLog",
"Notification",
"NotificationType",
"NotificationPreference",
"PasswordResetToken",
"ApiToken",
"Company",
+57 -2
View File
@@ -1,11 +1,20 @@
"""Notification model."""
"""Notification models."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -33,3 +42,49 @@ class Notification(Base, TenantMixin):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class NotificationType(Base):
"""Registered notification type from a plugin."""
__tablename__ = "notification_types"
__table_args__ = (Index("ix_notification_types_key", "type_key"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
type_key: Mapped[str] = mapped_column(String(20), nullable=False, unique=True)
plugin_name: Mapped[str] = mapped_column(String(100), nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False, default="general")
label: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_enabled_by_default: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class NotificationPreference(Base, TenantMixin):
"""User preference for a notification type (opt-in/opt-out)."""
__tablename__ = "notification_preferences"
__table_args__ = (
UniqueConstraint("user_id", "type_key", name="uq_notif_pref_user_type"),
Index("ix_notif_prefs_user", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
type_key: Mapped[str] = mapped_column(String(20), nullable=False)
is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+10
View File
@@ -72,6 +72,16 @@ class BasePlugin(ABC):
"""
return None
# ─── Notification Types ───
def get_notification_types(self) -> list[dict[str, Any]]:
"""Return notification types this plugin registers.
Override in subclasses to register notification types.
Each dict must have: type_key, category, label, description, is_enabled_by_default
"""
return []
# ─── Route Registration ───
def get_routes(self) -> list[APIRouter]:
+16
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import logging
from typing import Any
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
@@ -59,6 +60,21 @@ class MailPlugin(BasePlugin):
self._auto_sync_task = asyncio.create_task(_auto_sync_loop())
logger.info("Mail plugin: auto-sync background task started")
def get_notification_types(self) -> list[dict[str, Any]]:
"""Return the notification types this mail plugin registers."""
return [
{"type_key": "mail_new", "category": "mail", "label": "Neue E-Mail empfangen", "description": "Benachrichtigung bei neuen E-Mails", "is_enabled_by_default": True},
{"type_key": "mail_error", "category": "mail", "label": "IMAP-Verbindungsfehler", "description": "Fehler bei der Verbindung zum Mailserver", "is_enabled_by_default": True},
{"type_key": "mail_auth", "category": "mail", "label": "IMAP-Login-Fehler", "description": "Anmeldung am Mailserver fehlgeschlagen", "is_enabled_by_default": True},
{"type_key": "mail_quota", "category": "mail", "label": "Postfach fast voll", "description": "Warnung bei hohem Postfach-Füllstand", "is_enabled_by_default": True},
{"type_key": "mail_sync_error", "category": "mail", "label": "Sync-Fehler", "description": "Synchronisierung fehlgeschlagen", "is_enabled_by_default": True},
{"type_key": "mail_sent", "category": "mail", "label": "E-Mail gesendet", "description": "Bestätigung beim Senden einer E-Mail", "is_enabled_by_default": False},
{"type_key": "mail_send_error", "category": "mail", "label": "SMTP-Sendefehler", "description": "E-Mail konnte nicht gesendet werden", "is_enabled_by_default": True},
{"type_key": "mail_draft", "category": "mail", "label": "Entwurf gespeichert", "description": "Bestätigung beim Speichern eines Entwurfs", "is_enabled_by_default": False},
{"type_key": "mail_account", "category": "mail", "label": "Account deaktiviert", "description": "Warnung bei deaktiviertem Mail-Account", "is_enabled_by_default": True},
{"type_key": "mail_folder", "category": "mail", "label": "Ordner erstellt/gelöscht", "description": "Bestätigung bei Ordner-Operationen", "is_enabled_by_default": False},
]
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
+70
View File
@@ -117,6 +117,73 @@ class PluginRegistry:
"""List all discovered plugin names."""
return list(self._plugins.keys())
# ── Notification Type Sync ──
async def sync_notification_types(self, db: AsyncSession) -> None:
"""Sync notification types from all active plugins to DB.
For each active plugin, calls get_notification_types() and upserts
the returned types into the notification_types table. Removes types
belonging to plugins that are no longer active.
"""
from app.models.notification import NotificationType
# Collect all notification types from active plugins
active_types: dict[str, dict[str, Any]] = {} # type_key -> type_data
for name, plugin in self._plugins.items():
record = await self._get_plugin_record(db, name)
if record is None or not record.active:
continue
for nt in plugin.get_notification_types():
key = nt.get("type_key", "")
if not key:
continue
active_types[key] = {
"type_key": key,
"plugin_name": name,
"category": nt.get("category", "general"),
"label": nt.get("label", key),
"description": nt.get("description"),
"is_enabled_by_default": nt.get("is_enabled_by_default", True),
}
# Fetch existing types from DB
result = await db.execute(select(NotificationType))
existing = {row.type_key: row for row in result.scalars().all()}
# Upsert active types
for key, data in active_types.items():
if key in existing:
row = existing[key]
row.plugin_name = data["plugin_name"]
row.category = data["category"]
row.label = data["label"]
row.description = data["description"]
row.is_enabled_by_default = data["is_enabled_by_default"]
else:
new_row = NotificationType(
type_key=key,
plugin_name=data["plugin_name"],
category=data["category"],
label=data["label"],
description=data["description"],
is_enabled_by_default=data["is_enabled_by_default"],
)
db.add(new_row)
# Remove types from plugins that are no longer active
active_keys = set(active_types.keys())
for key, row in existing.items():
if key not in active_keys:
# Check if the plugin is still active
plugin_name = row.plugin_name
plugin_record = await self._get_plugin_record(db, plugin_name)
if plugin_record is None or not plugin_record.active:
await db.delete(row)
await db.flush()
logger.info("Synced %d notification types from active plugins", len(active_types))
# ── DB Status Sync ──
async def sync_db_status(self, db: AsyncSession) -> None:
@@ -418,6 +485,9 @@ class PluginRegistry:
mounted_routes.append(r)
self._mounted_routes[name] = mounted_routes
# Sync notification types from this plugin
await self.sync_notification_types(db)
# Update DB record
record.status = "active"
record.active = True
+126
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
@@ -14,6 +15,11 @@ from app.core.notifications import (
mark_notification_read,
)
from app.deps import get_current_user
from app.models.notification import (
NotificationPreference,
NotificationType,
)
from app.schemas.common import NotificationPreferenceUpdate
router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"])
@@ -71,3 +77,123 @@ async def unread_count_endpoint(
user_id = uuid.UUID(current_user["user_id"])
count = await get_unread_count(db, tenant_id, user_id)
return {"count": count}
@router.get("/types")
async def list_notification_types(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all registered notification types with the user's preference."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Fetch all registered types
types_result = await db.execute(select(NotificationType).order_by(NotificationType.plugin_name, NotificationType.category))
types = types_result.scalars().all()
# Fetch user's preferences
prefs_result = await db.execute(
select(NotificationPreference).where(
and_(
NotificationPreference.user_id == user_id,
NotificationPreference.tenant_id == tenant_id,
)
)
)
prefs = {p.type_key: p.is_enabled for p in prefs_result.scalars().all()}
items = []
for t in types:
if t.type_key in prefs:
is_enabled = prefs[t.type_key]
else:
is_enabled = t.is_enabled_by_default
items.append({
"type_key": t.type_key,
"plugin_name": t.plugin_name,
"category": t.category,
"label": t.label,
"description": t.description,
"is_enabled_by_default": t.is_enabled_by_default,
"is_enabled": is_enabled,
})
return {"items": items}
@router.get("/preferences")
async def list_preferences(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List user's notification preferences."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
result = await db.execute(
select(NotificationPreference).where(
and_(
NotificationPreference.user_id == user_id,
NotificationPreference.tenant_id == tenant_id,
)
)
)
prefs = result.scalars().all()
return {
"items": [
{"type_key": p.type_key, "is_enabled": p.is_enabled}
for p in prefs
]
}
@router.patch("/preferences/{type_key}")
async def update_preference(
type_key: str,
data: NotificationPreferenceUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update user's preference for a notification type (upsert)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Check that the type exists
type_result = await db.execute(
select(NotificationType).where(NotificationType.type_key == type_key)
)
type_row = type_result.scalar_one_or_none()
if type_row is None:
raise HTTPException(
404,
detail={"detail": f"Notification type '{type_key}' not found", "code": "not_found"},
)
# Upsert preference
result = await db.execute(
select(NotificationPreference).where(
and_(
NotificationPreference.user_id == user_id,
NotificationPreference.type_key == type_key,
NotificationPreference.tenant_id == tenant_id,
)
)
)
pref = result.scalar_one_or_none()
if pref is None:
pref = NotificationPreference(
tenant_id=tenant_id,
user_id=user_id,
type_key=type_key,
is_enabled=data.is_enabled,
)
db.add(pref)
else:
pref.is_enabled = data.is_enabled
await db.flush()
return {"type_key": type_key, "is_enabled": pref.is_enabled}
+19
View File
@@ -34,3 +34,22 @@ class NotificationListResponse(BaseModel):
class UnreadCountResponse(BaseModel):
count: int
class NotificationPreferenceUpdate(BaseModel):
is_enabled: bool
class NotificationTypeResponse(BaseModel):
type_key: str
plugin_name: str
category: str
label: str
description: str | None = None
is_enabled_by_default: bool
is_enabled: bool
class NotificationPreferenceResponse(BaseModel):
type_key: str
is_enabled: bool