247 lines
7.3 KiB
Go
247 lines
7.3 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/notification"
|
|
)
|
|
|
|
func (s *Server) handleListProjectNotifications(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := s.requireAuthenticatedUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.notifySvc() == nil || !s.notifySvc().Available() {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "notification service unavailable")
|
|
return
|
|
}
|
|
|
|
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil || projectID <= 0 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
|
|
return
|
|
}
|
|
|
|
sinceID, _ := strconv.ParseInt(r.URL.Query().Get("since_id"), 10, 64)
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
|
|
items, err := s.notifySvc().ListProjectNotifications(r.Context(), user.ID, projectID, sinceID, limit)
|
|
if err != nil {
|
|
writeDomainError(w, r, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, map[string]any{
|
|
"items": items,
|
|
})
|
|
}
|
|
|
|
// userNotificationView is the all-scope inbox row (includes severity / digest when migrated).
|
|
type userNotificationView struct {
|
|
ID int64 `json:"id"`
|
|
UserID int64 `json:"user_id"`
|
|
ProjectID int64 `json:"project_id"`
|
|
EventID int64 `json:"event_id,omitempty"`
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Status string `json:"status"`
|
|
Severity string `json:"severity,omitempty"`
|
|
DigestID int64 `json:"digest_id,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Payload map[string]any `json:"payload,omitempty"`
|
|
}
|
|
|
|
// handleListUserNotifications GET /api/v1/notifications — cross-project inbox.
|
|
func (s *Server) handleListUserNotifications(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := s.requireAuthenticatedUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.notifySvc() == nil || !s.notifySvc().Available() {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "notification service unavailable")
|
|
return
|
|
}
|
|
|
|
sinceID, _ := strconv.ParseInt(r.URL.Query().Get("since_id"), 10, 64)
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
|
|
items, err := s.listAllScopeNotifications(r.Context(), user.ID, sinceID, limit)
|
|
if err != nil {
|
|
writeDomainError(w, r, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, map[string]any{
|
|
"items": items,
|
|
})
|
|
}
|
|
|
|
func (s *Server) listAllScopeNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]userNotificationView, error) {
|
|
// Prefer free-collab ListUserNotifications when the method exists on *notification.Service.
|
|
type userLister interface {
|
|
ListUserNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]notification.Notification, error)
|
|
}
|
|
if lister, ok := any(s.notifySvc()).(userLister); ok {
|
|
raw, err := lister.ListUserNotifications(ctx, userID, sinceID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]userNotificationView, 0, len(raw))
|
|
for _, n := range raw {
|
|
out = append(out, userNotificationViewFrom(n))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Direct SQL via runtime pool when severity columns are available.
|
|
if s.rt != nil && s.rt.Pool != nil {
|
|
items, err := s.queryUserNotifications(ctx, userID, sinceID, limit)
|
|
if err == nil {
|
|
return items, nil
|
|
}
|
|
// Fall through to project fan-out if columns are missing pre-migration.
|
|
}
|
|
|
|
return s.fanOutProjectNotifications(ctx, userID, sinceID, limit)
|
|
}
|
|
|
|
func userNotificationViewFrom(n notification.Notification) userNotificationView {
|
|
var v userNotificationView
|
|
raw, err := json.Marshal(n)
|
|
if err != nil {
|
|
return userNotificationView{
|
|
ID: n.ID, UserID: n.UserID, ProjectID: n.ProjectID, EventID: n.EventID,
|
|
Type: n.Type, Title: n.Title, Body: n.Body, Status: n.Status,
|
|
CreatedAt: n.CreatedAt,
|
|
}
|
|
}
|
|
_ = json.Unmarshal(raw, &v)
|
|
return v
|
|
}
|
|
|
|
func (s *Server) queryUserNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]userNotificationView, error) {
|
|
query := `
|
|
SELECT n.id, n.user_id, n.project_id, COALESCE(n.event_id, 0), n.type, n.title, n.body, n.status,
|
|
COALESCE(n.severity, 'info'), COALESCE(n.digest_id, 0), n.created_at,
|
|
COALESCE(e.payload, '{}'::jsonb),
|
|
COALESCE(d.doc_id, ''),
|
|
COALESCE((
|
|
SELECT elem->>'proposal_id'
|
|
FROM jsonb_array_elements(COALESCE(d.item_summaries, '[]'::jsonb)) WITH ORDINALITY AS t(elem, ord)
|
|
WHERE COALESCE(elem->>'proposal_id', '') <> ''
|
|
ORDER BY ord DESC
|
|
LIMIT 1
|
|
), '')
|
|
FROM notifications n
|
|
LEFT JOIN outbox_events e ON e.id = n.event_id
|
|
LEFT JOIN notification_digests d ON d.id = n.digest_id
|
|
WHERE n.user_id = $1
|
|
`
|
|
args := []any{userID}
|
|
if sinceID > 0 {
|
|
query += ` AND n.id > $2`
|
|
args = append(args, sinceID)
|
|
}
|
|
query += ` ORDER BY CASE WHEN COALESCE(n.severity, 'info') = 'action_required' THEN 0 ELSE 1 END, n.id DESC`
|
|
query += ` LIMIT $` + strconv.Itoa(len(args)+1)
|
|
args = append(args, limit)
|
|
|
|
rows, err := s.rt.Pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]userNotificationView, 0)
|
|
for rows.Next() {
|
|
var n userNotificationView
|
|
var payloadRaw []byte
|
|
var digestDocID, digestProposalID string
|
|
if err := rows.Scan(
|
|
&n.ID, &n.UserID, &n.ProjectID, &n.EventID, &n.Type, &n.Title, &n.Body, &n.Status,
|
|
&n.Severity, &n.DigestID, &n.CreatedAt, &payloadRaw, &digestDocID, &digestProposalID,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(payloadRaw) > 0 {
|
|
_ = json.Unmarshal(payloadRaw, &n.Payload)
|
|
}
|
|
if n.Payload == nil {
|
|
n.Payload = map[string]any{}
|
|
}
|
|
if digestDocID != "" {
|
|
if _, ok := n.Payload["doc_id"]; !ok || n.Payload["doc_id"] == "" || n.Payload["doc_id"] == nil {
|
|
n.Payload["doc_id"] = digestDocID
|
|
}
|
|
}
|
|
if digestProposalID != "" {
|
|
if _, ok := n.Payload["proposal_id"]; !ok || n.Payload["proposal_id"] == "" || n.Payload["proposal_id"] == nil {
|
|
n.Payload["proposal_id"] = digestProposalID
|
|
}
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Server) fanOutProjectNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]userNotificationView, error) {
|
|
if s.pwSvc() == nil {
|
|
return []userNotificationView{}, nil
|
|
}
|
|
projects, err := s.pwSvc().ListProjects(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw := make([]notification.Notification, 0)
|
|
per := limit
|
|
if per < 20 {
|
|
per = 20
|
|
}
|
|
for _, p := range projects {
|
|
if p == nil {
|
|
continue
|
|
}
|
|
chunk, listErr := s.notifySvc().ListProjectNotifications(ctx, userID, p.ID, sinceID, per)
|
|
if listErr != nil {
|
|
continue
|
|
}
|
|
raw = append(raw, chunk...)
|
|
}
|
|
sort.SliceStable(raw, func(i, j int) bool {
|
|
si, sj := inboxSeverityRank(raw[i].Type), inboxSeverityRank(raw[j].Type)
|
|
if si != sj {
|
|
return si < sj
|
|
}
|
|
return raw[i].ID > raw[j].ID
|
|
})
|
|
if len(raw) > limit {
|
|
raw = raw[:limit]
|
|
}
|
|
out := make([]userNotificationView, 0, len(raw))
|
|
for _, n := range raw {
|
|
v := userNotificationViewFrom(n)
|
|
if inboxSeverityRank(n.Type) == 0 {
|
|
v.Severity = "action_required"
|
|
} else if v.Severity == "" {
|
|
v.Severity = "info"
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func inboxSeverityRank(typ string) int {
|
|
if strings.Contains(typ, "proposal_required") {
|
|
return 0
|
|
}
|
|
return 1
|
|
}
|