Files
multica/server/internal/handler/subscriber.go
Bohan Jiang 9b45e0d4a6 feat(cli): add issue subscriber commands (#1265)
* feat(cli): add `issue subscriber` commands

Wrap the existing /subscribers, /subscribe, and /unsubscribe endpoints as
`multica issue subscriber list|add|remove`, mirroring the comment subcommand
shape. `--user <name>` reuses resolveAssignee to resolve a member or agent;
without the flag, the action targets the caller.

* fix(issues): default subscribe target to resolveActor, not X-User-ID

When no user_id is posted, subscribe/unsubscribe hardcoded the target as
("member", X-User-ID). A CLI caller running as an agent (X-Agent-ID set)
then subscribed the underlying member rather than the agent itself,
which contradicts the "defaults to the caller" contract.

Derive the default via resolveActor so the endpoint mirrors caller
identity consistently — agent caller → agent row, member caller →
member row. Adds a regression test covering the agent caller path.
2026-04-17 16:26:00 +08:00

160 lines
4.8 KiB
Go

package handler
import (
"encoding/json"
"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 a user to an issue with reason "manual".
// If request body contains user_id, subscribes that user; otherwise subscribes the caller.
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
}
workspaceID := uuidToString(issue.WorkspaceID)
// Default target: the caller, derived via resolveActor so an agent caller
// (X-Agent-ID set) subscribes itself rather than the underlying member.
callerActorType, callerActorID := h.resolveActor(r, requestUserID(r), workspaceID)
targetUserType := callerActorType
targetUserID := callerActorID
var req struct {
UserID *string `json:"user_id"`
UserType *string `json:"user_type"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&req)
}
if req.UserID != nil && *req.UserID != "" {
targetUserID = *req.UserID
}
if req.UserType != nil && *req.UserType != "" {
targetUserType = *req.UserType
}
if !h.isWorkspaceEntity(r.Context(), targetUserType, targetUserID, workspaceID) {
writeError(w, http.StatusForbidden, "target user is not a member of this workspace")
return
}
err := h.Queries.AddIssueSubscriber(r.Context(), db.AddIssueSubscriberParams{
IssueID: issue.ID,
UserType: targetUserType,
UserID: parseUUID(targetUserID),
Reason: "manual",
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to subscribe")
return
}
h.publish(protocol.EventSubscriberAdded, workspaceID, callerActorType, callerActorID, map[string]any{
"issue_id": issueID,
"user_type": targetUserType,
"user_id": targetUserID,
"reason": "manual",
})
writeJSON(w, http.StatusOK, map[string]bool{"subscribed": true})
}
// UnsubscribeFromIssue removes a user's subscription from an issue.
// If request body contains user_id, unsubscribes that user; otherwise unsubscribes the caller.
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
}
workspaceID := uuidToString(issue.WorkspaceID)
// Default target: the caller, derived via resolveActor so an agent caller
// (X-Agent-ID set) unsubscribes itself rather than the underlying member.
callerActorType, callerActorID := h.resolveActor(r, requestUserID(r), workspaceID)
targetUserType := callerActorType
targetUserID := callerActorID
var req struct {
UserID *string `json:"user_id"`
UserType *string `json:"user_type"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&req)
}
if req.UserID != nil && *req.UserID != "" {
targetUserID = *req.UserID
}
if req.UserType != nil && *req.UserType != "" {
targetUserType = *req.UserType
}
if !h.isWorkspaceEntity(r.Context(), targetUserType, targetUserID, workspaceID) {
writeError(w, http.StatusForbidden, "target user is not a member of this workspace")
return
}
err := h.Queries.RemoveIssueSubscriber(r.Context(), db.RemoveIssueSubscriberParams{
IssueID: issue.ID,
UserType: targetUserType,
UserID: parseUUID(targetUserID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to unsubscribe")
return
}
h.publish(protocol.EventSubscriberRemoved, workspaceID, callerActorType, callerActorID, map[string]any{
"issue_id": issueID,
"user_type": targetUserType,
"user_id": targetUserID,
})
writeJSON(w, http.StatusOK, map[string]bool{"subscribed": false})
}