Prima Get an API key

Module kit / Forms and leads

Form Templates and Builder

forms-leads/form-templates-and-builder

Kind backend
Domain forms-leads
Maturity prod
Reusable extract
Stack fastapi-0.118, sqlalchemy-2.0-sync, postgres, pydantic-2.12, nh3-0.2, alembic-1.16
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/error-envelope, _shared/alembic-migrations, _shared/test-layout
Frontends forms-leads/ui/form-builder-page
Reference implementation
  • alchemy_labs/backend/models/forms.py
  • alchemy_labs/backend/schemas/forms.py
  • alchemy_labs/backend/api/routers/forms_router.py
  • alchemy_labs/backend/services/forms/template_service.py
  • alchemy_labs/backend/services/forms/validation_engine.py
  • alchemy_labs/backend/services/forms/conditional_logic.py
  • alchemy_labs/backend/services/forms/style_presets.py
  • alchemy_labs/backend/create_default_form.py
  • alchemy_labs/backend/tests/v2/coverage/test_forms_g6_templateservice_crud_design_normalization_service_layer_db_backed_.py
  • alchemy_labs/backend/tests/v2/coverage/test_forms_g8_authenticated_forms_router_template_crud_submit_validate_endpoints_testclient_auth_tenant_.py
  • alchemy_labs/backend/tests/v2/coverage/test_forms_g1_pure_validation_engines_no_db_validationengine_conditionallogicengine.py
  • alchemy_labs/backend/tests/v2/coverage/test_forms_g2_conditionallogic_operators_progress_visibility_helpers.py
  • alchemy_labs/backend/tests/v2/coverage/test_forms_g4_style_presets_color_math_preset_generation.py
  • alchemy_labs/backend/tests/v2/coverage/test_forms_g12_forms_py_model_behavior_methods_formfield_formtemplate_formsession_formembed_thankyoupage_.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

A workspace builds a form as one FormTemplate row plus an ordered list of FormField rows (11 types, per-field validation rules, conditional show/hide/require logic, multi-page grouping via page_id, a style preset, one or more conditional thank-you pages, and an optional lead-magnet file referenced from settings.lead_magnet), and the builder UI reads and writes that shape through one authenticated CRUD surface. The invariant that makes it trustworthy is that the exact same two engines (ValidationEngine, ConditionalLogicEngine) run against the exact same field definitions on both POST /forms/validate (client-side live feedback) and POST /forms/templates/{id}/submit (the authenticated test-submit path) - there is one source of truth for "is this field required right now", not a copy of the rules re-implemented on each caller (forms_router.py 443-500 and 338-440 both build field_defs the same way and hand them to the same two engine classes).

When to use

Read it before touching TemplateService.create_template/update_template/duplicate_template, FormField.get_field_schema/get_validation_errors/is_visible, ValidationEngine.validate_field/validate_form, or ConditionalLogicEngine.apply_conditional_logic; before changing what counts as a valid field_type, validation rule key, or conditional-logic operator/action; and when someone asks "why is this field still required after I hid it", "why didn't my duplicate keep the thank-you pages", "where does the lead-magnet file actually live", or "why does the style preset not match the brand colors". Trigger phrases: "form builder", "field types", "conditional logic", "multi-page form", "style preset", "thank-you page", "lead magnet upload", "form template CRUD", page_id, answer_piping, field_states.

Not this recipe: the anonymous session flow (POST /forms/sessions/start/save/email/complete, services/forms/session_service.py) that the embedded widget uses for partial-submission autosave and abandonment recovery - a sibling recipe, forms-leads/form-sessions-and-abandonment-recovery; the actual public, no-auth submission and embed-code path client sites hit (api/routers/public_forms_router.py, mounted surface_exempt("public", ...) at app_fastapi.py 949) - forms-leads/public-submission-and-widget; CRM lead creation on submit (services/forms/lead_integration.py) and the {{field}} substitution engine thank-you pages and lead-magnet emails use (services/forms/answer_piping.py), both read for context, neither owned here; the two separate AI-assist routers mounted at /api/forms/templates/.../redirect-rules and /api/forms/templates/.../ai-edit (api/forms/redirect_ai_router.py, api/forms/form_editor_ai_router.py, own files, own routers); the CRM-adjacent /forms/leads* endpoints (forms_router.py 632-869) that read Lead/TrackingContact rows joined through FormSubmission.lead_id, not template/field CRUD; and the actual AI-generation execution behind a form's ai_prompt (/submit-ai, on the public router) - this recipe only owns the two settings endpoints that read/write settings.ai_prompt (forms_router.py 1507-1571).

Stack assumptions

At 62ba4fc4: fastapi==0.118.2, synchronous handlers on a sqlalchemy.orm.Session (SQLAlchemy==2.0.43), pydantic==2.12.0 with the legacy @validator decorator (not @field_validator; still functional, just the deprecated compat path - pydantic_core==2.41.1), psycopg2-binary==2.9.10, alembic==1.16.5, nh3>=0.2.15 for sanitize_rich_content (thank-you-page rich_content only; if nh3 fails to import the sanitizer is a no-op passthrough, template_service.py 15-18 and 41-55 - a hard dependency in prod, not a defensive fallback anyone should rely on). Foundations assumed: S4 (fastapi-project-layout) for mount_router/app_fastapi.py; S5 (auth-dependency-pattern) for get_current_user (JWT bearer, then httpOnly cookie, then X-API-Key, api/dependencies.py 187-230-ish); S6 (workspace-scoping) for the WorkspaceID dependency every authenticated route in this router declares as a required parameter (_get_workspace_id, api/dependencies.py 680-779); S7 (error-envelope) for the global HTTPException handler (app_fastapi.py 724-793) every raise HTTPException(...) in this router passes through; S8 (alembic-migrations) for the incremental migrations under Data model; S9 (test-layout) for the tests/v2/coverage/ conventions the Tests section runs.

