Files
multica/server/internal/handler/notification_preferences.go
Jiayuan 45e1dfd325 feat(inbox): add notification preferences settings
Add per-type notification preference controls to the Inbox, allowing
users to toggle which notification types they receive. This addresses
the core pain point of agent-generated status change noise flooding
user inboxes.

Backend:
- New `notification_preference` table (migration 064) with per-user,
  per-workspace, per-type enabled/disabled toggle
- GET/PUT /api/inbox/preferences endpoints for CRUD
- notification_listeners.go checks user preferences before creating
  inbox items, using preloaded per-type batch queries for efficiency
- status_changed notifications default to OFF; all others default ON

Frontend:
- NotificationPreference type and API client methods
- TanStack Query options + optimistic mutation hook
- Settings dialog accessible via gear icon in Inbox header
- Grouped by category: Status, Comments, Assignments, Mentions,
  Priority, Due dates, Reactions, Agent events, Quick create
- Each category shows icon, label, description, and Switch toggle
2026-04-29 21:10:00 +02:00

134 lines
3.7 KiB
Go

package handler
import (
"encoding/json"
"net/http"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// validNotificationTypes is the set of notification types that users can configure.
var validNotificationTypes = map[string]bool{
"issue_assigned": true,
"unassigned": true,
"assignee_changed": true,
"status_changed": true,
"priority_changed": true,
"due_date_changed": true,
"new_comment": true,
"mentioned": true,
"reaction_added": true,
"task_completed": true,
"task_failed": true,
"agent_blocked": true,
"agent_completed": true,
"quick_create_done": true,
"quick_create_failed": true,
}
// DefaultDisabledTypes are notification types that are OFF by default (no row = disabled).
// All other types default to ON (no row = enabled).
// Exported so notification_listeners can reference the same defaults.
var DefaultDisabledTypes = map[string]bool{
"status_changed": true,
}
type NotificationPreferenceResponse struct {
NotificationType string `json:"notification_type"`
Enabled bool `json:"enabled"`
}
func (h *Handler) ListNotificationPreferences(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := ctxWorkspaceID(r.Context())
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
prefs, err := h.Queries.ListNotificationPreferences(r.Context(), db.ListNotificationPreferencesParams{
WorkspaceID: wsUUID,
UserID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list notification preferences")
return
}
// Build a map of explicitly set preferences
explicit := make(map[string]bool, len(prefs))
for _, p := range prefs {
explicit[p.NotificationType] = p.Enabled
}
// Build full response with defaults for all valid types
resp := make([]NotificationPreferenceResponse, 0, len(validNotificationTypes))
for t := range validNotificationTypes {
enabled := !DefaultDisabledTypes[t] // default: ON unless in defaultDisabledTypes
if v, ok := explicit[t]; ok {
enabled = v
}
resp = append(resp, NotificationPreferenceResponse{
NotificationType: t,
Enabled: enabled,
})
}
writeJSON(w, http.StatusOK, resp)
}
type updateNotificationPreferencesRequest struct {
Preferences []NotificationPreferenceResponse `json:"preferences"`
}
func (h *Handler) UpdateNotificationPreferences(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := ctxWorkspaceID(r.Context())
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
var req updateNotificationPreferencesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if len(req.Preferences) == 0 {
writeError(w, http.StatusBadRequest, "preferences cannot be empty")
return
}
// Validate all notification types
for _, p := range req.Preferences {
if !validNotificationTypes[p.NotificationType] {
writeError(w, http.StatusBadRequest, "invalid notification type: "+p.NotificationType)
return
}
}
// Upsert each preference
for _, p := range req.Preferences {
_, err := h.Queries.UpsertNotificationPreference(r.Context(), db.UpsertNotificationPreferenceParams{
WorkspaceID: wsUUID,
UserID: parseUUID(userID),
NotificationType: p.NotificationType,
Enabled: p.Enabled,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update notification preference")
return
}
}
// Return updated preferences
h.ListNotificationPreferences(w, r)
}