This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/workflow_assignment_handlers.go
Jeffrey Smith 748da6c2b4 Feat v0.3.4 team roles + multi-party validation (#18)
Custom team roles: removed CHECK constraint on team_members.role,
roles stored in teams.settings, roles API, stage_config.required_role
enforced on claim. Multi-party signoff: workflow_signoffs table,
validation gate in advanceInternal, SubmitSignoff engine method,
signoff HTTP API. Frontend: dynamic role management, stage config
validation UI, signoff panel. Design docs for extension lifecycle
and trigger composition. 20 store tests passing.

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

189 lines
5.6 KiB
Go

package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
"switchboard-core/workflow"
)
// ── 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 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": []struct{}{}})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"})
return
}
if result == nil {
c.JSON(http.StatusOK, gin.H{"data": []struct{}{}})
return
}
c.JSON(http.StatusOK, gin.H{"data": result})
}
// ListMine returns 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": []struct{}{}})
return
}
c.JSON(http.StatusOK, gin.H{"data": result})
}