Environment: no dedicated env var gates this recipe's behavior. The whole feature is import-guarded instead - app_fastapi.py 164-170 wraps from api.routers import forms_router in try/except ImportError, logs a warning, and sets FORMS_ROUTER_AVAILABLE = False on failure; the router is then only mounted if FORMS_ROUTER_AVAILABLE (927-931). A broken import in this file or its dependencies disables the entire forms feature silently at boot, not a 500 on first request. Fail behaviour otherwise is per-route, not centralized: create_form_template wraps TemplateService.create_template in a bare except Exception as e: raise HTTPException(400, f"Failed to create template: {str(e)}") (forms_router.py 134-138) - any failure, including a DB constraint violation or an unrelated outage, becomes a 400 with the raw exception string echoed to the caller (Security & tenancy).

Data model

Deployed shape (migrations/versions/baseline_prod_schema_001.py, a pg_dump-derived reconciliation of tables that predate Alembic tracking, taken 2026-08-02, plus the incremental migrations layered on top):

form_templates (
  id                  uuid PRIMARY KEY,
  name                varchar(255) NOT NULL,
  description         text,
  category            varchar(100),                 -- 15 allowed values, enforced only in Pydantic (below)
  created_by          uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  workspace_id        uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  is_public           boolean,
  settings            jsonb,                         -- design, multi_page, pages[], progress_bar, lead_magnet, ai_prompt (all ad hoc, no schema)
  metadata            jsonb,                          -- Python attr form_metadata, DB column "metadata"
  slug                varchar(255),                   -- declared + unique-constrained, but see Gotchas: nothing ever writes it
  attached_product_id uuid REFERENCES products(id) ON DELETE SET NULL,
  created_at, updated_at timestamptz DEFAULT now(),
  CHECK (length(trim(name)) > 0),
  UNIQUE (workspace_id, slug)                          -- unique_workspace_form_slug, form_slug_001 (2026-02-28)
);

form_fields (
  id               uuid PRIMARY KEY,
  template_id      uuid NOT NULL REFERENCES form_templates(id) ON DELETE CASCADE,
  field_type       varchar(50) NOT NULL,
  label            varchar(255) NOT NULL,
  name             varchar(255) NOT NULL,
  placeholder text, help_text text,
  required         boolean,
  "order"          integer,
  validation_rules jsonb, conditional_logic jsonb, options jsonb,
  default_value    text,
  metadata         jsonb,                              -- Python attr field_metadata, DB column "metadata"
  page_id          varchar(50),                          -- multi-page grouping, add_multipage_fields (2025-12-05)
  answer_piping    jsonb NOT NULL DEFAULT '{}',          -- {"format": "Hi {{name}}!"}, same migration
  allow_voice_input boolean, voice_max_duration_seconds integer,  -- default 300s in the model, unused by this recipe's surface
  created_at, updated_at timestamptz DEFAULT now(),
  CHECK (field_type = 'hidden' OR length(trim(label)) > 0),
  CHECK (length(trim(name)) > 0),
  CHECK (field_type IN ('text','textarea','number','email','phone','url','date','datetime',
                         'time','select','multiselect','radio','checkbox','file','password','hidden','slider')),
  UNIQUE (template_id, name)
);

form_embeds (       -- add_form_embeds, 2025-12-05; one auto-created per template at create_template time
  id uuid PRIMARY KEY, template_id uuid NOT NULL REFERENCES form_templates(id) ON DELETE CASCADE,
  embed_code varchar(64) UNIQUE NOT NULL, name varchar(255) DEFAULT 'Default Embed',
  embed_type varchar(50) NOT NULL DEFAULT 'inline' CHECK (embed_type IN ('inline','popup','slider','fullpage')),
  is_active boolean NOT NULL DEFAULT true,
  allowed_domains jsonb DEFAULT '[]', style_config jsonb DEFAULT '{}', trigger_config jsonb DEFAULT '{}',
  custom_css text, width varchar(50) DEFAULT '100%', max_width varchar(50) DEFAULT '600px', height varchar(50) DEFAULT 'auto',
  view_count integer DEFAULT 0, submission_count integer DEFAULT 0,   -- the only place a per-form submission count is tallied
  created_at, updated_at timestamptz
);

thank_you_pages (   -- same migration as form_embeds
  id uuid PRIMARY KEY, template_id uuid NOT NULL REFERENCES form_templates(id) ON DELETE CASCADE,
  title varchar(500) DEFAULT 'Thank you!', message text DEFAULT 'Your submission has been received.',
  rich_content text,                       -- sanitized on write via nh3 (template_service.sanitize_rich_content)
  redirect_url text, redirect_delay_seconds integer DEFAULT 0,
  conditions jsonb DEFAULT '[]',           -- [{field, operator, value}], checked in FormTemplate priority order
  is_default boolean DEFAULT false, priority integer DEFAULT 0,   -- higher priority checked first
  style_config jsonb DEFAULT '{}',
  cta_enabled boolean DEFAULT false, cta_text varchar(255), cta_url text,
  social_sharing_enabled boolean DEFAULT false, social_message text,
  created_at, updated_at timestamptz
);

