refactor(server): extract inbox creation to bus listeners, add agent visibility filtering

- Move all CreateInboxItem calls from handlers to centralized inbox_listeners.go
- Enrich issue:updated payload with change context (assignee_changed, status_changed, prev values)
- Enrich comment:created payload with issue context (assignee info)
- Bus listeners handle: issue assign, unassign, reassign, status change, comment notification
- ListAgents filters private agents: only visible to owner_id or workspace admin
- Zero CreateInboxItem calls remain in handler package

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-25 11:24:45 +08:00
parent 19504a217c
commit 759dd741bd
7 changed files with 279 additions and 125 deletions

View file

@ -123,7 +123,8 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
workspaceID := resolveWorkspaceID(r)
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
if !ok {
return
}
@ -133,12 +134,22 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
return
}
resp := make([]AgentResponse, len(agents))
for i, a := range agents {
resp[i] = agentToResponse(a)
userID := requestUserID(r)
isAdmin := roleAllowed(member.Role, "owner", "admin")
// Filter private agents: only visible to owner_id or workspace admin
var visible []AgentResponse
for _, a := range agents {
if a.Visibility == "private" && !isAdmin && uuidToString(a.OwnerID) != userID {
continue
}
visible = append(visible, agentToResponse(a))
}
if visible == nil {
visible = []AgentResponse{}
}
writeJSON(w, http.StatusOK, resp)
writeJSON(w, http.StatusOK, visible)
}
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {

View file

@ -98,27 +98,12 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
}
resp := commentToResponse(comment)
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{"comment": resp})
// Notify issue assignee about new comment (if assignee is a member and != commenter)
if issue.AssigneeType.Valid && issue.AssigneeID.Valid &&
issue.AssigneeType.String == "member" && uuidToString(issue.AssigneeID) != userID {
body := req.Content
inboxItem, inboxErr := h.Queries.CreateInboxItem(r.Context(), db.CreateInboxItemParams{
WorkspaceID: issue.WorkspaceID,
RecipientType: "member",
RecipientID: issue.AssigneeID,
Type: "mentioned",
Severity: "info",
IssueID: issue.ID,
Title: "New comment on: " + issue.Title,
Body: ptrToText(&body),
})
if inboxErr == nil {
h.publish(protocol.EventInboxNew, uuidToString(issue.WorkspaceID), "member", userID,
map[string]any{"item": inboxToResponse(inboxItem)})
}
}
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{
"comment": resp,
"issue_title": issue.Title,
"issue_assignee_type": textToPtr(issue.AssigneeType),
"issue_assignee_id": uuidToPtr(issue.AssigneeID),
})
writeJSON(w, http.StatusCreated, resp)
}

View file

