From ac20c03f01108c956e603da23079877d484c2b41 Mon Sep 17 00:00:00 2001 From: m Date: Mon, 30 Mar 2026 11:03:17 +0200 Subject: [PATCH] feat: email notifications + deadline reminder system Database: - notification_preferences table (user_id, tenant_id, reminder days, email/digest toggles) - notifications table (type, entity link, read/sent tracking, dedup index) Backend: - NotificationService with background goroutine checking reminders hourly - CheckDeadlineReminders: finds deadlines due in N days per user prefs, creates notifications - Overdue deadline detection and notification - Daily digest at 8am: compiles pending notifications into one email - SendEmail via `m mail send` CLI command - Deduplication: same notification type + entity + day = skip - API: GET/PATCH notifications, unread count, mark read/all-read - API: GET/PUT notification-preferences with upsert Frontend: - NotificationBell in header with unread count badge (polls every 30s) - Dropdown panel with notification list, type-colored dots, time-ago, entity links - Mark individual/all as read - NotificationSettings in Einstellungen page: reminder day toggles, email toggle, digest toggle --- backend/cmd/server/main.go | 7 +- backend/internal/handlers/notifications.go | 171 ++++++ backend/internal/integration_test.go | 2 +- backend/internal/models/notification.go | 32 ++ backend/internal/router/router.go | 18 +- .../internal/services/notification_service.go | 501 ++++++++++++++++++ frontend/src/app/(app)/einstellungen/page.tsx | 16 +- frontend/src/components/layout/Header.tsx | 2 + .../notifications/NotificationBell.tsx | 205 +++++++ .../settings/NotificationSettings.tsx | 167 ++++++ frontend/src/lib/types.ts | 31 ++ 11 files changed, 1148 insertions(+), 4 deletions(-) create mode 100644 backend/internal/handlers/notifications.go create mode 100644 backend/internal/models/notification.go create mode 100644 backend/internal/services/notification_service.go create mode 100644 frontend/src/components/notifications/NotificationBell.tsx create mode 100644 frontend/src/components/settings/NotificationSettings.tsx diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 84fe649..44e5f34 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -36,7 +36,12 @@ func main() { calDAVSvc.Start() defer calDAVSvc.Stop() - handler := router.New(database, authMW, cfg, calDAVSvc) + // Start notification reminder service + notifSvc := services.NewNotificationService(database) + notifSvc.Start() + defer notifSvc.Stop() + + handler := router.New(database, authMW, cfg, calDAVSvc, notifSvc) slog.Info("starting KanzlAI API server", "port", cfg.Port) if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil { diff --git a/backend/internal/handlers/notifications.go b/backend/internal/handlers/notifications.go new file mode 100644 index 0000000..469b722 --- /dev/null +++ b/backend/internal/handlers/notifications.go @@ -0,0 +1,171 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" +) + +// NotificationHandler handles notification API endpoints. +type NotificationHandler struct { + svc *services.NotificationService + db *sqlx.DB +} + +// NewNotificationHandler creates a new notification handler. +func NewNotificationHandler(svc *services.NotificationService, db *sqlx.DB) *NotificationHandler { + return &NotificationHandler{svc: svc, db: db} +} + +// List returns paginated notifications for the authenticated user. +func (h *NotificationHandler) List(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + userID, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + + notifications, total, err := h.svc.ListForUser(r.Context(), tenantID, userID, limit, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list notifications") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "data": notifications, + "total": total, + }) +} + +// UnreadCount returns the count of unread notifications. +func (h *NotificationHandler) UnreadCount(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + userID, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + count, err := h.svc.UnreadCount(r.Context(), tenantID, userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to count notifications") + return + } + + writeJSON(w, http.StatusOK, map[string]int{"unread_count": count}) +} + +// MarkRead marks a single notification as read. +func (h *NotificationHandler) MarkRead(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + userID, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + notifID, err := parsePathUUID(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid notification ID") + return + } + + if err := h.svc.MarkRead(r.Context(), tenantID, userID, notifID); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// MarkAllRead marks all notifications as read. +func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + userID, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + if err := h.svc.MarkAllRead(r.Context(), tenantID, userID); err != nil { + writeError(w, http.StatusInternalServerError, "failed to mark all read") + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// GetPreferences returns notification preferences for the authenticated user. +func (h *NotificationHandler) GetPreferences(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + userID, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + pref, err := h.svc.GetPreferences(r.Context(), tenantID, userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to get preferences") + return + } + + writeJSON(w, http.StatusOK, pref) +} + +// UpdatePreferences updates notification preferences for the authenticated user. +func (h *NotificationHandler) UpdatePreferences(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + userID, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + var input services.UpdatePreferencesInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + pref, err := h.svc.UpdatePreferences(r.Context(), tenantID, userID, input) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update preferences") + return + } + + writeJSON(w, http.StatusOK, pref) +} diff --git a/backend/internal/integration_test.go b/backend/internal/integration_test.go index fa5f4b0..c6ac3ce 100644 --- a/backend/internal/integration_test.go +++ b/backend/internal/integration_test.go @@ -46,7 +46,7 @@ func testServer(t *testing.T) (http.Handler, func()) { } authMW := auth.NewMiddleware(jwtSecret, database) - handler := router.New(database, authMW, cfg, nil) + handler := router.New(database, authMW, cfg, nil, nil) return handler, func() { database.Close() } } diff --git a/backend/internal/models/notification.go b/backend/internal/models/notification.go new file mode 100644 index 0000000..d5f529f --- /dev/null +++ b/backend/internal/models/notification.go @@ -0,0 +1,32 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "github.com/lib/pq" +) + +type Notification struct { + ID uuid.UUID `db:"id" json:"id"` + TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + Type string `db:"type" json:"type"` + EntityType *string `db:"entity_type" json:"entity_type,omitempty"` + EntityID *uuid.UUID `db:"entity_id" json:"entity_id,omitempty"` + Title string `db:"title" json:"title"` + Body *string `db:"body" json:"body,omitempty"` + SentAt *time.Time `db:"sent_at" json:"sent_at,omitempty"` + ReadAt *time.Time `db:"read_at" json:"read_at,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +type NotificationPreferences struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"` + DeadlineReminderDays pq.Int64Array `db:"deadline_reminder_days" json:"deadline_reminder_days"` + EmailEnabled bool `db:"email_enabled" json:"email_enabled"` + DailyDigest bool `db:"daily_digest" json:"daily_digest"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 3c6f8a5..57f30d4 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -15,7 +15,7 @@ import ( "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" ) -func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService) http.Handler { +func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService, notifSvc *services.NotificationService) http.Handler { mux := http.NewServeMux() // Services @@ -43,6 +43,12 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se noteSvc := services.NewNoteService(db) dashboardSvc := services.NewDashboardService(db) + // Notification handler (optional — nil in tests) + var notifH *handlers.NotificationHandler + if notifSvc != nil { + notifH = handlers.NewNotificationHandler(notifSvc, db) + } + // Handlers tenantH := handlers.NewTenantHandler(tenantSvc) caseH := handlers.NewCaseHandler(caseSvc) @@ -137,6 +143,16 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se scoped.HandleFunc("POST /api/ai/summarize-case", aiLimiter.LimitFunc(aiH.SummarizeCase)) } + // Notifications + if notifH != nil { + scoped.HandleFunc("GET /api/notifications", notifH.List) + scoped.HandleFunc("GET /api/notifications/unread-count", notifH.UnreadCount) + scoped.HandleFunc("PATCH /api/notifications/{id}/read", notifH.MarkRead) + scoped.HandleFunc("PATCH /api/notifications/read-all", notifH.MarkAllRead) + scoped.HandleFunc("GET /api/notification-preferences", notifH.GetPreferences) + scoped.HandleFunc("PUT /api/notification-preferences", notifH.UpdatePreferences) + } + // CalDAV sync endpoints if calDAVSvc != nil { calDAVH := handlers.NewCalDAVHandler(calDAVSvc) diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go new file mode 100644 index 0000000..0840f15 --- /dev/null +++ b/backend/internal/services/notification_service.go @@ -0,0 +1,501 @@ +package services + +import ( + "context" + "fmt" + "log/slog" + "os/exec" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/lib/pq" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" +) + +// NotificationService handles notification CRUD, deadline reminders, and email sending. +type NotificationService struct { + db *sqlx.DB + stopCh chan struct{} + wg sync.WaitGroup +} + +// NewNotificationService creates a new notification service. +func NewNotificationService(db *sqlx.DB) *NotificationService { + return &NotificationService{ + db: db, + stopCh: make(chan struct{}), + } +} + +// Start launches the background reminder checker (every hour) and daily digest (8am). +func (s *NotificationService) Start() { + s.wg.Add(1) + go s.backgroundLoop() +} + +// Stop gracefully shuts down background workers. +func (s *NotificationService) Stop() { + close(s.stopCh) + s.wg.Wait() +} + +func (s *NotificationService) backgroundLoop() { + defer s.wg.Done() + + // Check reminders on startup + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + s.CheckDeadlineReminders(ctx) + cancel() + + reminderTicker := time.NewTicker(1 * time.Hour) + defer reminderTicker.Stop() + + // Digest ticker: check every 15 minutes, send at 8am + digestTicker := time.NewTicker(15 * time.Minute) + defer digestTicker.Stop() + + var lastDigestDate string + + for { + select { + case <-s.stopCh: + return + case <-reminderTicker.C: + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + s.CheckDeadlineReminders(ctx) + cancel() + case now := <-digestTicker.C: + today := now.Format("2006-01-02") + hour := now.Hour() + if hour >= 8 && lastDigestDate != today { + lastDigestDate = today + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + s.SendDailyDigests(ctx) + cancel() + } + } + } +} + +// CheckDeadlineReminders finds deadlines due in N days matching user preferences and creates notifications. +func (s *NotificationService) CheckDeadlineReminders(ctx context.Context) { + slog.Info("checking deadline reminders") + + // Get all user preferences with email enabled + var prefs []models.NotificationPreferences + err := s.db.SelectContext(ctx, &prefs, + `SELECT user_id, tenant_id, deadline_reminder_days, email_enabled, daily_digest, created_at, updated_at + FROM notification_preferences`) + if err != nil { + slog.Error("failed to load notification preferences", "error", err) + return + } + + if len(prefs) == 0 { + return + } + + // Collect all unique reminder day values across all users + daySet := make(map[int64]bool) + for _, p := range prefs { + for _, d := range p.DeadlineReminderDays { + daySet[d] = true + } + } + if len(daySet) == 0 { + return + } + + // Build array of target dates + today := time.Now().Truncate(24 * time.Hour) + var targetDates []string + dayToDate := make(map[string]int64) + for d := range daySet { + target := today.AddDate(0, 0, int(d)) + dateStr := target.Format("2006-01-02") + targetDates = append(targetDates, dateStr) + dayToDate[dateStr] = d + } + + // Also check overdue deadlines + todayStr := today.Format("2006-01-02") + + // Find pending deadlines matching target dates + type deadlineRow struct { + models.Deadline + CaseTitle string `db:"case_title"` + CaseNumber string `db:"case_number"` + } + + // Reminder deadlines (due in N days) + var reminderDeadlines []deadlineRow + query, args, err := sqlx.In( + `SELECT d.*, c.title AS case_title, c.case_number + FROM deadlines d + JOIN cases c ON c.id = d.case_id + WHERE d.status = 'pending' AND d.due_date IN (?)`, + targetDates) + if err == nil { + query = s.db.Rebind(query) + err = s.db.SelectContext(ctx, &reminderDeadlines, query, args...) + } + if err != nil { + slog.Error("failed to query reminder deadlines", "error", err) + } + + // Overdue deadlines + var overdueDeadlines []deadlineRow + err = s.db.SelectContext(ctx, &overdueDeadlines, + `SELECT d.*, c.title AS case_title, c.case_number + FROM deadlines d + JOIN cases c ON c.id = d.case_id + WHERE d.status = 'pending' AND d.due_date < $1`, todayStr) + if err != nil { + slog.Error("failed to query overdue deadlines", "error", err) + } + + // Create notifications for each user based on their tenant and preferences + for _, pref := range prefs { + // Reminder notifications + for _, dl := range reminderDeadlines { + if dl.TenantID != pref.TenantID { + continue + } + daysUntil := dayToDate[dl.DueDate] + // Check if this user cares about this many days + if !containsDay(pref.DeadlineReminderDays, daysUntil) { + continue + } + + title := fmt.Sprintf("Frist in %d Tagen: %s", daysUntil, dl.Title) + body := fmt.Sprintf("Akte %s — %s\nFällig am %s", dl.CaseNumber, dl.CaseTitle, dl.DueDate) + entityType := "deadline" + + s.CreateNotification(ctx, CreateNotificationInput{ + TenantID: pref.TenantID, + UserID: pref.UserID, + Type: "deadline_reminder", + EntityType: &entityType, + EntityID: &dl.ID, + Title: title, + Body: &body, + SendEmail: pref.EmailEnabled && !pref.DailyDigest, + }) + } + + // Overdue notifications + for _, dl := range overdueDeadlines { + if dl.TenantID != pref.TenantID { + continue + } + + title := fmt.Sprintf("Frist überfällig: %s", dl.Title) + body := fmt.Sprintf("Akte %s — %s\nFällig seit %s", dl.CaseNumber, dl.CaseTitle, dl.DueDate) + entityType := "deadline" + + s.CreateNotification(ctx, CreateNotificationInput{ + TenantID: pref.TenantID, + UserID: pref.UserID, + Type: "deadline_overdue", + EntityType: &entityType, + EntityID: &dl.ID, + Title: title, + Body: &body, + SendEmail: pref.EmailEnabled && !pref.DailyDigest, + }) + } + } +} + +// SendDailyDigests compiles pending notifications into one email per user. +func (s *NotificationService) SendDailyDigests(ctx context.Context) { + slog.Info("sending daily digests") + + // Find users with daily_digest enabled + var prefs []models.NotificationPreferences + err := s.db.SelectContext(ctx, &prefs, + `SELECT user_id, tenant_id, deadline_reminder_days, email_enabled, daily_digest, created_at, updated_at + FROM notification_preferences + WHERE daily_digest = true AND email_enabled = true`) + if err != nil { + slog.Error("failed to load digest preferences", "error", err) + return + } + + for _, pref := range prefs { + // Get unsent notifications for this user from the last 24 hours + var notifications []models.Notification + err := s.db.SelectContext(ctx, ¬ifications, + `SELECT id, tenant_id, user_id, type, entity_type, entity_id, title, body, sent_at, read_at, created_at + FROM notifications + WHERE user_id = $1 AND tenant_id = $2 AND sent_at IS NULL + AND created_at > now() - interval '24 hours' + ORDER BY created_at DESC`, + pref.UserID, pref.TenantID) + if err != nil { + slog.Error("failed to load unsent notifications", "error", err, "user_id", pref.UserID) + continue + } + + if len(notifications) == 0 { + continue + } + + // Get user email + email := s.getUserEmail(ctx, pref.UserID) + if email == "" { + continue + } + + // Build digest + var lines []string + lines = append(lines, fmt.Sprintf("Guten Morgen! Hier ist Ihre Tagesübersicht mit %d Benachrichtigungen:\n", len(notifications))) + for _, n := range notifications { + body := "" + if n.Body != nil { + body = " — " + *n.Body + } + lines = append(lines, fmt.Sprintf("• %s%s", n.Title, body)) + } + lines = append(lines, "\n---\nKanzlAI Kanzleimanagement") + + subject := fmt.Sprintf("KanzlAI Tagesübersicht — %d Benachrichtigungen", len(notifications)) + bodyText := strings.Join(lines, "\n") + + if err := SendEmail(email, subject, bodyText); err != nil { + slog.Error("failed to send digest email", "error", err, "user_id", pref.UserID) + continue + } + + // Mark all as sent + ids := make([]uuid.UUID, len(notifications)) + for i, n := range notifications { + ids[i] = n.ID + } + query, args, err := sqlx.In( + `UPDATE notifications SET sent_at = now() WHERE id IN (?)`, ids) + if err == nil { + query = s.db.Rebind(query) + _, err = s.db.ExecContext(ctx, query, args...) + } + if err != nil { + slog.Error("failed to mark digest notifications sent", "error", err) + } + + slog.Info("sent daily digest", "user_id", pref.UserID, "count", len(notifications)) + } +} + +// CreateNotificationInput holds the data for creating a notification. +type CreateNotificationInput struct { + TenantID uuid.UUID + UserID uuid.UUID + Type string + EntityType *string + EntityID *uuid.UUID + Title string + Body *string + SendEmail bool +} + +// CreateNotification stores a notification in the DB and optionally sends an email. +func (s *NotificationService) CreateNotification(ctx context.Context, input CreateNotificationInput) (*models.Notification, error) { + // Dedup: check if we already sent this notification today + if input.EntityID != nil { + var count int + err := s.db.GetContext(ctx, &count, + `SELECT COUNT(*) FROM notifications + WHERE user_id = $1 AND entity_id = $2 AND type = $3 + AND created_at::date = CURRENT_DATE`, + input.UserID, input.EntityID, input.Type) + if err == nil && count > 0 { + return nil, nil // Already notified today + } + } + + var n models.Notification + err := s.db.QueryRowxContext(ctx, + `INSERT INTO notifications (tenant_id, user_id, type, entity_type, entity_id, title, body) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, tenant_id, user_id, type, entity_type, entity_id, title, body, sent_at, read_at, created_at`, + input.TenantID, input.UserID, input.Type, input.EntityType, input.EntityID, + input.Title, input.Body).StructScan(&n) + if err != nil { + slog.Error("failed to create notification", "error", err) + return nil, fmt.Errorf("create notification: %w", err) + } + + // Send email immediately if requested (non-digest users) + if input.SendEmail { + email := s.getUserEmail(ctx, input.UserID) + if email != "" { + go func() { + if err := SendEmail(email, input.Title, derefStr(input.Body)); err != nil { + slog.Error("failed to send notification email", "error", err, "user_id", input.UserID) + } else { + // Mark as sent + _, _ = s.db.Exec(`UPDATE notifications SET sent_at = now() WHERE id = $1`, n.ID) + } + }() + } + } + + return &n, nil +} + +// ListForUser returns notifications for a user in a tenant, paginated. +func (s *NotificationService) ListForUser(ctx context.Context, tenantID, userID uuid.UUID, limit, offset int) ([]models.Notification, int, error) { + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + + var total int + err := s.db.GetContext(ctx, &total, + `SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND tenant_id = $2`, + userID, tenantID) + if err != nil { + return nil, 0, fmt.Errorf("count notifications: %w", err) + } + + var notifications []models.Notification + err = s.db.SelectContext(ctx, ¬ifications, + `SELECT id, tenant_id, user_id, type, entity_type, entity_id, title, body, sent_at, read_at, created_at + FROM notifications + WHERE user_id = $1 AND tenant_id = $2 + ORDER BY created_at DESC + LIMIT $3 OFFSET $4`, + userID, tenantID, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("list notifications: %w", err) + } + + return notifications, total, nil +} + +// UnreadCount returns the number of unread notifications for a user. +func (s *NotificationService) UnreadCount(ctx context.Context, tenantID, userID uuid.UUID) (int, error) { + var count int + err := s.db.GetContext(ctx, &count, + `SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND tenant_id = $2 AND read_at IS NULL`, + userID, tenantID) + return count, err +} + +// MarkRead marks a single notification as read. +func (s *NotificationService) MarkRead(ctx context.Context, tenantID, userID, notificationID uuid.UUID) error { + result, err := s.db.ExecContext(ctx, + `UPDATE notifications SET read_at = now() + WHERE id = $1 AND user_id = $2 AND tenant_id = $3 AND read_at IS NULL`, + notificationID, userID, tenantID) + if err != nil { + return fmt.Errorf("mark notification read: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("notification not found or already read") + } + return nil +} + +// MarkAllRead marks all notifications as read for a user. +func (s *NotificationService) MarkAllRead(ctx context.Context, tenantID, userID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `UPDATE notifications SET read_at = now() + WHERE user_id = $1 AND tenant_id = $2 AND read_at IS NULL`, + userID, tenantID) + return err +} + +// GetPreferences returns notification preferences for a user, creating defaults if needed. +func (s *NotificationService) GetPreferences(ctx context.Context, tenantID, userID uuid.UUID) (*models.NotificationPreferences, error) { + var pref models.NotificationPreferences + err := s.db.GetContext(ctx, &pref, + `SELECT user_id, tenant_id, deadline_reminder_days, email_enabled, daily_digest, created_at, updated_at + FROM notification_preferences + WHERE user_id = $1 AND tenant_id = $2`, + userID, tenantID) + if err != nil { + // Return defaults if no preferences set + return &models.NotificationPreferences{ + UserID: userID, + TenantID: tenantID, + DeadlineReminderDays: pq.Int64Array{7, 3, 1}, + EmailEnabled: true, + DailyDigest: false, + }, nil + } + return &pref, nil +} + +// UpdatePreferences upserts notification preferences for a user. +func (s *NotificationService) UpdatePreferences(ctx context.Context, tenantID, userID uuid.UUID, input UpdatePreferencesInput) (*models.NotificationPreferences, error) { + var pref models.NotificationPreferences + err := s.db.QueryRowxContext(ctx, + `INSERT INTO notification_preferences (user_id, tenant_id, deadline_reminder_days, email_enabled, daily_digest) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id, tenant_id) + DO UPDATE SET deadline_reminder_days = $3, email_enabled = $4, daily_digest = $5, updated_at = now() + RETURNING user_id, tenant_id, deadline_reminder_days, email_enabled, daily_digest, created_at, updated_at`, + userID, tenantID, pq.Int64Array(input.DeadlineReminderDays), input.EmailEnabled, input.DailyDigest).StructScan(&pref) + if err != nil { + return nil, fmt.Errorf("update preferences: %w", err) + } + return &pref, nil +} + +// UpdatePreferencesInput holds the data for updating notification preferences. +type UpdatePreferencesInput struct { + DeadlineReminderDays []int64 `json:"deadline_reminder_days"` + EmailEnabled bool `json:"email_enabled"` + DailyDigest bool `json:"daily_digest"` +} + +// SendEmail sends an email using the `m mail send` CLI command. +func SendEmail(to, subject, body string) error { + cmd := exec.Command("m", "mail", "send", + "--to", to, + "--subject", subject, + "--body", body, + "--yes") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("m mail send failed: %w (output: %s)", err, string(output)) + } + slog.Info("email sent", "to", to, "subject", subject) + return nil +} + +// getUserEmail looks up the email for a user from Supabase auth.users. +func (s *NotificationService) getUserEmail(ctx context.Context, userID uuid.UUID) string { + var email string + err := s.db.GetContext(ctx, &email, + `SELECT email FROM auth.users WHERE id = $1`, userID) + if err != nil { + slog.Error("failed to get user email", "error", err, "user_id", userID) + return "" + } + return email +} + +func containsDay(arr pq.Int64Array, day int64) bool { + for _, d := range arr { + if d == day { + return true + } + } + return false +} + +func derefStr(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/frontend/src/app/(app)/einstellungen/page.tsx b/frontend/src/app/(app)/einstellungen/page.tsx index 10628f8..efa37e7 100644 --- a/frontend/src/app/(app)/einstellungen/page.tsx +++ b/frontend/src/app/(app)/einstellungen/page.tsx @@ -1,11 +1,12 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { Settings, Calendar, Users } from "lucide-react"; +import { Settings, Calendar, Users, Bell } from "lucide-react"; import Link from "next/link"; import { api } from "@/lib/api"; import type { Tenant } from "@/lib/types"; import { CalDAVSettings } from "@/components/settings/CalDAVSettings"; +import { NotificationSettings } from "@/components/settings/NotificationSettings"; import { SkeletonCard } from "@/components/ui/Skeleton"; import { EmptyState } from "@/components/ui/EmptyState"; @@ -97,6 +98,19 @@ export default function EinstellungenPage() { + {/* Notification Settings */} +
+
+ +

+ Benachrichtigungen +

+
+
+ +
+
+ {/* CalDAV Settings */}
diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 35d5284..cb34437 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -2,6 +2,7 @@ import { createClient } from "@/lib/supabase/client"; import { TenantSwitcher } from "./TenantSwitcher"; +import { NotificationBell } from "@/components/notifications/NotificationBell"; import { LogOut } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; @@ -29,6 +30,7 @@ export function Header() {
+ {email && ( {email} diff --git a/frontend/src/components/notifications/NotificationBell.tsx b/frontend/src/components/notifications/NotificationBell.tsx new file mode 100644 index 0000000..74cc705 --- /dev/null +++ b/frontend/src/components/notifications/NotificationBell.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Bell, Check, CheckCheck, ExternalLink } from "lucide-react"; +import { api } from "@/lib/api"; +import type { Notification, NotificationListResponse } from "@/lib/types"; + +function getEntityLink(n: Notification): string | null { + if (!n.entity_type || !n.entity_id) return null; + switch (n.entity_type) { + case "deadline": + return `/fristen/${n.entity_id}`; + case "appointment": + return `/termine/${n.entity_id}`; + case "case": + return `/akten/${n.entity_id}`; + default: + return null; + } +} + +function getTypeColor(type: Notification["type"]): string { + switch (type) { + case "deadline_overdue": + return "bg-red-500"; + case "deadline_reminder": + return "bg-amber-500"; + case "case_update": + return "bg-blue-500"; + case "assignment": + return "bg-violet-500"; + default: + return "bg-neutral-500"; + } +} + +function timeAgo(dateStr: string): string { + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now.getTime() - date.getTime(); + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return "gerade eben"; + if (diffMin < 60) return `vor ${diffMin} Min.`; + const diffHours = Math.floor(diffMin / 60); + if (diffHours < 24) return `vor ${diffHours} Std.`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays === 1) return "gestern"; + return `vor ${diffDays} Tagen`; +} + +export function NotificationBell() { + const [open, setOpen] = useState(false); + const panelRef = useRef(null); + const queryClient = useQueryClient(); + + const { data: unreadData } = useQuery({ + queryKey: ["notifications-unread-count"], + queryFn: () => + api.get<{ unread_count: number }>("/api/notifications/unread-count"), + refetchInterval: 30_000, + }); + + const { data: notifData } = useQuery({ + queryKey: ["notifications"], + queryFn: () => + api.get("/api/notifications?limit=20"), + enabled: open, + }); + + const markRead = useMutation({ + mutationFn: (id: string) => + api.patch(`/api/notifications/${id}/read`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notifications"] }); + queryClient.invalidateQueries({ + queryKey: ["notifications-unread-count"], + }); + }, + }); + + const markAllRead = useMutation({ + mutationFn: () => api.patch("/api/notifications/read-all"), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notifications"] }); + queryClient.invalidateQueries({ + queryKey: ["notifications-unread-count"], + }); + }, + }); + + // Close on click outside + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + if (open) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [open]); + + const unreadCount = unreadData?.unread_count ?? 0; + const notifications = notifData?.data ?? []; + + return ( +
+ + + {open && ( +
+ {/* Header */} +
+

+ Benachrichtigungen +

+ {unreadCount > 0 && ( + + )} +
+ + {/* Notification list */} +
+ {notifications.length === 0 ? ( +
+ Keine Benachrichtigungen +
+ ) : ( + notifications.map((n) => { + const link = getEntityLink(n); + return ( +
+
+
+

+ {n.title} +

+ {n.body && ( +

+ {n.body} +

+ )} + +
+ {!n.read_at && ( + + )} +
+ ); + }) + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/settings/NotificationSettings.tsx b/frontend/src/components/settings/NotificationSettings.tsx new file mode 100644 index 0000000..5b7c75a --- /dev/null +++ b/frontend/src/components/settings/NotificationSettings.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { NotificationPreferences } from "@/lib/types"; + +const REMINDER_OPTIONS = [ + { value: 14, label: "14 Tage" }, + { value: 7, label: "7 Tage" }, + { value: 3, label: "3 Tage" }, + { value: 1, label: "1 Tag" }, +]; + +export function NotificationSettings() { + const queryClient = useQueryClient(); + const [saved, setSaved] = useState(false); + + const { data: prefs, isLoading } = useQuery({ + queryKey: ["notification-preferences"], + queryFn: () => + api.get("/api/notification-preferences"), + }); + + const [reminderDays, setReminderDays] = useState([]); + const [emailEnabled, setEmailEnabled] = useState(true); + const [dailyDigest, setDailyDigest] = useState(false); + const [initialized, setInitialized] = useState(false); + + // Sync state from server once loaded + if (prefs && !initialized) { + setReminderDays(prefs.deadline_reminder_days); + setEmailEnabled(prefs.email_enabled); + setDailyDigest(prefs.daily_digest); + setInitialized(true); + } + + const update = useMutation({ + mutationFn: (input: { + deadline_reminder_days: number[]; + email_enabled: boolean; + daily_digest: boolean; + }) => api.put("/api/notification-preferences", input), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["notification-preferences"], + }); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }, + }); + + function toggleDay(day: number) { + setReminderDays((prev) => + prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day].sort((a, b) => b - a), + ); + } + + function handleSave() { + update.mutate({ + deadline_reminder_days: reminderDays, + email_enabled: emailEnabled, + daily_digest: dailyDigest, + }); + } + + if (isLoading) { + return ( +
+
+
+
+
+ ); + } + + return ( +
+ {/* Reminder days */} +
+

+ Fristen-Erinnerungen +

+

+ Erinnern Sie mich vor Fristablauf: +

+
+ {REMINDER_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Email toggle */} + + + {/* Daily digest toggle */} + + + {/* Save */} +
+ + {saved && ( + Gespeichert + )} +
+
+ ); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 458cc5b..80e61c6 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -189,6 +189,37 @@ export interface Note { updated_at: string; } +// Notifications + +export interface Notification { + id: string; + tenant_id: string; + user_id: string; + type: "deadline_reminder" | "deadline_overdue" | "case_update" | "assignment"; + entity_type?: "deadline" | "appointment" | "case"; + entity_id?: string; + title: string; + body?: string; + sent_at?: string; + read_at?: string; + created_at: string; +} + +export interface NotificationPreferences { + user_id: string; + tenant_id: string; + deadline_reminder_days: number[]; + email_enabled: boolean; + daily_digest: boolean; + created_at?: string; + updated_at?: string; +} + +export interface NotificationListResponse { + data: Notification[]; + total: number; +} + export interface ApiError { error: string; status: number;