This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/workflow_forms.go
2026-03-19 18:50:27 +00:00

288 lines
9.3 KiB
Go

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
}