Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
- Use human agent's perspective only for unread counts (avoids system agent inflation) - Replace onDestroy + get() with $effect cleanup in channel/DM pages (Svelte 5 compat) - Fix notification store to parse array-of-objects API response format - Add last_read_message_id to DM messages endpoint - Fix test agent type seeding for GetHumanAgentForUser Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
204 lines
6.2 KiB
Go
204 lines
6.2 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/synapbus/synapbus/internal/agents"
|
|
"github.com/synapbus/synapbus/internal/channels"
|
|
"github.com/synapbus/synapbus/internal/messaging"
|
|
)
|
|
|
|
// NotificationsHandler handles REST API requests for notification badges.
|
|
type NotificationsHandler struct {
|
|
msgService *messaging.MessagingService
|
|
agentService *agents.AgentService
|
|
channelService *channels.Service
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewNotificationsHandler creates a new notifications handler.
|
|
func NewNotificationsHandler(msgService *messaging.MessagingService, agentService *agents.AgentService, channelService *channels.Service) *NotificationsHandler {
|
|
return &NotificationsHandler{
|
|
msgService: msgService,
|
|
agentService: agentService,
|
|
channelService: channelService,
|
|
logger: slog.Default().With("component", "api.notifications"),
|
|
}
|
|
}
|
|
|
|
// channelUnread is the JSON shape for a channel's unread info.
|
|
type channelUnread struct {
|
|
Name string `json:"name"`
|
|
UnreadCount int `json:"unread_count"`
|
|
LastMessageID int64 `json:"last_message_id"`
|
|
}
|
|
|
|
// dmUnread is the JSON shape for a DM peer's unread info.
|
|
type dmUnread struct {
|
|
Agent string `json:"agent"`
|
|
UnreadCount int `json:"unread_count"`
|
|
LastMessageID int64 `json:"last_message_id"`
|
|
}
|
|
|
|
// UnreadCounts handles GET /api/notifications/unread.
|
|
func (h *NotificationsHandler) UnreadCounts(w http.ResponseWriter, r *http.Request) {
|
|
ownerID, ok := OwnerIDFromContext(r.Context())
|
|
if !ok {
|
|
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
|
return
|
|
}
|
|
|
|
// Use only the human agent's perspective for notification counts.
|
|
// This avoids system/AI agents inflating unread counts.
|
|
humanAgent, err := h.agentService.GetHumanAgentForUser(r.Context(), ownerID)
|
|
if err != nil || humanAgent == nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"channels": []channelUnread{},
|
|
"dms": []dmUnread{},
|
|
"total_unread": 0,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Channel summaries for the human agent
|
|
channelsList := []channelUnread{}
|
|
if h.channelService != nil {
|
|
summaries, err := h.channelService.GetChannelSummaries(r.Context(), humanAgent.Name)
|
|
if err != nil {
|
|
h.logger.Error("get channel summaries failed", "agent", humanAgent.Name, "error", err)
|
|
} else {
|
|
for _, cs := range summaries {
|
|
channelsList = append(channelsList, channelUnread{
|
|
Name: cs.Name,
|
|
UnreadCount: cs.UnreadCount,
|
|
LastMessageID: cs.LastMessageID,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// DM unread counts for the human agent
|
|
dmMap := make(map[string]*dmUnread)
|
|
counts, err := h.msgService.GetDMUnreadCounts(r.Context(), humanAgent.Name)
|
|
if err != nil {
|
|
h.logger.Error("get dm unread counts failed", "agent", humanAgent.Name, "error", err)
|
|
} else {
|
|
for _, dc := range counts {
|
|
dmMap[dc.Agent] = &dmUnread{
|
|
Agent: dc.Agent,
|
|
UnreadCount: dc.UnreadCount,
|
|
LastMessageID: dc.LastMessageID,
|
|
}
|
|
}
|
|
}
|
|
|
|
dmsList := make([]dmUnread, 0, len(dmMap))
|
|
for _, du := range dmMap {
|
|
dmsList = append(dmsList, *du)
|
|
}
|
|
|
|
totalUnread := 0
|
|
for _, cu := range channelsList {
|
|
totalUnread += cu.UnreadCount
|
|
}
|
|
for _, du := range dmsList {
|
|
totalUnread += du.UnreadCount
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"channels": channelsList,
|
|
"dms": dmsList,
|
|
"total_unread": totalUnread,
|
|
})
|
|
}
|
|
|
|
// MarkRead handles POST /api/notifications/mark-read.
|
|
func (h *NotificationsHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
|
|
ownerID, ok := OwnerIDFromContext(r.Context())
|
|
if !ok {
|
|
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Type string `json:"type"`
|
|
Target string `json:"target"`
|
|
LastMessageID int64 `json:"last_message_id"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
|
|
return
|
|
}
|
|
|
|
if req.Type == "" || req.Target == "" || req.LastMessageID <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "type, target, and last_message_id are required"))
|
|
return
|
|
}
|
|
|
|
if req.Type != "channel" && req.Type != "dm" {
|
|
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "type must be 'channel' or 'dm'"))
|
|
return
|
|
}
|
|
|
|
humanAgent, err := h.agentService.GetHumanAgentForUser(r.Context(), ownerID)
|
|
if err != nil || humanAgent == nil {
|
|
writeJSON(w, http.StatusBadRequest, errorBody("no_agents", "No human agent found"))
|
|
return
|
|
}
|
|
|
|
agentNames := []string{humanAgent.Name}
|
|
|
|
if req.Type == "channel" {
|
|
if h.channelService == nil {
|
|
writeJSON(w, http.StatusBadRequest, errorBody("not_available", "Channel service not available"))
|
|
return
|
|
}
|
|
|
|
ch, err := h.channelService.GetChannelByName(r.Context(), req.Target)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Channel not found"))
|
|
return
|
|
}
|
|
|
|
convIDs, err := h.msgService.GetConversationIDsForChannel(r.Context(), ch.ID, req.LastMessageID)
|
|
if err != nil {
|
|
h.logger.Error("get conversation ids failed", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get conversations"))
|
|
return
|
|
}
|
|
|
|
for _, convID := range convIDs {
|
|
if err := h.msgService.UpdateInboxState(r.Context(), humanAgent.Name, convID, req.LastMessageID); err != nil {
|
|
h.logger.Error("update inbox state failed",
|
|
"agent", humanAgent.Name,
|
|
"conversation_id", convID,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
// DM mark-read
|
|
convIDs, err := h.msgService.GetConversationIDsForDM(r.Context(), agentNames, req.Target, req.LastMessageID)
|
|
if err != nil {
|
|
h.logger.Error("get dm conversation ids failed", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get conversations"))
|
|
return
|
|
}
|
|
|
|
for _, convID := range convIDs {
|
|
if err := h.msgService.UpdateInboxState(r.Context(), humanAgent.Name, convID, req.LastMessageID); err != nil {
|
|
h.logger.Error("update inbox state failed",
|
|
"agent", humanAgent.Name,
|
|
"conversation_id", convID,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|