Skip to content

Form Validation

Server-side validation rules for form field values.

Overview

Field definitions in form.fields (snapshotted from templates) support validation constraints beyond required. These are enforced server-side on form save.

Validation happens in two places:

  1. On template publish — Field definitions validated for consistency
  2. On form save — Values validated against field definitions

Field Validation Properties

Extended field properties (beyond basic type, label, required):

PropertyTypeApplies toDescription
min_lengthinttext, textareaMinimum character count
max_lengthinttext, textareaMaximum character count
patternstringtext, email, phoneRegex pattern for validation
minnumbernumberMinimum numeric value
maxnumbernumberMaximum numeric value
max_file_sizeintfileMaximum file size in bytes
allowed_file_typesstring[]fileMIME types, e.g. ["image/png", "image/jpeg", "application/pdf"]

Example Field with Validation

json
{
  "custom_field_id": 10,
  "label": "Phone Number",
  "type": "phone",
  "required": true,
  "pattern": "^\\+?[0-9]{7,15}$",
  "min_length": 7,
  "max_length": 15
}

Validation rules:

  • Must be provided (required)
  • Must be 7-15 characters long
  • Must match phone number pattern (optional + prefix, 7-15 digits)
  • Value syncs to patient profile (custom_field_values for custom_field_id: 10)

Server-Side Validation Logic

Go Implementation

go
func (s *formService) validateField(field FieldDef, value any) error {
    if value == nil || value == "" {
        if field.Required {
            return fmt.Errorf("field %q is required", field.Key)
        }
        return nil // Empty non-required field is valid
    }

    switch field.Type {
    case "text", "textarea", "email", "phone":
        str, ok := value.(string)
        if !ok {
            return fmt.Errorf("field %q: expected string", field.Key)
        }
        if field.MinLength != nil && len(str) < *field.MinLength {
            return fmt.Errorf("field %q: minimum length is %d", field.Key, *field.MinLength)
        }
        if field.MaxLength != nil && len(str) > *field.MaxLength {
            return fmt.Errorf("field %q: maximum length is %d", field.Key, *field.MaxLength)
        }
        if field.Pattern != nil {
            re, err := regexp.Compile(*field.Pattern)
            if err != nil {
                return fmt.Errorf("field %q: invalid pattern", field.Key)
            }
            if !re.MatchString(str) {
                return fmt.Errorf("field %q: does not match required format", field.Key)
            }
        }

    case "number":
        num, ok := toFloat64(value)
        if !ok {
            return fmt.Errorf("field %q: expected number", field.Key)
        }
        if field.Min != nil && num < *field.Min {
            return fmt.Errorf("field %q: minimum value is %v", field.Key, *field.Min)
        }
        if field.Max != nil && num > *field.Max {
            return fmt.Errorf("field %q: maximum value is %v", field.Key, *field.Max)
        }

    case "select", "radio":
        str, ok := value.(string)
        if !ok {
            return fmt.Errorf("field %q: expected string", field.Key)
        }
        if !slices.Contains(field.Options, str) {
            return fmt.Errorf("field %q: value %q not in allowed options", field.Key, str)
        }

    case "checkbox":
        // Checkbox value can be bool or []string (multi-select)
        switch v := value.(type) {
        case bool:
            // Single checkbox, always valid
        case []any:
            for _, item := range v {
                str, ok := item.(string)
                if !ok || !slices.Contains(field.Options, str) {
                    return fmt.Errorf("field %q: invalid option %v", field.Key, item)
                }
            }
        default:
            return fmt.Errorf("field %q: expected boolean or array", field.Key)
        }

    case "date":
        str, ok := value.(string)
        if !ok {
            return fmt.Errorf("field %q: expected date string", field.Key)
        }
        if _, err := time.Parse("2006-01-02", str); err != nil {
            return fmt.Errorf("field %q: invalid date format (expected YYYY-MM-DD)", field.Key)
        }

    case "file":
        // File validation happens at upload time, not at form save
        // See file upload section
    }

    return nil
}

Validation by Field Type

Text / Textarea / Email / Phone

Validators:

  • required — Must be non-empty
  • min_length — Minimum character count
  • max_length — Maximum character count
  • pattern — Regex pattern (e.g., email format, phone format)

Example:

json
{
  "key": "email",
  "label": "Email Address",
  "type": "email",
  "required": true,
  "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
  "max_length": 255
}

Error response:

json
{
  "error": {
    "code": "validation_error",
    "message": "Form validation failed",
    "details": {
      "errors": [
        {"field": "email", "message": "does not match required format"}
      ]
    }
  }
}

Number

Validators:

  • required — Must be non-empty
  • min — Minimum numeric value
  • max — Maximum numeric value

Example:

json
{
  "key": "age",
  "label": "Age",
  "type": "number",
  "required": true,
  "min": 0,
  "max": 150
}

Error response:

json
{
  "error": {
    "code": "validation_error",
    "message": "Form validation failed",
    "details": {
      "errors": [
        {"field": "age", "message": "minimum value is 0"}
      ]
    }
  }
}

Select / Radio

Validators:

  • required — Must be non-empty
  • options — Value must be in the allowed options list

Example:

json
{
  "key": "pain_level",
  "label": "Pain Level",
  "type": "select",
  "required": true,
  "options": ["No pain", "Less pain", "Big pain"]
}

