package models import ( "encoding/json" "fmt" "regexp" "strings" "time" ) // ── Workflow ──────────────────────────────── // Workflow is a team-owned or global staged process definition. type Workflow struct { ID string `json:"id"` TeamID *string `json:"team_id,omitempty"` Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description"` Branding json.RawMessage `json:"branding"` EntryMode string `json:"entry_mode"` // public_link | team_only IsActive bool `json:"is_active"` Version int `json:"version"` OnComplete json.RawMessage `json:"on_complete,omitempty"` Retention json.RawMessage `json:"retention"` WebhookURL string `json:"webhook_url,omitempty"` WebhookSecret string `json:"webhook_secret,omitempty"` CreatedBy string `json:"created_by"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // Joined data (not stored directly) Stages []WorkflowStage `json:"stages,omitempty"` } // WorkflowPatch contains optional fields for updating a workflow. type WorkflowPatch struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` Branding *json.RawMessage `json:"branding,omitempty"` EntryMode *string `json:"entry_mode,omitempty"` IsActive *bool `json:"is_active,omitempty"` OnComplete *json.RawMessage `json:"on_complete,omitempty"` Retention *json.RawMessage `json:"retention,omitempty"` WebhookURL *string `json:"webhook_url,omitempty"` WebhookSecret *string `json:"webhook_secret,omitempty"` } // ── Workflow Stage ────────────────────────── // WorkflowStage is an ordered step within a workflow definition. type WorkflowStage struct { ID string `json:"id"` WorkflowID string `json:"workflow_id"` Ordinal int `json:"ordinal"` Name string `json:"name"` PersonaID *string `json:"persona_id,omitempty"` AssignmentTeamID *string `json:"assignment_team_id,omitempty"` FormTemplate json.RawMessage `json:"form_template"` StageMode string `json:"stage_mode"` // chat_only | form_only | form_chat | review HistoryMode string `json:"history_mode"` // full | summary | fresh AutoTransition bool `json:"auto_transition"` TransitionRules json.RawMessage `json:"transition_rules"` SurfacePkgID *string `json:"surface_pkg_id,omitempty"` SLASeconds *int `json:"sla_seconds,omitempty"` CreatedAt time.Time `json:"created_at"` } // ── Stage Mode Constants ──────────────────── const ( StageModeChatOnly = "chat_only" StageModeFormOnly = "form_only" StageModeFormChat = "form_chat" StageModeReview = "review" ) // ValidStageModes is the set of valid stage_mode values. var ValidStageModes = map[string]bool{ StageModeChatOnly: true, StageModeFormOnly: true, StageModeFormChat: true, StageModeReview: true, } // ── Typed Form Template ───────────────────── // TypedFormTemplate is the structured form_template schema (v0.29.3). // v0.35.0: added Fieldsets for progressive multi-step forms. type TypedFormTemplate struct { Fields []FormField `json:"fields"` Fieldsets []FormFieldset `json:"fieldsets,omitempty"` // v0.35.0: multi-step form groups Hooks *FormHooks `json:"hooks,omitempty"` } // FormFieldset groups fields into a labelled step for progressive forms (v0.35.0). // When fieldsets is present, top-level fields is ignored. type FormFieldset struct { Label string `json:"label"` Fields []FormField `json:"fields"` } // FieldCondition controls conditional visibility of a form field (v0.35.0). // When set, the field is shown/validated only if the condition is met. type FieldCondition struct { When string `json:"when"` // key of the field to check Op string `json:"op,omitempty"` // eq (default) | neq | in | exists Value any `json:"value,omitempty"` // value to compare against } // FormField is a single field in a typed form template. type FormField struct { Key string `json:"key"` Type string `json:"type"` // text | email | select | number | date | textarea | checkbox | file Label string `json:"label"` Placeholder string `json:"placeholder,omitempty"` Required bool `json:"required,omitempty"` Default interface{} `json:"default,omitempty"` Options []FormOption `json:"options,omitempty"` Validation *FormValidation `json:"validation,omitempty"` Condition *FieldCondition `json:"condition,omitempty"` // v0.35.0: conditional visibility } // FormOption is a choice for select fields. type FormOption struct { Value string `json:"value"` Label string `json:"label"` } // FormValidation holds type-specific validation rules. type FormValidation struct { MinLength *int `json:"min_length,omitempty"` // text, textarea, email MaxLength *int `json:"max_length,omitempty"` // text, textarea, email Pattern string `json:"pattern,omitempty"` // text, email (regex) Min *float64 `json:"min,omitempty"` // number Max *float64 `json:"max,omitempty"` // number Step *float64 `json:"step,omitempty"` // number MinDate string `json:"min_date,omitempty"` // date (YYYY-MM-DD) MaxDate string `json:"max_date,omitempty"` // date (YYYY-MM-DD) Accept string `json:"accept,omitempty"` // file (MIME types) MaxSize *int64 `json:"max_size_bytes,omitempty"` // file } // FormHooks wires Starlark validation/submission hooks for a stage's form. type FormHooks struct { PackageID string `json:"package_id"` Validate string `json:"validate,omitempty"` // entry point name OnSubmit string `json:"on_submit,omitempty"` // entry point name } // ValidFormFieldTypes is the set of recognized field types. var ValidFormFieldTypes = map[string]bool{ "text": true, "email": true, "select": true, "number": true, "date": true, "textarea": true, "checkbox": true, "file": true, } // ParseTypedFormTemplate parses a raw JSON form_template into the typed schema. // Returns nil if the template is empty, legacy format, or not a typed template. func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate { if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" { return nil } var tpl TypedFormTemplate if err := json.Unmarshal(raw, &tpl); err != nil { return nil } // v0.35.0: If fieldsets present, flatten into fields for backward compat if len(tpl.Fieldsets) > 0 { var allFields []FormField for _, fs := range tpl.Fieldsets { allFields = append(allFields, fs.Fields...) } if len(allFields) > 0 { tpl.Fields = allFields return &tpl } } if len(tpl.Fields) == 0 { return nil } // Verify this is actually the typed format (first field must have key+type) if tpl.Fields[0].Key == "" || tpl.Fields[0].Type == "" { return nil } return &tpl } // AllFields returns all fields from the template, whether from top-level fields or fieldsets. func (t *TypedFormTemplate) AllFields() []FormField { return t.Fields // ParseTypedFormTemplate already flattens fieldsets into Fields } // FieldError is a validation error for a specific form field. type FieldError struct { Key string `json:"key"` Message string `json:"message"` } // ValidateFormData validates submitted data against a typed form template. func ValidateFormData(tpl *TypedFormTemplate, data map[string]interface{}) []FieldError { var errs []FieldError for _, f := range tpl.Fields { // v0.35.0: Skip fields whose condition is not met if f.Condition != nil && !evaluateFieldCondition(f.Condition, data) { continue } val, present := data[f.Key] // Required check if f.Required && (!present || val == nil || val == "") { errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " is required"}) continue } if !present || val == nil { continue } switch f.Type { case "text", "textarea": errs = append(errs, validateString(f, val)...) case "email": errs = append(errs, validateEmail(f, val)...) case "number": errs = append(errs, validateNumber(f, val)...) case "date": errs = append(errs, validateDate(f, val)...) case "select": errs = append(errs, validateSelect(f, val)...) case "checkbox": if _, ok := val.(bool); !ok { errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " must be true or false"}) } } } return errs } func validateString(f FormField, val interface{}) []FieldError { s, ok := val.(string) if !ok { return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}} } var errs []FieldError v := f.Validation if v == nil { return nil } if v.MinLength != nil && len(s) < *v.MinLength { errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %d characters", f.Label, *v.MinLength)}) } if v.MaxLength != nil && len(s) > *v.MaxLength { errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %d characters", f.Label, *v.MaxLength)}) } if v.Pattern != "" { if re, err := regexp.Compile(v.Pattern); err == nil && !re.MatchString(s) { errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"}) } } return errs } var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) func validateEmail(f FormField, val interface{}) []FieldError { s, ok := val.(string) if !ok { return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}} } if !emailRe.MatchString(s) { return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid email address"}} } var errs []FieldError if f.Validation != nil { if f.Validation.Pattern != "" { if re, err := regexp.Compile(f.Validation.Pattern); err == nil && !re.MatchString(s) { errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"}) } } } return errs } func validateNumber(f FormField, val interface{}) []FieldError { var n float64 switch v := val.(type) { case float64: n = v case int: n = float64(v) case json.Number: var err error n, err = v.Float64() if err != nil { return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}} } case string: return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}} default: return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}} } var errs []FieldError if f.Validation != nil { if f.Validation.Min != nil && n < *f.Validation.Min { errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %g", f.Label, *f.Validation.Min)}) } if f.Validation.Max != nil && n > *f.Validation.Max { errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %g", f.Label, *f.Validation.Max)}) } } return errs } func validateDate(f FormField, val interface{}) []FieldError { s, ok := val.(string) if !ok { return []FieldError{{Key: f.Key, Message: f.Label + " must be a date string (YYYY-MM-DD)"}} } _, err := time.Parse("2006-01-02", s) if err != nil { return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid date (YYYY-MM-DD)"}} } if f.Validation != nil { if f.Validation.MinDate != "" { if minD, e := time.Parse("2006-01-02", f.Validation.MinDate); e == nil { if d, _ := time.Parse("2006-01-02", s); d.Before(minD) { return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or after %s", f.Label, f.Validation.MinDate)}} } } } if f.Validation.MaxDate != "" { if maxD, e := time.Parse("2006-01-02", f.Validation.MaxDate); e == nil { if d, _ := time.Parse("2006-01-02", s); d.After(maxD) { return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or before %s", f.Label, f.Validation.MaxDate)}} } } } } return nil } func validateSelect(f FormField, val interface{}) []FieldError { s, ok := val.(string) if !ok { return []FieldError{{Key: f.Key, Message: f.Label + " must be a string"}} } if len(f.Options) == 0 { return nil } for _, o := range f.Options { if strings.EqualFold(o.Value, s) { return nil } } return []FieldError{{Key: f.Key, Message: f.Label + " is not a valid option"}} } // ── Field Condition Evaluator (v0.35.0) ───── // evaluateFieldCondition checks if a conditional field should be visible/validated. func evaluateFieldCondition(cond *FieldCondition, data map[string]interface{}) bool { val, exists := data[cond.When] op := cond.Op if op == "" { op = "eq" } switch op { case "exists": return exists case "eq": if !exists { return false } return fmt.Sprintf("%v", val) == fmt.Sprintf("%v", cond.Value) case "neq": if !exists { return true } return fmt.Sprintf("%v", val) != fmt.Sprintf("%v", cond.Value) case "in": if !exists { return false } arr, ok := cond.Value.([]interface{}) if !ok { return false } vs := fmt.Sprintf("%v", val) for _, item := range arr { if fmt.Sprintf("%v", item) == vs { return true } } return false default: return true } } // ── Workflow Version (immutable snapshot) ─── // WorkflowVersion is an immutable snapshot of a workflow definition // at a point in time. Active workflow instances run against a pinned version. type WorkflowVersion struct { ID string `json:"id"` WorkflowID string `json:"workflow_id"` VersionNumber int `json:"version_number"` Snapshot json.RawMessage `json:"snapshot"` CreatedAt time.Time `json:"created_at"` }