package handlers import ( "database/sql" "encoding/json" "log" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/notifications" "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Handler ───────────────────────────────── // NotificationHandler handles notification CRUD endpoints. type NotificationHandler struct { stores store.Stores hub *events.Hub } // NewNotificationHandler creates a new notification handler. func NewNotificationHandler(s store.Stores, hub *events.Hub) *NotificationHandler { return &NotificationHandler{stores: s, hub: hub} } // ── GET /api/v1/notifications ─────────────── // Returns paginated notifications for the authenticated user. // Query params: ?limit=20&offset=0&unread_only=false func (h *NotificationHandler) List(c *gin.Context) { userID := getUserID(c) if userID == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) unreadOnly := c.Query("unread_only") == "true" if limit <= 0 || limit > 100 { limit = 20 } if offset < 0 { offset = 0 } items, total, err := h.stores.Notifications.ListByUser( c.Request.Context(), userID, limit, offset, unreadOnly) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } c.JSON(http.StatusOK, gin.H{ "data": items, "total": total, "limit": limit, "offset": offset, }) } // ── GET /api/v1/notifications/unread-count ── func (h *NotificationHandler) UnreadCount(c *gin.Context) { userID := getUserID(c) if userID == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } count, err := h.stores.Notifications.UnreadCount(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } c.JSON(http.StatusOK, gin.H{"count": count}) } // ── PATCH /api/v1/notifications/:id/read ──── func (h *NotificationHandler) MarkRead(c *gin.Context) { userID := getUserID(c) if userID == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } id := c.Param("id") if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "notification id required"}) return } if err := h.stores.Notifications.MarkRead(c.Request.Context(), id, userID); err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "notification not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"}) return } // Sync badge across tabs if h.hub != nil { payload, _ := json.Marshal(map[string]string{"id": id}) h.hub.SendToUser(userID, events.Event{ Label: "notification.read", Payload: payload, Ts: time.Now().UnixMilli(), }) } c.JSON(http.StatusOK, gin.H{"ok": true}) } // ── POST /api/v1/notifications/mark-all-read ─ func (h *NotificationHandler) MarkAllRead(c *gin.Context) { userID := getUserID(c) if userID == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } if err := h.stores.Notifications.MarkAllRead(c.Request.Context(), userID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"}) return } // Sync badge across tabs if h.hub != nil { payload, _ := json.Marshal(map[string]string{"action": "mark_all_read"}) h.hub.SendToUser(userID, events.Event{ Label: "notification.read", Payload: payload, Ts: time.Now().UnixMilli(), }) } c.JSON(http.StatusOK, gin.H{"ok": true}) } // ── DELETE /api/v1/notifications/:id ──────── func (h *NotificationHandler) Delete(c *gin.Context) { userID := getUserID(c) if userID == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } id := c.Param("id") if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "notification id required"}) return } if err := h.stores.Notifications.Delete(c.Request.Context(), id, userID); err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "notification not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // ── Notification Preferences (v0.20.0 Phase 3) ── // GET /api/v1/notifications/preferences func (h *NotificationHandler) ListPreferences(c *gin.Context) { userID := getUserID(c) if userID == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } if h.stores.NotifPrefs == nil { c.JSON(http.StatusOK, gin.H{"data": []models.NotificationPreference{}}) return } prefs, err := h.stores.NotifPrefs.ListForUser(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } if prefs == nil { prefs = []models.NotificationPreference{} } c.JSON(http.StatusOK, gin.H{"data": prefs}) } // PUT /api/v1/notifications/preferences/:type func (h *NotificationHandler) SetPreference(c *gin.Context) { userID := getUserID(c) if userID == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } notifType := c.Param("type") if notifType == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "type is required"}) return } var req struct { InApp *bool `json:"in_app"` Email *bool `json:"email"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if h.stores.NotifPrefs == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "preferences not available"}) return } // Load existing or create new existing, _ := h.stores.NotifPrefs.Get(c.Request.Context(), userID, notifType) pref := &models.NotificationPreference{ UserID: userID, Type: notifType, InApp: true, // default Email: false, // default } if existing != nil { pref = existing } if req.InApp != nil { pref.InApp = *req.InApp } if req.Email != nil { pref.Email = *req.Email } if err := h.stores.NotifPrefs.Upsert(c.Request.Context(), pref); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"}) return } c.JSON(http.StatusOK, pref) } // DELETE /api/v1/notifications/preferences/:type func (h *NotificationHandler) DeletePreference(c *gin.Context) { userID := getUserID(c) if userID == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } notifType := c.Param("type") if notifType == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "type is required"}) return } if h.stores.NotifPrefs == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "preferences not available"}) return } if err := h.stores.NotifPrefs.Delete(c.Request.Context(), userID, notifType); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // ── Admin Broadcast (v0.28.6) ─────────────── // POST /api/v1/admin/notifications/broadcast // Admin-only: sends a system.announcement notification to all active users. func (h *NotificationHandler) Broadcast(c *gin.Context) { var req struct { Title string `json:"title" binding:"required"` Message string `json:"message" binding:"required"` Level string `json:"level"` // info | warning | critical (default: info) } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } level := req.Level if level == "" { level = "info" } if level != "info" && level != "warning" && level != "critical" { c.JSON(http.StatusBadRequest, gin.H{"error": "level must be info, warning, or critical"}) return } // Get all active user IDs userIDs, err := h.stores.Users.ListActiveUserIDs(c.Request.Context()) if err != nil { log.Printf("[broadcast] failed to list active users: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"}) return } if len(userIDs) == 0 { c.JSON(http.StatusOK, gin.H{"message": "no active users", "count": 0}) return } // Fan out via notification service svc := notifications.Default() if svc == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "notification service not available"}) return } template := models.Notification{ Type: models.NotifTypeAnnouncement, Title: req.Title, Body: req.Message, } svc.NotifyMany(c.Request.Context(), userIDs, template) // Also emit a real-time WS broadcast for immediate visibility if h.hub != nil { payload, _ := json.Marshal(map[string]string{ "title": req.Title, "message": req.Message, "level": level, }) for _, uid := range userIDs { h.hub.SendToUser(uid, events.Event{ Label: "system.broadcast", Payload: payload, Ts: time.Now().UnixMilli(), }) } } log.Printf("[broadcast] admin sent announcement to %d users: %s", len(userIDs), req.Title) c.JSON(http.StatusOK, gin.H{"message": "broadcast sent", "count": len(userIDs)}) }