package handlers import ( "fmt" "log" "net/http" "time" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Workflow Entry Handler ────────────────── // Handles the visitor-facing workflow start flow. // Landing page rendering is in pages/pages.go (RenderWorkflowLanding). type WorkflowEntryHandler struct { stores store.Stores } func NewWorkflowEntryHandler(stores store.Stores) *WorkflowEntryHandler { return &WorkflowEntryHandler{stores: stores} } // StartVisitor creates an anonymous session + workflow instance for a visitor. // POST /api/v1/workflow-entry/:scope/:slug func (h *WorkflowEntryHandler) StartVisitor(c *gin.Context) { ctx := c.Request.Context() scope := c.Param("scope") slug := c.Param("slug") var teamID *string if scope != "global" { teamID = &scope } wf, err := h.stores.Workflows.GetBySlug(ctx, teamID, slug) if err != nil || wf == nil { c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) return } if !wf.IsActive { c.JSON(http.StatusBadRequest, gin.H{"error": "workflow not active"}) return } if wf.EntryMode != "public_link" { c.JSON(http.StatusForbidden, gin.H{"error": "workflow requires authentication"}) return } ver, err := h.stores.Workflows.GetLatestVersion(ctx, wf.ID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "workflow has no published version"}) return } stages, err := h.stores.Workflows.ListStages(ctx, wf.ID) if err != nil || len(stages) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "workflow has no stages"}) return } // Create the workflow channel (owned by workflow creator) ch := &models.Channel{ UserID: wf.CreatedBy, 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"}) return } // Set workflow columns allowAnonVal := interface{}(true) if database.CurrentDialect == database.DialectSQLite { allowAnonVal = 1 } _, 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 `), wf.ID, ver.VersionNumber, time.Now().UTC(), allowAnonVal, ch.ID) if err != nil { log.Printf("Failed to set workflow columns: %v", err) } // Create anonymous session sessionToken := store.NewID() visitorCount, _ := h.stores.Sessions.CountForChannel(ctx, ch.ID) displayName := "Visitor" if visitorCount > 0 { displayName = fmt.Sprintf("Visitor %d", visitorCount+1) } sess := &models.SessionParticipant{ SessionToken: sessionToken, ChannelID: ch.ID, DisplayName: displayName, } if err := h.stores.Sessions.Create(ctx, sess); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"}) return } // Add session as channel participant _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ ChannelID: ch.ID, ParticipantType: "session", ParticipantID: sess.ID, Role: "visitor", }) // Bind stage 0 persona if stages[0].PersonaID != nil { _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ ChannelID: ch.ID, ParticipantType: "persona", ParticipantID: *stages[0].PersonaID, Role: "member", }) } // Set session cookie (30 day expiry, matching v0.24.3) c.SetCookie("sb_session", sessionToken, 30*24*60*60, "/", "", false, true) // Redirect to chat — visitor lands on existing /w/:channelId c.JSON(http.StatusCreated, gin.H{ "channel_id": ch.ID, "session_id": sess.ID, "redirect_to": "/w/" + ch.ID, }) }