package handlers import ( "database/sql" "encoding/json" "net/http" "regexp" "strconv" "strings" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Workflow Handler ──────────────────────── type WorkflowHandler struct { stores store.Stores } func NewWorkflowHandler(stores store.Stores) *WorkflowHandler { return &WorkflowHandler{stores: stores} } var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`) func validSlug(s string) bool { if len(s) < 2 || len(s) > 64 { return false } return slugRe.MatchString(s) } // ── Workflow CRUD ─────────────────────────── // List returns workflows visible to the caller. // GET /api/v1/workflows?team_id=... func (h *WorkflowHandler) List(c *gin.Context) { teamID := c.Query("team_id") var result []models.Workflow var err error if teamID != "" { result, err = h.stores.Workflows.ListForTeam(c.Request.Context(), teamID) } else { result, err = h.stores.Workflows.ListGlobal(c.Request.Context()) } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workflows"}) return } if result == nil { result = []models.Workflow{} } c.JSON(http.StatusOK, gin.H{"data": result}) } // Get returns a single workflow with its stages. // GET /api/v1/workflows/:id func (h *WorkflowHandler) Get(c *gin.Context) { w, err := h.stores.Workflows.GetByID(c.Request.Context(), c.Param("id")) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get workflow: " + err.Error()}) return } stages, _ := h.stores.Workflows.ListStages(c.Request.Context(), w.ID) if stages == nil { stages = []models.WorkflowStage{} } w.Stages = stages c.JSON(http.StatusOK, w) } // Create creates a new workflow definition. // POST /api/v1/workflows func (h *WorkflowHandler) Create(c *gin.Context) { var w models.Workflow if err := c.ShouldBindJSON(&w); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if w.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } if w.Slug == "" { w.Slug = slugify(w.Name) } if !validSlug(w.Slug) { c.JSON(http.StatusBadRequest, gin.H{"error": "slug must be 2-64 chars, lowercase alphanumeric and hyphens"}) return } if w.EntryMode == "" { w.EntryMode = "public_link" } if w.EntryMode != "public_link" && w.EntryMode != "team_only" { c.JSON(http.StatusBadRequest, gin.H{"error": "entry_mode must be public_link or team_only"}) return } w.CreatedBy = c.GetString("user_id") if err := h.stores.Workflows.Create(c.Request.Context(), &w); err != nil { if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") { c.JSON(http.StatusConflict, gin.H{"error": "slug already exists in this scope"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workflow: " + err.Error()}) return } c.JSON(http.StatusCreated, w) } // Update patches a workflow definition. // PATCH /api/v1/workflows/:id func (h *WorkflowHandler) Update(c *gin.Context) { id := c.Param("id") var patch models.WorkflowPatch if err := c.ShouldBindJSON(&patch); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if patch.EntryMode != nil && *patch.EntryMode != "public_link" && *patch.EntryMode != "team_only" { c.JSON(http.StatusBadRequest, gin.H{"error": "entry_mode must be public_link or team_only"}) return } if err := h.stores.Workflows.Update(c.Request.Context(), id, patch); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update workflow"}) return } w, _ := h.stores.Workflows.GetByID(c.Request.Context(), id) if w == nil { c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) return } c.JSON(http.StatusOK, w) } // Delete deletes a workflow and all its stages/versions (CASCADE). // DELETE /api/v1/workflows/:id func (h *WorkflowHandler) Delete(c *gin.Context) { if err := h.stores.Workflows.Delete(c.Request.Context(), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete workflow"}) return } c.JSON(http.StatusOK, gin.H{"deleted": true}) } // ── Stage CRUD ────────────────────────────── // ListStages returns stages for a workflow, ordered by ordinal. // GET /api/v1/workflows/:id/stages func (h *WorkflowHandler) ListStages(c *gin.Context) { stages, err := h.stores.Workflows.ListStages(c.Request.Context(), c.Param("id")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list stages"}) return } if stages == nil { stages = []models.WorkflowStage{} } c.JSON(http.StatusOK, gin.H{"data": stages}) } // CreateStage adds a stage to a workflow. // POST /api/v1/workflows/:id/stages func (h *WorkflowHandler) CreateStage(c *gin.Context) { var st models.WorkflowStage if err := c.ShouldBindJSON(&st); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } st.WorkflowID = c.Param("id") if st.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } if st.HistoryMode == "" { st.HistoryMode = "full" } if st.HistoryMode != "full" && st.HistoryMode != "summary" && st.HistoryMode != "fresh" { c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"}) return } if st.StageMode == "" { st.StageMode = models.StageModeChatOnly } if !models.ValidStageModes[st.StageMode] { c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, or form_chat"}) return } if st.Ordinal == 0 { existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID) st.Ordinal = len(existing) } if err := h.stores.Workflows.CreateStage(c.Request.Context(), &st); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create stage: " + err.Error()}) return } c.JSON(http.StatusCreated, st) } // UpdateStage updates a stage. // PUT /api/v1/workflows/:id/stages/:sid func (h *WorkflowHandler) UpdateStage(c *gin.Context) { var st models.WorkflowStage if err := c.ShouldBindJSON(&st); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } st.ID = c.Param("sid") st.WorkflowID = c.Param("id") if st.HistoryMode != "" && st.HistoryMode != "full" && st.HistoryMode != "summary" && st.HistoryMode != "fresh" { c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"}) return } if st.StageMode != "" && !models.ValidStageModes[st.StageMode] { c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, or form_chat"}) return } if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"}) return } c.JSON(http.StatusOK, st) } // DeleteStage removes a stage. // DELETE /api/v1/workflows/:id/stages/:sid func (h *WorkflowHandler) DeleteStage(c *gin.Context) { if err := h.stores.Workflows.DeleteStage(c.Request.Context(), c.Param("sid")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete stage"}) return } c.JSON(http.StatusOK, gin.H{"deleted": true}) } // ReorderStages sets the ordinal for all stages. // PATCH /api/v1/workflows/:id/stages/reorder func (h *WorkflowHandler) ReorderStages(c *gin.Context) { var body struct { OrderedIDs []string `json:"ordered_ids"` } if err := c.ShouldBindJSON(&body); err != nil || len(body.OrderedIDs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "ordered_ids required"}) return } if err := h.stores.Workflows.ReorderStages(c.Request.Context(), c.Param("id"), body.OrderedIDs); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reorder stages"}) return } c.JSON(http.StatusOK, gin.H{"reordered": true}) } // ── Publish ───────────────────────────────── // Publish creates an immutable version snapshot of the current workflow state. // POST /api/v1/workflows/:id/publish func (h *WorkflowHandler) Publish(c *gin.Context) { wfID := c.Param("id") w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get workflow: " + err.Error()}) return } stages, _ := h.stores.Workflows.ListStages(c.Request.Context(), wfID) if stages == nil { stages = []models.WorkflowStage{} } // Snapshot includes persona tool grants at publish time (frozen for running instances) type stageSnapshot struct { models.WorkflowStage ToolGrants []string `json:"tool_grants,omitempty"` } snapshotStages := make([]stageSnapshot, len(stages)) for i, st := range stages { snapshotStages[i] = stageSnapshot{WorkflowStage: st} if st.PersonaID != nil && h.stores.Personas != nil { grants, _ := h.stores.Personas.GetToolGrants(c.Request.Context(), *st.PersonaID) snapshotStages[i].ToolGrants = grants } } snapshot := map[string]interface{}{ "workflow": w, "stages": snapshotStages, } snapshotJSON, err := json.Marshal(snapshot) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize snapshot"}) return } v := &models.WorkflowVersion{ WorkflowID: wfID, VersionNumber: w.Version, Snapshot: snapshotJSON, } existing, _ := h.stores.Workflows.GetVersion(c.Request.Context(), wfID, w.Version) if existing != nil { c.JSON(http.StatusConflict, gin.H{"error": "version already published — edit the workflow to increment"}) return } if err := h.stores.Workflows.Publish(c.Request.Context(), v); err != nil { if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") { c.JSON(http.StatusConflict, gin.H{"error": "version already published"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to publish: " + err.Error()}) return } c.JSON(http.StatusCreated, v) } // GetVersion returns a specific version snapshot. // GET /api/v1/workflows/:id/versions/:version func (h *WorkflowHandler) GetVersion(c *gin.Context) { wfID := c.Param("id") vNum, err := strconv.Atoi(c.Param("version")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"}) return } v, err := h.stores.Workflows.GetVersion(c.Request.Context(), wfID, vNum) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get version"}) return } c.JSON(http.StatusOK, v) } // ── Helpers ───────────────────────────────── func slugify(name string) string { s := strings.ToLower(strings.TrimSpace(name)) s = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(s, "-") s = strings.Trim(s, "-") if len(s) > 64 { s = s[:64] } return s }