forms

Form definitions, fields, submissions, and file uploads with RLS

Forms

Configurable forms with ordered fields, submission tracking, and file uploads -- all scoped by organization with row-level security. Depends on the identity skill for organizations and users.

Tables

form_definitions

ColumnTypeDescription
iduuidPrimary key
org_iduuidFK to organizations
creator_iduuidFK to users. The user who created the form. NULL if creator was deleted
nametextHuman-readable form name
slugtextURL-safe identifier, unique per org
descriptiontextOptional description shown to respondents
statusform_statusLifecycle state: draft, active, or archived
submit_messagetextConfirmation message shown after submission
redirect_urltextOptional URL to redirect to after submission
created_attimestamptzRow creation timestamp
updated_attimestamptzLast update timestamp (auto-set by trigger)
metadatajsonbArbitrary JSON for form-level settings

Unique constraint on (org_id, slug).

form_fields

ColumnTypeDescription
iduuidPrimary key
org_iduuidFK to organizations
form_iduuidFK to form_definitions
labeltextDisplay label shown to the respondent
field_keytextMachine-readable key stored in submission data JSON
field_typeform_field_typeInput type: text, email, number, select, multiselect, checkbox, textarea, date, or file
positionintegerDisplay order within the form. Lower values appear first
is_requiredbooleanWhether the field must be filled before submission
optionsjsonbOption objects for select/multiselect fields
validationjsonbValidation rules, e.g. {"min_length": 3, "max_length": 500}
created_attimestamptzRow creation timestamp
updated_attimestamptzLast update timestamp (auto-set by trigger)
metadatajsonbArbitrary JSON

Unique constraint on (form_id, field_key).

form_submissions

ColumnTypeDescription
iduuidPrimary key
org_iduuidFK to organizations
form_iduuidFK to form_definitions
contact_iduuidOptional reference to a CRM contact (not enforced as FK)
datajsonbJSON object mapping field_key to submitted value
submitted_attimestamptzWhen the respondent submitted the form
ip_addressinetIP address of the respondent
user_agenttextBrowser user agent string
created_attimestamptzRow creation timestamp
updated_attimestamptzLast update timestamp (auto-set by trigger)
metadatajsonbArbitrary JSON

form_uploads

ColumnTypeDescription
iduuidPrimary key
org_iduuidFK to organizations
submission_iduuidFK to form_submissions
field_iduuidFK to form_fields. NULL if originating field was deleted
file_nametextOriginal file name
file_sizebigintFile size in bytes
mime_typetextMIME type of the uploaded file
storage_pathtextPath within Supabase Storage
created_attimestamptzRow creation timestamp
updated_attimestamptzLast update timestamp (auto-set by trigger)
metadatajsonbArbitrary JSON

Enums

form_status

ValueDescription
draftNot yet accepting submissions
activeAccepting submissions
archivedNo longer accepting submissions, kept for reference

form_field_type

ValueDescription
textSingle-line text input
emailEmail address input
numberNumeric input
selectSingle-choice dropdown
multiselectMulti-choice dropdown
checkboxBoolean checkbox
textareaMulti-line text input
dateDate picker
fileFile upload

Row-Level Security

All tables have RLS enabled and are scoped to the current user's organization via get_user_org_id().

  • form_definitions: SELECT/INSERT/UPDATE for own org. DELETE requires admin.
  • form_fields: SELECT/INSERT/UPDATE for own org. DELETE requires admin.
  • form_submissions: SELECT/INSERT/UPDATE for own org. DELETE requires admin.
  • form_uploads: SELECT/INSERT/UPDATE for own org. DELETE requires admin.

Dependencies

  • identity -- organizations, users

Example Queries

List all active forms in the current organization:

SELECT fd.id, fd.name, fd.slug, fd.status,
       count(fs.id) AS submission_count
FROM form_definitions fd
LEFT JOIN form_submissions fs ON fs.form_id = fd.id
WHERE fd.org_id = get_user_org_id()
  AND fd.status = 'active'
GROUP BY fd.id
ORDER BY fd.name;

Get a form with its fields in display order:

SELECT fd.name AS form_name,
       ff.label, ff.field_key, ff.field_type, ff.is_required, ff.position
FROM form_definitions fd
JOIN form_fields ff ON ff.form_id = fd.id
WHERE fd.slug = 'contact-us'
  AND fd.org_id = get_user_org_id()
ORDER BY ff.position;

Retrieve submissions for a form with submitted values:

SELECT fs.id, fs.submitted_at, fs.data, fs.ip_address
FROM form_submissions fs
WHERE fs.form_id = '<form_id>'
  AND fs.org_id = get_user_org_id()
ORDER BY fs.submitted_at DESC
LIMIT 50;

Count submissions per form grouped by day:

SELECT fd.name,
       date_trunc('day', fs.submitted_at) AS day,
       count(*) AS submissions
FROM form_submissions fs
JOIN form_definitions fd ON fd.id = fs.form_id
WHERE fs.org_id = get_user_org_id()
GROUP BY fd.name, day
ORDER BY day DESC;

List all uploads for a given submission:

SELECT fu.file_name, fu.file_size, fu.mime_type, fu.storage_path,
       ff.label AS field_label
FROM form_uploads fu
LEFT JOIN form_fields ff ON ff.id = fu.field_id
WHERE fu.submission_id = '<submission_id>'
  AND fu.org_id = get_user_org_id()
ORDER BY fu.created_at;

Calculate NPS score from survey submissions:

SELECT
  count(*) FILTER (WHERE (data->>'score')::int >= 9)  AS promoters,
  count(*) FILTER (WHERE (data->>'score')::int BETWEEN 7 AND 8) AS passives,
  count(*) FILTER (WHERE (data->>'score')::int <= 6)  AS detractors,
  round(
    100.0 * (
      count(*) FILTER (WHERE (data->>'score')::int >= 9)
      - count(*) FILTER (WHERE (data->>'score')::int <= 6)
    ) / nullif(count(*), 0),
    1
  ) AS nps
FROM form_submissions
WHERE form_id = '<nps_form_id>'
  AND org_id = get_user_org_id();