package handlers import ( "context" "encoding/json" "log" "net/http" "github.com/gin-gonic/gin" "chat-switchboard/events" "chat-switchboard/models" "chat-switchboard/sandbox" "chat-switchboard/store" "chat-switchboard/tools" "go.starlark.net/starlark" ) // ── Workflow Form Handler ───────────────────── // Handles typed form submission for workflow stages. type WorkflowFormHandler struct { stores store.Stores runner *sandbox.Runner hub *events.Hub } func NewWorkflowFormHandler(stores store.Stores, runner *sandbox.Runner, hub *events.Hub) *WorkflowFormHandler { return &WorkflowFormHandler{stores: stores, runner: runner, hub: hub} } // GetFormTemplate returns the current stage's typed form template. // GET /api/v1/w/:id/form func (h *WorkflowFormHandler) GetFormTemplate(c *gin.Context) { ctx := c.Request.Context() channelID := c.Param("id") ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID) if err != nil || ws == nil { c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"}) return } if ws.WorkflowID == nil || *ws.WorkflowID == "" { c.JSON(http.StatusNotFound, gin.H{"error": "not a workflow channel"}) return } stages, err := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID) if err != nil || ws.CurrentStage >= len(stages) { c.JSON(http.StatusNotFound, gin.H{"error": "stage not found"}) return } stage := stages[ws.CurrentStage] tpl := models.ParseTypedFormTemplate(stage.FormTemplate) // Check if form was already submitted for this stage var formSubmitted bool if len(ws.StageData) > 0 { var sd map[string]interface{} if json.Unmarshal(ws.StageData, &sd) == nil { _, formSubmitted = sd["_form_submitted"] } } c.JSON(http.StatusOK, gin.H{ "stage_name": stage.Name, "stage_mode": stage.StageMode, "form_template": tpl, "form_submitted": formSubmitted, "current_stage": ws.CurrentStage, "status": ws.Status, }) } // SubmitForm handles form data submission for a workflow stage. // POST /api/v1/w/:id/form-submit func (h *WorkflowFormHandler) SubmitForm(c *gin.Context) { ctx := c.Request.Context() channelID := c.Param("id") // 1. Verify workflow channel ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID) if err != nil || ws == nil { c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"}) return } if ws.Status != "active" { c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + ws.Status + ", cannot submit form"}) return } if ws.WorkflowID == nil || *ws.WorkflowID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "not a workflow channel"}) return } // 2. Load stage definition stages, err := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID) if err != nil || ws.CurrentStage >= len(stages) { c.JSON(http.StatusBadRequest, gin.H{"error": "stage not found"}) return } stage := stages[ws.CurrentStage] if stage.StageMode == models.StageModeChatOnly { c.JSON(http.StatusBadRequest, gin.H{"error": "this stage does not accept form submissions"}) return } // 3. Parse typed form template tpl := models.ParseTypedFormTemplate(stage.FormTemplate) if tpl == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "stage has no typed form template"}) return } // 4. Parse submitted data (flat fields per ICD spec) var formData map[string]interface{} if err := c.ShouldBindJSON(&formData); err != nil || len(formData) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "form data is required"}) return } // 5. Go-level validation if errs := models.ValidateFormData(tpl, formData); len(errs) > 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "validation failed", "errors": errs}) return } // 6. Starlark validate hook (if configured) if tpl.Hooks != nil && tpl.Hooks.PackageID != "" && tpl.Hooks.Validate != "" { if hookErrs := h.runValidateHook(c, tpl.Hooks, channelID, formData, ws.StageData, stage.Name); hookErrs != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "validation failed", "errors": hookErrs}) return } } // 7. Merge into stage_data formData["_form_submitted"] = true dataJSON, _ := json.Marshal(formData) mergedData := tools.MergeWorkflowStageData(ctx, h.stores.Channels, channelID, dataJSON) // 8. Starlark on_submit hook (fire-and-forget) if tpl.Hooks != nil && tpl.Hooks.PackageID != "" && tpl.Hooks.OnSubmit != "" { go h.runOnSubmitHook(tpl.Hooks, channelID, formData, json.RawMessage(mergedData), stage.Name) } // 9. Auto-advance if form_only + auto_transition if stage.StageMode == models.StageModeFormOnly && stage.AutoTransition { nextStage := ws.CurrentStage + 1 if nextStage >= len(stages) { // Complete _ = h.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData)) tools.CreateWorkflowStageNote(ctx, h.stores, channelID, ws.CurrentStage, dataJSON, "") h.emitFormEvent(channelID, "workflow.completed", ws.CurrentStage) c.JSON(http.StatusOK, gin.H{"status": "completed", "current_stage": nextStage}) return } _ = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, json.RawMessage(mergedData)) tools.CreateWorkflowStageNote(ctx, h.stores, channelID, ws.CurrentStage, dataJSON, "") h.emitFormEvent(channelID, "workflow.advanced", nextStage) c.JSON(http.StatusOK, gin.H{"status": "advanced", "current_stage": nextStage, "stage": stages[nextStage]}) return } // 10. Not auto-advancing — persist stage_data and confirm submission _ = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, ws.CurrentStage, json.RawMessage(mergedData)) tools.CreateWorkflowStageNote(ctx, h.stores, channelID, ws.CurrentStage, dataJSON, "") h.emitFormEvent(channelID, "workflow.form_submitted", ws.CurrentStage) c.JSON(http.StatusOK, gin.H{"status": "submitted", "current_stage": ws.CurrentStage}) } // ── Starlark Hooks ────────────────────────── func (h *WorkflowFormHandler) runValidateHook(c *gin.Context, hooks *models.FormHooks, channelID string, data map[string]interface{}, stageData json.RawMessage, stageName string) []models.FieldError { if h.runner == nil { return nil } pkg, err := h.stores.Packages.Get(c.Request.Context(), hooks.PackageID) if err != nil || pkg == nil { log.Printf("[workflow-forms] validate hook: package %s not found", hooks.PackageID) return nil } ctxDict := starlark.NewDict(4) _ = ctxDict.SetKey(starlark.String("data"), jsonToStarlark(data)) _ = ctxDict.SetKey(starlark.String("stage_data"), starlark.String(string(stageData))) _ = ctxDict.SetKey(starlark.String("stage_name"), starlark.String(stageName)) _ = ctxDict.SetKey(starlark.String("channel_id"), starlark.String(channelID)) val, _, err := h.runner.CallEntryPoint(c.Request.Context(), pkg, hooks.Validate, starlark.Tuple{ctxDict}, nil, nil) if err != nil { log.Printf("[workflow-forms] validate hook error: %v", err) return nil } return parseValidationResult(val) } func (h *WorkflowFormHandler) runOnSubmitHook(hooks *models.FormHooks, channelID string, data map[string]interface{}, stageData json.RawMessage, stageName string) { if h.runner == nil { return } ctx := context.Background() pkg, err := h.stores.Packages.Get(ctx, hooks.PackageID) if err != nil || pkg == nil { return } ctxDict := starlark.NewDict(4) _ = ctxDict.SetKey(starlark.String("data"), jsonToStarlark(data)) _ = ctxDict.SetKey(starlark.String("stage_data"), starlark.String(string(stageData))) _ = ctxDict.SetKey(starlark.String("stage_name"), starlark.String(stageName)) _ = ctxDict.SetKey(starlark.String("channel_id"), starlark.String(channelID)) _, _, err = h.runner.CallEntryPoint(ctx, pkg, hooks.OnSubmit, starlark.Tuple{ctxDict}, nil, nil) if err != nil { log.Printf("[workflow-forms] on_submit hook error: %v", err) } } // ── Helpers ───────────────────────────────── func (h *WorkflowFormHandler) emitFormEvent(channelID, label string, stage int) { if h.hub == nil { return } payload, _ := json.Marshal(map[string]interface{}{ "channel_id": channelID, "stage": stage, }) ctx := context.Background() pids, err := h.stores.Channels.ListUserParticipantIDs(ctx, channelID, "") if err != nil { return } evt := events.Event{Label: label, Payload: payload} for _, uid := range pids { h.hub.PublishToUser(uid, evt) } } // parseValidationResult extracts field errors from a Starlark return value. // Expected: None (pass) or {"errors": [{"key": "...", "message": "..."}, ...]} func parseValidationResult(val starlark.Value) []models.FieldError { if val == nil || val == starlark.None { return nil } d, ok := val.(*starlark.Dict) if !ok { return nil } errVal, found, _ := d.Get(starlark.String("errors")) if !found { return nil } list, ok := errVal.(*starlark.List) if !ok { return nil } var errs []models.FieldError iter := list.Iterate() defer iter.Done() var item starlark.Value for iter.Next(&item) { ed, ok := item.(*starlark.Dict) if !ok { continue } keyVal, _, _ := ed.Get(starlark.String("key")) msgVal, _, _ := ed.Get(starlark.String("message")) k, _ := keyVal.(starlark.String) m, _ := msgVal.(starlark.String) if k != "" && m != "" { errs = append(errs, models.FieldError{Key: string(k), Message: string(m)}) } } if len(errs) == 0 { return nil } return errs }