package handlers import ( "context" "encoding/json" "log" "net/http" "time" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/notifications" "git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/tools" ) // ── Workflow Instance Handler ─────────────── // Manages the runtime lifecycle of workflow channels: starting instances, // advancing/rejecting stages, and querying status. type WorkflowInstanceHandler struct { stores store.Stores hub *events.Hub notifSvc *notifications.Service } func NewWorkflowInstanceHandler(stores store.Stores, hub *events.Hub, notifSvc *notifications.Service) *WorkflowInstanceHandler { return &WorkflowInstanceHandler{stores: stores, hub: hub, notifSvc: notifSvc} } // ── Start ─────────────────────────────────── // Start creates a new workflow channel from a published workflow version. // POST /api/v1/workflows/:id/start func (h *WorkflowInstanceHandler) Start(c *gin.Context) { ctx := c.Request.Context() wfID := c.Param("id") userID := c.GetString("user_id") wf, err := h.stores.Workflows.GetByID(ctx, wfID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) return } if !wf.IsActive { c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is not active"}) return } ver, err := h.stores.Workflows.GetLatestVersion(ctx, wfID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "workflow has no published version — publish first"}) return } stages, err := h.stores.Workflows.ListStages(ctx, wfID) if err != nil || len(stages) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "workflow has no stages"}) return } // Create the workflow channel ch := &models.Channel{ UserID: userID, Title: wf.Name, Description: wf.Description, Type: "workflow", TeamID: wf.TeamID, } if err := h.stores.Channels.Create(ctx, ch); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel: " + err.Error()}) return } // Set workflow-specific columns (not part of base Channel.Create) allowAnon := wf.EntryMode == "public_link" var allowAnonVal interface{} = allowAnon if database.CurrentDialect == database.DialectSQLite { if allowAnon { allowAnonVal = 1 } else { allowAnonVal = 0 } } _, err = database.DB.ExecContext(ctx, database.Q(` UPDATE channels SET workflow_id = $1, workflow_version = $2, current_stage = 0, stage_data = '{}', workflow_status = 'active', last_activity_at = $3, allow_anonymous = $4, ai_mode = 'auto' WHERE id = $5 `), wfID, ver.VersionNumber, time.Now().UTC(), allowAnonVal, ch.ID) if err != nil { log.Printf("Failed to set workflow columns on channel %s: %v", ch.ID, err) } // Add caller as channel owner _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ ChannelID: ch.ID, ParticipantType: "user", ParticipantID: userID, Role: "owner", }) // Bind stage 0 persona as participant firstStage := stages[0] if firstStage.PersonaID != nil { _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ ChannelID: ch.ID, ParticipantType: "persona", ParticipantID: *firstStage.PersonaID, Role: "member", }) } c.JSON(http.StatusCreated, gin.H{ "channel_id": ch.ID, "workflow_id": wfID, "workflow_version": ver.VersionNumber, "current_stage": 0, "stage": firstStage, }) } // ── Status ────────────────────────────────── // WorkflowChannelStatus holds the runtime state of a workflow instance. type WorkflowChannelStatus struct { WorkflowID *string `json:"workflow_id"` WorkflowVersion *int `json:"workflow_version"` CurrentStage int `json:"current_stage"` StageData json.RawMessage `json:"stage_data"` Status string `json:"status"` LastActivityAt *time.Time `json:"last_activity_at"` } // GetStatus returns the workflow state for a channel. // GET /api/v1/channels/:id/workflow/status func (h *WorkflowInstanceHandler) GetStatus(c *gin.Context) { channelID := c.Param("id") var ws WorkflowChannelStatus var stageData []byte err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` SELECT workflow_id, workflow_version, current_stage, COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'), last_activity_at FROM channels WHERE id = $1 AND type = 'workflow' `), channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion, &ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"}) return } ws.StageData = stageData c.JSON(http.StatusOK, ws) } // ── Advance ───────────────────────────────── // Advance moves the workflow to the next stage. // POST /api/v1/channels/:id/workflow/advance func (h *WorkflowInstanceHandler) Advance(c *gin.Context) { ctx := c.Request.Context() channelID := c.Param("id") workflowID, currentStage, status, err := h.readWorkflowState(ctx, channelID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"}) return } if status != "active" { c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot advance"}) return } stages, err := h.stores.Workflows.ListStages(ctx, workflowID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"}) return } var body struct { Data json.RawMessage `json:"data,omitempty"` } _ = c.ShouldBindJSON(&body) mergedData := tools.MergeWorkflowStageData(ctx, channelID, body.Data) nextStage := currentStage + 1 if nextStage >= len(stages) { // Workflow complete _, err = database.DB.ExecContext(ctx, database.Q(` UPDATE channels SET current_stage = $1, workflow_status = 'completed', stage_data = $2, last_activity_at = $3, ai_mode = 'off' WHERE id = $4 `), nextStage, mergedData, time.Now().UTC(), channelID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to complete workflow"}) return } tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "") // v0.27.0: Emit workflow.completed WS event h.emitWorkflowEvent("workflow.completed", channelID, map[string]any{ "channel_id": channelID, "workflow_id": workflowID, "stage": nextStage, }) // v0.27.0: on_complete chaining — trigger target workflow if configured h.triggerOnComplete(ctx, workflowID, channelID, mergedData) c.JSON(http.StatusOK, gin.H{"status": "completed", "current_stage": nextStage}) return } // Advance to next stage _, err = database.DB.ExecContext(ctx, database.Q(` UPDATE channels SET current_stage = $1, stage_data = $2, last_activity_at = $3 WHERE id = $4 `), nextStage, mergedData, time.Now().UTC(), channelID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to advance stage"}) return } tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "") nextStageDef := stages[nextStage] // Bind next persona if nextStageDef.PersonaID != nil { alreadyIn, _ := h.stores.Channels.IsParticipant(ctx, channelID, "persona", *nextStageDef.PersonaID) if !alreadyIn { _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ ChannelID: channelID, ParticipantType: "persona", ParticipantID: *nextStageDef.PersonaID, Role: "member", }) } } // v0.27.0: Assignment + round-robin + WS notifications if nextStageDef.AssignmentTeamID != nil { assignmentID := tools.CreateWorkflowAssignment(ctx, channelID, nextStage, *nextStageDef.AssignmentTeamID) // Round-robin auto-assignment if configured assignedTo := h.tryRoundRobin(ctx, nextStageDef, assignmentID) // Notify team members about new assignment h.notifyAssignment(ctx, *nextStageDef.AssignmentTeamID, channelID, nextStageDef.Name, assignedTo) } // v0.27.0: Emit workflow.advanced WS event h.emitWorkflowEvent("workflow.advanced", channelID, map[string]any{ "channel_id": channelID, "workflow_id": workflowID, "stage": nextStage, "stage_name": nextStageDef.Name, }) c.JSON(http.StatusOK, gin.H{ "status": "active", "current_stage": nextStage, "stage": nextStageDef, }) } // ── Reject ────────────────────────────────── // Reject returns the workflow to the previous stage with a reason. // POST /api/v1/channels/:id/workflow/reject func (h *WorkflowInstanceHandler) Reject(c *gin.Context) { ctx := c.Request.Context() channelID := c.Param("id") var body struct { Reason string `json:"reason"` } if err := c.ShouldBindJSON(&body); err != nil || body.Reason == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "reason is required"}) return } _, currentStage, status, err := h.readWorkflowState(ctx, channelID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"}) return } if status != "active" { c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot reject"}) return } if currentStage <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "cannot reject from the first stage"}) return } prevStage := currentStage - 1 _, err = database.DB.ExecContext(ctx, database.Q(` UPDATE channels SET current_stage = $1, last_activity_at = $2 WHERE id = $3 `), prevStage, time.Now().UTC(), channelID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject stage"}) return } // Persist rejection as system message if h.stores.Messages != nil { _ = h.stores.Messages.Create(ctx, &models.Message{ ChannelID: channelID, Role: "system", Content: "Stage rejected: " + body.Reason, ParticipantType: "user", ParticipantID: c.GetString("user_id"), }) } c.JSON(http.StatusOK, gin.H{ "status": "active", "current_stage": prevStage, "reason": body.Reason, }) } // ── Helpers ───────────────────────────────── func (h *WorkflowInstanceHandler) readWorkflowState(ctx context.Context, channelID string) (workflowID string, currentStage int, status string, err error) { var wfID *string err = database.DB.QueryRowContext(ctx, database.Q(` SELECT workflow_id, COALESCE(current_stage, 0), COALESCE(workflow_status, 'active') FROM channels WHERE id = $1 AND type = 'workflow' `), channelID).Scan(&wfID, ¤tStage, &status) if wfID != nil { workflowID = *wfID } return } // emitWorkflowEvent pushes a workflow event to all connected clients in the channel's room. func (h *WorkflowInstanceHandler) emitWorkflowEvent(label, channelID string, data map[string]any) { if h.hub == nil { return } payload, _ := json.Marshal(data) h.hub.GetBus().Publish(events.Event{ Label: label, Room: channelID, Payload: payload, Ts: time.Now().UnixMilli(), }) } // triggerOnComplete checks if the workflow has an on_complete chain config // and starts the target workflow if so. func (h *WorkflowInstanceHandler) triggerOnComplete(ctx context.Context, workflowID, channelID, mergedData string) { if h.stores.Workflows == nil { return } wf, err := h.stores.Workflows.GetByID(ctx, workflowID) if err != nil || wf == nil || len(wf.OnComplete) == 0 || string(wf.OnComplete) == "null" { return } var chain struct { Action string `json:"action"` TargetSlug string `json:"target_slug"` DataMap map[string]string `json:"data_map"` // source_key → target_key } if err := json.Unmarshal(wf.OnComplete, &chain); err != nil || chain.Action != "start_workflow" || chain.TargetSlug == "" { return } // Look up target workflow target, err := h.stores.Workflows.GetBySlug(ctx, wf.TeamID, chain.TargetSlug) if err != nil || target == nil || !target.IsActive { log.Printf("[workflow] on_complete: target workflow '%s' not found or inactive", chain.TargetSlug) return } // Map stage_data from completed workflow to initial stage_data for target var sourceData map[string]any if err := json.Unmarshal([]byte(mergedData), &sourceData); err != nil { sourceData = map[string]any{} } targetData := map[string]any{} if len(chain.DataMap) > 0 { for srcKey, tgtKey := range chain.DataMap { if v, ok := sourceData[srcKey]; ok { targetData[tgtKey] = v } } } else { // No mapping = pass all data through targetData = sourceData } // Get the channel owner to start the chained workflow as var ownerID string _ = database.DB.QueryRowContext(ctx, database.Q(` SELECT user_id FROM channels WHERE id = $1 `), channelID).Scan(&ownerID) if ownerID == "" { return } // Create a new workflow channel for the target ver, err := h.stores.Workflows.GetLatestVersion(ctx, target.ID) if err != nil { log.Printf("[workflow] on_complete: target '%s' has no published version", chain.TargetSlug) return } stages, err := h.stores.Workflows.ListStages(ctx, target.ID) if err != nil || len(stages) == 0 { return } ch := &models.Channel{ UserID: ownerID, Title: target.Name + " (chained)", Description: target.Description, Type: "workflow", TeamID: target.TeamID, } if err := h.stores.Channels.Create(ctx, ch); err != nil { log.Printf("[workflow] on_complete: failed to create chained channel: %v", err) return } initialData, _ := json.Marshal(targetData) _, err = database.DB.ExecContext(ctx, database.Q(` UPDATE channels SET workflow_id = $1, workflow_version = $2, current_stage = 0, stage_data = $3, workflow_status = 'active', last_activity_at = $4, ai_mode = 'auto' WHERE id = $5 `), target.ID, ver.VersionNumber, string(initialData), time.Now().UTC(), ch.ID) if err != nil { log.Printf("[workflow] on_complete: failed to set workflow columns: %v", err) return } _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ ChannelID: ch.ID, ParticipantType: "user", ParticipantID: ownerID, Role: "owner", }) if stages[0].PersonaID != nil { _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ ChannelID: ch.ID, ParticipantType: "persona", ParticipantID: *stages[0].PersonaID, Role: "member", }) } log.Printf("[workflow] on_complete: chained '%s' → '%s' (channel %s → %s)", wf.Slug, target.Slug, channelID, ch.ID) } // tryRoundRobin checks if the stage has auto_assign:"round_robin" in // transition_rules and assigns the newly created assignment to the // least-recently-assigned team member. Returns the assigned user ID or "". func (h *WorkflowInstanceHandler) tryRoundRobin(ctx context.Context, stage models.WorkflowStage, assignmentID string) string { if assignmentID == "" || stage.AssignmentTeamID == nil { return "" } // Check transition_rules for auto_assign var rules struct { AutoAssign string `json:"auto_assign"` } if len(stage.TransitionRules) > 0 { _ = json.Unmarshal(stage.TransitionRules, &rules) } if rules.AutoAssign != "round_robin" { return "" } // Get team members members, err := h.stores.Teams.ListMembers(ctx, *stage.AssignmentTeamID) if err != nil || len(members) == 0 { return "" } // Find the least-recently-assigned member. // Query: for each member, find their most recent claimed_at in workflow_assignments. // Pick the member with the oldest (or null) claimed_at. var bestUserID string bestUserID = members[0].UserID // fallback to first member rows, err := database.DB.QueryContext(ctx, database.Q(` SELECT m.user_id, MAX(wa.claimed_at) as last_claim FROM team_members m LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = $1 WHERE m.team_id = $2 GROUP BY m.user_id ORDER BY last_claim ASC NULLS FIRST LIMIT 1 `), *stage.AssignmentTeamID, *stage.AssignmentTeamID) if err == nil { defer rows.Close() if rows.Next() { var uid string var lastClaim *time.Time if err := rows.Scan(&uid, &lastClaim); err == nil { bestUserID = uid } } } // Claim the assignment for this user now := time.Now().UTC() _, err = database.DB.ExecContext(ctx, database.Q(` UPDATE workflow_assignments SET assigned_to = $1, status = 'claimed', claimed_at = $2 WHERE id = $3 AND status = 'unassigned' `), bestUserID, now, assignmentID) if err != nil { log.Printf("[workflow] round-robin: failed to auto-assign %s to %s: %v", assignmentID, bestUserID, err) return "" } log.Printf("[workflow] round-robin: auto-assigned %s to user %s", assignmentID, bestUserID) return bestUserID } // notifyAssignment sends notifications to team members about a new workflow assignment. func (h *WorkflowInstanceHandler) notifyAssignment(ctx context.Context, teamID, channelID, stageName, assignedTo string) { if h.notifSvc == nil || h.stores.Teams == nil { return } members, err := h.stores.Teams.ListMembers(ctx, teamID) if err != nil { return } for _, m := range members { title := "New workflow assignment" body := "Stage '" + stageName + "' needs review" if assignedTo != "" && m.UserID == assignedTo { title = "Workflow assigned to you" body = "Stage '" + stageName + "' has been assigned to you (round-robin)" } n := &models.Notification{ UserID: m.UserID, Type: "workflow.assigned", Title: title, Body: body, ResourceType: models.ResourceTypeChannel, ResourceID: channelID, } if err := h.notifSvc.Notify(ctx, n); err != nil { log.Printf("[workflow] notify assignment: %v", err) } } // Also emit targeted WS event for immediate UI update if h.hub != nil { payload, _ := json.Marshal(map[string]any{ "channel_id": channelID, "team_id": teamID, "stage_name": stageName, "assigned_to": assignedTo, }) for _, m := range members { h.hub.SendToUser(m.UserID, events.Event{ Label: "workflow.assigned", Payload: payload, Ts: time.Now().UnixMilli(), }) } } } // createStageNote and mergeStageData are now in tools/workflow.go // (shared between handler and workflow_advance tool).