package handlers import ( "fmt" "log" "net/http" "github.com/gin-gonic/gin" "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 err = h.stores.Channels.SetWorkflowInstance(ctx, ch.ID, wf.ID, ver.VersionNumber, []byte("{}"), "active") if err != nil { log.Printf("Failed to set workflow columns: %v", err) } // Enable anonymous access + auto AI mode for visitor entry _ = h.stores.Channels.Update(ctx, ch.ID, map[string]interface{}{ "allow_anonymous": true, "ai_mode": "auto", }) // 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, }) }