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
172 lines
4.7 KiB
Go
172 lines
4.7 KiB
Go
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)
|
|
}
|