package handlers // workflow_assignments.go — Assignment queue for human review stages. // // v0.29.0: Raw SQL replaced with WorkflowStore + ChannelStore methods. import ( "encoding/json" "fmt" "net/http" "time" "github.com/gin-gonic/gin" "chat-switchboard/events" "chat-switchboard/notifications" "chat-switchboard/store" ) // ── Workflow Assignment Handler ───────────── type WorkflowAssignmentHandler struct { stores store.Stores hub *events.Hub } func NewWorkflowAssignmentHandler(stores store.Stores, hub ...*events.Hub) *WorkflowAssignmentHandler { h := &WorkflowAssignmentHandler{stores: stores} if len(hub) > 0 { h.hub = hub[0] } return h } // ListForTeam returns unassigned + claimed assignments for a team. // GET /api/v1/teams/:teamId/assignments func (h *WorkflowAssignmentHandler) ListForTeam(c *gin.Context) { teamID := c.Param("teamId") status := c.DefaultQuery("status", "unassigned") result, err := h.stores.Workflows.ListAssignmentsForTeam(c.Request.Context(), teamID, status) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"}) return } c.JSON(http.StatusOK, gin.H{"data": result}) } // ListMine returns assignments claimed by the current user plus // unassigned assignments for teams the user belongs to. // GET /api/v1/workflow-assignments/mine func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) { userID := c.GetString("user_id") result, err := h.stores.Workflows.ListAssignmentsMine(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"}) return } c.JSON(http.StatusOK, gin.H{"data": result}) } // Claim assigns a workflow to the current user via optimistic lock. // POST /api/v1/workflow-assignments/:id/claim func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) { assignmentID := c.Param("id") userID := c.GetString("user_id") now := time.Now().UTC() n, err := h.stores.Workflows.ClaimAssignment(c.Request.Context(), assignmentID, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to claim assignment"}) return } if n == 0 { c.JSON(http.StatusConflict, gin.H{"error": "assignment already claimed or not found"}) return } // Look up channel for WS delivery + notification channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID) // Emit workflow.claimed WS event to all user participants in the channel if h.hub != nil && channelID != "" { payload, _ := json.Marshal(map[string]any{ "assignment_id": assignmentID, "claimed_by": userID, "channel_id": channelID, }) evt := events.Event{ Label: "workflow.claimed", Payload: payload, Ts: now.UnixMilli(), } pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "") for _, uid := range pids { h.hub.PublishToUser(uid, evt) } } // Persist notification for bell/inbox if svc := notifications.Default(); svc != nil && channelID != "" { notifications.NotifyWorkflowClaimed(svc, userID, assignmentID, channelID) } c.JSON(http.StatusOK, gin.H{"claimed": true, "assignment_id": assignmentID}) } // Complete marks an assignment as completed. // POST /api/v1/workflow-assignments/:id/complete func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) { assignmentID := c.Param("id") n, err := h.stores.Workflows.CompleteAssignment(c.Request.Context(), assignmentID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to complete assignment"}) return } if n == 0 { c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"}) return } c.JSON(http.StatusOK, gin.H{"completed": true, "assignment_id": assignmentID}) } // ── Lifecycle operations (v0.37.15) ── // Unclaim returns a claimed assignment to unassigned. // POST /api/v1/workflow-assignments/:id/unclaim func (h *WorkflowAssignmentHandler) Unclaim(c *gin.Context) { assignmentID := c.Param("id") userID := c.GetString("user_id") role, _ := c.Get("role") // Auth: claimer or team admin or global admin a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID) if err != nil || a == nil { c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"}) return } if role != "admin" && (a.AssignedTo == nil || *a.AssignedTo != userID) { isTA := false if h.stores.Teams != nil { isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID) } if !isTA { c.JSON(http.StatusForbidden, gin.H{"error": "only the claimer or a team admin can unclaim"}) return } } n, err := h.stores.Workflows.UnclaimAssignment(c.Request.Context(), assignmentID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unclaim assignment"}) return } if n == 0 { c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"}) return } // Emit WS event channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID) if h.hub != nil && channelID != "" { payload, _ := json.Marshal(map[string]any{ "assignment_id": assignmentID, "unclaimed_by": userID, "channel_id": channelID, }) evt := events.Event{ Label: "workflow.unclaimed", Payload: payload, Ts: time.Now().UnixMilli(), } pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "") for _, uid := range pids { h.hub.PublishToUser(uid, evt) } } c.JSON(http.StatusOK, gin.H{"unclaimed": true, "assignment_id": assignmentID}) } // Reassign changes the assigned_to on a claimed assignment. // POST /api/v1/workflow-assignments/:id/reassign func (h *WorkflowAssignmentHandler) Reassign(c *gin.Context) { assignmentID := c.Param("id") userID := c.GetString("user_id") role, _ := c.Get("role") var body struct { UserID string `json:"user_id"` } if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"}) return } // Auth: team admin or global admin a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID) if err != nil || a == nil { c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"}) return } if role != "admin" { isTA := false if h.stores.Teams != nil { isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID) } if !isTA { c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"}) return } } n, err := h.stores.Workflows.ReassignAssignment(c.Request.Context(), assignmentID, body.UserID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reassign assignment"}) return } if n == 0 { c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"}) return } // Emit WS event channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID) if h.hub != nil && channelID != "" { payload, _ := json.Marshal(map[string]any{ "assignment_id": assignmentID, "reassigned_by": userID, "new_assignee": body.UserID, "channel_id": channelID, }) evt := events.Event{ Label: "workflow.reassigned", Payload: payload, Ts: time.Now().UnixMilli(), } pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "") for _, uid := range pids { h.hub.PublishToUser(uid, evt) } } c.JSON(http.StatusOK, gin.H{"reassigned": true, "assignment_id": assignmentID, "new_assignee": body.UserID}) } // CancelAssignment cancels a single assignment. // POST /api/v1/workflow-assignments/:id/cancel func (h *WorkflowAssignmentHandler) CancelAssignment(c *gin.Context) { assignmentID := c.Param("id") userID := c.GetString("user_id") role, _ := c.Get("role") // Auth: team admin or global admin a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID) if err != nil || a == nil { c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"}) return } if role != "admin" { isTA := false if h.stores.Teams != nil { isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID) } if !isTA { c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"}) return } } n, err := h.stores.Workflows.CancelAssignment(c.Request.Context(), assignmentID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel assignment"}) return } if n == 0 { c.JSON(http.StatusConflict, gin.H{"error": "assignment not in cancellable state"}) return } c.JSON(http.StatusOK, gin.H{"cancelled": true, "assignment_id": assignmentID}) } // CommentOnAssignment adds a review comment to an assignment. // POST /api/v1/workflow-assignments/:id/comment func (h *WorkflowAssignmentHandler) CommentOnAssignment(c *gin.Context) { assignmentID := c.Param("id") userID := c.GetString("user_id") var body struct { Text string `json:"text"` } if err := c.ShouldBindJSON(&body); err != nil || body.Text == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "text is required"}) return } comment := store.ReviewComment{ Text: body.Text, UserID: userID, CreatedAt: time.Now().UTC().Format(time.RFC3339), } if err := h.stores.Workflows.AddReviewComment(c.Request.Context(), assignmentID, comment); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add comment: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true, "comment": comment}) } // GetAssignment returns a single assignment with review comments. // GET /api/v1/workflow-assignments/:id func (h *WorkflowAssignmentHandler) GetAssignment(c *gin.Context) { assignmentID := c.Param("id") a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("assignment not found: %v", err)}) return } // Parse review_comments for the response var comments []store.ReviewComment if len(a.ReviewComments) > 0 { _ = json.Unmarshal(a.ReviewComments, &comments) } c.JSON(http.StatusOK, gin.H{ "id": a.ID, "channel_id": a.ChannelID, "stage": a.Stage, "team_id": a.TeamID, "assigned_to": a.AssignedTo, "status": a.Status, "review_comments": comments, "created_at": a.CreatedAt, "claimed_at": a.ClaimedAt, "completed_at": a.CompletedAt, }) }