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}) }