The inbox UI deduplicates items by issue_id (showing only the latest notification per issue). Previously, clicking archive only archived the single visible item, so older items for the same issue would reappear. Now archiving operates at the issue level — both the backend and frontend archive all inbox items sharing the same issue_id.
286 lines
8.6 KiB
Go
286 lines
8.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/logger"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
type InboxItemResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
RecipientType string `json:"recipient_type"`
|
|
RecipientID string `json:"recipient_id"`
|
|
Type string `json:"type"`
|
|
Severity string `json:"severity"`
|
|
IssueID *string `json:"issue_id"`
|
|
Title string `json:"title"`
|
|
Body *string `json:"body"`
|
|
Read bool `json:"read"`
|
|
Archived bool `json:"archived"`
|
|
CreatedAt string `json:"created_at"`
|
|
IssueStatus *string `json:"issue_status"`
|
|
ActorType *string `json:"actor_type"`
|
|
ActorID *string `json:"actor_id"`
|
|
Details json.RawMessage `json:"details"`
|
|
}
|
|
|
|
func inboxToResponse(i db.InboxItem) InboxItemResponse {
|
|
return InboxItemResponse{
|
|
ID: uuidToString(i.ID),
|
|
WorkspaceID: uuidToString(i.WorkspaceID),
|
|
RecipientType: i.RecipientType,
|
|
RecipientID: uuidToString(i.RecipientID),
|
|
Type: i.Type,
|
|
Severity: i.Severity,
|
|
IssueID: uuidToPtr(i.IssueID),
|
|
Title: i.Title,
|
|
Body: textToPtr(i.Body),
|
|
Read: i.Read,
|
|
Archived: i.Archived,
|
|
CreatedAt: timestampToString(i.CreatedAt),
|
|
ActorType: textToPtr(i.ActorType),
|
|
ActorID: uuidToPtr(i.ActorID),
|
|
Details: json.RawMessage(i.Details),
|
|
}
|
|
}
|
|
|
|
func inboxRowToResponse(r db.ListInboxItemsRow) InboxItemResponse {
|
|
return InboxItemResponse{
|
|
ID: uuidToString(r.ID),
|
|
WorkspaceID: uuidToString(r.WorkspaceID),
|
|
RecipientType: r.RecipientType,
|
|
RecipientID: uuidToString(r.RecipientID),
|
|
Type: r.Type,
|
|
Severity: r.Severity,
|
|
IssueID: uuidToPtr(r.IssueID),
|
|
Title: r.Title,
|
|
Body: textToPtr(r.Body),
|
|
Read: r.Read,
|
|
Archived: r.Archived,
|
|
CreatedAt: timestampToString(r.CreatedAt),
|
|
IssueStatus: textToPtr(r.IssueStatus),
|
|
ActorType: textToPtr(r.ActorType),
|
|
ActorID: uuidToPtr(r.ActorID),
|
|
Details: json.RawMessage(r.Details),
|
|
}
|
|
}
|
|
|
|
func (h *Handler) enrichInboxResponse(ctx context.Context, resp InboxItemResponse, issueID pgtype.UUID) InboxItemResponse {
|
|
if !issueID.Valid {
|
|
return resp
|
|
}
|
|
issue, err := h.Queries.GetIssue(ctx, issueID)
|
|
if err == nil {
|
|
s := issue.Status
|
|
resp.IssueStatus = &s
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
workspaceID := r.Header.Get("X-Workspace-ID")
|
|
|
|
items, err := h.Queries.ListInboxItems(r.Context(), db.ListInboxItemsParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
RecipientType: "member",
|
|
RecipientID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list inbox")
|
|
return
|
|
}
|
|
|
|
resp := make([]InboxItemResponse, len(items))
|
|
for i, item := range items {
|
|
resp[i] = inboxRowToResponse(item)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) MarkInboxRead(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if _, ok := h.loadInboxItemForUser(w, r, id); !ok {
|
|
return
|
|
}
|
|
item, err := h.Queries.MarkInboxRead(r.Context(), parseUUID(id))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to mark read")
|
|
return
|
|
}
|
|
|
|
userID := requestUserID(r)
|
|
workspaceID := uuidToString(item.WorkspaceID)
|
|
h.publish(protocol.EventInboxRead, workspaceID, "member", userID, map[string]any{
|
|
"item_id": uuidToString(item.ID),
|
|
"recipient_id": uuidToString(item.RecipientID),
|
|
})
|
|
|
|
resp := h.enrichInboxResponse(r.Context(), inboxToResponse(item), item.IssueID)
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) ArchiveInboxItem(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if _, ok := h.loadInboxItemForUser(w, r, id); !ok {
|
|
return
|
|
}
|
|
item, err := h.Queries.ArchiveInboxItem(r.Context(), parseUUID(id))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to archive")
|
|
return
|
|
}
|
|
|
|
// Archive all sibling inbox items for the same issue (issue-level archive)
|
|
if item.IssueID.Valid {
|
|
h.Queries.ArchiveInboxByIssue(r.Context(), db.ArchiveInboxByIssueParams{
|
|
WorkspaceID: item.WorkspaceID,
|
|
RecipientType: item.RecipientType,
|
|
RecipientID: item.RecipientID,
|
|
IssueID: item.IssueID,
|
|
})
|
|
}
|
|
|
|
userID := requestUserID(r)
|
|
workspaceID := uuidToString(item.WorkspaceID)
|
|
h.publish(protocol.EventInboxArchived, workspaceID, "member", userID, map[string]any{
|
|
"item_id": uuidToString(item.ID),
|
|
"issue_id": uuidToPtr(item.IssueID),
|
|
"recipient_id": uuidToString(item.RecipientID),
|
|
})
|
|
|
|
resp := h.enrichInboxResponse(r.Context(), inboxToResponse(item), item.IssueID)
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) CountUnreadInbox(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
workspaceID := r.Header.Get("X-Workspace-ID")
|
|
|
|
count, err := h.Queries.CountUnreadInbox(r.Context(), db.CountUnreadInboxParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
RecipientType: "member",
|
|
RecipientID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to count unread inbox")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]int64{"count": count})
|
|
}
|
|
|
|
func (h *Handler) MarkAllInboxRead(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
workspaceID := r.Header.Get("X-Workspace-ID")
|
|
|
|
count, err := h.Queries.MarkAllInboxRead(r.Context(), db.MarkAllInboxReadParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
RecipientID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to mark all inbox read")
|
|
return
|
|
}
|
|
|
|
slog.Info("inbox: mark all read", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
|
|
h.publish(protocol.EventInboxBatchRead, workspaceID, "member", userID, map[string]any{
|
|
"recipient_id": userID,
|
|
"count": count,
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"count": count})
|
|
}
|
|
|
|
func (h *Handler) ArchiveAllInbox(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
workspaceID := r.Header.Get("X-Workspace-ID")
|
|
|
|
count, err := h.Queries.ArchiveAllInbox(r.Context(), db.ArchiveAllInboxParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
RecipientID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to archive all inbox")
|
|
return
|
|
}
|
|
|
|
slog.Info("inbox: archive all", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
|
|
h.publish(protocol.EventInboxBatchArchived, workspaceID, "member", userID, map[string]any{
|
|
"recipient_id": userID,
|
|
"count": count,
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"count": count})
|
|
}
|
|
|
|
func (h *Handler) ArchiveAllReadInbox(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
workspaceID := r.Header.Get("X-Workspace-ID")
|
|
|
|
count, err := h.Queries.ArchiveAllReadInbox(r.Context(), db.ArchiveAllReadInboxParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
RecipientID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to archive all read inbox")
|
|
return
|
|
}
|
|
|
|
slog.Info("inbox: archive all read", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
|
|
h.publish(protocol.EventInboxBatchArchived, workspaceID, "member", userID, map[string]any{
|
|
"recipient_id": userID,
|
|
"count": count,
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"count": count})
|
|
}
|
|
|
|
func (h *Handler) ArchiveCompletedInbox(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
workspaceID := r.Header.Get("X-Workspace-ID")
|
|
|
|
count, err := h.Queries.ArchiveCompletedInbox(r.Context(), db.ArchiveCompletedInboxParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
RecipientID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to archive completed inbox")
|
|
return
|
|
}
|
|
|
|
slog.Info("inbox: archive completed", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
|
|
h.publish(protocol.EventInboxBatchArchived, workspaceID, "member", userID, map[string]any{
|
|
"recipient_id": userID,
|
|
"count": count,
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"count": count})
|
|
}
|