Compare commits

...

1 Commits

Author SHA1 Message Date
m
ac20c03f01 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
2026-03-30 11:03:17 +02:00
11 changed files with 1148 additions and 4 deletions

View File

@@ -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 {

View File

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

View File

@@ -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() }
}

View File

@@ -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"`
}

View File

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

View File

@@ -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, &notifications,
`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, &notifications,
`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
}

View File

@@ -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() {
</div>
</section>
{/* Notification Settings */}
<section className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2.5 border-b border-neutral-100 pb-3">
<Bell className="h-4 w-4 text-neutral-500" />
<h2 className="text-sm font-semibold text-neutral-900">
Benachrichtigungen
</h2>
</div>
<div className="mt-4">
<NotificationSettings />
</div>
</section>
{/* CalDAV Settings */}
<section className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2.5 border-b border-neutral-100 pb-3">

View File

@@ -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() {
<div className="w-8 lg:w-0" />
<div className="flex items-center gap-2 sm:gap-3">
<TenantSwitcher />
<NotificationBell />
{email && (
<span className="hidden text-sm text-neutral-500 sm:inline">
{email}

View File

@@ -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<HTMLDivElement>(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<NotificationListResponse>("/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 (
<div className="relative" ref={panelRef}>
<button
onClick={() => setOpen(!open)}
className="relative rounded-md p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
title="Benachrichtigungen"
>
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-xl border border-neutral-200 bg-white shadow-lg sm:w-96">
{/* Header */}
<div className="flex items-center justify-between border-b border-neutral-100 px-4 py-3">
<h3 className="text-sm font-semibold text-neutral-900">
Benachrichtigungen
</h3>
{unreadCount > 0 && (
<button
onClick={() => markAllRead.mutate()}
className="flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700"
>
<CheckCheck className="h-3 w-3" />
Alle gelesen
</button>
)}
</div>
{/* Notification list */}
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-6 text-center text-sm text-neutral-400">
Keine Benachrichtigungen
</div>
) : (
notifications.map((n) => {
const link = getEntityLink(n);
return (
<div
key={n.id}
className={`flex items-start gap-3 border-b border-neutral-50 px-4 py-3 transition-colors last:border-0 ${
n.read_at
? "bg-white"
: "bg-blue-50/50"
}`}
>
<div
className={`mt-1.5 h-2 w-2 flex-shrink-0 rounded-full ${getTypeColor(n.type)}`}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-neutral-900 leading-snug">
{n.title}
</p>
{n.body && (
<p className="mt-0.5 text-xs text-neutral-500 line-clamp-2">
{n.body}
</p>
)}
<div className="mt-1.5 flex items-center gap-2">
<span className="text-[11px] text-neutral-400">
{timeAgo(n.created_at)}
</span>
{link && (
<a
href={link}
onClick={() => setOpen(false)}
className="flex items-center gap-0.5 text-[11px] text-blue-600 hover:text-blue-700"
>
<ExternalLink className="h-2.5 w-2.5" />
Anzeigen
</a>
)}
</div>
</div>
{!n.read_at && (
<button
onClick={() => markRead.mutate(n.id)}
className="flex-shrink-0 rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
title="Als gelesen markieren"
>
<Check className="h-3 w-3" />
</button>
)}
</div>
);
})
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -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<NotificationPreferences>("/api/notification-preferences"),
});
const [reminderDays, setReminderDays] = useState<number[]>([]);
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<NotificationPreferences>("/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 (
<div className="animate-pulse space-y-3">
<div className="h-4 w-48 rounded bg-neutral-200" />
<div className="h-8 w-full rounded bg-neutral-100" />
<div className="h-8 w-full rounded bg-neutral-100" />
</div>
);
}
return (
<div className="space-y-5">
{/* Reminder days */}
<div>
<p className="text-sm font-medium text-neutral-700">
Fristen-Erinnerungen
</p>
<p className="mt-0.5 text-xs text-neutral-500">
Erinnern Sie mich vor Fristablauf:
</p>
<div className="mt-2 flex flex-wrap gap-2">
{REMINDER_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => toggleDay(opt.value)}
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
reminderDays.includes(opt.value)
? "border-blue-500 bg-blue-50 text-blue-700"
: "border-neutral-200 bg-white text-neutral-600 hover:border-neutral-300"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Email toggle */}
<label className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-neutral-700">
E-Mail-Benachrichtigungen
</p>
<p className="text-xs text-neutral-500">
Erinnerungen per E-Mail erhalten
</p>
</div>
<button
onClick={() => setEmailEnabled(!emailEnabled)}
className={`relative h-6 w-11 rounded-full transition-colors ${
emailEnabled ? "bg-blue-500" : "bg-neutral-300"
}`}
>
<span
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
emailEnabled ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</label>
{/* Daily digest toggle */}
<label className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-neutral-700">
Tagesübersicht
</p>
<p className="text-xs text-neutral-500">
Alle Benachrichtigungen gesammelt um 8:00 Uhr per E-Mail
</p>
</div>
<button
onClick={() => setDailyDigest(!dailyDigest)}
className={`relative h-6 w-11 rounded-full transition-colors ${
dailyDigest ? "bg-blue-500" : "bg-neutral-300"
}`}
>
<span
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
dailyDigest ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</label>
{/* Save */}
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSave}
disabled={update.isPending}
className="rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
{update.isPending ? "Speichern..." : "Speichern"}
</button>
{saved && (
<span className="text-sm text-green-600">Gespeichert</span>
)}
</div>
</div>
);
}

View File

@@ -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;