From b0e987f790b8f50b768c88891effad177405aa0e Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 15 Jul 2026 21:00:32 +0200 Subject: [PATCH] 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) --- .../versions/0017_notification_preferences.py | 93 +++++++++++ app/core/notifications.py | 41 ++++- app/models/__init__.py | 4 +- app/models/notification.py | 59 ++++++- app/plugins/base.py | 10 ++ app/plugins/builtins/mail/plugin.py | 16 ++ app/plugins/registry.py | 70 ++++++++ app/routes/notifications.py | 126 ++++++++++++++ app/schemas/common.py | 19 +++ frontend/src/api/hooks.ts | 36 ++++ frontend/src/i18n/locales/de.json | 14 +- frontend/src/i18n/locales/en.json | 14 +- frontend/src/pages/Settings.tsx | 1 + frontend/src/pages/SettingsNotifications.tsx | 158 ++++++++++++++++++ frontend/src/routes/index.tsx | 2 + 15 files changed, 652 insertions(+), 11 deletions(-) create mode 100644 alembic/versions/0017_notification_preferences.py create mode 100644 frontend/src/pages/SettingsNotifications.tsx diff --git a/alembic/versions/0017_notification_preferences.py b/alembic/versions/0017_notification_preferences.py new file mode 100644 index 0000000..9fee2b5 --- /dev/null +++ b/alembic/versions/0017_notification_preferences.py @@ -0,0 +1,93 @@ +"""Add notification_types and notification_preferences tables. + +Creates tables for the notification preference system: +- notification_types: registered notification types from plugins +- notification_preferences: per-user opt-in/opt-out for notification types + +Also seeds the 10 mail plugin notification types. + +Revision ID: 0017_notification_preferences +Revises: 0016_plugin_is_core +Create Date: 2026-07-15 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "0017_notification_preferences" +down_revision: Union[str, None] = "0016_plugin_is_core" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +MAIL_NOTIFICATION_TYPES = [ + {"type_key": "mail_new", "label": "Neue E-Mail empfangen", "description": "Benachrichtigung bei neuen E-Mails", "is_enabled_by_default": True}, + {"type_key": "mail_error", "label": "IMAP-Verbindungsfehler", "description": "Fehler bei der Verbindung zum Mailserver", "is_enabled_by_default": True}, + {"type_key": "mail_auth", "label": "IMAP-Login-Fehler", "description": "Anmeldung am Mailserver fehlgeschlagen", "is_enabled_by_default": True}, + {"type_key": "mail_quota", "label": "Postfach fast voll", "description": "Warnung bei hohem Postfach-Füllstand", "is_enabled_by_default": True}, + {"type_key": "mail_sync_error", "label": "Sync-Fehler", "description": "Synchronisierung fehlgeschlagen", "is_enabled_by_default": True}, + {"type_key": "mail_sent", "label": "E-Mail gesendet", "description": "Bestätigung beim Senden einer E-Mail", "is_enabled_by_default": False}, + {"type_key": "mail_send_error", "label": "SMTP-Sendefehler", "description": "E-Mail konnte nicht gesendet werden", "is_enabled_by_default": True}, + {"type_key": "mail_draft", "label": "Entwurf gespeichert", "description": "Bestätigung beim Speichern eines Entwurfs", "is_enabled_by_default": False}, + {"type_key": "mail_account", "label": "Account deaktiviert", "description": "Warnung bei deaktiviertem Mail-Account", "is_enabled_by_default": True}, + {"type_key": "mail_folder", "label": "Ordner erstellt/gelöscht", "description": "Bestätigung bei Ordner-Operationen", "is_enabled_by_default": False}, +] + + +def upgrade() -> None: + # notification_types table + op.create_table( + "notification_types", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("type_key", sa.String(20), nullable=False, unique=True), + sa.Column("plugin_name", sa.String(100), nullable=False), + sa.Column("category", sa.String(50), nullable=False, server_default="general"), + sa.Column("label", sa.String(200), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("is_enabled_by_default", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_notification_types_key", "notification_types", ["type_key"]) + + # notification_preferences table + op.create_table( + "notification_preferences", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("type_key", sa.String(20), nullable=False), + sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("user_id", "type_key", name="uq_notif_pref_user_type"), + ) + op.create_index("ix_notif_prefs_user", "notification_preferences", ["user_id"]) + op.create_index("ix_notif_prefs_tenant", "notification_preferences", ["tenant_id"]) + + # Seed mail plugin notification types + for nt in MAIL_NOTIFICATION_TYPES: + op.execute( + sa.text( + "INSERT INTO notification_types (id, type_key, plugin_name, category, label, description, is_enabled_by_default) " + "VALUES (gen_random_uuid(), :type_key, 'mail', 'mail', :label, :description, :is_enabled_by_default)" + ).bindparams( + type_key=nt["type_key"], + label=nt["label"], + description=nt["description"], + is_enabled_by_default=nt["is_enabled_by_default"], + ) + ) + + +def downgrade() -> None: + op.drop_index("ix_notif_prefs_tenant", table_name="notification_preferences") + op.drop_index("ix_notif_prefs_user", table_name="notification_preferences") + op.drop_table("notification_preferences") + op.drop_index("ix_notification_types_key", table_name="notification_types") + op.drop_table("notification_types") diff --git a/app/core/notifications.py b/app/core/notifications.py index a6b8b0f..8dbfad4 100644 --- a/app/core/notifications.py +++ b/app/core/notifications.py @@ -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, diff --git a/app/models/__init__.py b/app/models/__init__.py index 8d1455f..f9119ae 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", diff --git a/app/models/notification.py b/app/models/notification.py index 98c144f..f70f795 100644 --- a/app/models/notification.py +++ b/app/models/notification.py @@ -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() + ) diff --git a/app/plugins/base.py b/app/plugins/base.py index 990c568..3883357 100644 --- a/app/plugins/base.py +++ b/app/plugins/base.py @@ -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]: diff --git a/app/plugins/builtins/mail/plugin.py b/app/plugins/builtins/mail/plugin.py index 0c631a8..2c2fa92 100644 --- a/app/plugins/builtins/mail/plugin.py +++ b/app/plugins/builtins/mail/plugin.py @@ -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: diff --git a/app/plugins/registry.py b/app/plugins/registry.py index 0f7c7c0..879ae37 100644 --- a/app/plugins/registry.py +++ b/app/plugins/registry.py @@ -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 diff --git a/app/routes/notifications.py b/app/routes/notifications.py index 85cb368..4d2120b 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -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} diff --git a/app/schemas/common.py b/app/schemas/common.py index 4f7195b..7af25ef 100644 --- a/app/schemas/common.py +++ b/app/schemas/common.py @@ -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 diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index d413515..d37e541 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -459,6 +459,42 @@ export function useDeleteNotification() { }); } +export interface NotificationTypeItem { + type_key: string; + plugin_name: string; + category: string; + label: string; + description: string | null; + is_enabled_by_default: boolean; + is_enabled: boolean; +} + +export function useNotificationTypes() { + return useQuery({ + queryKey: ['notification-types'], + queryFn: () => apiGet<{ items: NotificationTypeItem[] }>('/notifications/types'), + }); +} + +export function useNotificationPreferences() { + return useQuery({ + queryKey: ['notification-preferences'], + queryFn: () => apiGet<{ items: { type_key: string; is_enabled: boolean }[] }>('/notifications/preferences'), + }); +} + +export function useUpdateNotificationPreference() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ typeKey, isEnabled }: { typeKey: string; isEnabled: boolean }) => + apiPatch(`/notifications/preferences/${typeKey}`, { is_enabled: isEnabled }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notification-preferences'] }); + queryClient.invalidateQueries({ queryKey: ['notification-types'] }); + }, + }); +} + export function usePlugins() { return useQuery({ queryKey: ['plugins'], diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 2530f53..8d4f5ef 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -407,7 +407,7 @@ "testConnection": "Verbindung testen", "connectionSuccess": "Verbindung erfolgreich.", "syncNow": "Jetzt synchronisieren", - "syncSuccess": "{{count}} E-Mails synchronisiert.", + "syncSuccess": "Synchronisierung erfolgreich", "selectAccount": "Account auswählen", "selectAccountFirst": "Bitte wählen Sie zuerst einen Account aus.", "folders": "Ordner", @@ -560,9 +560,19 @@ "sortSubject": "Betreff", "sortAsc": "Aufsteigend", "sortDesc": "Absteigend", - "syncSuccess": "Synchronisierung erfolgreich", "syncFailed": "Synchronisierung fehlgeschlagen", "syncing": "Synchronisiere...", "autoSyncEnabled": "Auto-Sync aktiv" + }, + "notifications": { + "title": "Benachrichtigungseinstellungen", + "description": "Wählen Sie welche Benachrichtigungen Sie erhalten möchten", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "saved": "Einstellung gespeichert", + "category": { + "mail": "E-Mail", + "general": "Allgemein" + } } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index ce2a18e..bc3a391 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -407,7 +407,7 @@ "testConnection": "Test Connection", "connectionSuccess": "Connection successful.", "syncNow": "Sync Now", - "syncSuccess": "{{count}} emails synced.", + "syncSuccess": "Sync successful", "selectAccount": "Select Account", "selectAccountFirst": "Please select an account first.", "folders": "Folders", @@ -560,9 +560,19 @@ "sortSubject": "Subject", "sortAsc": "Ascending", "sortDesc": "Descending", - "syncSuccess": "Sync successful", "syncFailed": "Sync failed", "syncing": "Syncing...", "autoSyncEnabled": "Auto-sync enabled" + }, + "notifications": { + "title": "Notification Settings", + "description": "Choose which notifications you want to receive", + "enabled": "Enabled", + "disabled": "Disabled", + "saved": "Preference saved", + "category": { + "mail": "Mail", + "general": "General" + } } } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 279cdb9..697856a 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -15,6 +15,7 @@ export function SettingsPage() { { to: '/settings/taxes', label: t('taxes.title'), icon: '\ud83d\udccb' }, { to: '/settings/sequences', label: t('sequences.title'), icon: '\ud83d\udd22' }, { to: '/settings/mail', label: t('mail.settings'), icon: '\ud83d\udce7' }, + { to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' }, ]; return ( diff --git a/frontend/src/pages/SettingsNotifications.tsx b/frontend/src/pages/SettingsNotifications.tsx new file mode 100644 index 0000000..5cf011b --- /dev/null +++ b/frontend/src/pages/SettingsNotifications.tsx @@ -0,0 +1,158 @@ +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useToast } from '@/components/ui/Toast'; +import { + useNotificationTypes, + useUpdateNotificationPreference, +} from '@/api/hooks'; + +interface NotificationTypeItem { + type_key: string; + plugin_name: string; + category: string; + label: string; + description: string | null; + is_enabled_by_default: boolean; + is_enabled: boolean; +} + +export function SettingsNotificationsPage() { + const { t } = useTranslation(); + const toast = useToast(); + const { data, isLoading, error } = useNotificationTypes(); + const updateMutation = useUpdateNotificationPreference(); + + const items: NotificationTypeItem[] = data?.items ?? []; + + // Group by plugin_name + const grouped = useMemo(() => { + const map = new Map(); + for (const item of items) { + const key = item.plugin_name; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(item); + } + return Array.from(map.entries()); + }, [items]); + + const handleToggle = (typeKey: string, isEnabled: boolean) => { + updateMutation.mutate( + { typeKey, isEnabled }, + { + onSuccess: () => { + toast.success(t('notifications.saved')); + }, + onError: (err: any) => { + toast.error(err.message || t('common.error')); + }, + } + ); + }; + + if (isLoading) { + return ( +
+

{t('common.loading')}

+
+ ); + } + + if (error) { + return ( +
+

{t('common.error')}

+
+ ); + } + + return ( +
+
+

+ {t('notifications.title')} +

+

+ {t('notifications.description')} +

+
+ + {grouped.length === 0 && ( +
+

{t('common.noResults')}

+
+ )} + +
+ {grouped.map(([pluginName, pluginItems]) => ( +
+
+

+ {pluginName} +

+
+
+ {pluginItems.map((item) => ( +
+
+

+ {item.label} +

+ {item.description && ( +

+ {item.description} +

+ )} +
+ handleToggle(item.type_key, checked)} + disabled={updateMutation.isPending} + ariaLabel={item.label} + /> +
+ ))} +
+
+ ))} +
+
+ ); +} + +function ToggleSwitch({ + checked, + onChange, + disabled, + ariaLabel, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + ariaLabel: string; +}) { + return ( + + ); +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index eff3251..6e5c2a4 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -29,6 +29,7 @@ import { DmsPage } from '@/pages/Dms'; import { DmsTrashPage } from '@/pages/DmsTrash'; import { MailPage } from '@/pages/Mail'; import { MailSettingsPage } from '@/pages/MailSettings'; +import { SettingsNotificationsPage } from '@/pages/SettingsNotifications'; const router = createBrowserRouter([ { @@ -81,6 +82,7 @@ const router = createBrowserRouter([ { path: 'taxes', element: }, { path: 'sequences', element: }, { path: 'mail', element: }, + { path: 'notifications', element: }, ], }, ],