Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
289 lines
9.4 KiB
Go
289 lines
9.4 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"
|
|
"chat-switchboard/workflow"
|
|
|
|
"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, _ := workflow.ResolveNextStage(stages, ws.CurrentStage, json.RawMessage(mergedData))
|
|
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
|
|
}
|