All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Has been skipped
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-go-pg (pull_request) Successful in 2m43s
CI/CD / test-sqlite (pull_request) Successful in 2m52s
CI/CD / build-and-deploy (pull_request) Successful in 2m15s
Consolidate duplicate Go↔Starlark converters into sandbox/convert.go and snapshot parsers into models/snapshot.go. Standardize snapshot creation on wrapped format. Net -393 lines across 21 files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
290 lines
8.7 KiB
Go
290 lines
8.7 KiB
Go
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, _ := models.ParseSnapshotStages(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
|
|
}
|
|
|