Files
multica/server/internal/handler/activity.go
Naiyuan Qing f0f3cb5c3a fix(server): resolve X-Workspace-Slug in middleware-less handlers (#1165)
Problem
-------
The v2 workspace URL refactor (#1141) switched the frontend from sending
X-Workspace-ID (UUID) to X-Workspace-Slug. The workspace middleware was
updated to accept the slug and translate it via GetWorkspaceBySlug.

But the handler package maintained a PARALLEL resolver
(`resolveWorkspaceID` in handler.go) used by endpoints that sit outside
the workspace middleware — and that resolver was never updated. It only
checked context / ?workspace_id / X-Workspace-ID, never the slug.

/api/upload-file is the one production route that hit the broken path:
it's user-scoped (not behind workspace middleware) because it also
serves avatar uploads (no workspace). Post-refactor requests from the
frontend arrived with only X-Workspace-Slug; the handler resolver
returned "", the code fell into the "no workspace context" branch, and
every file upload since v2 landed in S3 with no corresponding DB
attachment row — files orphaned, invisible to the UI.

Root cause is structural: two resolvers doing the same job, written
independently, diverged silently when one was updated.

Fix
---
Collapse to a single shared helper. middleware.ResolveWorkspaceIDFromRequest
is the new canonical resolver; both the middleware's internal
`resolveWorkspaceUUID` (for middleware gating) and the handler-side
`(h *Handler).resolveWorkspaceID` (promoted from a package function)
now delegate to it. Priority order matches what the middleware has had
since v2: context > X-Workspace-Slug header > ?workspace_slug query >
X-Workspace-ID header > ?workspace_id query.

Impact analysis
---------------
47 call sites of the old `resolveWorkspaceID(r)` are renamed to
`h.resolveWorkspaceID(r)`. 46 of them sit behind workspace middleware,
so they hit the context fast path and see zero behavior change. The
one caller that actually gains capability is UploadFile — which now
correctly recognizes slug requests and creates DB attachment rows.

Tests
-----
- New table-driven unit test for ResolveWorkspaceIDFromRequest covers
  all priority levels and the unknown-slug fallback.
- Regression tests for UploadFile: once with X-Workspace-Slug only
  (the broken path), once with X-Workspace-ID only (legacy CLI/daemon
  compat path). Both assert that a DB attachment row is created.
- Full Go test suite passes; typecheck + pnpm test unaffected.

Plan
----
See docs/plans/2026-04-16-unify-workspace-identity-resolver.md for the
full first-principles writeup.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:01:56 +08:00

196 lines
5.6 KiB
Go

package handler
import (
"encoding/json"
"net/http"
"sort"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// TimelineEntry represents a single entry in the issue timeline, which can be
// either an activity log record or a comment.
type TimelineEntry struct {
Type string `json:"type"` // "activity" or "comment"
ID string `json:"id"`
ActorType string `json:"actor_type"`
ActorID string `json:"actor_id"`
CreatedAt string `json:"created_at"`
// Activity-only fields
Action *string `json:"action,omitempty"`
Details json.RawMessage `json:"details,omitempty"`
// Comment-only fields
Content *string `json:"content,omitempty"`
ParentID *string `json:"parent_id,omitempty"`
UpdatedAt *string `json:"updated_at,omitempty"`
CommentType *string `json:"comment_type,omitempty"`
Reactions []ReactionResponse `json:"reactions,omitempty"`
Attachments []AttachmentResponse `json:"attachments,omitempty"`
}
// ListTimeline returns a merged, chronologically-sorted timeline of activities
// and comments for a given issue.
func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, id)
if !ok {
return
}
activities, err := h.Queries.ListActivities(r.Context(), db.ListActivitiesParams{
IssueID: issue.ID,
Limit: 200,
Offset: 0,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list activities")
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
}
timeline := make([]TimelineEntry, 0, len(activities)+len(comments))
for _, a := range activities {
action := a.Action
actorType := ""
if a.ActorType.Valid {
actorType = a.ActorType.String
}
timeline = append(timeline, TimelineEntry{
Type: "activity",
ID: uuidToString(a.ID),
ActorType: actorType,
ActorID: uuidToString(a.ActorID),
Action: &action,
Details: a.Details,
CreatedAt: timestampToString(a.CreatedAt),
})
}
// Fetch reactions and attachments for all comments in one batch.
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)
for _, c := range comments {
content := c.Content
commentType := c.Type
updatedAt := timestampToString(c.UpdatedAt)
cid := uuidToString(c.ID)
timeline = append(timeline, TimelineEntry{
Type: "comment",
ID: cid,
ActorType: c.AuthorType,
ActorID: uuidToString(c.AuthorID),
Content: &content,
CommentType: &commentType,
ParentID: uuidToPtr(c.ParentID),
CreatedAt: timestampToString(c.CreatedAt),
UpdatedAt: &updatedAt,
Reactions: grouped[cid],
Attachments: groupedAtt[cid],
})
}
// Sort chronologically (ascending by created_at)
sort.Slice(timeline, func(i, j int) bool {
return timeline[i].CreatedAt < timeline[j].CreatedAt
})
writeJSON(w, http.StatusOK, timeline)
}
// AssigneeFrequencyEntry represents how often a user assigns to a specific target.
type AssigneeFrequencyEntry struct {
AssigneeType string `json:"assignee_type"`
AssigneeID string `json:"assignee_id"`
Frequency int64 `json:"frequency"`
}
// GetAssigneeFrequency returns assignee usage frequency for the current user,
// combining data from assignee change activities and initial issue assignments.
func (h *Handler) GetAssigneeFrequency(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := h.resolveWorkspaceID(r)
// Aggregate frequency from both data sources.
freq := map[string]int64{} // key: "type:id"
// Source 1: assignee_changed activities by this user.
activityCounts, err := h.Queries.CountAssigneeChangesByActor(r.Context(), db.CountAssigneeChangesByActorParams{
WorkspaceID: parseUUID(workspaceID),
ActorID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get assignee frequency")
return
}
for _, row := range activityCounts {
aType, _ := row.AssigneeType.(string)
aID, _ := row.AssigneeID.(string)
if aType != "" && aID != "" {
freq[aType+":"+aID] += row.Frequency
}
}
// Source 2: issues created by this user with an assignee.
issueCounts, err := h.Queries.CountCreatedIssueAssignees(r.Context(), db.CountCreatedIssueAssigneesParams{
WorkspaceID: parseUUID(workspaceID),
CreatorID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get assignee frequency")
return
}
for _, row := range issueCounts {
if !row.AssigneeType.Valid || !row.AssigneeID.Valid {
continue
}
key := row.AssigneeType.String + ":" + uuidToString(row.AssigneeID)
freq[key] += row.Frequency
}
// Build sorted response.
result := make([]AssigneeFrequencyEntry, 0, len(freq))
for key, count := range freq {
// Split "type:id" — type is always "member" or "agent" (no colons).
var aType, aID string
for i := 0; i < len(key); i++ {
if key[i] == ':' {
aType = key[:i]
aID = key[i+1:]
break
}
}
result = append(result, AssigneeFrequencyEntry{
AssigneeType: aType,
AssigneeID: aID,
Frequency: count,
})
}
sort.Slice(result, func(i, j int) bool {
return result[i].Frequency > result[j].Frequency
})
writeJSON(w, http.StatusOK, result)
}