form_submissions exists in the same baseline (data jsonb NOT NULL, metadata jsonb, email, lead_id, contact_id, tracking_data jsonb DEFAULT '{}') and is written by this recipe's submit_form endpoint, but its read/analytics surface (get_form_submissions, lead linking) belongs to the sibling public-submission recipe; it is cited here only because submit_form inserts into it.

What is NOT stored: a per-field interaction history beyond FormAnalytics rows (event_data jsonb, no dedicated aggregation table - get_template_analytics computes nothing from these, see Gotchas); the lead-magnet file itself (only settings.lead_magnet.file_id/file_name/file_size - the bytes live in RenderStorageService, local disk or Infomaniak Swift object storage depending on STORAGE_BACKEND, services/storage/render_storage.py 1-20); the analytics views/function the module's own README.md documents under "Query Analytics" (form_responses, form_analytics_summary, field_analytics, get_field_completion_rate()) - none exist in any migration (measured 2026-09-12: zero matches for any of those four names across all 101 files in migrations/versions/), so that section of the README describes a feature that was never built.

API surface

All routes below require get_current_user and the WorkspaceID dependency (workspace resolved from X-Workspace-ID or single-workspace inference, S6) unless noted.

RouteCodesNotes
GET /forms/style-presets200Brand-derived (via BrandEssence) or Alchemy-default 4-preset bundle; has_brand: bool in the body (forms_router.py 43-93)
POST /forms/templates201, 400TemplateService.create_template; auto-creates one FormEmbed styled from the brand or an explicit design_override; writes a template_created FormAnalytics row; catches every exception into 400 (96-138)
GET /forms/templates200Filtered/paginated list; submission_count per template is computed with one grouped SUM(FormEmbed.submission_count) query, not per-row (was N+1, fixed - 141-190)
GET /forms/templates/{id}200, 404Fields ordered by order; writes a template_viewed event on every read (193-231)
PUT /forms/templates/{id}200, 404Full-replace on fields if the key is present and non-empty (see Gotchas for the empty-list edge case); merges settings rather than overwriting; syncs design into the auto-created embed's style_config (234-273, TemplateService.update_template 379-473)
DELETE /forms/templates/{id}204, 404Non-admin: creator-owned only; admin (current_user.is_admin): any template in any workspace, workspace_id filter is skipped entirely (276-303, delete_template 494-497)
POST /forms/templates/{id}/duplicate200, 404Copies template + fields only - does NOT copy FormEmbed or ThankYouPage rows (517-578); starts private (is_public=False) regardless of the original
POST /forms/templates/{id}/submit200, 404, 422Authenticated test-submit: runs ConditionalLogicEngine then ValidationEngine over the live field defs, inserts FormSubmission + per-field field_interaction events + one form_submitted event (338-440)
POST /forms/validate200, 404Same two-engine pipeline as submit, no persistence; returns {valid, errors, field_states} (443-500)
GET /forms/templates/{id}/analytics200, 404get_template_analytics - see Gotchas, three of five fields are hardcoded placeholders (553-583)
POST /forms/templates/{id}/fields/{name}/track200, 404Writes one FormAnalytics row per call, no validation of event_type against the model's own CHECK constraint's allowed list (586-626)
POST /forms/templates/{id}/lead-magnet/upload200, 400, 404, 500PDF/ZIP, 25MB cap, content-type OR extension match (LEAD_MAGNET_ALLOWED_TYPES/ext in ('pdf','zip')); stores via RenderStorageService, writes nothing back to settings.lead_magnet itself - the caller (builder UI) is expected to PUT the template with the returned file_id (1327-1372)
DELETE /forms/templates/{id}/lead-magnet/file200, 404Deletes the stored file only; does not clear settings.lead_magnet on the template (1375-1400)
GET /forms/templates/{id}/lead-magnet/preview200, 404Renders sample-variable email preview; the fields_rel attribute it checks via hasattr does not exist on FormTemplate (see Gotchas - dead branch, field-name sample vars never populate) (1403-1453)
POST /forms/templates/{id}/lead-magnet/test200, 400, 404, 500Sends a real test email through email_service.send_lead_magnet_email (1456-1500)
GET/PUT /forms/templates/{id}/ai-prompt200, 400, 404Reads/writes settings.ai_prompt; empty/whitespace string on PUT clears the key rather than storing "" (1507-1571)

Application logic

Create (TemplateService.create_template, 64-145): insert FormTemplate, flush for its id, insert one FormField per item in fields with order = enumerate(...) index (client-supplied order is discarded, index order wins); mint an embed code (secrets.token_urlsafe(16)) and insert one FormEmbed, styled from design_override when the caller passed one (_normalize_design_to_style_config, snake_case AI-design schema to camelCase style_config, 147-172) or else from the creating user's most recent BrandEssence row via generate_brand_style_presets(...)['default'] (_get_brand_style_for_user, 174-219, silently returns None - and therefore an unstyled {} embed - on any exception, including no brand at all); optionally replace thank-you pages if thank_you_pages was supplied; one commit.