Error response:

json
{
  "error": {
    "code": "validation_error",
    "message": "Form validation failed",
    "details": {
      "errors": [
        {"field": "pain_level", "message": "value \"Invalid\" not in allowed options"}
      ]
    }
  }
}

Checkbox

Validators:

  • required — Must be checked (for single checkbox) or have at least one selection (for multi-select)
  • options — For multi-select, all selected values must be in the allowed options list

Single checkbox:

json
{
  "key": "consent",
  "label": "I agree to the terms",
  "type": "checkbox",
  "required": true
}

Value: true or false

Multi-select checkbox:

json
{
  "key": "symptoms",
  "label": "Symptoms",
  "type": "checkbox",
  "required": true,
  "options": ["Headache", "Fever", "Cough", "Fatigue"]
}

Value: ["Headache", "Fever"]


Date

Validators:

  • required — Must be non-empty
  • Format — Must be valid ISO 8601 date (YYYY-MM-DD)

Example:

json
{
  "key": "birth_date",
  "label": "Date of Birth",
  "type": "date",
  "required": true
}

Valid values: "1990-05-15", "2025-01-20"Invalid values: "15/05/1990", "2025-01-20T10:00:00Z"

Error response:

json
{
  "error": {
    "code": "validation_error",
    "message": "Form validation failed",
    "details": {
      "errors": [
        {"field": "birth_date", "message": "invalid date format (expected YYYY-MM-DD)"}
      ]
    }
  }
}

File

File validation happens at upload time (not at form save).

See file-uploads.md for details.

Validators:

  • required — Must have a file uploaded
  • max_file_size — Maximum file size in bytes (default: 10MB)
  • allowed_file_types — MIME types allowed (default: ["image/png", "image/jpeg", "image/webp", "application/pdf"])

Validation Error Response Format

All validation errors return 400 Bad Request with this format:

json
{
  "error": {
    "code": "validation_error",
    "message": "Form validation failed",
    "details": {
      "errors": [
        {
          "field": "phone_number",
          "message": "does not match required format"
        },
        {
          "field": "pain_level",
          "message": "value \"Invalid\" not in allowed options"
        }
      ]
    }
  }
}

Fields:

  • code — Always "validation_error"
  • message — Human-readable summary
  • details.errors — Array of field-level errors
    • field — Field key
    • message — Error message

Required Field Validation

All fields marked required: true must have a value before the form can transition to completed status.

On save:

  • If any required field is missing → status remains in_progress
  • If all required fields filled → status transitions to completed

On sign:

  • If form.status is not completed → returns 400 form_not_completed

Custom Field Validation

When a form field has custom_field_id set, the Go API validates that the custom field exists and matches the entity type.

On template publish:

go
func (s *formTemplateService) validateFields(ctx context.Context, fields []FieldDef) error {
    for _, field := range fields {
        if field.CustomFieldID == nil {
            continue
        }

        customField, err := s.customFieldRepo.Get(ctx, *field.CustomFieldID)
        if err != nil {
            return fmt.Errorf("custom_field_id %d does not exist", *field.CustomFieldID)
        }
        if customField.EntityType != "patient" {
            return fmt.Errorf("custom_field_id %d is not a patient field", *field.CustomFieldID)
        }
    }
    return nil
}

Error response:

json
{
  "error": {
    "code": "invalid_custom_field",
    "message": "custom_field_id 999 does not exist",
    "details": {
      "custom_field_id": 999
    }
  }
}

Immutability Validation

Signed forms are immutable. Any attempt to update a signed form returns 409 Conflict:

go
func (s *formService) SaveForm(ctx context.Context, formID int64, values map[string]any) error {
    form, err := s.repo.Get(ctx, formID)
    if err != nil {
        return err
    }

    if form.Status == "signed" {
        return &ConflictError{
            Code:    "form_already_signed",
            Message: "Cannot update a signed form",
            Details: map[string]any{
                "form_id":   formID,
                "signed_at": form.SignedAt,
            },
        }
    }

    // Proceed with validation and save
    // ...
}

Error response:

json
{
  "error": {
    "code": "form_already_signed",
    "message": "Cannot update a signed form",
    "details": {
      "form_id": 42,
      "signed_at": "2025-01-15T12:00:00Z"
    }
  }
}

Default Validation Rules

When validation constraints are not specified:

Field TypeDefault Constraints
textNo min/max length, no pattern
textareaNo min/max length
emailPattern: basic email regex
phonePattern: ^\\+?[0-9]{7,15}$
numberNo min/max
selectMust be in options list
radioMust be in options list
checkboxNo validation (any boolean or array of options)
dateMust be valid YYYY-MM-DD
filemax_file_size: 10MB, allowed_file_types: images + PDF

Frontend vs Backend Validation

Frontend validation:

  • Provides immediate feedback to users
  • Improves UX (no round-trip to server)
  • Can be bypassed (never trust client-side validation alone)

Backend validation (REQUIRED):

  • Always runs server-side (security requirement)
  • Cannot be bypassed by manipulating frontend
  • Final source of truth for data integrity

Best practice:

  • Duplicate validation rules in both frontend and backend
  • Frontend validates on blur/change for UX
  • Backend validates on save for security