Add @all mention type that notifies all workspace members (excluding agents). Includes backend parsing, notification expansion to all members, and frontend UI with autocomplete suggestion, rendering, and hover card.
367 lines
12 KiB
Go
367 lines
12 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"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
type CommentResponse struct {
|
|
ID string `json:"id"`
|
|
IssueID string `json:"issue_id"`
|
|
AuthorType string `json:"author_type"`
|
|
AuthorID string `json:"author_id"`
|
|
Content string `json:"content"`
|
|
Type string `json:"type"`
|
|
ParentID *string `json:"parent_id"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
Reactions []ReactionResponse `json:"reactions"`
|
|
Attachments []AttachmentResponse `json:"attachments"`
|
|
}
|
|
|
|
func commentToResponse(c db.Comment, reactions []ReactionResponse, attachments []AttachmentResponse) CommentResponse {
|
|
if reactions == nil {
|
|
reactions = []ReactionResponse{}
|
|
}
|
|
if attachments == nil {
|
|
attachments = []AttachmentResponse{}
|
|
}
|
|
return CommentResponse{
|
|
ID: uuidToString(c.ID),
|
|
IssueID: uuidToString(c.IssueID),
|
|
AuthorType: c.AuthorType,
|
|
AuthorID: uuidToString(c.AuthorID),
|
|
Content: c.Content,
|
|
Type: c.Type,
|
|
ParentID: uuidToPtr(c.ParentID),
|
|
CreatedAt: timestampToString(c.CreatedAt),
|
|
UpdatedAt: timestampToString(c.UpdatedAt),
|
|
Reactions: reactions,
|
|
Attachments: attachments,
|
|
}
|
|
}
|
|
|
|
func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
|
|
issueID := chi.URLParam(r, "id")
|
|
issue, ok := h.loadIssueForUser(w, r, issueID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
comments, err := h.Queries.ListComments(r.Context(), db.ListCommentsParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list comments")
|
|
return
|
|
}
|
|
|
|
commentIDs := make([]pgtype.UUID, len(comments))
|
|
for i, c := range comments {
|
|
commentIDs[i] = c.ID
|
|
}
|
|
grouped := h.groupReactions(r, commentIDs)
|
|
groupedAtt := h.groupAttachments(r, commentIDs)
|
|
|
|
resp := make([]CommentResponse, len(comments))
|
|
for i, c := range comments {
|
|
cid := uuidToString(c.ID)
|
|
resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid])
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
type CreateCommentRequest struct {
|
|
Content string `json:"content"`
|
|
Type string `json:"type"`
|
|
ParentID *string `json:"parent_id"`
|
|
AttachmentIDs []string `json:"attachment_ids"`
|
|
}
|
|
|
|
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
|
issueID := chi.URLParam(r, "id")
|
|
issue, ok := h.loadIssueForUser(w, r, issueID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req CreateCommentRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Content == "" {
|
|
writeError(w, http.StatusBadRequest, "content is required")
|
|
return
|
|
}
|
|
if req.Type == "" {
|
|
req.Type = "comment"
|
|
}
|
|
|
|
var parentID pgtype.UUID
|
|
if req.ParentID != nil {
|
|
parentID = parseUUID(*req.ParentID)
|
|
parent, err := h.Queries.GetComment(r.Context(), parentID)
|
|
if err != nil || uuidToString(parent.IssueID) != issueID {
|
|
writeError(w, http.StatusBadRequest, "invalid parent comment")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Determine author identity: agent (via X-Agent-ID header) or member.
|
|
authorType, authorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID))
|
|
|
|
comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
AuthorType: authorType,
|
|
AuthorID: parseUUID(authorID),
|
|
Content: req.Content,
|
|
Type: req.Type,
|
|
ParentID: parentID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("create comment failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to create comment: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Link uploaded attachments to this comment.
|
|
if len(req.AttachmentIDs) > 0 {
|
|
h.linkAttachmentsByIDs(r.Context(), comment.ID, issue.ID, req.AttachmentIDs)
|
|
}
|
|
|
|
// Fetch linked attachments so the response includes them.
|
|
groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID})
|
|
resp := commentToResponse(comment, nil, groupedAtt[uuidToString(comment.ID)])
|
|
slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...)
|
|
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{
|
|
"comment": resp,
|
|
"issue_title": issue.Title,
|
|
"issue_assignee_type": textToPtr(issue.AssigneeType),
|
|
"issue_assignee_id": uuidToPtr(issue.AssigneeID),
|
|
"issue_status": issue.Status,
|
|
})
|
|
|
|
// If the issue is assigned to an agent with on_comment trigger, enqueue a new task.
|
|
// Skip when the comment comes from the assigned agent itself to avoid loops.
|
|
// Also skip when the comment @mentions others but not the assignee agent —
|
|
// the user is talking to someone else, not requesting work from the assignee.
|
|
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) &&
|
|
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) {
|
|
// Resolve thread root: if the comment is a reply, agent should reply
|
|
// to the thread root (matching frontend behavior where all replies
|
|
// in a thread share the same top-level parent).
|
|
replyTo := comment.ID
|
|
if comment.ParentID.Valid {
|
|
replyTo = comment.ParentID
|
|
}
|
|
if _, err := h.TaskService.EnqueueTaskForIssue(r.Context(), issue, replyTo); err != nil {
|
|
slog.Warn("enqueue agent task on comment failed", "issue_id", issueID, "error", err)
|
|
}
|
|
}
|
|
|
|
// Trigger @mentioned agents: parse agent mentions and enqueue tasks for each.
|
|
h.enqueueMentionedAgentTasks(r.Context(), issue, comment, authorType, authorID)
|
|
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
// commentMentionsOthersButNotAssignee returns true if the comment @mentions
|
|
// anyone but does NOT @mention the issue's assignee agent. This is used to
|
|
// suppress the on_comment trigger when the user is directing their comment at
|
|
// someone else (e.g. sharing results with a colleague, asking another agent).
|
|
func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.Issue) bool {
|
|
mentions := util.ParseMentions(content)
|
|
if len(mentions) == 0 {
|
|
return false // No mentions — normal on_comment behavior
|
|
}
|
|
if !issue.AssigneeID.Valid {
|
|
return true // No assignee — mentions target others
|
|
}
|
|
assigneeID := uuidToString(issue.AssigneeID)
|
|
for _, m := range mentions {
|
|
if m.IsMentionAll() {
|
|
return false // @all includes everyone — allow trigger
|
|
}
|
|
if m.ID == assigneeID {
|
|
return false // Assignee is mentioned — allow trigger
|
|
}
|
|
}
|
|
return true // Others mentioned but not assignee — suppress trigger
|
|
}
|
|
|
|
// enqueueMentionedAgentTasks parses @agent mentions from comment content and
|
|
// enqueues a task for each mentioned agent. Skips self-mentions, agents that
|
|
// are already the issue's assignee (handled by on_comment), and agents with
|
|
// on_mention trigger disabled.
|
|
func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue, comment db.Comment, authorType, authorID string) {
|
|
mentions := util.ParseMentions(comment.Content)
|
|
for _, m := range mentions {
|
|
if m.Type != "agent" {
|
|
continue
|
|
}
|
|
// Prevent self-trigger: skip if the comment author is this agent.
|
|
if authorType == "agent" && authorID == m.ID {
|
|
continue
|
|
}
|
|
agentUUID := parseUUID(m.ID)
|
|
// Prevent duplicate: skip if this agent is the issue's assignee
|
|
// (already handled by the on_comment trigger above).
|
|
if issue.AssigneeType.Valid && issue.AssigneeType.String == "agent" &&
|
|
issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID {
|
|
continue
|
|
}
|
|
// Check if the agent has on_mention trigger enabled.
|
|
if !h.isAgentMentionTriggerEnabled(ctx, agentUUID) {
|
|
continue
|
|
}
|
|
// Dedup: skip if this agent already has a pending task for this issue.
|
|
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
|
|
IssueID: issue.ID,
|
|
AgentID: agentUUID,
|
|
})
|
|
if err != nil || hasPending {
|
|
continue
|
|
}
|
|
// Resolve thread root for reply threading.
|
|
replyTo := comment.ID
|
|
if comment.ParentID.Valid {
|
|
replyTo = comment.ParentID
|
|
}
|
|
if _, err := h.TaskService.EnqueueTaskForMention(ctx, issue, agentUUID, replyTo); err != nil {
|
|
slog.Warn("enqueue mention agent task failed", "issue_id", uuidToString(issue.ID), "agent_id", m.ID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
|
commentId := chi.URLParam(r, "commentId")
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Load comment scoped to current workspace.
|
|
workspaceID := resolveWorkspaceID(r)
|
|
existing, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
|
|
ID: parseUUID(commentId),
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "comment not found")
|
|
return
|
|
}
|
|
|
|
member, ok := h.workspaceMember(w, r, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
isAuthor := existing.AuthorType == actorType && uuidToString(existing.AuthorID) == actorID
|
|
isAdmin := roleAllowed(member.Role, "owner", "admin")
|
|
if !isAuthor && !isAdmin {
|
|
writeError(w, http.StatusForbidden, "only comment author or admin can edit")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Content string `json:"content"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Content == "" {
|
|
writeError(w, http.StatusBadRequest, "content is required")
|
|
return
|
|
}
|
|
|
|
comment, err := h.Queries.UpdateComment(r.Context(), db.UpdateCommentParams{
|
|
ID: parseUUID(commentId),
|
|
Content: req.Content,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("update comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to update comment")
|
|
return
|
|
}
|
|
|
|
// Fetch reactions and attachments for the updated comment.
|
|
grouped := h.groupReactions(r, []pgtype.UUID{comment.ID})
|
|
groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID})
|
|
cid := uuidToString(comment.ID)
|
|
resp := commentToResponse(comment, grouped[cid], groupedAtt[cid])
|
|
slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...)
|
|
h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, map[string]any{"comment": resp})
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
|
|
commentId := chi.URLParam(r, "commentId")
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Load comment scoped to current workspace.
|
|
workspaceID := resolveWorkspaceID(r)
|
|
comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
|
|
ID: parseUUID(commentId),
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "comment not found")
|
|
return
|
|
}
|
|
|
|
member, ok := h.workspaceMember(w, r, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
isAuthor := comment.AuthorType == actorType && uuidToString(comment.AuthorID) == actorID
|
|
isAdmin := roleAllowed(member.Role, "owner", "admin")
|
|
if !isAuthor && !isAdmin {
|
|
writeError(w, http.StatusForbidden, "only comment author or admin can delete")
|
|
return
|
|
}
|
|
|
|
// Collect attachment URLs before CASCADE delete removes them.
|
|
attachmentURLs, _ := h.Queries.ListAttachmentURLsByCommentID(r.Context(), parseUUID(commentId))
|
|
|
|
if err := h.Queries.DeleteComment(r.Context(), parseUUID(commentId)); err != nil {
|
|
slog.Warn("delete comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to delete comment")
|
|
return
|
|
}
|
|
|
|
h.deleteS3Objects(r.Context(), attachmentURLs)
|
|
slog.Info("comment deleted", append(logger.RequestAttrs(r), "comment_id", commentId, "issue_id", uuidToString(comment.IssueID))...)
|
|
h.publish(protocol.EventCommentDeleted, workspaceID, actorType, actorID, map[string]any{
|
|
"comment_id": commentId,
|
|
"issue_id": uuidToString(comment.IssueID),
|
|
})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|