@ -236,23 +236,8 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
resp := issueToResponse(issue)
h.publish(protocol.EventIssueCreated, workspaceID, "member", creatorID, map[string]any{"issue": resp})
// Create inbox notification for assignee
// Only ready issues in todo are enqueued for agents.
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
inboxItem, err := h.Queries.CreateInboxItem(r.Context(), db.CreateInboxItemParams{
WorkspaceID: issue.WorkspaceID,
RecipientType: issue.AssigneeType.String,
RecipientID: issue.AssigneeID,
Type: "issue_assigned",
Severity: "action_required",
IssueID: issue.ID,
Title: "New issue assigned: " + issue.Title,
Body: ptrToText(req.Description),
})
if err == nil {
h.publish(protocol.EventInboxNew, workspaceID, "member", creatorID, map[string]any{"item": inboxToResponse(inboxItem)})
}
// Only ready issues in todo are enqueued for agents.
if h.shouldEnqueueAgentTask(r.Context(), issue) {
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
}
@ -368,12 +353,22 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
resp := issueToResponse(issue)
h.publish(protocol.EventIssueUpdated, workspaceID, "member", userID, map[string]any{"issue": resp})
assigneeChanged := (req.AssigneeType != nil || req.AssigneeID != nil) &&
(prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID))
statusChanged := req.Status != nil && prevIssue.Status != issue.Status
h.publish(protocol.EventIssueUpdated, workspaceID, "member", userID, map[string]any{
"issue": resp,
"assignee_changed": assigneeChanged,
"status_changed": statusChanged,
"prev_assignee_type": textToPtr(prevIssue.AssigneeType),
"prev_assignee_id": uuidToPtr(prevIssue.AssigneeID),
"prev_status": prevIssue.Status,
"creator_type": prevIssue.CreatorType,
"creator_id": uuidToString(prevIssue.CreatorID),
})
// If assignee or readiness status changed, reconcile the task queue.
if assigneeChanged || statusChanged {
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
@ -383,84 +378,6 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
}
// If assignee changed, create a notification for the new assignee.
if assigneeChanged {
// Notify old assignee about unassignment
if prevIssue.AssigneeID.Valid && prevIssue.AssigneeType.Valid &&
prevIssue.AssigneeType.String == "member" && uuidToString(prevIssue.AssigneeID) != userID {
oldInbox, oErr := h.Queries.CreateInboxItem(r.Context(), db.CreateInboxItemParams{
WorkspaceID: prevIssue.WorkspaceID,
RecipientType: "member",
RecipientID: prevIssue.AssigneeID,
Type: "status_change",
Severity: "info",
IssueID: prevIssue.ID,
Title: "Unassigned from: " + issue.Title,
Body: ptrToText(nil),
})
if oErr == nil {
h.publish(protocol.EventInboxNew, workspaceID, "member", userID,
map[string]any{"item": inboxToResponse(oldInbox)})
}
}
// Create inbox notification for new assignee
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
inboxItem, err := h.Queries.CreateInboxItem(r.Context(), db.CreateInboxItemParams{
WorkspaceID: issue.WorkspaceID,
RecipientType: issue.AssigneeType.String,
RecipientID: issue.AssigneeID,
Type: "issue_assigned",
Severity: "action_required",
IssueID: issue.ID,
Title: "Assigned to you: " + issue.Title,
})
if err == nil {
h.publish(protocol.EventInboxNew, workspaceID, "member", userID, map[string]any{"item": inboxToResponse(inboxItem)})
}
}
}
// If status changed, create a notification
if req.Status != nil {
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
inboxItem, err := h.Queries.CreateInboxItem(r.Context(), db.CreateInboxItemParams{
WorkspaceID: issue.WorkspaceID,
RecipientType: issue.AssigneeType.String,
RecipientID: issue.AssigneeID,
Type: "status_change",
Severity: "info",
IssueID: issue.ID,
Title: issue.Title + " moved to " + *req.Status,
})
if err == nil {
h.publish(protocol.EventInboxNew, workspaceID, "member", userID, map[string]any{"item": inboxToResponse(inboxItem)})
}
}
// Notify creator about status change (if creator is member and != the person making change)
if prevIssue.CreatorType == "member" && uuidToString(prevIssue.CreatorID) != userID {
// Don't double-notify if creator is also the assignee
isAlsoAssignee := prevIssue.AssigneeID.Valid && uuidToString(prevIssue.AssigneeID) == uuidToString(prevIssue.CreatorID)
if !isAlsoAssignee {
creatorInbox, cErr := h.Queries.CreateInboxItem(r.Context(), db.CreateInboxItemParams{
WorkspaceID: prevIssue.WorkspaceID,
RecipientType: "member",
RecipientID: prevIssue.CreatorID,
Type: "status_change",
Severity: "info",
IssueID: prevIssue.ID,
Title: "Status changed: " + issue.Title,
Body: ptrToText(nil),
})
if cErr == nil {
h.publish(protocol.EventInboxNew, workspaceID, "member", userID,
map[string]any{"item": inboxToResponse(creatorInbox)})
}
}
}
}
writeJSON(w, http.StatusOK, resp)
}