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/models/workflow.go
Jeffrey Smith 965885a8f7
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m23s
CI/CD / test-sqlite (push) Successful in 2m42s
CI/CD / build-and-deploy (push) Successful in 1m28s
Feat v0.3.0 workflow schema (#14)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-27 17:52:29 +00:00

446 lines
15 KiB
Go

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"`
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
FormTemplate json.RawMessage `json:"form_template"`
StageMode string `json:"stage_mode"` // form | review | delegated | automated
Audience string `json:"audience"` // team | public | system
StageType string `json:"stage_type"` // simple | dynamic | automated
AutoTransition bool `json:"auto_transition"`
StageConfig json.RawMessage `json:"stage_config"`
BranchRules json.RawMessage `json:"branch_rules"`
StarlarkHook *string `json:"starlark_hook,omitempty"`
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
SLASeconds *int `json:"sla_seconds,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ── Stage Mode Constants ────────────────────
const (
StageModeForm = "form"
StageModeReview = "review"
StageModeDelegated = "delegated"
StageModeAutomated = "automated"
)
// ValidStageModes is the set of valid stage_mode values.
var ValidStageModes = map[string]bool{
StageModeForm: true,
StageModeReview: true,
StageModeDelegated: true,
StageModeAutomated: true,
}
// ── Stage Type Constants ────────────────────
const (
StageTypeSimple = "simple"
StageTypeDynamic = "dynamic"
StageTypeAutomated = "automated"
)
// ValidStageTypes is the set of valid stage_type values.
var ValidStageTypes = map[string]bool{
StageTypeSimple: true,
StageTypeDynamic: true,
StageTypeAutomated: true,
}
// ── Audience Constants ──────────────────────
const (
AudienceTeam = "team"
AudiencePublic = "public"
AudienceSystem = "system"
)
// ValidAudiences is the set of valid audience values.
var ValidAudiences = map[string]bool{
AudienceTeam: true,
AudiencePublic: true,
AudienceSystem: 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"`
}