Forms architecture

Model, connections, public submission flow, the builder API

How the module is built. For how it is used — building a form, publishing it, routing what it collects — see Forms.

Forms in Aexy are a general-purpose data-collection layer that can produce tickets, CRM records, deals, hiring applications, or just FormSubmission rows. What a form produces is a list of connections. The same form definition powers both authenticated internal use and public anonymous submission.

Pieces#

  • Form — definition (fields, theme, routing, automations)
  • FormField — typed column on the form
  • FormSubmission — one user's response
  • FormConnection — one thing a form's submissions create: a module, its settings, and field mappings
  • FormSubmissionResult — what one connection did with one submission
  • TicketForm — a specialized Form sized for support intake (lives in models/ticketing.py)
  • Visual builder — the schema + preview API that powers the drag-drop editor

Routers#

RouterPrefixUse
api/forms.py/workspaces/{ws}/formsAuthenticated CRUD
api/public_forms.py/public/formsAnonymous submission
api/ticket_forms.py/workspaces/{ws}/ticket-formsTicketing-specific forms (see tickets-and-projects.md)
api/visual_builder.py—Schema, validation, preview for the visual builder
api/question_bank.py/workspaces/{ws}/question-bankReusable questions

Form model (models/forms.py)#

FieldNote
workspace_id, name, slugIdentity
descriptionHelp text shown at the top of the form
template_typebug_report / feature_request / support / contact / lead_capture / feedback / job_application / custom
public_url_tokenUnique — drives the /public/forms/{token} route
is_active
auth_modeanonymous / email_verification (OTP)
require_email
theme (JSONB)Full visual customization (see below)
success_message, redirect_urlPost-submit UX
connectionsRelationship to form_connections (below)
trigger_automationsWhether to fire workspace automations on submit
conditional_rules (JSONB)Show/hide/require logic
submission_countCached

Theme JSONB#

The theme is fully structured — frontend reads it and applies element-by-element:

{
  "preset": "light|dark|minimal|modern|colorful|corporate|null",
  "global": {
    "primary_color": "#...",
    "secondary_color": "#...",
    "background_color": "#...",
    "font_family": "...",
    "border_radius": "...",
    "spacing": "compact|normal|relaxed"
  },
  "elements": {
    "form": { "background_color": "...", "padding": "...", "max_width": "...", "shadow": "..." },
    "header": { "text": "...", "text_color": "...", "font_size": "...", "font_weight": "...", "alignment": "left|center|right", "logo_url": "...", "logo_position": "..." },
    "labels": { "text_color": "...", "font_size": "...", "required_indicator_color": "..." },
    "inputs": { "background_color": "...", "border_color": "...", "text_color": "...", "focus_border_color": "...", "border_radius": "..." },
    "buttons": { "primary": { "...": "..." }, "secondary": { "...": "..." } },
    "errors": { "text_color": "...", "background_color": "...", "border_color": "...", "icon_color": "..." },
    "help_text": { "text_color": "...", "font_size": "..." }
  },
  "custom_css": "..."
}

A preset is a named bundle of global+elements values; setting a preset and then overriding specific fields is the common pattern.

Conditional rules JSONB#

[
  { "field_key": "...", "operator": "equals|contains|...", "value": "...", "action": "show|hide|require" }
]

Evaluated client-side as the user types. The server still treats every defined field as authoritative — conditional hide is a UX nicety, not a validation guarantee.

FormField#

FieldNote
form_id, name, field_keyIdentity
field_typetext / textarea / email / phone / number / url / select / multiselect / checkbox / radio / file / date / datetime / hidden
placeholder, default_value, help_textUX
is_required
validation_rules (JSONB){validation_type, min_length, max_length, pattern, custom_message, allowed_file_types, max_file_size_mb}
optionsFor select/radio
position, is_visible, widthLayout
crm_attribute_idMap to a CRM attribute for record creation
external_mappings (JSONB){github, jira, linear} field mappings

Connections (form_connections, services/form_connections.py)#

Until 0.49.0 each destination was a column set on forms (ticket: eight, CRM record: four, deal: five, external: a JSON list) with its own foreign key on form_submissions, its own endpoints, tab and handler branch. They are one table now:

FieldNote
form_id, workspace_idOwner
moduleticket / crm_record / deal / hiring_candidate; github / jira / linear are legacy (below)
is_enabledOff keeps the settings
positionRun order
config (JSONB)The module's settings
field_mappings (JSONB){form_field_key: target_field} — the same shape for every module

Unique on (form_id, module): a form makes at most one of each.

Each module in MODULES is a ConnectionModule that declares three things:

  • targets — the fields answers can map onto (None for CRM record and deal: the attributes of the chosen CRM object). A target with accepts="file" only takes file fields.
  • validate(db, workspace_id, config) — run on save. Every id in the settings must belong to the form's workspace (team, assignee, CRM object, job, stage, owner). Mappings may only name the form's own fields plus the contact block's name and email.
  • run(ctx, config, mappings) — create the record from a submission.
