497 lines
16 KiB
Go
497 lines
16 KiB
Go
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 *string `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 user participants in the channel.
|
|
// Uses SendToUser (not room-scoped Bus.Publish) because room subscriptions are
|
|
// not yet wired on the client side. See websocket.md § Room Model.
|
|
func (h *WorkflowInstanceHandler) emitWorkflowEvent(label, channelID string, data map[string]any) {
|
|
if h.hub == nil {
|
|
return
|
|
}
|
|
payload, _ := json.Marshal(data)
|
|
evt := events.Event{
|
|
Label: label,
|
|
Payload: payload,
|
|
Ts: time.Now().UnixMilli(),
|
|
}
|
|
|
|
// Send to all user participants in the channel
|
|
rows, err := database.DB.Query(database.Q(`
|
|
SELECT participant_id FROM channel_participants
|
|
WHERE channel_id = $1 AND participant_type = 'user'
|
|
`), channelID)
|
|
if err != nil {
|
|
log.Printf("[ws] %s: failed to query participants for channel %s: %v", label, channelID[:min(8, len(channelID))], err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var uid string
|
|
if rows.Scan(&uid) == nil {
|
|
h.hub.SendToUser(uid, evt)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
// v0.27.3: Delegate to shared implementation (also handles webhooks)
|
|
go tools.TriggerWorkflowOnComplete(ctx, h.stores, workflowID, channelID, mergedData)
|
|
}
|
|
|
|
// 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, COALESCE(MAX(wa.claimed_at), '1970-01-01T00:00:00Z') 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
|
|
LIMIT 1
|
|
`), *stage.AssignmentTeamID, *stage.AssignmentTeamID)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
if rows.Next() {
|
|
var uid string
|
|
var lastClaim string // COALESCE returns TEXT on both dialects
|
|
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).
|