Update (update_template, 379-473): re-fetch scoped by workspace_id (or created_by if no workspace passed - the service layer supports both, the router always passes workspace); update_data.dict(exclude_unset=True) so only fields the caller actually sent are touched; thank_you_pages, if present, fully replaces the set (_replace_thank_you_pages, delete-all-then-insert, 228-240, sanitizing rich_content on the way in); settings is merged ({**existing, **incoming}), not overwritten, specifically so a partial settings payload from one UI panel does not erase what another panel wrote; design, if present, is written into settings['design'] AND separately pushed into the auto-created embed's style_config (_sync_design_to_embed, 242-277, maps primaryColor→also inputFocusBorderColor, secondaryColor→also textSecondaryColor, builds fontFamily/headingFontFamily as quoted CSS font-family lists); fields, if present and non-empty (falsy check, not is not None), deletes every existing FormField row for the template and re-inserts from the payload in list order - an explicit fields: [] on the wire is indistinguishable from "field not sent" and silently leaves the old fields in place (Gotchas).

Delete (delete_template, 475-515): admin bypasses the workspace_id/created_by filter entirely and can delete any template in any workspace; cascade (ondelete='CASCADE' on every child FK) removes fields, embeds, thank-you pages, submissions and analytics in one statement.

Duplicate (duplicate_template, 517-578): copies FormTemplate columns and every FormField (new ids, same order); does not touch FormEmbed or ThankYouPage - a duplicated form has no embed code and no thank-you page until the builder UI creates them, which the UI does implicitly by never calling GET .../submit against a duplicate until the operator opens and re-saves it in the builder.

Submit / Validate (both forms_router.py, 338-500), identical field-definition build:

  1. Fetch the template scoped to the workspace; 404 if missing.
  2. Build field_defs: {name, field_type, required, validation_rules, conditional_logic} per field (note: options - choice lists - is NOT included, so ValidationEngine._validate_choice's allowed_values check can only ever fire from an explicit validation_rules.allowed_values, never from the field's own options.choices).
  3. ConditionalLogicEngine.apply_conditional_logic(field_defs, data) computes field_states (visible/enabled/required/value per field, conditional_logic.py 128-211).
  4. Overlay states onto field_defs: a field whose visible is False has required forced to False; otherwise required is taken from the state. Visibility does not exempt a field from type/pattern/length validation - only from the required check - so a hidden field with a stray value in the payload (e.g. a client bug, or a field the UI hid after the user had already typed in it) still gets validated against every other rule.
  5. ValidationEngine.validate_form(data, field_defs) (319-356): per-field validate_field, short-circuits on required first, then dispatches by field_type to a type-specific validator (email/phone/number/url/text/date/select-radio/checkbox/file, else a no-op generic validator that only checks a custom_validator KEY exists without ever calling it, 308-316); returns (is_valid, {field_name: message}).
  6. submit: on invalid, 422 with {"errors": {...}} (no persistence); on valid, insert FormSubmission, one field_interaction FormAnalytics row per submitted key, one form_submitted event, commit, return the ORM row serialized through FormSubmissionResponse. validate: always 200, body is {valid, errors, field_states} regardless of validity.

