package handlers // workflow_packages.go — v0.30.2 CS1 // // Handles workflow-specific package operations: // - ExportWorkflowPackage: bundles a workflow definition + stages into a .pkg // - InstallWorkflowFromManifest: creates/updates a workflow from a .pkg manifest import ( "archive/zip" "encoding/json" "fmt" "log" "net/http" "github.com/gin-gonic/gin" "chat-switchboard/models" "chat-switchboard/store" ) // WorkflowPackageHandler handles workflow package export and install. type WorkflowPackageHandler struct { stores store.Stores } // NewWorkflowPackageHandler creates a new workflow package handler. func NewWorkflowPackageHandler(s store.Stores) *WorkflowPackageHandler { return &WorkflowPackageHandler{stores: s} } // ExportWorkflowPackage exports a workflow definition as a downloadable .pkg. // GET /api/v1/admin/workflows/:id/export func (h *WorkflowPackageHandler) ExportWorkflowPackage(c *gin.Context) { ctx := c.Request.Context() wfID := c.Param("id") wf, err := h.stores.Workflows.GetByID(ctx, wfID) if err != nil || wf == nil { c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) return } stages, err := h.stores.Workflows.ListStages(ctx, wfID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"}) return } // Build stage definitions for the manifest stageDefs := make([]map[string]any, 0, len(stages)) for _, s := range stages { sd := map[string]any{ "name": s.Name, "ordinal": s.Ordinal, "stage_mode": s.StageMode, "history_mode": s.HistoryMode, "auto_transition": s.AutoTransition, } if s.PersonaID != nil { sd["persona_id"] = *s.PersonaID } if s.AssignmentTeamID != nil { sd["assignment_team_id"] = *s.AssignmentTeamID } if s.SurfacePkgID != nil { sd["surface_pkg_id"] = *s.SurfacePkgID } if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "{}" { var ft any if json.Unmarshal(s.FormTemplate, &ft) == nil { sd["form_template"] = ft } } if len(s.TransitionRules) > 0 && string(s.TransitionRules) != "{}" { var tr any if json.Unmarshal(s.TransitionRules, &tr) == nil { sd["transition_rules"] = tr } } stageDefs = append(stageDefs, sd) } // Build the manifest manifest := map[string]any{ "id": wf.Slug, "title": wf.Name, "type": "workflow", "workflow_definition": map[string]any{ "name": wf.Name, "slug": wf.Slug, "entry_mode": wf.EntryMode, "stages": stageDefs, }, } if wf.Description != "" { manifest["description"] = wf.Description } // Include branding if set if len(wf.Branding) > 0 && string(wf.Branding) != "{}" { var branding any if json.Unmarshal(wf.Branding, &branding) == nil { wfDef := manifest["workflow_definition"].(map[string]any) wfDef["branding"] = branding } } // Set response headers filename := fmt.Sprintf("%s.pkg", wf.Slug) c.Header("Content-Type", "application/zip") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) // Create zip writer directly to response zw := zip.NewWriter(c.Writer) defer zw.Close() // Write manifest.json manifestJSON, err := json.MarshalIndent(manifest, "", " ") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize manifest"}) return } mf, err := zw.Create("manifest.json") if err != nil { return } mf.Write(manifestJSON) } // InstallWorkflowFromManifest creates or updates a workflow from a package manifest. // Called from InstallPackage when type="workflow". func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID string, manifest map[string]any) error { wfDefRaw, ok := manifest["workflow_definition"] if !ok { return fmt.Errorf("missing workflow_definition in manifest") } // Re-marshal and parse for type safety wfDefJSON, err := json.Marshal(wfDefRaw) if err != nil { return fmt.Errorf("invalid workflow_definition: %w", err) } var wfDef struct { Name string `json:"name"` Slug string `json:"slug"` EntryMode string `json:"entry_mode"` Branding json.RawMessage `json:"branding"` Stages []workflowPkgStage `json:"stages"` } if err := json.Unmarshal(wfDefJSON, &wfDef); err != nil { return fmt.Errorf("invalid workflow_definition: %w", err) } if wfDef.Name == "" || wfDef.Slug == "" { return fmt.Errorf("workflow_definition requires name and slug") } if wfDef.EntryMode == "" { wfDef.EntryMode = "public_link" } userID := ctx.GetString("user_id") reqCtx := ctx.Request.Context() // Check if workflow already exists (by slug, global scope) existing, _ := stores.Workflows.GetBySlug(reqCtx, nil, wfDef.Slug) var workflowID string if existing != nil { // Update existing workflow workflowID = existing.ID patch := models.WorkflowPatch{ Name: &wfDef.Name, EntryMode: &wfDef.EntryMode, } if len(wfDef.Branding) > 0 { b := json.RawMessage(wfDef.Branding) patch.Branding = &b } if err := stores.Workflows.Update(reqCtx, workflowID, patch); err != nil { return fmt.Errorf("failed to update workflow: %w", err) } // Delete existing stages (will be replaced) existingStages, _ := stores.Workflows.ListStages(reqCtx, workflowID) for _, s := range existingStages { stores.Workflows.DeleteStage(reqCtx, s.ID) } } else { // Create new workflow wf := &models.Workflow{ Name: wfDef.Name, Slug: wfDef.Slug, EntryMode: wfDef.EntryMode, CreatedBy: userID, } if len(wfDef.Branding) > 0 { wf.Branding = json.RawMessage(wfDef.Branding) } if err := stores.Workflows.Create(reqCtx, wf); err != nil { return fmt.Errorf("failed to create workflow: %w", err) } workflowID = wf.ID } // Create stages from definition for _, s := range wfDef.Stages { st := &models.WorkflowStage{ WorkflowID: workflowID, Ordinal: s.Ordinal, Name: s.Name, StageMode: s.StageMode, HistoryMode: s.HistoryMode, AutoTransition: s.AutoTransition, PersonaID: s.PersonaID, AssignmentTeamID: s.AssignmentTeamID, SurfacePkgID: s.SurfacePkgID, } if st.StageMode == "" { st.StageMode = models.StageModeChatOnly } if st.HistoryMode == "" { st.HistoryMode = "full" } if s.FormTemplate != nil { st.FormTemplate, _ = json.Marshal(s.FormTemplate) } if s.TransitionRules != nil { st.TransitionRules, _ = json.Marshal(s.TransitionRules) } if err := stores.Workflows.CreateStage(reqCtx, st); err != nil { log.Printf("[workflow-pkg] failed to create stage %q: %v", s.Name, err) } } // Store workflow_id in package_settings for reference settingsJSON, _ := json.Marshal(map[string]string{"workflow_id": workflowID}) stores.Packages.SetPackageSettings(reqCtx, pkgID, json.RawMessage(settingsJSON)) log.Printf("[workflow-pkg] installed workflow %q (id=%s) from package %s", wfDef.Name, workflowID, pkgID) return nil } // workflowPkgStage is the stage definition within a workflow package manifest. type workflowPkgStage struct { Name string `json:"name"` Ordinal int `json:"ordinal"` StageMode string `json:"stage_mode"` HistoryMode string `json:"history_mode"` AutoTransition bool `json:"auto_transition"` PersonaID *string `json:"persona_id,omitempty"` AssignmentTeamID *string `json:"assignment_team_id,omitempty"` SurfacePkgID *string `json:"surface_pkg_id,omitempty"` FormTemplate any `json:"form_template,omitempty"` TransitionRules any `json:"transition_rules,omitempty"` }