Docs / Admin
Forms and the form builder
Summary — Admins build custom forms (surveys, feedback forms, self-assessments) using a Google Forms-style builder with 10 field types, sections, conditional visibility, and optional scoring. Forms can be shared as a public link for anonymous collection. Staff review submissions from the workbench.
Purpose — Understand how to create a form definition, choose field types, publish it publicly, and review responses. Also covers the AI-assisted field extraction from an uploaded document.
Audience — Admins who build and publish forms; staff who review submissions.
Prerequisites — None. Forms are a standalone module — you do not need an opportunity, organization, or consultation to create one, though a submission can optionally be linked to another entity via linkedEntityType / linkedEntityId.
Overview
A form definition is a named, versioned set of fields that can be filled out and stored as form submissions. Forms are used for feedback surveys, stakeholder satisfaction surveys, consultation follow-ups, and investor readiness self-assessments.
Key concepts:
- Form definition — The schema: title, description, fields, sections, and settings (
scoringEnabled,maxScore,sections). Stored with aversionnumber that increments on every update. - Field — A single question. Ten types are supported: Short answer, Paragraph, Number, Dropdown, Multiple choice, Checkboxes, Yes/No, Date, Rating, File upload.
- Section — An optional grouping of fields, rendered as its own card in the builder and on the public form.
- Conditional visibility — A rule (
visibleWhen) that shows a field only when another field's answer matches a condition. - Scoring — An optional per-field weight; a numeric score is computed automatically at submission time if
scoringEnabledis on. - Form submission — One respondent's answers, with a lifecycle status of
draft,submitted, orreviewed. - Public link — When a form is marked "Public link," it becomes reachable and fillable at
/feedback/{slug}with no authentication.
Screenshot: Form Definitions admin list (
/admin/forms) showing slug, title, version, field count, Public/Private badge, Active/Inactive badge, and an Edit link.
Field types
The builder exposes ten field types. The label shown to the person building the form differs from the internal type name stored in the database — use this table to translate between what you see in the builder and what appears in code, exports, or error messages.
| Builder label | Internal type (FieldType) | Notes |
|---|---|---|
| Short answer | text | Single-line text. |
| Paragraph | textarea | Multi-line text. |
| Number | number | Has Min/Max range inputs. |
| Dropdown | select | Single choice from an options list. |
| Multiple choice | radio | Single choice, shown as radio buttons. |
| Checkboxes | multiselect | Multiple choice, shown as checkboxes. |
| Yes / No | checkbox | Boolean field. |
| Date | date | Date picker. |
| Rating | rating | Numeric scale; defaults to Min 1 / Max 5 when first selected. |
| File upload | file | Stores a { fileKey, fileName } reference; the file itself is uploaded separately to /api/uploads/public. |
Each field has: slug (stable machine identifier, generated once and never rewritten so existing submissions keep rendering correctly), label, type, required, and optionally options (for Dropdown/Multiple choice/Checkboxes), min/max (Number/Rating), placeholder, helpText, scoringWeight, sectionId, and visibleWhen.
Validation limits (server-enforced via createFormDefinitionSchema / fieldDefinitionSchema): field slug max 100 chars, label max 255 chars, helpText max 1000 chars, placeholder max 500 chars, at least one field is required to create a form definition ("At least one field is required").
Creating a form
Path: Staff workbench → Forms → Create Form (/admin/forms/new)
- Pick a starting point from the template library: Blank Form, Event Feedback, Stakeholder Satisfaction Survey, Consultation Follow-up, or Investor Readiness Self-Assessment. Selecting a non-blank template seeds the builder with that template's fields and sections (deep-copied so edits don't mutate the shared template).
- Enter the Form title — the URL slug auto-generates from it until you edit the slug field directly.
- Optionally enter a Form description.
- Toggle Public link (defaults off), Accepting responses (defaults on), and Scoring (defaults off). When Scoring is on, a Max score number input appears.
- Add questions with + Add question, or + Add section to group fields into cards. A section can only be deleted while it has zero fields in it — the delete button shows the tooltip "Section has {n} field(s) — must be empty to delete" otherwise.
- For each question: type the question text, pick its type from the dropdown, add choices (for Dropdown/Multiple choice/Checkboxes) via + Add option, set Min/Max (Number/Rating), toggle Required, and optionally add + Help text or a + Add visibility rule.
- Reorder or duplicate questions using the up/down arrow, duplicate, and delete icons in each question's footer, or drag the card by its border.
- Click Preview to see the form as a respondent would, then Back to editing to return.
- Click Create form.
The Save button is disabled until both title and slug are non-empty (canSave).
Importing fields from a document
Click Import from file and upload a PDF, Word doc, Excel/CSV file, or image. The system runs OCR/text extraction, truncates to 15,000 characters, and sends it to the AI provider chain with a structured extraction prompt asking for field slug, label, type, required flag, section, options, help text, and min/max. Extracted fields are appended to the current field list; on create mode, an empty title/description/slug is also backfilled from the extracted title. If any extracted field has a scoring weight, Scoring is turned on automatically.
- Max file size: 10MB (
"File too large (max 10MB)"). - If extracted text is under 20 characters:
"Could not extract enough text from the document. Try a different format." - Requires admin role (
requireRole("admin")inside the action).
Sections and conditional visibility
Sections group related fields under a shared heading (e.g. "About You", "Engagement", "Satisfaction" in the Stakeholder Satisfaction template). Each section has an id (stable, generated once), title, and optional description. If no sections exist, all fields render in one implicit card.
Conditional visibility (visibleWhen) hides a field until a condition on another field's answer is met. Set it via + Add visibility rule on a field: pick the controlling field, an operator (equals, not equals, contains, answered), and (except for answered) a comparison value. A field with an active rule shows a "Conditional visibility set" chip near its question text. Hidden fields are never required and never validated on submit — the validator (isFieldVisible) skips them entirely.
Example from the built-in Consultation Follow-up template: the field action_items_desc ("Describe the action items and timeline") is only shown visibleWhen action_items_set equals "yes".
Scoring
Scoring is optional and off by default. When Scoring is toggled on:
- A Scoring weight number input appears on every field.
- At submission time,
computeScore()sumsvalue × scoringWeightacross all fields that have a weight greater than 0. - Value extraction by type:
rating/number— the numeric response itself;checkbox— 1 if checked (true/"true"), 0 otherwise;select/radio— the field's optionvalueparsed as a number (so give options numeric values like"5"if you want them scored);multiselectandtext/textarea/date/filefields are never scored. - If no fields carry a weight, or no weighted field received a response, the computed score is
null. - The form's
settings.maxScoreis stored but not enforced or used to normalize the computed score — it is informational only, set by the admin for reference.
The score appears on the submission list and submission detail page as submission.score.toFixed(1) (one decimal place), or — when null.
Publishing and sharing a form
Toggle Public link in the builder, then save. On the Edit Form page (/admin/forms/{id}), a Share card appears once the form is public, showing:
- The public URL:
{origin}/feedback/{slug} - A generated QR code image for that URL
- A Copy button (copies the link, shows toast "Link copied", or "Could not copy — copy it manually" on failure)
- An "Open form ↗" link that opens the public page in a new tab
From the staff-facing form detail page (/forms/{slug}), the same actions are available via the Edit Questions, Open Public Form, and Copy Link buttons (FormActionsBar). If the form is not public, that area instead shows: "Publish this form (in Edit Questions) to share a public link."
Only when is_active — "Accepting responses" — is on should respondents be pointed to the link; the builder does not block submission when inactive at the UI level documented here (no code path found that blocks submitFormAction on isActive, so treat "Accepting responses" as an informational toggle unless a dedicated guide says otherwise — a dedicated guide is planned).
Public submission flow
Path: Public respondent visits /feedback/{slug} — no authentication required.
- The page loads the form definition by slug. If the form does not exist or
isPublicis false, it renders a 404 (notFound()). - The respondent fills in the rendered fields (
FormRenderer), grouped into sections fromformDef.settings.sectionsif present. - On submit, any
file-type field with an uploaded file is first sent toPOST /api/uploads/publicwith the file and the formslug; the response'sfileKey/fileNamereplaces the raw file in the payload. - The cleaned responses are submitted via the
submitFormActionserver action asformDefinitionId+responses(JSON). - On success, the page shows "Thank you!" with the message "Your feedback has been submitted successfully. We appreciate your input." and a Confirmation code — the first 8 characters of the submission ID, uppercased.
- A Back to Home button returns to
/.
Server-side enforcement on every public submission:
- Rate limit: 10 submissions per hour per client IP (
form-submit:{ip},rateLimit(..., 10, "1 h")). Exceeding it throws"Too many submissions. Please try again later."IPs that resolve to"unknown"(non-Fly runtimes) are not rate-limited. - Field validation (
validateResponses): required fields must be non-empty;number/ratingmust be numeric and withinmin/max("{label} must be at least {min}","{label} must be at most {max}");select/radio/multiselectvalues must match a defined option ("{label} has an invalid selection");checkboxmust be boolean or"true"/"false"("{label} must be true or false"). All violations are collected and thrown together as a joined message.
A submission is created with status: "draft" via submitForm/submitFormAction. A separate submitFormPublished/submitFormPublishedAction path exists in the service and actions layer that submits and immediately advances status to "submitted" — check which one your form's calling code uses; the public /feedback/{slug} page in this codebase calls submitFormAction (draft).
Reviewing submissions (staff)
Path: Staff workbench → Forms (/forms) → click a form → submissions list (/forms/{slug})
The list shows Respondent, Email, Status, Score, and Submitted date/time, sorted newest first. Click a respondent's name to open the submission detail page (/forms/{slug}/{id}), which shows:
- Respondent name/email, submission or creation timestamp, status badge, and score badge (if scored).
- Every response, with the field's human-readable label (not its slug) and, for choice fields, the option label (not its raw stored
value). - A Linked Entity card if the submission has
linkedEntityType/linkedEntityIdset (e.g. linked to an opportunity, contact, or consultation by another module).
Status workflow: draft → submitted → reviewed. Advancing status is done via reviewFormSubmissionAction, which requires the staff role (requireRole("staff", { module: "forms" })) and logs an audit event form_submission.reviewed recording the previous and new status. No dedicated UI button for this transition was found in the reviewed pages — the action exists in actions.ts but the review-status control belongs to a caller not covered here (a dedicated guide is planned if/when a "Mark reviewed" button is added to the submission detail page).
Roles and permissions
| Action | Role required | Where enforced |
|---|---|---|
| Create form definition | admin | requireRole("admin", { module: "forms" }) in createFormDefinitionAction |
| Update form definition | admin | requireRole("admin", { module: "forms" }) in updateFormDefinitionAction |
| Extract fields from uploaded template | admin | requireRole("admin") in extractFieldsFromTemplateAction |
| Submit a public form response | none (anonymous) | rate-limited only, enforcePublicSubmitRateLimit |
| Review / change submission status | staff | requireRole("staff", { module: "forms" }) in reviewFormSubmissionAction |
| View form definitions / submissions | authenticated (route is under (authenticated)) | no explicit role check found in getFormDefinitionAction, listSubmissionsAction, etc. |
See Roles and access for the full role model across the platform.
Best practices
- Never rename a field's slug after publishing. Existing submissions store responses keyed by slug; the builder deliberately keeps slugs stable across edits so past answers keep displaying correctly.
- Set scoring weights only on fields whose values are meaningful numerically.
select/radiofields only score if their optionvaluestrings parse as numbers — plain text option values (like"yes") will not contribute to the score. - Group long forms into sections. Sections with empty descriptions still render cleanly and make long surveys (e.g. Investor Readiness) easier to scan.
- Use conditional visibility to shorten forms, not to enforce business logic — hidden fields are simply skipped by validation, they are not a substitute for server-side workflow rules.
- Test with Preview before publishing. The Preview toggle renders the exact
FormRenderera public respondent will see. - Keep "Accepting responses" and "Public link" intentional. Turning off Public link removes the
/feedback/{slug}route's usability (the page still 404s cleanly) without deleting existing submissions.
Warnings
File uploads are size- and mime-gated but not virus-scanned in the reviewed code path. The 10MB limit applies to the AI field-extraction upload only; the public respondent-facing file field goes through /api/uploads/public, not reviewed here.
Public submission is fully anonymous unless a session exists. submitFormAction tries to resolve a logged-in user but falls back to anonymous (userId: undefined) without error — do not assume every submission has a respondent identity.
Scoring is silent when misconfigured. A form with Scoring enabled but no field weights, or all weights on non-numeric option values, returns score: null for every submission with no warning surfaced to the admin.
Section deletion is blocked, not cascaded. You cannot delete a section that still has fields in it — move or delete its fields first.
Troubleshooting
"At least one field is required"
Symptom: Creating a form definition fails validation.
Cause: createFormDefinitionSchema requires fields.length >= 1.
Fix: Add at least one question with + Add question before saving.
"Could not extract enough text from the document. Try a different format."
Symptom: Import from file fails immediately after upload.
Cause: OCR/text extraction returned fewer than 20 characters of usable text — usually a blank, image-only, or unreadable file.
Fix: Try a text-based PDF/DOCX instead of a scanned image, or add fields manually with + Add question.
"File too large (max 10MB)"
Symptom: Import from file rejects the upload before processing.
Cause: The uploaded template file exceeds 10MB.
Fix: Reduce the file size or split it, then re-upload.
"Too many submissions. Please try again later."
Symptom: A public respondent's submission is rejected.
Cause: More than 10 submissions from the same client IP within one hour.
Fix: Wait for the rate-limit window to reset, or confirm this is not a bot/abuse pattern. IPs that cannot be resolved (non-Fly environments) are exempt from this limit.
Field-level errors like "{label} is required" or "{label} has an invalid selection"
Symptom: Public submission is rejected with one or more field-specific messages joined together.
Cause: validateResponses re-validates every visible field server-side regardless of client-side form state — required fields were empty, a number was out of min/max range, or a select/radio/multiselect value didn't match a defined option.
Fix: Re-check the form definition's field constraints match what the public form actually renders; if the constraint is wrong (e.g. min/max too strict), edit the field in Edit Questions.
Section delete button is disabled
Symptom: Clicking the trash icon on a section does nothing, and the tooltip reads "Section has {n} field(s) — must be empty to delete".
Cause: The section still contains one or more fields.
Fix: Move the fields to another section or delete them individually, then delete the section.
FAQ
Can respondents edit a submission after submitting?
Not found in the reviewed code — submissions are created once via submitFormAction; there is no public edit/resume path in the pages reviewed.
Is there a connection between forms and the consultation RSVP flow at /respond/rsvp?
No. /respond/rsvp and /api/comms/respond belong to the communications module's campaign response ledger (recordCampaignResponse), a separate system from form definitions/submissions. They are unrelated code paths that happen to also be public-facing.
What does linkedEntityType / linkedEntityId do?
It lets a form submission reference another record elsewhere in the platform (for example, an opportunity or contact) for display on that record's page. The forms module itself does not create this link automatically for public submissions — it must be passed in by whatever caller invokes submitFormAction/submitForm with those fields populated.
Does changing a form's fields affect past submissions?
No. Each submission stores the formVersion it was submitted against, and the response values are stored independently of the current field definition. The Edit Form page's subtitle states this directly: "Changes apply to new responses; past submissions keep their answers."
What are the built-in templates?
Blank Form, Event Feedback, Stakeholder Satisfaction Survey, Consultation Follow-up, and Investor Readiness Self-Assessment — all defined in code (FORM_TEMPLATES) and selectable when creating a new form.
Related articles
- Roles and access — Who can do what across the platform.
- Approval chains and workflow states — Configurable approvals and state machines, a separate mechanism from form submission status.
- Issue tracking and types — Another configurable-schema module, useful for comparison with how forms model custom fields.
- Staff workbench overview — Where Forms sits in the workbench navigation.