ConditionalLogicEngine internals (conditional_logic.py): 15 operators declared in the ConditionOperator enum (9-25) but only 14 are wired into self.operators (47-62) - MATCHES_PATTERN is declared, documented nowhere as unsupported, and silently falls through evaluate_condition's op_func = self.operators.get(operator); if not op_func: return False (89-93), i.e. a rule using it always evaluates to False. apply_conditional_logic's inverse_actions branch (198-207) only implements SHOW/HIDE - the same eight actions the if conditions_met branch supports (ENABLE/DISABLE/REQUIRE/UNREQUIRE/SET_VALUE/CLEAR_VALUE) have no inverse counterpart; the code comment at line 207 (# ... (same as above)) documents the gap without closing it.

FormField.is_visible/_evaluate_condition (models/forms.py 300-351) is a second, independent conditional-logic evaluator with its own eight operators (no starts_with/ends_with/in_list/matches_pattern) - used by the model's own to_dict()/preview helpers, not by the request pipeline above, which uses ConditionalLogicEngine instead. The two evaluators can disagree on the same conditional_logic payload for any operator only one of them implements.

Invariants

  • Every authenticated route in this file takes WorkspaceID as a required parameter; every service-layer read/write filters by workspace_id (or, in the two TemplateService methods that accept a bare user_id with no workspace, by created_by) - no route in this recipe returns or mutates a row without one of those two scopes applied (forms_router.py, every handler; template_service.py 305-308, 351-354, 398-402, 499-503).
  • submit and validate build field_defs and run both engines identically - divergent behavior between "what the builder shows as a live error" and "what a real submission is checked against" is a regression in one of these two handlers, not expected drift (338-500).
  • A field's required-ness can be overridden by conditional logic; a field's other validation rules cannot be bypassed by hiding it (step 4 above) - this is deliberate (a required-but-hidden field must not block submission) and distinct from "any rule can be skipped by hiding the field" (it cannot).
  • FormTemplate.settings is merge-updated, never replace-updated, on PUT (update_template 420-424) - a payload that sends only {settings: {foo: 1}} never erases design, lead_magnet, or ai_prompt sitting in the same JSONB blob.
  • Admin delete (is_admin=True) is the one path in this recipe that intentionally skips workspace scoping; every other read/write is scoped (Security & tenancy).

Frontend

forms-leads/ui/form-builder-page   the builder canvas this whole CRUD surface exists to serve: field
                                    palette, per-field validation/conditional-logic/answer-piping panels,
                                    multi-page layout, style-preset picker, thank-you-page editor, and the
                                    lead-magnet upload widget. At 62ba4fc4 there is no dedicated backend
                                    "preview" endpoint for the canvas itself - grep for "preview" in
                                    forms_router.py matches only the lead-magnet email preview route
                                    (1403-1453); the canvas preview is presumably rendered client-side from
                                    the template it already fetched via GET /templates/{id}, not a separate
                                    backend round trip, but that rendering path lives in the frontend recipe,
                                    not measured here.

Minimum admin/debug surface: no dedicated route exists beyond what's in the API table. SELECT id, name, workspace_id, slug FROM form_templates WHERE slug IS NOT NULL is expected to return zero rows at this commit (Gotchas - nothing ever sets slug); SELECT event_type, count(*) FROM form_analytics WHERE template_id = :id GROUP BY event_type is the only way to see interaction volume today, since get_template_analytics does not aggregate it.

Security & tenancy

Auth dependency: get_current_user (JWT bearer → httpOnly cookie → X-API-Key, S5), fails closed with 401 (credentials_exception, api/dependencies.py 195-199). Workspace derivation: WorkspaceID (_get_workspace_id, S6) - header-supplied workspace is re-validated against _user_has_workspace_access (403 on mismatch), no header falls back to single-workspace inference and 400/409 on zero or multiple workspaces; every handler in this router declares workspace_id: WorkspaceID as a required parameter, so there is no route in this file reachable without a resolved workspace.

Fail direction: mostly closed (404 on cross-tenant reads via the workspace_id filter - proven directly by test_get_template_cross_tenant_returns_none, test_duplicate_cross_tenant_returns_none, test_submit_form_cross_tenant_404), with two deliberate exceptions worth calling out rather than treating as bugs: admin DELETE skips the workspace filter entirely by design (is_admin=True, 494-497); the public catch-all GET /forms/{template_id} (873-896, no WorkspaceID dependency at all, include_in_schema=False) returns any template with is_public=True regardless of workspace - this is the intended public-onboarding-form path, not a leak, but it means is_public is the only tenancy boundary on that one route.

Secrets/PII at rest: none of this recipe's own tables hold a credential. create_form_template's catch-all (134-138) puts str(e) - the raw exception message - into the 400 response body; SQLAlchemy/psycopg2 exception strings can include column values (e.g. a CHECK-constraint violation message echoes the offending value), so a caller who can trigger a DB-level failure (a name that's all whitespace, an invalid field_type that slips past Pydantic) can get more detail back than a generic "bad request" should give; nothing observed in this file redacts it. Rate limits: none dedicated to this router (inherits whatever the global rate-limit middleware in S4 applies). Attacker-controlled inputs: template_data.category is restricted to 15 literal values by a Pydantic validator (schemas/forms.py 104-117) but FormField.field_type on update goes through FormFieldCreate inside FormTemplateUpdate.fields, which does carry its own @validator('field_type') (30-39) - so the 17 allowed field types are enforced consistently on create and full-field-replace update; validation_rules/conditional_logic/options are arbitrary caller-supplied JSONB with no schema validation at all beyond what the two engines tolerate at read time (an unknown key is silently ignored, not rejected).

Tests

Run 2026-09-12 against the deap-postgres-test scratch Postgres (port 5434, docker-compose.test.yml's postgres-test service), using alchemy_labs/backend/.venv on main at 62ba4fc4:

TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db \
    python -m pytest tests/v2/coverage/test_forms_g6_templateservice_crud_design_normalization_service_layer_db_backed_.py \
    tests/v2/coverage/test_forms_g8_authenticated_forms_router_template_crud_submit_validate_endpoints_testclient_auth_tenant_.py \
    tests/v2/coverage/test_forms_g1_pure_validation_engines_no_db_validationengine_conditionallogicengine.py \
    tests/v2/coverage/test_forms_g2_conditionallogic_operators_progress_visibility_helpers.py \
    tests/v2/coverage/test_forms_g4_style_presets_color_math_preset_generation.py \
    tests/v2/coverage/test_forms_g12_forms_py_model_behavior_methods_formfield_formtemplate_formsession_formembed_thankyoupage_.py \
    -q --no-cov -o addopts="" -p no:cacheprovider

printed 314 passed, 637 warnings, 7 errors in 40.89s. The 7 errors were all setup-phase psycopg2.errors.UniqueViolation: duplicate key value violates unique constraint "pg_type_typname_nsp_index" on the session-scoped test_engine fixture's CREATE TYPE ... AS ENUM calls - this is the exact failure mode the coverage suite's own conftest.py docstring warns about ("intended to be run against a freshly reset public schema... does not always round-trip cleanly between separate pytest invocations"): this session's scratch database already carried ENUM types from an earlier, unrelated test run in the same environment. None of the 7 errored tests are specific to this recipe's code (they span both test_forms_g6 and test_forms_g8, at setup, before any test body ran) and no assertion inside a collected test failed. 315 def test_ functions across the six files (grep -c "^def test_": 34, 31, 83, 44, 40, 83) plus 2 @pytest.mark.parametrize decorators (test_forms_g2, adding a handful of extra parametrized cases) account for the 314 passed + 7 errored = 321 collected total.

Hermetic vs live: test_forms_g1/g2/g4(color math)/g12 are pure, no DB, no auth (ValidationEngine, ConditionalLogicEngine, style_presets helpers, models.forms instance methods built in memory); test_forms_g4's brand-preset-endpoint tests and test_forms_g6/g8 are DB-backed against the real Postgres via the shared db_session/test_engine fixtures (test-layout, S9), g8 additionally spins a real TestClient(app, raise_server_exceptions=False) with get_current_user/_get_workspace_id overridden per-tenant so cross-tenant 404s are asserted through the actual HTTP stack, not mocked. Known-answer coverage: style_presets' lighten('#000000', 0.5)/darken(...)/hex_to_rgba blend math and _find_brand_white_black's luminance-threshold branching (0.3/0.85 cutoffs) are asserted with real hex values in test_forms_g4; ValidationEngine's 6 built-in PATTERNS and every type-specific validator's boundary (min/max, min_length/max_length, require_https, allowed_domains/blocked_domains) are asserted in test_forms_g1.

What a rebuild adds and this suite does not cover: no test asserts on ConditionOperator.MATCHES_PATTERN's silent-False fallthrough or the inverse_actions action gap (Application logic) - both are read from the source, not pinned by a failing-then-fixed test; no test exercises the settings: {} empty-object vs fields: [] empty-list update edge case (Gotchas) end to end through the router.

Deploy & ops

Config keys: none dedicated (Stack assumptions) beyond the import-availability guard. Migrations, in the order that touches this recipe's tables: baseline_prod_schema_001 (down_revision None, reconciliation of pre-Alembic tables including form_templates/form_fields/form_submissions/form_analytics in their 2026-08-02 live-staging shape) → add_multipage_fields (down-revision add_form_sessions, adds page_id/answer_piping to form_fields) → add_form_embeds (down-revision add_multipage_fields, creates form_embeds AND thank_you_pages) → form_slug_001 (down-revision fix_hidden_label_001, adds form_templates.slug + the unique_workspace_form_slug constraint, both via add_column_if_missing/create_unique_constraint_if_missing - idempotent because form_templates is a phantom/baselined table) → form_analytics_event_types_002 (down-revision booking_slot_lock_001, extends the form_analytics CHECK constraint's allowed event_type list). At 62ba4fc4, alembic heads against the scratch database resolves to a single head, prima_device_codes_001 - no branch/merge conflict in the chain.

Scheduler/background jobs: none owned by this recipe (no cron, no APScheduler job touches form_templates/form_fields). Argus-style checks: POST /api/forms/validate with a known template id and an intentionally-incomplete data body returns 200 {"valid": false, ...} with a non-empty errors dict - proves the two-engine pipeline is reachable without risking a write. Rollback: every table here cascades from form_templates, so a code rollback needs no data unwind; the four incremental migrations are each individually reversible (downgrade() defined in all of them) but baseline_prod_schema_001 has no meaningful downgrade (it documents a snapshot, not a change).

Gotchas

  • 2026-09-12 (measured at 62ba4fc4, services/forms/README.md): the module's own README lists "Multi-page forms with progress tracking" under Future Enhancements (line 336) even though multi-page is fully shipped - FormTemplate.is_multi_page/get_pages/get_fields_for_page/get_page_count/get_progress_bar_settings (models/forms.py 112-135) and the page_id/answer_piping columns (migration add_multipage_fields, 2025-12-05) predate this README's "future" framing by about ten months. The same README's "Query Analytics" section documents a form_responses view, a form_analytics_summary view, a field_analytics view, and a get_field_completion_rate() function - none of the four exist in any of the 101 files under migrations/versions/ (measured directly). Treat this file as stale, not authoritative.
  • 2026-09-12 (measured at 62ba4fc4, template_service.py 580-639): get_template_analytics - the function behind GET /forms/templates/{id}/analytics - returns total_views: 0 and completion_rate: 0.0 as literal hardcoded values (the code comments say # Placeholder), and its per-field field_analytics dict always reports interactions: 0, completion_rate: 0.0 for every field regardless of how many FormAnalytics rows actually exist for that template. Only total_submissions (a real COUNT(*) on form_submissions) and created_at are real. This is why maturity: prod above carries a caveat rather than an unqualified claim: the CRUD/validate/submit/lead-magnet surface is live and tested, the analytics surface is not functional.
  • 2026-09-12 (measured at 62ba4fc4, schemas/forms.py 138-148 vs 125-135): FormTemplateCreate.fields has a @validator('fields') that rejects duplicate field names and an empty list; FormTemplateUpdate.fields has no such validator. A PUT that fully replaces a template's fields with two fields sharing a name will pass Pydantic validation and then fail at the database's form_fields_unique_name_per_template constraint instead, surfacing as an unhandled IntegrityError (500, not a clean 400/422) rather than the same-shape validation error POST /templates would have given for the identical payload.
  • 2026-09-12 (measured at 62ba4fc4, template_service.py 441 and forms_router.py - the router passes the raw FormTemplateUpdate): the field-replace branch on update is guarded by if 'fields' in update_dict and update_dict['fields']: - a truthy check, not is not None. A caller that explicitly sends {"fields": []} intending "delete all fields" has that intent silently ignored; the existing fields are left untouched with no error and no indication anything was skipped.
  • 2026-09-12 (measured at 62ba4fc4, services/forms/conditional_logic.py 9-62 and 128-211): ConditionOperator.MATCHES_PATTERN is declared in the enum and nowhere else - it is not in self.operators, so any conditional-logic rule using it always evaluates False with no error, warning, or log line. Separately, apply_conditional_logic's inverse_actions branch only implements SHOW/HIDE; a rule that sets ENABLE/DISABLE/REQUIRE/UNREQUIRE/SET_VALUE/CLEAR_VALUE as an inverse action is silently a no-op on that branch. The module's own # ... (same as above) comment at line 207 documents the gap without closing it.
  • 2026-09-12 (measured at 62ba4fc4, models/forms.py 300-351 vs services/forms/conditional_logic.py): there are two independent conditional-logic evaluators in this recipe's scope - FormField._evaluate_condition (8 operators, no starts_with/ends_with/in_list/not_in_list/matches_pattern) and ConditionalLogicEngine (14 wired operators). The request pipeline (submit/validate) uses only the second; the first is reachable from FormField.is_visible, called nowhere in this recipe's router at this commit (not verified against the frontend, which is out of scope) but present as live model surface a caller could invoke and get a different answer from than the API would give for the same conditional_logic payload on an operator only one of the two implements.
  • 2026-09-12 (measured at 62ba4fc4, services/forms/validation_engine.py 251-258 and models/forms.py 271-272): ValidationEngine._validate_choice checks value against validation_rules['allowed_values'] only - it never reads the field's own options.choices (the actual dropdown/radio options the builder UI stores per FormField.get_field_schema, 270-272). A select/radio field with choices defined only in options (the normal way the builder stores them) and no separate validation_rules.allowed_values accepts ANY string value at submit time, not just one of its declared choices.
  • 2026-09-12 (measured at 62ba4fc4, forms_router.py 1432-1436): preview_lead_magnet_email's per-field sample-variable loop checks hasattr(fields, 'fields_rel') - FormTemplate has no attribute named fields_rel (the relationship is fields, models/forms.py 37) - so this branch is permanently dead; the lead-magnet preview only ever substitutes the four hardcoded sample vars (first_name, last_name, email, company), never a real field name, regardless of what the form actually collects.
  • 2026-09-12 (measured at 62ba4fc4, backend/create_default_form.py, whole file): this standalone maintenance script constructs FormTemplate(...) with no workspace_id at all, against a schema where form_templates.workspace_id is NOT NULL (Data model). Run as-is against this commit's schema it fails with an IntegrityError on db.commit(), not the friendly ERROR: message its own except Exception branch prints for other failure modes (that branch does catch and roll back, so it fails cleanly, just not for a reason its own error message describes). The script is referenced only from .claude/FORM_BUILDER_IMPLEMENTATION.md, a docs file, and from no deploy/init script found in the repo - it reads as dead/unmaintained rather than a real bootstrap path.
  • 2026-09-12 (measured at 62ba4fc4, schemas/forms.py, whole file): FormFieldUpdate is defined (56-70) and imported nowhere else in the repo (grepped every .py file outside schemas/forms.py) - the router always replaces the full field list via FormFieldCreate on update, never patches one field in place. Treat FormFieldUpdate as unused surface, not a documented-but-hidden per-field PATCH capability.
  • 2026-09-12 (measured at 62ba4fc4, app_fastapi.py 724-793 and forms_router.py 390-394): the global error-envelope handler's dict-detail branch reads detail.get("error") or detail.get("message") or detail to build the client-facing message. submit_form's 422 sends detail={"errors": errors} (plural key, no singular error/message) - so the envelope's message field ends up as the Python-repr string of the whole dict ("{'errors': {'email': 'email is required'}}") rather than a clean sentence. The field-level detail is still present (in details, via jsonable_encoder(detail)), just not through the message field the way every {"error": ...}-shaped 4xx in the rest of the codebase reads.
  • 2026-09-12 (measured at 62ba4fc4, models/forms.py, TemplateService, forms_router.py): FormTemplate.slug is a real, migrated, unique-constrained column, but no code path in this recipe's surface ever assigns it - grepped template_service.py and forms_router.py for slug=/.slug =, zero hits. The column and its unique_workspace_form_slug constraint exist for a feature (slug-addressable public forms) with no write path here; Postgres's standard NULL-is-distinct behavior means the constraint does not even stop multiple templates in one workspace from all having slug IS NULL.

Reference implementation

Paths relative to the repo root; all at alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.

  • alchemy_labs/backend/models/forms.py (958 lines): FormTemplate (18-136, to_dict 55-78, multi-page helpers 112-135); FormField (138-440, get_field_schema 212-294, is_visible/_evaluate_condition 296-351, get_validation_errors 353-440); FormSubmission (443-519); FormAnalytics (522-558, event_type CHECK 542-548); FormSession (561-702, out of this recipe's scope, cited for the FK form_submissions.session_id points at); FormEmbed (704-853, default style config 763-813); ThankYouPage (856-958, matches_conditions 934-958).
  • alchemy_labs/backend/schemas/forms.py (709 lines, whole file in scope): field schemas 14-83; template schemas 89-176; submission schemas 182-207; validation schemas 214-224; embed/style schemas 434-559; thank-you-page schemas 565-624; AI-prompt schemas 701-709.
  • alchemy_labs/backend/api/routers/forms_router.py (1571 lines): style presets 43-93; template CRUD 96-335; submit/validate 338-500; submissions/analytics/tracking 503-626; leads (out of scope, cited for the catch-all-route ordering comment at 630-631) 632-869; public catch-all 873-896; sessions (out of scope) 903-1313; lead magnet 1316-1500; AI prompt 1503-1571.
  • alchemy_labs/backend/services/forms/template_service.py (639 lines, whole file in scope): sanitize helper 15-55; TemplateService 58-639.
  • alchemy_labs/backend/services/forms/validation_engine.py (356 lines, whole file in scope).
  • alchemy_labs/backend/services/forms/conditional_logic.py (375 lines, whole file in scope).
  • alchemy_labs/backend/services/forms/style_presets.py (229 lines, whole file in scope).
  • alchemy_labs/backend/create_default_form.py (220 lines, whole file in scope - see Gotchas).
  • alchemy_labs/backend/migrations/versions/baseline_prod_schema_001.py (form tables 1043-1127), 20251205_add_multipage_fields.py, 20251205_add_form_embeds.py, 20260228_add_form_slug.py, 20260612_0001_form_analytics_event_types.py.
  • Tests as listed under Tests, plus tests/v2/coverage/conftest.py for the shared test_engine/db_session fixtures this recipe's Acceptance checks depend on.

Deviations of the reference from an ideal implementation, all dated 2026-09-12: get_template_analytics is two-thirds placeholder; ConditionOperator.MATCHES_PATTERN and half of inverse_actions are declared but not implemented; the model-level and engine-level conditional-logic evaluators can disagree; select/radio validation does not check the field's own options.choices; slug has no write path; create_default_form.py is broken against the live schema and appears unmaintained; the lead-magnet email preview's per-field sample-variable substitution is dead code (wrong attribute name).

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc4 with the deap-postgres-test scratch Postgres up on port 5434.

  1. TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db python -m pytest tests/v2/coverage/test_forms_g6_templateservice_crud_design_normalization_service_layer_db_backed_.py tests/v2/coverage/test_forms_g8_authenticated_forms_router_template_crud_submit_validate_endpoints_testclient_auth_tenant_.py tests/v2/coverage/test_forms_g1_pure_validation_engines_no_db_validationengine_conditionallogicengine.py tests/v2/coverage/test_forms_g2_conditionallogic_operators_progress_visibility_helpers.py tests/v2/coverage/test_forms_g4_style_presets_color_math_preset_generation.py tests/v2/coverage/test_forms_g12_forms_py_model_behavior_methods_formfield_formtemplate_formsession_formembed_thankyoupage_.py -q --no-cov -o addopts="" -p no:cacheprovider prints 314 passed (see Tests for the 7 setup-phase errors and why they are environmental, not a code failure).
  2. grep -c "^def test_" tests/v2/coverage/test_forms_g6_templateservice_crud_design_normalization_service_layer_db_backed_.py tests/v2/coverage/test_forms_g8_authenticated_forms_router_template_crud_submit_validate_endpoints_testclient_auth_tenant_.py tests/v2/coverage/test_forms_g1_pure_validation_engines_no_db_validationengine_conditionallogicengine.py tests/v2/coverage/test_forms_g2_conditionallogic_operators_progress_visibility_helpers.py tests/v2/coverage/test_forms_g4_style_presets_color_math_preset_generation.py tests/v2/coverage/test_forms_g12_forms_py_model_behavior_methods_formfield_formtemplate_formsession_formembed_thankyoupage_.py prints 34, 31, 83, 44, 40, 83 (315 total).
  3. python -c "from models.forms import FormSubmission; print(FormSubmission().metadata)" (run inside .venv with app_fastapi imported first for model registration) prints MetaData(), not {} - confirms metadata is a reserved SQLAlchemy declarative attribute name shadowing the submission_metadata column (Column('metadata', JSONB, ...)) on any instance that was not constructed with an explicit metadata= keyword argument.
  4. grep -n "ConditionOperator.MATCHES_PATTERN" services/forms/conditional_logic.py shows it declared in the enum (line 25) and grep -c "MATCHES_PATTERN" services/forms/conditional_logic.py prints 1 - the single declaration, confirming it is never referenced again in the file (not wired into self.operators).
  5. python3 -c "import os; hits=[f for f in os.listdir('migrations/versions') if 'form_responses' in open('migrations/versions/'+f,encoding='utf-8',errors='ignore').read() or 'get_field_completion_rate' in open('migrations/versions/'+f,encoding='utf-8',errors='ignore').read()]; print(hits)" prints [], confirming the README's documented analytics views/function do not exist.
  6. grep -n "slug=" services/forms/template_service.py api/routers/forms_router.py prints nothing, confirming no write path sets FormTemplate.slug.
  7. alembic heads (with DATABASE_URL/SYNC_DATABASE_URL pointed at the scratch database and alembic upgrade head already run) prints a single head, prima_device_codes_001 (head).

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.