package handlers import ( "context" "database/sql" "encoding/json" "net/http" "time" "github.com/gin-gonic/gin" "armature/models" "armature/store" "armature/workflow" ) // assignmentView is the enriched response for assignment list endpoints. type assignmentView struct { models.WorkflowAssignment WorkflowID string `json:"workflow_id"` WorkflowName string `json:"workflow_name"` StageName string `json:"stage_name"` SLABreached bool `json:"sla_breached"` } // ── Assignment Handlers ───────────────────── // WorkflowAssignmentHandler manages workflow assignment HTTP endpoints. type WorkflowAssignmentHandler struct { engine *workflow.Engine stores store.Stores } // NewWorkflowAssignmentHandler creates an assignment handler. func NewWorkflowAssignmentHandler(engine *workflow.Engine, stores store.Stores) *WorkflowAssignmentHandler { return &WorkflowAssignmentHandler{engine: engine, stores: stores} } // Claim claims an assignment for the current user. // POST /api/v1/assignments/:id/claim func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) { userID := c.GetString("user_id") assignmentID := c.Param("id") ctx := c.Request.Context() // Claim first, then verify role. Rollback if role check fails. if err := h.stores.Workflows.ClaimAssignment(ctx, assignmentID, userID); err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } // Role check: find the assignment in user's claimed list and verify role claimed, _ := h.stores.Workflows.ListAssignmentsByUser(ctx, userID, models.AssignmentStatusClaimed) for _, a := range claimed { if a.ID == assignmentID { if rerr := workflow.CheckClaimRole(ctx, h.stores, &a, userID); rerr != nil { // Rollback: unclaim h.stores.Workflows.UnclaimAssignment(ctx, assignmentID) c.JSON(http.StatusForbidden, gin.H{"error": rerr.Error()}) return } break } } c.JSON(http.StatusOK, gin.H{"claimed": true}) } // Unclaim releases a claimed assignment back to the queue. // POST /api/v1/assignments/:id/unclaim func (h *WorkflowAssignmentHandler) Unclaim(c *gin.Context) { userID := c.GetString("user_id") assignmentID := c.Param("id") // Verify current user is the claimer userAssignments, err := h.stores.Workflows.ListAssignmentsByUser(c.Request.Context(), userID, models.AssignmentStatusClaimed) if err == nil { found := false for _, a := range userAssignments { if a.ID == assignmentID { found = true break } } if !found { c.JSON(http.StatusForbidden, gin.H{"error": "assignment not claimed by you"}) return } } if err := h.stores.Workflows.UnclaimAssignment(c.Request.Context(), assignmentID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"unclaimed": true}) } // Complete marks a claimed assignment as completed. // POST /api/v1/assignments/:id/complete func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) { userID := c.GetString("user_id") assignmentID := c.Param("id") var body struct { ReviewData json.RawMessage `json:"review_data"` } if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } // Verify current user is the claimer userAssignments, err := h.stores.Workflows.ListAssignmentsByUser(c.Request.Context(), userID, models.AssignmentStatusClaimed) if err == nil { found := false for _, a := range userAssignments { if a.ID == assignmentID { found = true break } } if !found { c.JSON(http.StatusForbidden, gin.H{"error": "assignment not claimed by you"}) return } } if err := h.stores.Workflows.CompleteAssignment(c.Request.Context(), assignmentID, body.ReviewData); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // If the stage has auto_transition, advance the instance. // Look up the assignment's instance to get context. userAssignments, _ = h.stores.Workflows.ListAssignmentsByUser(c.Request.Context(), userID, models.AssignmentStatusCompleted) for _, a := range userAssignments { if a.ID == assignmentID { // Try to advance — engine will handle validation h.engine.Advance(c.Request.Context(), a.InstanceID, body.ReviewData, userID) break } } c.JSON(http.StatusOK, gin.H{"completed": true}) } // Cancel cancels an open assignment. // POST /api/v1/assignments/:id/cancel func (h *WorkflowAssignmentHandler) Cancel(c *gin.Context) { assignmentID := c.Param("id") if err := h.stores.Workflows.CancelAssignment(c.Request.Context(), assignmentID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"cancelled": true}) } // ListByTeam returns enriched assignments for a team. // GET /api/v1/teams/:teamId/assignments?status= func (h *WorkflowAssignmentHandler) ListByTeam(c *gin.Context) { teamID := c.Param("teamId") status := c.Query("status") result, err := h.stores.Workflows.ListAssignmentsByTeam(c.Request.Context(), teamID, status) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusOK, gin.H{"data": []assignmentView{}}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"}) return } if result == nil { c.JSON(http.StatusOK, gin.H{"data": []assignmentView{}}) return } c.JSON(http.StatusOK, gin.H{"data": h.enrichAssignments(c.Request.Context(), result)}) } // Assign lets a team admin assign an unassigned assignment to a specific user. // POST /api/v1/assignments/:id/assign func (h *WorkflowAssignmentHandler) Assign(c *gin.Context) { assignmentID := c.Param("id") ctx := c.Request.Context() 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 } // Verify the target user exists targetUser, err := h.stores.Users.GetByID(ctx, body.UserID) if err != nil || targetUser == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"}) return } // ClaimAssignment sets assigned_to and status=claimed — works for admin assignment if err := h.stores.Workflows.ClaimAssignment(ctx, assignmentID, body.UserID); err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"assigned": true, "assigned_to": body.UserID}) } // ListMine returns enriched assignments claimed by or assigned to the current user. // GET /api/v1/assignments/mine?status= func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) { userID := c.GetString("user_id") status := c.Query("status") result, err := h.stores.Workflows.ListAssignmentsByUser(c.Request.Context(), userID, status) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"}) return } if result == nil { c.JSON(http.StatusOK, gin.H{"data": []assignmentView{}}) return } c.JSON(http.StatusOK, gin.H{"data": h.enrichAssignments(c.Request.Context(), result)}) } // enrichAssignments resolves workflow_name, stage_name, and sla_breached // for each assignment by looking up instance → workflow → stage snapshot. func (h *WorkflowAssignmentHandler) enrichAssignments(ctx context.Context, assignments []models.WorkflowAssignment) []assignmentView { views := make([]assignmentView, 0, len(assignments)) // Cache lookups to avoid repeated DB hits for the same workflow/instance instanceCache := map[string]*models.WorkflowInstance{} workflowCache := map[string]*models.Workflow{} for _, a := range assignments { v := assignmentView{WorkflowAssignment: a} // Resolve instance → workflow inst, ok := instanceCache[a.InstanceID] if !ok { inst, _ = h.stores.Workflows.GetInstance(ctx, a.InstanceID) instanceCache[a.InstanceID] = inst } if inst != nil { v.WorkflowID = inst.WorkflowID wf, ok := workflowCache[inst.WorkflowID] if !ok { wf, _ = h.stores.Workflows.GetByID(ctx, inst.WorkflowID) workflowCache[inst.WorkflowID] = wf } if wf != nil { v.WorkflowName = wf.Name } // Resolve stage name and SLA from the published version snapshot ver, _ := h.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion) if ver != nil { stages := parseSnapshotStagesForEnrich(ver.Snapshot) for _, s := range stages { if s.Name == a.Stage { v.StageName = s.Name if s.SLASeconds != nil && *s.SLASeconds > 0 { elapsed := time.Since(a.CreatedAt) v.SLABreached = elapsed.Seconds() > float64(*s.SLASeconds) } break } } } } // Fallback: use stage field as stage_name if not resolved if v.StageName == "" { v.StageName = a.Stage } views = append(views, v) } return views } // parseSnapshotStagesForEnrich is a lightweight stage parser for enrichment. func parseSnapshotStagesForEnrich(snapshot json.RawMessage) []models.WorkflowStage { var wrapped struct { Stages []models.WorkflowStage `json:"stages"` } if err := json.Unmarshal(snapshot, &wrapped); err == nil && len(wrapped.Stages) > 0 { return wrapped.Stages } var stages []models.WorkflowStage json.Unmarshal(snapshot, &stages) return stages }