Moduleconfig
ticketteam_id, assignment_mode (none / oncall / round_robin / specific_user), assignee_id, default_priority, default_severity, title_template
crm_recordobject_id (required), owner_id
dealpipeline_id, stage_id, owner_id, link_to_record; object_id is the workspace's deal object, filled on save
hiring_candidaterequirement_id (a job) or role, stage_id (an open stage on that job's board), owner_id, source, tags

github, jira and linear were configurable before and never implemented — their handlers returned None. They cannot be added any more; forms that had them keep the rows, and each run records a skipped result saying so.

Adding a destination is a new ConnectionModule in MODULES, with no schema change.

FormSubmission#

FieldNote
form_idSource
statusPENDING / PROCESSING / COMPLETED / PARTIALLY_FAILED / FAILED
email, nameThe contact block
data (JSONB)Keyed by field_key
attachments (JSONB)Files staged by the public upload endpoint
resultsRelationship to form_submission_results (below)
processing_errors (JSONB)One entry per failed connection

Results (form_submission_results)#

One row per connection that ran: module, status (created / failed / skipped), target_id (the ticket, record or hiring application — no foreign key, since it points at a different table per module), target_label (a ticket number, a record name), details, error. The submission responses derive ticket_id, crm_record_id, deal_id and application_id from them.

PARTIALLY_FAILED means some connections created their record and some failed; FAILED means none did.

Endpoints#

# Authenticated CRUD
GET    /workspaces/{ws}/forms
POST   /workspaces/{ws}/forms
GET    /workspaces/{ws}/forms/{form_id}                       include_fields=true to embed fields
PATCH  /workspaces/{ws}/forms/{form_id}
DELETE /workspaces/{ws}/forms/{form_id}
POST   /workspaces/{ws}/forms/{form_id}/duplicate             clone with new name

# Fields
POST   /workspaces/{ws}/forms/{form_id}/fields
PATCH  /workspaces/{ws}/forms/{form_id}/fields/{field_id}
DELETE /workspaces/{ws}/forms/{form_id}/fields/{field_id}
POST   /workspaces/{ws}/forms/{form_id}/fields/reorder        field_id[] + position[]

# Submissions
GET    /workspaces/{ws}/forms/{form_id}/submissions           filter by status + date
GET    /workspaces/{ws}/forms/{form_id}/submissions/{id}
PATCH  /workspaces/{ws}/forms/{form_id}/submissions/{id}      update status
DELETE /workspaces/{ws}/forms/{form_id}/submissions/{id}

# Connections
GET    /workspaces/{ws}/forms/connection-modules          what can be created, and each one's targets
GET    /workspaces/{ws}/forms/{form_id}/connections
PUT    /workspaces/{ws}/forms/{form_id}/connections/{module}   {is_enabled, config, field_mappings, position}
DELETE /workspaces/{ws}/forms/{form_id}/connections/{module}

# Automations
GET/POST/DELETE /workspaces/{ws}/forms/{form_id}/automations

# Public (no auth)
GET  /public/forms/{public_token}                  render
POST /public/forms/{public_token}/submit           submission (rate-limited)
POST /public/forms/{public_token}/verify-email     OTP send for email_verification mode

Public submission flow#

  1. GET /public/forms/{public_token} returns the PublicFormResponse — fields filtered to is_visible=true, no external_mappings exposed, theme included.
  2. If auth_mode = email_verification: the client sends the email to /verify-email, server issues an OTP, user enters it, server verifies.
  3. Client posts to /submit with {email, field_values}.
  4. Server validates, creates the FormSubmission, then runs the form's enabled connections one at a time, in position order, each inside a savepoint. A module listed as building on another (a deal on the CRM record) runs after it whatever the positions say. A connection that raises is rolled back alone and recorded as failed; the rest still land. They used to run under asyncio.gather on one session, which SQLAlchemy does not allow.
  5. Linked automations, then the form.submitted automation event (with the created ids, including application_id).
  6. Response returns {submission_id, ticket_number, success_message, redirect_url, requires_email_verification}.

Each downstream step that fails updates submission status to PARTIALLY_FAILED (or FAILED if everything failed), recording details on error_message.

Visual builder#

api/visual_builder.py exposes schema introspection used by the drag-drop editor:

GET  /workspaces/{ws}/visual-builder/schema      JSON-schema describing all field types
POST /workspaces/{ws}/visual-builder/validate    validate a form JSON against schema
POST /workspaces/{ws}/visual-builder/preview     render a form definition without persisting

Question bank (api/question_bank.py) is a reusable library of pre-defined form fields — useful when the same "Severity (low/medium/high/critical)" select shows up in dozens of forms.

Frontend#

/frontend/src/app/(app)/forms/ — form builder UI, field editor, submission inbox, theme customizer, conditional-logic designer.

Public render lives in /frontend/src/app/public/forms/[token]/page.tsx.

Common pitfalls#

  • external_mappings exposed to anonymous users. PublicFormResponse filters them out — never call the authenticated form endpoint from the public flow.
  • Conditional rules are not validation. A hide action removes the field from the UI but not from the schema; server-side, the field is still allowed in the submission. Don't rely on conditional rules to enforce data shape — use is_required + validation_rules.
  • A ticket connection without team_id. Submissions create unassigned tickets, which sit in the inbox until someone routes them.
  • Reading form.connections in the submit path. The handler queries form_connections itself: a form loaded without them would otherwise need a lazy load, which an async session cannot do.
  • PARTIALLY_FAILED is silent. Submissions still respond 200 because the form was accepted. Surface the partial-failure state in the submission inbox so admins notice the CRM record didn't get created.
  • Public form rate limits. The submission endpoint is aggressively rate-limited per IP. For internal batch imports use the authenticated path.
  • Theme JSONB is open-ended. Frontend renders unknown keys gracefully but stores them. Don't sneak app state into theme — it'll surface in the public payload.