All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
535 lines
18 KiB
Go
535 lines
18 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"`
|
|
StalenessTimeoutHours *int `json:"staleness_timeout_hours,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"`
|
|
TeamID *string `json:"team_id,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"`
|
|
StalenessTimeoutHours *int `json:"staleness_timeout_hours,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.
|
|
type TypedFormTemplate struct {
|
|
Fields []FormField `json:"fields"`
|
|
Fieldsets []FormFieldset `json:"fieldsets,omitempty"`
|
|
Hooks *FormHooks `json:"hooks,omitempty"`
|
|
}
|
|
|
|
// FormFieldset groups fields into a labelled step for progressive forms.
|
|
// 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.
|
|
// 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"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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 {
|
|
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 ─────
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// ── Instance Status Constants ───────────────
|
|
|
|
const (
|
|
InstanceStatusActive = "active"
|
|
InstanceStatusCompleted = "completed"
|
|
InstanceStatusCancelled = "cancelled"
|
|
InstanceStatusStale = "stale"
|
|
InstanceStatusError = "error"
|
|
)
|
|
|
|
// ValidInstanceStatuses is the set of valid workflow instance status values.
|
|
var ValidInstanceStatuses = map[string]bool{
|
|
InstanceStatusActive: true,
|
|
InstanceStatusCompleted: true,
|
|
InstanceStatusCancelled: true,
|
|
InstanceStatusStale: true,
|
|
InstanceStatusError: true,
|
|
}
|
|
|
|
// ── Assignment Status Constants ─────────────
|
|
|
|
const (
|
|
AssignmentStatusUnassigned = "unassigned"
|
|
AssignmentStatusClaimed = "claimed"
|
|
AssignmentStatusCompleted = "completed"
|
|
AssignmentStatusCancelled = "cancelled"
|
|
)
|
|
|
|
// ValidAssignmentStatuses is the set of valid workflow assignment status values.
|
|
var ValidAssignmentStatuses = map[string]bool{
|
|
AssignmentStatusUnassigned: true,
|
|
AssignmentStatusClaimed: true,
|
|
AssignmentStatusCompleted: true,
|
|
AssignmentStatusCancelled: true,
|
|
}
|
|
|
|
// ── Workflow Instance ───────────────────────
|
|
|
|
// WorkflowInstance tracks a single execution of a workflow definition.
|
|
type WorkflowInstance struct {
|
|
ID string `json:"id"`
|
|
WorkflowID string `json:"workflow_id"`
|
|
WorkflowVersion int `json:"workflow_version"`
|
|
CurrentStage string `json:"current_stage"`
|
|
StageData json.RawMessage `json:"stage_data"`
|
|
Status string `json:"status"`
|
|
StartedBy string `json:"started_by"`
|
|
EntryToken *string `json:"entry_token,omitempty"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
StageEnteredAt time.Time `json:"stage_entered_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// ── Workflow Assignment ─────────────────────
|
|
|
|
// WorkflowAssignment tracks per-stage work items in the team queue.
|
|
type WorkflowAssignment struct {
|
|
ID string `json:"id"`
|
|
InstanceID string `json:"instance_id"`
|
|
Stage string `json:"stage"`
|
|
TeamID string `json:"team_id"`
|
|
AssignedTo *string `json:"assigned_to,omitempty"`
|
|
Status string `json:"status"`
|
|
ReviewData json.RawMessage `json:"review_data"`
|
|
ClaimedAt *time.Time `json:"claimed_at,omitempty"`
|
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ── Workflow Signoff ───────────────
|
|
|
|
// WorkflowSignoff records a user's approval or rejection at a stage boundary.
|
|
type WorkflowSignoff struct {
|
|
ID string `json:"id"`
|
|
InstanceID string `json:"instance_id"`
|
|
Stage string `json:"stage"`
|
|
UserID string `json:"user_id"`
|
|
Decision string `json:"decision"` // approve | reject
|
|
Comment string `json:"comment"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// Signoff decision constants.
|
|
const (
|
|
SignoffApprove = "approve"
|
|
SignoffReject = "reject"
|
|
)
|
|
|
|
// ── 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"`
|
|
}
|