multica/server/internal/handler/subscriber.go
Naiyuan Qing bfe9498def feat(notifications): replace hardcoded inbox notifications with subscriber-driven model
Replace inbox_listeners.go with a subscriber-driven notification system:

- Add issue_subscriber table with auto-subscribe on create/assign/comment
- New subscriber_listeners.go: maintains subscriber data on domain events
- New notification_listeners.go: notifySubscribers (fanout to all subscribers
  minus actor) and notifyDirect (targeted, punches through unsubscribe)
- Subscriber API: list/subscribe/unsubscribe endpoints
- Frontend: subscribers section in issue detail sidebar with real-time sync
- Frontend: inbox notification grouping by (issue_id, type, actor_id)
- Remove createInboxForIssueCreator from task.go (unified through event bus)
- 21 new Go tests, all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:33:20 +08:00

110 lines
2.9 KiB
Go

package handler
import (
"net/http"
"github.com/go-chi/chi/v5"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// SubscriberResponse is the JSON shape returned for each issue subscriber.
type SubscriberResponse struct {
IssueID string `json:"issue_id"`
UserType string `json:"user_type"`
UserID string `json:"user_id"`
Reason string `json:"reason"`
CreatedAt string `json:"created_at"`
}
func subscriberToResponse(s db.IssueSubscriber) SubscriberResponse {
return SubscriberResponse{
IssueID: uuidToString(s.IssueID),
UserType: s.UserType,
UserID: uuidToString(s.UserID),
Reason: s.Reason,
CreatedAt: timestampToString(s.CreatedAt),
}
}
// ListIssueSubscribers returns all subscribers for an issue.
func (h *Handler) ListIssueSubscribers(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
subscribers, err := h.Queries.ListIssueSubscribers(r.Context(), issue.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list subscribers")
return
}
resp := make([]SubscriberResponse, len(subscribers))
for i, s := range subscribers {
resp[i] = subscriberToResponse(s)
}
writeJSON(w, http.StatusOK, resp)
}
// SubscribeToIssue subscribes the current user to an issue with reason "manual".
func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
userID := requestUserID(r)
err := h.Queries.AddIssueSubscriber(r.Context(), db.AddIssueSubscriberParams{
IssueID: issue.ID,
UserType: "member",
UserID: parseUUID(userID),
Reason: "manual",
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to subscribe")
return
}
workspaceID := uuidToString(issue.WorkspaceID)
h.publish(protocol.EventSubscriberAdded, workspaceID, "member", userID, map[string]any{
"issue_id": issueID,
"user_id": userID,
"reason": "manual",
})
writeJSON(w, http.StatusOK, map[string]bool{"subscribed": true})
}
// UnsubscribeFromIssue removes the current user's subscription from an issue.
func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
userID := requestUserID(r)
err := h.Queries.RemoveIssueSubscriber(r.Context(), db.RemoveIssueSubscriberParams{
IssueID: issue.ID,
UserType: "member",
UserID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to unsubscribe")
return
}
workspaceID := uuidToString(issue.WorkspaceID)
h.publish(protocol.EventSubscriberRemoved, workspaceID, "member", userID, map[string]any{
"issue_id": issueID,
"user_id": userID,
})
writeJSON(w, http.StatusOK, map[string]bool{"subscribed": false})
}