diff --git a/AGENTS.md b/AGENTS.md
index 89340ea2a7..70347172a3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -259,6 +259,8 @@ action, or `authorize! :workshop, to: :summary?`).
### Forms
+- `PublicFormSubmission` — Records a submission to a **standalone, published** `Form` filled out at its public pretty URL (`/f/:slug`, `PublicFormsController`). No event, role, or account: the respondent is find-or-created as a `Person` from the form's name/email answers (email + last-name match reuses an existing person), consent recorded once, answers stored as a `role: "public"` `FormSubmission` via `FormSubmission#persist_answer`, then `OtherResponses::CaptureFromSubmission` like every other submission path. Returns a `Result` (`success?`, `form_submission`, `person`, `errors`)
+- `Honeypot` — The spam trap shared by all four public, account-free forms (public forms, public registrations, bulk payment, contact us). `FIELD_NAME` is the decoy param every one of them renders via `shared/_honeypot` (off-screen and zero-size rather than `display: none`, which bots skip); `.tripped?(params, scope)` is the guard each controller calls to drop a filled-in submission silently. The name must never match a real column or `FormField` identifier — change it here and all four follow
- `SmartFormFields` — Catalog of the `field_identifier`s that carry backend behavior and what each does when a submission arrives with it, grouped by the record they write to (person identity, profile, mailing address, phone, organization, tagging, payment, consent, CE, bulk payment), plus `ANSWER_ONLY_IDENTIFIERS` for the library questions that only store an answer. Powers the admin-only **Smart form settings** page (`FormsController#smart_form_settings`, linked from both form editors), which answers what the editor's "Field identifier" box actually does. `spec/services/smart_form_fields_spec.rb` fails when the app grows an identifier the page doesn't document — it diffs the catalog against `FormBuilderService::SECTION_FIELD_IDENTIFIERS`, the `FormField`/`OtherResponse` identifier constants, and every `field_value("…")` read in `PublicRegistration`, so **add new identifiers to the catalog when you wire one up**
### Organizations
diff --git a/app/controllers/contact_us_controller.rb b/app/controllers/contact_us_controller.rb
index f24c4da7fa..45ec3678c1 100644
--- a/app/controllers/contact_us_controller.rb
+++ b/app/controllers/contact_us_controller.rb
@@ -25,7 +25,7 @@ def create
authorize! :contact_us, to: :create?
from = "story_share" if params[:from] == "story_share"
- if params[:contact_us][:website_url].present?
+ if Honeypot.tripped?(params, :contact_us)
redirect_to contact_us_path(from: from)
return
end
diff --git a/app/controllers/events/bulk_payment_form_submissions_controller.rb b/app/controllers/events/bulk_payment_form_submissions_controller.rb
index 80babbcd38..7b66080b24 100644
--- a/app/controllers/events/bulk_payment_form_submissions_controller.rb
+++ b/app/controllers/events/bulk_payment_form_submissions_controller.rb
@@ -16,6 +16,11 @@ def new
def create
authorize! :form_submission
+ if Honeypot.tripped?(params, :bulk_payment)
+ redirect_to new_event_bulk_payment_path(@event)
+ return
+ end
+
@form_params = params.dig(:bulk_payment, :form_fields)&.to_unsafe_h || {}
@field_errors = validate_required_fields
diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb
index 91daf0deaf..fc5dbd3a15 100644
--- a/app/controllers/events/public_registrations_controller.rb
+++ b/app/controllers/events/public_registrations_controller.rb
@@ -23,7 +23,7 @@ def new
def create
authorize! :public_registration, to: :create?
- if params[:public_registration][:website_url].present?
+ if Honeypot.tripped?(params, :public_registration)
redirect_to new_event_public_registration_path(@event)
return
end
diff --git a/app/controllers/form_submissions_controller.rb b/app/controllers/form_submissions_controller.rb
index 67e180197e..5218498e6c 100644
--- a/app/controllers/form_submissions_controller.rb
+++ b/app/controllers/form_submissions_controller.rb
@@ -1,12 +1,20 @@
class FormSubmissionsController < ApplicationController
def index
authorize! FormSubmission
- submissions = FormSubmission.includes(:form, :event, :person)
- if params[:person_id].present?
- submissions = submissions.where(person_id: params[:person_id])
- @person = Person.find_by(id: params[:person_id])
+
+ @person = Person.find_by(id: params[:person_id]) if params[:person_id].present?
+ @form = Form.find_by(id: params[:form_id]) if params[:form_id].present?
+
+ if turbo_frame_request?
+ submissions = FormSubmission.includes(:form, :event, :person)
+ submissions = submissions.where(person_id: @person.id) if @person
+ submissions = submissions.where(form_id: @form.id) if @form
+ @form_submissions = submissions.order(created_at: :desc).paginate(page: params[:page], per_page: 50)
+ render :form_submissions_results
+ else
+ @forms = Form.order(:name)
+ render :index
end
- @form_submissions = submissions.order(created_at: :desc)
end
def show
diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb
index 6e5be9055a..90853666f4 100644
--- a/app/controllers/forms_controller.rb
+++ b/app/controllers/forms_controller.rb
@@ -147,7 +147,7 @@ def set_dashboard_event
def form_params
params.require(:form).permit(
- :name, :role, :header, :hide_answered_person_questions, :hide_answered_form_questions,
+ :name, :role, :header, :hide_answered_person_questions, :hide_answered_form_questions, :slug, :published,
form_fields_attributes: [
:id, :name, :answer_type, :required, :subtitle, :hint_text,
:field_identifier, :section, :position, :visibility, :one_time, :width, :min_words, :max_characters, :_destroy,
diff --git a/app/controllers/public_forms_controller.rb b/app/controllers/public_forms_controller.rb
new file mode 100644
index 0000000000..f99cfc5576
--- /dev/null
+++ b/app/controllers/public_forms_controller.rb
@@ -0,0 +1,87 @@
+# Public, account-free pretty-URL endpoint for a standalone, published form
+# (/f/:slug). Reuses the public-registration field partials, so answers arrive
+# under the shared `public_registration[form_fields]` param namespace.
+class PublicFormsController < ApplicationController
+ skip_before_action :authenticate_user!, only: %i[show create thank_you]
+ before_action :set_form
+
+ def show
+ authorize! @form, to: :public_show?
+ @form_fields = ordered_fields
+ end
+
+ def create
+ authorize! @form, to: :public_show?
+
+ # A bot that fills the hidden field is silently bounced.
+ if Honeypot.tripped?(params, :public_registration)
+ redirect_to public_form_path(@form.slug)
+ return
+ end
+
+ @form_fields = ordered_fields
+ form_params = merge_retained_uploads(params.dig(:public_registration, :form_fields)&.to_unsafe_h || {})
+
+ @field_errors = validate_required_fields(form_params)
+ if @field_errors.any?
+ flash.now[:alert] = "Your submission is not complete yet. Scroll down to check for any errors or missing information."
+ render :show, status: :unprocessable_content
+ return
+ end
+
+ Current.source = "public_form"
+ result = PublicFormSubmission.call(form: @form, form_params: form_params)
+
+ if result.success?
+ redirect_to thank_you_public_form_path(@form.slug), notice: "Thank you — your response has been submitted!"
+ else
+ flash.now[:alert] = result.errors.join(", ").presence || "Something went wrong. Please try again."
+ render :show, status: :unprocessable_content
+ end
+ end
+
+ def thank_you
+ authorize! @form, to: :public_show?
+ end
+
+ private
+
+ # Scoped so a draft, an event form, or an unknown slug 404s.
+ def set_form
+ @form = Form.standalone.published.find_by!(slug: params[:slug])
+ end
+
+ def ordered_fields
+ @form.form_fields.reorder(position: :asc)
+ end
+
+ # A file input can't be repopulated, so on re-render after an error fall back to
+ # the already-uploaded blob's signed id (carried in retained_uploads).
+ def merge_retained_uploads(form_params)
+ retained = params.dig(:public_registration, :retained_uploads)&.to_unsafe_h || {}
+ return form_params if retained.blank?
+
+ retained.each do |field_id, signed_id|
+ next if signed_id.blank? || form_params[field_id].present?
+
+ form_params[field_id] = signed_id
+ end
+ form_params
+ end
+
+ def validate_required_fields(form_params)
+ fields = @form_fields.reject(&:group_header?)
+ errors = FormAnswerValidator.call(fields, form_params)
+
+ fields_by_identifier = fields.select { |f| f.field_identifier.present? }.index_by(&:field_identifier)
+ confirm_field = fields_by_identifier["confirm_email"]
+ email_field = fields_by_identifier["primary_email"]
+ if confirm_field && email_field && errors[confirm_field.id].nil?
+ confirm_value = form_params[confirm_field.id.to_s].to_s.strip
+ email_value = form_params[email_field.id.to_s].to_s.strip
+ errors[confirm_field.id] = "must match email" if confirm_value.present? && confirm_value != email_value
+ end
+
+ errors
+ end
+end
diff --git a/app/jobs/notification_mailer_job.rb b/app/jobs/notification_mailer_job.rb
index edf9e84d69..b9adcf72c1 100644
--- a/app/jobs/notification_mailer_job.rb
+++ b/app/jobs/notification_mailer_job.rb
@@ -19,7 +19,9 @@ def perform(notification_id, persist_delivered_email: true)
"event_registration_cancelled_fyi" => ->(n) { NotificationMailer.event_registration_cancelled_fyi(n) },
"event_registration_reminder" => ->(n) { EventMailer.event_registration_reminder(n.noticeable, custom_message: n.custom_message, custom_subject: n.custom_subject, hide_event_card: n.hide_event_card) },
"bulk_payment_confirmation" => ->(n) { EventMailer.bulk_payment_confirmation(n.noticeable) },
- "bulk_payment_confirmation_fyi" => ->(n) { NotificationMailer.bulk_payment_confirmation_fyi(n) }
+ "bulk_payment_confirmation_fyi" => ->(n) { NotificationMailer.bulk_payment_confirmation_fyi(n) },
+ "form_submission_confirmation" => ->(n) { NotificationMailer.form_submission_confirmation(n) },
+ "form_submission_confirmation_fyi" => ->(n) { NotificationMailer.form_submission_confirmation_fyi(n) }
}
mailer = mailer_map[notification.kind]&.call(notification)
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index 98cc3c3ead..80768fc5b0 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -167,6 +167,28 @@ def workshop_log_submitted_fyi(notification)
)
end
+ def form_submission_confirmation(notification)
+ @submission = notification.noticeable
+ @form = @submission.form
+ @person = @submission.person
+
+ mail(
+ to: notification.recipient_email,
+ subject: "#{SUBJECT_PREFIX} We received your response to #{@form.display_name}"
+ )
+ end
+
+ def form_submission_confirmation_fyi(notification)
+ @submission = notification.noticeable
+ @form = @submission.form
+ @person = @submission.person
+ @answers = @submission.form_answers.includes(:form_field)
+
+ mail(
+ subject: "#{FYI_PREFIX} New form submission: #{@form.display_name} by #{@person.full_name}"
+ )
+ end
+
private
def extract_attachments(noticeable)
diff --git a/app/models/form.rb b/app/models/form.rb
index eac21430ab..6c5f4433ed 100644
--- a/app/models/form.rb
+++ b/app/models/form.rb
@@ -18,8 +18,38 @@ class Form < ApplicationRecord
reject_if: proc { |attrs| attrs["name"].blank? && attrs["id"].blank? }
scope :standalone, -> { where(owner_id: nil, owner_type: nil) }
+ scope :published, -> { where(published: true) }
+
+ before_validation :normalize_slug
+
+ validates :slug, uniqueness: true, allow_nil: true
+ validates :slug, format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/,
+ message: "may only contain lowercase letters, numbers, and hyphens" }, allow_blank: true
+ validate :published_form_has_slug
def display_name
name.presence || (owner ? "#{owner.try(:name)} Form" : "New Form")
end
+
+ def standalone?
+ owner_id.nil? && owner_type.nil?
+ end
+
+ # Gates the public /f/:slug endpoint (controller + FormPolicy#public_show?).
+ def publicly_fillable?
+ standalone? && published? && slug.present?
+ end
+
+ private
+
+ # Blank stays nil (not "") so the unique index tolerates the many forms with none.
+ def normalize_slug
+ self.slug = slug.presence&.parameterize
+ end
+
+ def published_form_has_slug
+ return unless published? && slug.blank?
+
+ errors.add(:slug, "is required to publish a form")
+ end
end
diff --git a/app/models/form_submission.rb b/app/models/form_submission.rb
index b174a7731a..b4df73fd03 100644
--- a/app/models/form_submission.rb
+++ b/app/models/form_submission.rb
@@ -7,6 +7,12 @@ class FormSubmission < ApplicationRecord
accepts_nested_attributes_for :form_answers
+ # Raised when a file-upload answer's value isn't a usable upload (tampered/stale
+ # signed id); callers rescue it into a form error rather than a 500.
+ UnreadableUpload = Class.new(StandardError)
+
+ UNREADABLE_UPLOAD_MESSAGE = "We couldn't read one of your uploaded files. Please choose it again.".freeze
+
scope :bulk_payment, -> { where(role: "bulk_payment") }
validates :slug, uniqueness: true, allow_nil: true
@@ -22,6 +28,21 @@ def self.generate_unique_slug
end
end
+ # Persist one field's answer onto this submission. File-upload fields attach
+ # their blob to the answer's Asset (hardened against forged/stale/oversized
+ # uploads); everything else stores the (comma-joined) text. Shared by every
+ # submission flow — event registration, public forms, and bulk payment.
+ def persist_answer(field, raw_value)
+ record = form_answers.find_or_initialize_by(form_field: field)
+ record.question_name_when_answered = field.name
+
+ if field.file_upload?
+ attach_uploaded_file(record, raw_value)
+ else
+ record.update!(submitted_answer: answer_text(raw_value))
+ end
+ end
+
def bulk_payment?
role == "bulk_payment"
end
@@ -89,6 +110,32 @@ def linked_registrations
private
+ def answer_text(raw_value)
+ raw_value.is_a?(Array) ? raw_value.reject(&:blank?).join(", ") : raw_value.to_s
+ end
+
+ def attach_uploaded_file(record, raw_value)
+ # An untouched file input posts blank — keep the file the answer already has.
+ record.sync_uploaded_filename!
+ return if raw_value.blank?
+
+ # Named type: assets.type defaults to PrimaryAsset (images only), which would
+ # reject the document types this field offers.
+ asset = record.asset || record.build_asset(type: FormUploadAsset.name)
+ asset.file.attach(upload_attachable(raw_value))
+ asset.save!
+ record.sync_uploaded_filename!
+ end
+
+ # Resolve a direct-upload signed id leniently: find_signed! would raise (and 500
+ # a public endpoint) on a forged/stale id, so turn a miss into a form error. A
+ # multipart UploadedFile (no direct-upload JS) attaches as-is.
+ def upload_attachable(raw_value)
+ return raw_value unless raw_value.is_a?(String)
+
+ ActiveStorage::Blob.find_signed(raw_value) || raise(UnreadableUpload, UNREADABLE_UPLOAD_MESSAGE)
+ end
+
def generate_slug
self.slug ||= self.class.generate_unique_slug
end
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 645171dc94..daa89f0baf 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -38,6 +38,9 @@ class Notification < ApplicationRecord
workshop_log_submitted
workshop_log_submitted_fyi
+ form_submission_confirmation
+ form_submission_confirmation_fyi
+
manual_log
].freeze
@@ -84,7 +87,9 @@ class Notification < ApplicationRecord
[ "Admin FYI: story promoted", "Story idea promoted" ],
[ "Admin FYI: password reset", "[FYI] New password reset" ],
[ "Admin FYI: workshop log submission", "New WorkshopLog submission" ],
+ [ "Admin FYI: form submission", "[FYI] New form submission" ],
[ "Admin FYI: contact form submission", "contact form submission" ],
+ [ "Form: submission confirmation", "We received your response" ],
[ "Contact: form confirmation", "We received your message" ],
[ "Event registration cancelled", "Event registration cancelled" ],
[ "Event scholarship registration cancelled", "Event scholarship registration cancelled" ],
diff --git a/app/policies/form_policy.rb b/app/policies/form_policy.rb
index f2514e20cb..86fbfcc038 100644
--- a/app/policies/form_policy.rb
+++ b/app/policies/form_policy.rb
@@ -1,3 +1,8 @@
class FormPolicy < ApplicationPolicy
# Admin-only — all CRUD actions inherit manage? from ApplicationPolicy
+
+ # The public /f/:slug form — open to anyone, but only a published standalone form.
+ def public_show?
+ record.publicly_fillable?
+ end
end
diff --git a/app/services/event_registration_services/bulk_payment.rb b/app/services/event_registration_services/bulk_payment.rb
index 34bce257c5..b7dfa15e52 100644
--- a/app/services/event_registration_services/bulk_payment.rb
+++ b/app/services/event_registration_services/bulk_payment.rb
@@ -143,14 +143,7 @@ def save_form_answers(submission)
next unless field
next if field.group_header?
- text = if raw_value.is_a?(Array)
- raw_value.reject(&:blank?).join(", ")
- else
- raw_value.to_s
- end
-
- record = submission.form_answers.find_or_initialize_by(form_field: field)
- record.update!(submitted_answer: text, question_name_when_answered: field.name)
+ submission.persist_answer(field, raw_value)
end
end
end
diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb
index c0b0c16b71..b6fa67bfbb 100644
--- a/app/services/event_registration_services/public_registration.rb
+++ b/app/services/event_registration_services/public_registration.rb
@@ -2,13 +2,6 @@ module EventRegistrationServices
class PublicRegistration
Result = Struct.new(:success?, :event_registration, :form_submission, :errors, keyword_init: true)
- # Raised when a file-upload answer's value isn't a usable upload — a tampered,
- # stale, or otherwise unverifiable direct-upload signed id. Rescued in #call so
- # the registrant gets a form error instead of an unhandled exception.
- UnreadableUpload = Class.new(StandardError)
-
- UNREADABLE_UPLOAD_MESSAGE = "We couldn't read one of your uploaded files. Please choose it again."
-
# Well-known field_identifier of the "magic" CE question seeded onto the
# registration form. Answering it "Yes" creates a ContinuingEducationRegistration
# (hours come from the event). Kept here so the seed, service, and specs agree.
@@ -134,7 +127,7 @@ def call
Result.new(success?: true, event_registration: event_registration, form_submission: submission, errors: [])
end
- rescue UnreadableUpload => e
+ rescue FormSubmission::UnreadableUpload => e
Result.new(success?: false, event_registration: nil, errors: [ e.message ])
rescue ActiveRecord::ValueTooLong => e
Result.new(success?: false, event_registration: nil, errors: [ too_long_message(e) ])
@@ -520,59 +513,10 @@ def save_form_answers(submission)
next unless field
next if field.group_header? || field.field_identifier == "confirm_email"
- persist_answer(submission, field, raw_value)
- end
- end
-
- # Save one field's answer. File-upload fields attach their blob to the
- # answer's Asset; everything else stores the (comma-joined) text.
- def persist_answer(submission, field, raw_value)
- record = submission.form_answers.find_or_initialize_by(form_field: field)
- record.question_name_when_answered = field.name
-
- if field.file_upload?
- attach_uploaded_file(record, raw_value)
- else
- record.update!(submitted_answer: answer_text(raw_value))
+ submission.persist_answer(field, raw_value)
end
end
- def answer_text(raw_value)
- raw_value.is_a?(Array) ? raw_value.reject(&:blank?).join(", ") : raw_value.to_s
- end
-
- # Attach the uploaded blob (a direct-upload signed id, or an uploaded file)
- # to the answer's Asset. The answer row is saved first so the polymorphic
- # owner id resolves; submitted_answer then caches the filename so text-only
- # views, exports, and notifications still read. Asset enforces the content
- # type and size on save, rolling back the whole submission on a rejected file.
- def attach_uploaded_file(record, raw_value)
- # An untouched file input still posts a blank value, so blank means "no new
- # upload" — keep the file, and its filename, the answer already has.
- record.sync_uploaded_filename!
- return if raw_value.blank?
-
- # Named explicitly: assets.type defaults to "PrimaryAsset", which accepts
- # only images, so a bare build_asset would reject every document type the
- # upload field offers.
- asset = record.asset || record.build_asset(type: FormUploadAsset.name)
- asset.file.attach(upload_attachable(raw_value))
- asset.save!
- record.sync_uploaded_filename!
- end
-
- # A direct upload arrives as a signed blob id. Handing the raw string to
- # `attach` resolves it with find_signed!, which raises InvalidSignature or
- # RecordNotFound on a tampered or stale id — neither is rescued here, so a
- # forged param would 500 a public endpoint. Resolve it leniently instead and
- # turn a miss into a form error. Anything else (a multipart UploadedFile,
- # when the direct-upload JS didn't run) attaches as-is.
- def upload_attachable(raw_value)
- return raw_value unless raw_value.is_a?(String)
-
- ActiveStorage::Blob.find_signed(raw_value) || raise(UnreadableUpload, UNREADABLE_UPLOAD_MESSAGE)
- end
-
# Persist the answers to the separate scholarship form (when one is asked and a
# scholarship was requested) as its own role: "scholarship" submission tied to
# the event, mirroring how the registration submission is saved above.
@@ -590,7 +534,7 @@ def save_scholarship_submission(person)
next unless field
next if field.group_header?
- persist_answer(submission, field, raw_value)
+ submission.persist_answer(field, raw_value)
end
OtherResponses::CaptureFromSubmission.call(submission)
@@ -610,7 +554,7 @@ def save_continuing_education_submission(person)
next unless field
next if field.group_header?
- persist_answer(submission, field, raw_value)
+ submission.persist_answer(field, raw_value)
end
end
diff --git a/app/services/honeypot.rb b/app/services/honeypot.rb
new file mode 100644
index 0000000000..55786afc5d
--- /dev/null
+++ b/app/services/honeypot.rb
@@ -0,0 +1,23 @@
+# Spam trap for the public, account-free forms. Every one of them renders a
+# decoy input (see shared/_honeypot) that no human can reach — off-screen,
+# zero-size, untabbable, and hidden from assistive tech — so anything arriving
+# in it means an automated form-filler, and the submission is dropped silently
+# rather than answered with an error a bot could learn from.
+#
+# The name must be something these public forms never collect, or a genuine
+# answer would read as spam — so not `website_url` (a real column on both
+# Organization and Story) and not any FormField identifier. A person's LinkedIn
+# is stored as `people.linked_in_url` and only ever edited on their profile, so
+# it can't reach a public form. Kept as one constant so all four forms stay in
+# step and a future collision is a one-line fix.
+class Honeypot
+ FIELD_NAME = "linkedin_profile".freeze
+
+ # Only bots read this; it exists so the decoy looks like a real labelled field.
+ LABEL = "LinkedIn profile".freeze
+
+ # `scope` is the param namespace the surrounding form posts under.
+ def self.tripped?(params, scope)
+ params.dig(scope, FIELD_NAME).present?
+ end
+end
diff --git a/app/services/other_responses/capture_from_submission.rb b/app/services/other_responses/capture_from_submission.rb
index 26f4f480af..5cdf222866 100644
--- a/app/services/other_responses/capture_from_submission.rb
+++ b/app/services/other_responses/capture_from_submission.rb
@@ -11,7 +11,8 @@ module OtherResponses
# on the "Other:" prefix, so named specify options ("Word of Mouth: …") and the
# CE "Yes: 3" box are ignored. De-dupes per person + question.
#
- # Shared by the registration, scholarship, and bulk-payment submission paths.
+ # Shared by the registration, scholarship, bulk-payment, and public-form
+ # submission paths.
class CaptureFromSubmission
def self.call(submission)
new(submission).call
diff --git a/app/services/public_form_submission.rb b/app/services/public_form_submission.rb
new file mode 100644
index 0000000000..f238294040
--- /dev/null
+++ b/app/services/public_form_submission.rb
@@ -0,0 +1,117 @@
+# Records a submission to a standalone, published form filled out at its public
+# pretty URL (see PublicFormsController). Unlike event registration there is no
+# event, role, or account — the respondent is find-or-created as a Person from
+# the form's name/email answers. Answers persist via FormSubmission#persist_answer.
+class PublicFormSubmission
+ Result = Struct.new(:success?, :form_submission, :person, :errors, keyword_init: true)
+
+ ROLE = "public".freeze
+
+ # Shown when the form lacks the name/email questions needed to build a Person.
+ IDENTITY_MISSING_MESSAGE =
+ "This form can't accept submissions yet — it needs a name and email question. Please contact us.".freeze
+
+ def self.call(form:, form_params:)
+ new(form:, form_params:).call
+ end
+
+ def initialize(form:, form_params:)
+ @form = form
+ @form_params = form_params || {}
+ end
+
+ def call
+ ActiveRecord::Base.transaction do
+ person = find_or_create_person
+ return Result.new(success?: false, errors: [ IDENTITY_MISSING_MESSAGE ]) unless person
+
+ record_mailing_list_consent(person)
+
+ submission = FormSubmission.create!(person: person, form: @form, role: ROLE)
+ save_form_answers(submission)
+ OtherResponses::CaptureFromSubmission.call(submission)
+ send_notifications(submission)
+
+ Result.new(success?: true, form_submission: submission, person: person, errors: [])
+ end
+ rescue FormSubmission::UnreadableUpload => e
+ Result.new(success?: false, errors: [ e.message ])
+ rescue ActiveRecord::ValueTooLong
+ Result.new(success?: false, errors: [ "One of your answers is too long. Please shorten it and try again." ])
+ rescue ActiveRecord::RecordInvalid => e
+ Result.new(success?: false, errors: [ e.message ])
+ end
+
+ private
+
+ def field_value(identifier)
+ field = @form.form_fields.find_by(field_identifier: identifier)
+ return nil unless field
+
+ @form_params[field.id.to_s]
+ end
+
+ # Reuses an existing Person on an email + last-name match so a returning
+ # respondent isn't duplicated; nil when the form didn't collect name + email.
+ def find_or_create_person
+ first_name = field_value("first_name")&.strip
+ last_name = field_value("last_name")&.strip
+ email = field_value("primary_email")&.strip&.downcase
+ return nil if email.blank? || first_name.blank? || last_name.blank?
+
+ find_matching_person(last_name: last_name, email: email) || Person.create!(
+ first_name: first_name,
+ last_name: last_name,
+ pronouns: field_value("pronouns")&.strip,
+ email: email,
+ email_type: field_value("primary_email_type")&.downcase
+ )
+ end
+
+ def find_matching_person(last_name:, email:)
+ Person
+ .where("LOWER(email) = ? AND LOWER(last_name) = ?", email.downcase, last_name.downcase)
+ .first
+ end
+
+ # Opt-in, recorded once — never re-stamped or cleared from here.
+ def record_mailing_list_consent(person)
+ return if person.mailing_list_consent_at.present?
+ return unless Array(field_value("communication_consent")).any? { |value| value.to_s.strip.present? }
+
+ person.update!(
+ mailing_list_consent_at: Time.current,
+ mailing_list_consent_source: "#{@form.display_name} (public form)"
+ )
+ end
+
+ def save_form_answers(submission)
+ @form_params.each do |field_id, raw_value|
+ field = @form.form_fields.find_by(id: field_id)
+ next unless field
+ next if field.group_header? || field.field_identifier == "confirm_email"
+
+ submission.persist_answer(field, raw_value)
+ end
+ end
+
+ # A confirmation to the submitter and an FYI to the AWBW team, mirroring the
+ # event-registration flow.
+ def send_notifications(submission)
+ NotificationServices::CreateNotification.call(
+ noticeable: submission,
+ kind: :form_submission_confirmation,
+ recipient_role: :person,
+ recipient_email: submission.person.preferred_email,
+ notification_type: 0
+ )
+
+ NotificationServices::CreateNotification.call(
+ noticeable: submission,
+ kind: :form_submission_confirmation_fyi,
+ recipient_role: :admin,
+ recipient_email: ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org"),
+ notification_type: 0
+ )
+ end
+end
diff --git a/app/views/contact_us/index.html.erb b/app/views/contact_us/index.html.erb
index a938f791d1..a8a38a7622 100644
--- a/app/views/contact_us/index.html.erb
+++ b/app/views/contact_us/index.html.erb
@@ -174,11 +174,7 @@
<% end %>
-
-
- Form submissions
- <%= @form_submissions.size %>
-
-
+
Form submissions
+
<% if @person %>
Forms submitted by <%= @person.name %>.
<% else %>
- Forms submitted across the site.
+ Forms submitted across the site — registrations, scholarships, and public forms.
<% end %>
- <% if @form_submissions.any? %>
-
-
-
-
-
Form
-
Role
-
Event
- <% unless @person %>
Person
<% end %>
-
Submitted
-
-
-
-
- <% @form_submissions.each do |submission| %>
-
+ <% if @form.standalone? %>
+ <%# Only a standalone form can be published publicly; event forms use their event. %>
+
+
+
+
Public form
+
+
+
+ <%= f.check_box :published, class: "rounded border-gray-300 text-purple-600" %>
+ Publish this form at a public link (anyone can fill it out, no account needed)
+
+
Only affects the public link below — this form keeps working in event registration whether or not it's published.
<% end %>
diff --git a/app/views/notification_mailer/form_submission_confirmation.html.erb b/app/views/notification_mailer/form_submission_confirmation.html.erb
new file mode 100644
index 0000000000..c564bdf008
--- /dev/null
+++ b/app/views/notification_mailer/form_submission_confirmation.html.erb
@@ -0,0 +1,22 @@
+
We received your response
+
+
+
+ Hello <%= @person.name %>,
+
+
+
+ Thank you for your response to <%= @form.display_name %>. We have received it and will be in touch if any follow-up is needed.
+
+
+
+
+ <%= @form.display_name %>
+
+
+ Submitted on <%= @submission.created_at
+ .in_time_zone("Pacific Time (US & Canada)")
+ .strftime("%B %-d, %Y at %-l:%M %p %Z") %>
+
+
+
diff --git a/app/views/notification_mailer/form_submission_confirmation.text.erb b/app/views/notification_mailer/form_submission_confirmation.text.erb
new file mode 100644
index 0000000000..8e62d8cb5f
--- /dev/null
+++ b/app/views/notification_mailer/form_submission_confirmation.text.erb
@@ -0,0 +1,13 @@
+We received your response
+
+Hello <%= @person.name %>,
+
+Thank you for your response to <%= @form.display_name %>. We have received it and will be in touch if any follow-up is needed.
+
+Form: <%= @form.display_name %>
+Submitted on <%= @submission.created_at
+ .in_time_zone("Pacific Time (US & Canada)")
+ .strftime("%B %-d, %Y at %-l:%M %p %Z") %>
+
+--
+This is an automated confirmation from AWBW.
diff --git a/app/views/notification_mailer/form_submission_confirmation_fyi.html.erb b/app/views/notification_mailer/form_submission_confirmation_fyi.html.erb
new file mode 100644
index 0000000000..1bd2546b8b
--- /dev/null
+++ b/app/views/notification_mailer/form_submission_confirmation_fyi.html.erb
@@ -0,0 +1,39 @@
+<% profile_url = person_url(@person) %>
+
+
diff --git a/app/views/shared/_honeypot.html.erb b/app/views/shared/_honeypot.html.erb
new file mode 100644
index 0000000000..380ec811cf
--- /dev/null
+++ b/app/views/shared/_honeypot.html.erb
@@ -0,0 +1,8 @@
+<%# Spam trap — see Honeypot. Hidden off-screen at zero size rather than with
+ `display: none`, which many bots skip. Locals: scope (the param namespace
+ the surrounding form posts under). %>
+
+ <%= Honeypot::LABEL %>
+
+
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
index d4deeea56f..ee31d21686 100644
--- a/config/brakeman.ignore
+++ b/config/brakeman.ignore
@@ -151,13 +151,13 @@
{
"warning_type": "Mass Assignment",
"warning_code": 105,
- "fingerprint": "a750b09d4d42ef567df595f7a9035933dc1178c533bf8ae8f8986ae8e837c6b8",
+ "fingerprint": "a46122ae2994612e9507c0c854c6d8f66039324246131f28ee068a69d9ce4024",
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/forms_controller.rb",
- "line": 138,
+ "line": 150,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
- "code": "params.require(:form).permit(:name, :role, :header, :hide_answered_person_questions, :hide_answered_form_questions, :form_fields_attributes => ([:id, :name, :answer_type, :required, :subtitle, :hint_text, :field_identifier, :section, :position, :visibility, :one_time, :width, :min_words, :max_characters, :_destroy, { :form_field_answer_options_attributes => ([:id, :option_name, :_destroy]) }]))",
+ "code": "params.require(:form).permit(:name, :role, :header, :hide_answered_person_questions, :hide_answered_form_questions, :slug, :published, :form_fields_attributes => ([:id, :name, :answer_type, :required, :subtitle, :hint_text, :field_identifier, :section, :position, :visibility, :one_time, :width, :min_words, :max_characters, :_destroy, { :form_field_answer_options_attributes => ([:id, :option_name, :_destroy]) }]))",
"render_path": null,
"location": {
"type": "method",
@@ -169,7 +169,7 @@
"cwe_id": [
915
],
- "note": "admin only"
+ "note": "Admin only — FormsController inherits manage? (super-admin) from ApplicationPolicy, so role/slug/published are only mass-assignable by admins editing a form. (:published added for the public standalone-form endpoint.)"
},
{
"warning_type": "Mass Assignment",
diff --git a/config/features.yml b/config/features.yml
index 91ad92c865..eaf7f66c1e 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -1452,3 +1452,13 @@
summary: >-
A universal recent-activity feed showing the latest content changes across the
portal.
+
+- name: "Public form links"
+ area: content
+ display_status: admin_facing
+ released_on: 2026-08-16
+ action_path: "/forms"
+ summary: >-
+ Publish a standalone form (one not tied to an event) at a shareable public
+ link that anyone can fill out without an account. Responses appear in the
+ form submissions index, filterable by form.
diff --git a/config/routes.rb b/config/routes.rb
index 6a0cbfa1ee..8f8326c4bf 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -75,6 +75,10 @@
end
end
resources :community_news
+ # Public pretty-URL for a standalone, published form — fillable with no account.
+ get "f/:slug", to: "public_forms#show", as: :public_form
+ post "f/:slug", to: "public_forms#create"
+ get "f/:slug/thank-you", to: "public_forms#thank_you", as: :thank_you_public_form
get "bulk_payment/:slug", to: "events/bulk_payment_form_submissions#ticket", as: :bulk_payment_ticket
post "bulk_payment/:slug/resend_confirmation", to: "events/bulk_payment_form_submissions#resend_confirmation", as: :bulk_payment_resend_confirmation
get "registration/:slug", to: "events/registrations#show", as: :registration_ticket
diff --git a/db/migrate/20260817012835_add_slug_and_published_to_forms.rb b/db/migrate/20260817012835_add_slug_and_published_to_forms.rb
new file mode 100644
index 0000000000..95eb1fa048
--- /dev/null
+++ b/db/migrate/20260817012835_add_slug_and_published_to_forms.rb
@@ -0,0 +1,13 @@
+class AddSlugAndPublishedToForms < ActiveRecord::Migration[8.1]
+ def up
+ add_column :forms, :slug, :string unless column_exists?(:forms, :slug)
+ add_index :forms, :slug, unique: true unless index_exists?(:forms, :slug)
+ add_column :forms, :published, :boolean, default: false, null: false unless column_exists?(:forms, :published)
+ end
+
+ def down
+ remove_index :forms, :slug if index_exists?(:forms, :slug)
+ remove_column :forms, :slug if column_exists?(:forms, :slug)
+ remove_column :forms, :published if column_exists?(:forms, :published)
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index f28a3f0114..3000855194 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -716,10 +716,13 @@
t.string "name"
t.integer "owner_id"
t.string "owner_type"
+ t.boolean "published", default: false, null: false
t.string "role"
t.json "sections"
+ t.string "slug"
t.datetime "updated_at", precision: nil, null: false
t.index ["form_builder_id"], name: "index_forms_on_form_builder_id"
+ t.index ["slug"], name: "index_forms_on_slug", unique: true
end
create_table "grants", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
diff --git a/db/seeds/dev/public_forms.rb b/db/seeds/dev/public_forms.rb
new file mode 100644
index 0000000000..1a788d05fe
--- /dev/null
+++ b/db/seeds/dev/public_forms.rb
@@ -0,0 +1,89 @@
+# Standalone public forms (dev-only).
+#
+# A couple of forms not connected to any event, published at their public pretty
+# URL (/f/:slug), each with a few submissions + answers — so the public endpoint,
+# the form editor's "Public form" card, and the form submissions index (where
+# these appear as role: "public", event-less) all have real data to eyeball.
+#
+# Idempotent throughout: forms are looked up by slug, submissions by (form,
+# person), answers skipped when already present.
+
+puts "Creating standalone public forms…"
+
+public_forms = [
+ {
+ slug: "volunteer-interest",
+ name: "Volunteer interest",
+ header: "Interested in volunteering with A Window Between Worlds? Tell us a little about yourself.",
+ questions: [
+ { name: "Why do you want to volunteer with us?", answer_type: :free_form_input_paragraph },
+ { name: "What days are you generally available?", answer_type: :free_form_input_one_line }
+ ],
+ submissions: [
+ { first_name: "Dana", last_name: "Volunteer", email: "dana.volunteer@example.com",
+ answers: [ "I run art workshops for teens and want to bring AWBW's approach to my community.",
+ "Weekday evenings and Saturdays" ] },
+ { first_name: "Emil", last_name: "Helper", email: "emil.helper@example.com",
+ answers: [ "I'm a retired social worker looking to give back.", "Weekday mornings" ] }
+ ]
+ },
+ {
+ slug: "general-inquiry",
+ name: "General inquiry",
+ header: "Have a question for our team? Send it our way and we'll get back to you.",
+ questions: [
+ { name: "Your message", answer_type: :free_form_input_paragraph }
+ ],
+ submissions: [
+ { first_name: "Priya", last_name: "Question", email: "priya.question@example.com",
+ answers: [ "Do you offer facilitator training in the Pacific Northwest?" ] },
+ { first_name: "Sam", last_name: "Inquiry", email: "sam.inquiry@example.com",
+ answers: [ "How can our shelter partner with AWBW?" ] },
+ { first_name: "Alex", last_name: "Reachout", email: "alex.reachout@example.com",
+ answers: [ "Requesting a media kit for an upcoming article." ] }
+ ]
+ }
+]
+
+public_forms.each do |spec|
+ form = Form.standalone.find_by(slug: spec[:slug])
+ unless form
+ form = FormBuilderService.new(name: spec[:name], sections: %i[person_identifier]).call
+ form.update!(slug: spec[:slug], published: true, header: spec[:header])
+ end
+
+ question_fields = spec[:questions].map do |question|
+ form.form_fields.find_by(name: question[:name]) ||
+ form.form_fields.create!(name: question[:name], answer_type: question[:answer_type], status: :active)
+ end
+
+ spec[:submissions].each do |data|
+ person = Person.find_or_create_by!(email: data[:email]) do |p|
+ p.first_name = data[:first_name]
+ p.last_name = data[:last_name]
+ end
+
+ submission = FormSubmission.find_or_create_by!(form: form, person: person, role: "public")
+
+ identity = {
+ "first_name" => data[:first_name],
+ "last_name" => data[:last_name],
+ "primary_email" => data[:email]
+ }
+ question_answers = question_fields.zip(data[:answers]).to_h
+
+ identity.each do |identifier, value|
+ field = form.form_fields.find_by(field_identifier: identifier)
+ next unless field
+ next if submission.form_answers.where(form_field: field).any?
+ submission.form_answers.create!(form_field: field, submitted_answer: value,
+ question_name_when_answered: field.name)
+ end
+
+ question_answers.each do |field, value|
+ next if value.blank? || submission.form_answers.where(form_field: field).any?
+ submission.form_answers.create!(form_field: field, submitted_answer: value,
+ question_name_when_answered: field.name)
+ end
+ end
+end
diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake
index 53a42a8d37..3da230837f 100644
--- a/lib/tasks/dev.rake
+++ b/lib/tasks/dev.rake
@@ -24,6 +24,7 @@ namespace :db do
membership
bulk_payments
legacy_form_identifiers
+ public_forms
]
desc "Generate representative sample data for development"
@@ -128,5 +129,10 @@ namespace :db do
task legacy_form_identifiers: :environment do
load Rails.root.join("db/seeds/dev/legacy_form_identifiers.rb")
end
+
+ desc "Seed standalone public forms with submissions and answers (dev only)"
+ task public_forms: :environment do
+ load Rails.root.join("db/seeds/dev/public_forms.rb")
+ end
end
end
diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb
index abb8b81b41..0d7810089a 100644
--- a/spec/mailers/notification_mailer_spec.rb
+++ b/spec/mailers/notification_mailer_spec.rb
@@ -371,4 +371,42 @@
end
end
end
+
+ describe "form submission emails" do
+ let(:form) { create(:form, name: "Volunteer interest") }
+ let(:person) { create(:person, first_name: "Dana", last_name: "Volunteer", email: "dana@example.com") }
+ let(:submission) do
+ s = FormSubmission.create!(form: form, person: person, role: "public")
+ field = create(:form_field, form: form, name: "Why volunteer?")
+ s.persist_answer(field, "I care about the mission.")
+ s
+ end
+
+ describe "#form_submission_confirmation" do
+ let(:notification) do
+ create(:notification, kind: "form_submission_confirmation", noticeable: submission,
+ recipient_role: "person", recipient_email: person.email)
+ end
+ let(:mail) { described_class.form_submission_confirmation(notification) }
+
+ it "thanks the submitter and names the form" do
+ expect(mail.to).to include("dana@example.com")
+ expect(mail.subject).to include("Volunteer interest")
+ expect(mail.body.encoded).to include("Dana")
+ end
+ end
+
+ describe "#form_submission_confirmation_fyi" do
+ let(:notification) do
+ create(:notification, kind: "form_submission_confirmation_fyi", noticeable: submission)
+ end
+ let(:mail) { described_class.form_submission_confirmation_fyi(notification) }
+
+ it "names the submitter and lists their answers" do
+ expect(mail.subject).to include("New form submission")
+ expect(mail.subject).to include("Dana Volunteer")
+ expect(mail.body.encoded).to include("I care about the mission.")
+ end
+ end
+ end
end
diff --git a/spec/models/form_spec.rb b/spec/models/form_spec.rb
index b22e58da37..18a8257356 100644
--- a/spec/models/form_spec.rb
+++ b/spec/models/form_spec.rb
@@ -67,4 +67,54 @@
end
end
end
+
+ describe 'slug + publishing' do
+ it 'normalizes a slug to url-safe form' do
+ form = create(:form, slug: 'Volunteer Interest!')
+ expect(form.slug).to eq('volunteer-interest')
+ end
+
+ it 'stores a blank slug as nil so the uniqueness index tolerates many' do
+ form = create(:form, slug: '')
+ expect(form.slug).to be_nil
+ end
+
+ it 'rejects a duplicate slug' do
+ create(:form, slug: 'apply')
+ dup = build(:form, slug: 'apply')
+ expect(dup).not_to be_valid
+ end
+
+ it 'requires a slug to publish' do
+ form = build(:form, published: true, slug: nil)
+ expect(form).not_to be_valid
+ expect(form.errors[:slug]).to include('is required to publish a form')
+ end
+
+ describe '#publicly_fillable?' do
+ it 'is true for a standalone, published, slugged form' do
+ form = create(:form, slug: 'apply', published: true)
+ expect(form).to be_publicly_fillable
+ end
+
+ it 'is false when not published' do
+ form = create(:form, slug: 'apply', published: false)
+ expect(form).not_to be_publicly_fillable
+ end
+
+ it 'is false when owned by an event/other record' do
+ form = create(:form, :with_owner, slug: 'apply')
+ form.update_column(:published, true)
+ expect(form).not_to be_publicly_fillable
+ end
+ end
+
+ describe '.published scope' do
+ it 'returns only published forms' do
+ published = create(:form, slug: 'a', published: true)
+ create(:form, slug: 'b', published: false)
+ expect(Form.published).to contain_exactly(published)
+ end
+ end
+ end
end
diff --git a/spec/models/form_submission_spec.rb b/spec/models/form_submission_spec.rb
index 5bf4101b67..6c44ec6502 100644
--- a/spec/models/form_submission_spec.rb
+++ b/spec/models/form_submission_spec.rb
@@ -157,4 +157,40 @@
end
end
end
+
+ describe "#persist_answer" do
+ let(:submission) { create(:form_submission) }
+
+ it "stores a text answer with the field's name" do
+ field = create(:form_field, form: submission.form, name: "Message")
+ submission.persist_answer(field, "Hello there")
+
+ answer = submission.form_answers.find_by(form_field: field)
+ expect(answer.submitted_answer).to eq("Hello there")
+ expect(answer.question_name_when_answered).to eq("Message")
+ end
+
+ it "comma-joins a multi-value answer, dropping blanks" do
+ field = create(:form_field, form: submission.form, answer_type: :multi_select_checkbox)
+ submission.persist_answer(field, [ "A", "", "B" ])
+
+ expect(submission.form_answers.find_by(form_field: field).submitted_answer).to eq("A, B")
+ end
+
+ it "updates the existing answer rather than duplicating it" do
+ field = create(:form_field, form: submission.form)
+ submission.persist_answer(field, "first")
+ submission.persist_answer(field, "second")
+
+ expect(submission.form_answers.where(form_field: field).count).to eq(1)
+ expect(submission.form_answers.find_by(form_field: field).submitted_answer).to eq("second")
+ end
+
+ it "raises UnreadableUpload for a forged file-upload signed id" do
+ field = create(:form_field, :file_upload, form: submission.form)
+
+ expect { submission.persist_answer(field, "forged-signed-id") }
+ .to raise_error(FormSubmission::UnreadableUpload)
+ end
+ end
end
diff --git a/spec/policies/form_policy_spec.rb b/spec/policies/form_policy_spec.rb
index 694abb81ab..79b8127ced 100644
--- a/spec/policies/form_policy_spec.rb
+++ b/spec/policies/form_policy_spec.rb
@@ -37,4 +37,22 @@ def policy_for(record: nil, user:)
it { is_expected.not_to be_allowed_to(:destroy?) }
end
end
+
+ describe "#public_show?" do
+ it "allows anyone (no account) for a publicly-fillable form" do
+ form = create(:form, slug: "apply", published: true)
+ expect(policy_for(record: form, user: nil)).to be_allowed_to(:public_show?)
+ end
+
+ it "denies an unpublished standalone form" do
+ form = create(:form, slug: "apply", published: false)
+ expect(policy_for(record: form, user: nil)).not_to be_allowed_to(:public_show?)
+ end
+
+ it "denies an event-owned form even when published" do
+ form = create(:form, :with_owner, slug: "apply")
+ form.update_column(:published, true)
+ expect(policy_for(record: form, user: nil)).not_to be_allowed_to(:public_show?)
+ end
+ end
end
diff --git a/spec/requests/events/bulk_payment_form_submissions_spec.rb b/spec/requests/events/bulk_payment_form_submissions_spec.rb
index abc0d38691..2250fbc3a8 100644
--- a/spec/requests/events/bulk_payment_form_submissions_spec.rb
+++ b/spec/requests/events/bulk_payment_form_submissions_spec.rb
@@ -46,6 +46,18 @@ def post_bulk_payment(answer)
params: { bulk_payment: { form_fields: { org_field.id.to_s => answer } } }
end
+ describe "POST create with the honeypot tripped" do
+ it "silently bounces a bot without recording a submission" do
+ expect {
+ post event_bulk_payment_path(event),
+ params: { bulk_payment: { Honeypot::FIELD_NAME => "http://spam.example",
+ form_fields: { org_field.id.to_s => "this answer easily has plenty of words" } } }
+ }.not_to change(FormSubmission, :count)
+
+ expect(response).to redirect_to(new_event_bulk_payment_path(event))
+ end
+ end
+
describe "POST create with a minimum word count" do
it "rejects an answer with too few words" do
post_bulk_payment("not quite enough")
diff --git a/spec/requests/form_submissions_spec.rb b/spec/requests/form_submissions_spec.rb
index a9041bd9a0..c5234cf2da 100644
--- a/spec/requests/form_submissions_spec.rb
+++ b/spec/requests/form_submissions_spec.rb
@@ -8,25 +8,71 @@
context "as an admin" do
before { sign_in admin }
+ # The rows load lazily inside the results Turbo frame.
+ let(:frame_headers) { { "Turbo-Frame" => "form_submissions_results" } }
+
+ it "renders the filterable index shell" do
+ get form_submissions_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Form submissions")
+ end
+
it "lists a person's submissions and links each to its detail page" do
person = create(:person, first_name: "Priya", last_name: "Patel")
other = create(:person)
mine = create(:form_submission, person: person)
theirs = create(:form_submission, person: other)
- get form_submissions_path(person_id: person.id)
+ get form_submissions_path(person_id: person.id), headers: frame_headers
expect(response).to have_http_status(:ok)
- expect(response.body).to include("Priya Patel")
expect(response.body).to include(form_submission_path(mine))
expect(response.body).not_to include(form_submission_path(theirs))
end
+ it "filters by form" do
+ wanted = create(:form, name: "Volunteer interest")
+ other = create(:form, name: "Something else")
+ mine = create(:form_submission, form: wanted)
+ theirs = create(:form_submission, form: other)
+
+ get form_submissions_path(form_id: wanted.id), headers: frame_headers
+
+ expect(response.body).to include(form_submission_path(mine))
+ expect(response.body).not_to include(form_submission_path(theirs))
+ end
+
+ it "breaks the View link out of the results frame" do
+ create(:form_submission)
+ get form_submissions_path, headers: frame_headers
+ expect(response.body).to include('data-turbo-frame="_top"')
+ end
+
+ it "shows a Forms eyebrow anchored to the form when arriving from the forms index" do
+ form = create(:form, name: "Volunteer interest")
+ get form_submissions_path(form_id: form.id, return_to: "forms")
+
+ expect(response.body).to include(CGI.escapeHTML(forms_path(anchor: "form_#{form.id}")))
+ end
+
+ it "each View link carries the form filter so the trip back keeps it" do
+ form = create(:form, name: "Volunteer interest")
+ submission = create(:form_submission, form: form)
+
+ get form_submissions_path(form_id: form.id), headers: frame_headers
+
+ expect(response.body).to include(
+ CGI.escapeHTML(form_submission_path(submission, return_to: "form_submissions",
+ person_id: submission.person_id, form_id: form.id))
+ )
+ end
+
it "each View link carries a return_to back to the person's index" do
person = create(:person)
submission = create(:form_submission, person: person)
- get form_submissions_path(person_id: person.id)
+ get form_submissions_path(person_id: person.id), headers: frame_headers
expect(response.body).to include(
CGI.escapeHTML(form_submission_path(submission, return_to: "form_submissions", person_id: person.id))
@@ -67,6 +113,14 @@
expect(response.body).to include("Back to form submissions")
end
+ it "carries the form filter back when arriving from the form-filtered index" do
+ get form_submission_path(submission, return_to: "form_submissions", form_id: submission.form_id)
+
+ expect(response.body).to include(
+ CGI.escapeHTML(form_submissions_path(form_id: submission.form_id))
+ )
+ end
+
it "resolves the sector/age-group ids stored behind the professional fields to names" do
sector = create(:sector, :published, name: "Mental Health")
sector_field = create(:form_field, form: submission.form, name: "Additional sectors",
diff --git a/spec/requests/forms_spec.rb b/spec/requests/forms_spec.rb
index c5ecd6a7d2..6d0717f247 100644
--- a/spec/requests/forms_spec.rb
+++ b/spec/requests/forms_spec.rb
@@ -14,6 +14,40 @@
expect(response).to have_http_status(:success)
expect(response.body).to include("My Form")
end
+
+ it "shows the public link for a published form" do
+ create(:form, :standalone, name: "Volunteer", slug: "volunteer", published: true)
+ get forms_path
+ expect(response.body).to include(public_form_path("volunteer"))
+ expect(response.body).to include("/f/volunteer")
+ end
+
+ it "marks an event-connected unpublished form as an event form, not 'Not published'" do
+ form = create(:form, :standalone, name: "Reg Form")
+ EventForm.create!(form: form, event: create(:event), role: "registration")
+ get forms_path
+ expect(response.body).to include("Event form")
+ end
+
+ it "shows both the public link and event-form chips when a form is both" do
+ form = create(:form, :standalone, name: "Dual Form", slug: "dual", published: true)
+ EventForm.create!(form: form, event: create(:event), role: "registration")
+ get forms_path
+ expect(response.body).to include("/f/dual")
+ expect(response.body).to include("Event form")
+ end
+
+ it "marks a standalone form with no events and no public link as not published" do
+ create(:form, :standalone, name: "Orphan Form")
+ get forms_path
+ expect(response.body).to include("Not published")
+ end
+
+ it "has no Delete link" do
+ form = create(:form, :standalone, name: "My Form")
+ get forms_path
+ expect(response.body).not_to include(">Delete<")
+ end
end
context "as regular user" do
diff --git a/spec/requests/public_forms_spec.rb b/spec/requests/public_forms_spec.rb
new file mode 100644
index 0000000000..2a7acf1f6a
--- /dev/null
+++ b/spec/requests/public_forms_spec.rb
@@ -0,0 +1,84 @@
+require "rails_helper"
+
+RSpec.describe "PublicForms", type: :request do
+ let(:form) { create(:form, name: "Volunteer interest", slug: "volunteer-interest", published: true) }
+
+ let!(:first_name_field) { create(:form_field, form: form, name: "First name", field_identifier: "first_name", required: true) }
+ let!(:last_name_field) { create(:form_field, form: form, name: "Last name", field_identifier: "last_name", required: true) }
+ let!(:email_field) { create(:form_field, form: form, name: "Email", field_identifier: "primary_email", required: true) }
+
+ def submission_params(first: "Sam", last: "Rivera", email: "sam@example.com")
+ {
+ public_registration: {
+ form_fields: {
+ first_name_field.id.to_s => first,
+ last_name_field.id.to_s => last,
+ email_field.id.to_s => email
+ }
+ }
+ }
+ end
+
+ describe "GET /f/:slug" do
+ it "renders the form for anyone, no account needed" do
+ get public_form_path(form.slug)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Volunteer interest")
+ expect(response.body).to include("First name")
+ end
+
+ it "404s an unpublished form" do
+ form.update_column(:published, false)
+ get public_form_path(form.slug)
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it "404s an event-owned form even if published" do
+ owned = create(:form, :with_owner, slug: "internal")
+ owned.update_column(:published, true)
+ get public_form_path(owned.slug)
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it "404s an unknown slug" do
+ get public_form_path("nope")
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe "POST /f/:slug" do
+ it "records a submission and redirects to the thank-you page" do
+ expect { post public_form_path(form.slug), params: submission_params }
+ .to change(FormSubmission, :count).by(1)
+ .and change(Person, :count).by(1)
+
+ expect(response).to redirect_to(thank_you_public_form_path(form.slug))
+ end
+
+ it "re-renders with errors when a required field is missing" do
+ expect { post public_form_path(form.slug), params: submission_params(email: "") }
+ .not_to change(FormSubmission, :count)
+
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it "silently bounces a honeypot-tripping bot without recording anything" do
+ params = submission_params.deep_merge(public_registration: { Honeypot::FIELD_NAME => "http://spam.example" })
+
+ expect { post public_form_path(form.slug), params: params }
+ .not_to change(FormSubmission, :count)
+
+ expect(response).to redirect_to(public_form_path(form.slug))
+ end
+ end
+
+ describe "GET /f/:slug/thank-you" do
+ it "renders a confirmation" do
+ get thank_you_public_form_path(form.slug)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Thank you")
+ end
+ end
+end
diff --git a/spec/services/honeypot_spec.rb b/spec/services/honeypot_spec.rb
new file mode 100644
index 0000000000..b91648edff
--- /dev/null
+++ b/spec/services/honeypot_spec.rb
@@ -0,0 +1,37 @@
+require "rails_helper"
+
+RSpec.describe Honeypot do
+ def params_with(scope_attrs)
+ ActionController::Parameters.new(contact_us: scope_attrs)
+ end
+
+ describe ".tripped?" do
+ it "is true when the decoy field carries a value" do
+ expect(described_class.tripped?(params_with(described_class::FIELD_NAME => "spam"), :contact_us)).to be(true)
+ end
+
+ it "is false when the decoy field is blank, as a human always leaves it" do
+ expect(described_class.tripped?(params_with(described_class::FIELD_NAME => ""), :contact_us)).to be(false)
+ end
+
+ it "is false when the decoy field is absent entirely" do
+ expect(described_class.tripped?(params_with(message: "hello"), :contact_us)).to be(false)
+ end
+
+ it "is false when the whole scope is missing" do
+ expect(described_class.tripped?(ActionController::Parameters.new, :contact_us)).to be(false)
+ end
+ end
+
+ describe "FIELD_NAME" do
+ it "names nothing the portal actually stores, so a real answer can't read as spam" do
+ [ Person, Organization, Story, FormField ].each do |model|
+ expect(model.column_names).not_to include(described_class::FIELD_NAME)
+ end
+ end
+
+ it "is not a FormField identifier any form could collect" do
+ expect(FormField.distinct.pluck(:field_identifier).compact).not_to include(described_class::FIELD_NAME)
+ end
+ end
+end
diff --git a/spec/services/public_form_submission_spec.rb b/spec/services/public_form_submission_spec.rb
new file mode 100644
index 0000000000..0e2835cbec
--- /dev/null
+++ b/spec/services/public_form_submission_spec.rb
@@ -0,0 +1,85 @@
+require "rails_helper"
+
+RSpec.describe PublicFormSubmission do
+ let(:form) { create(:form, slug: "volunteer-interest", published: true) }
+
+ let!(:first_name_field) { create(:form_field, form: form, name: "First name", field_identifier: "first_name") }
+ let!(:last_name_field) { create(:form_field, form: form, name: "Last name", field_identifier: "last_name") }
+ let!(:email_field) { create(:form_field, form: form, name: "Email", field_identifier: "primary_email") }
+ let!(:question_field) { create(:form_field, form: form, name: "Why do you want to volunteer?") }
+
+ def params_for(first: "Sam", last: "Rivera", email: "sam@example.com", answer: "I care.")
+ {
+ first_name_field.id.to_s => first,
+ last_name_field.id.to_s => last,
+ email_field.id.to_s => email,
+ question_field.id.to_s => answer
+ }
+ end
+
+ it "creates a person, submission, and answers" do
+ result = nil
+ expect { result = described_class.call(form: form, form_params: params_for) }
+ .to change(Person, :count).by(1)
+ .and change(FormSubmission, :count).by(1)
+
+ expect(result.success?).to be(true)
+ expect(result.person.email).to eq("sam@example.com")
+ expect(result.form_submission.role).to eq("public")
+ expect(result.form_submission.event).to be_nil
+
+ answer = result.form_submission.form_answers.find_by(form_field: question_field)
+ expect(answer.submitted_answer).to eq("I care.")
+ end
+
+ it "reuses an existing person matched on email + last name" do
+ existing = create(:person, first_name: "Sam", last_name: "Rivera", email: "sam@example.com")
+
+ expect { described_class.call(form: form, form_params: params_for) }
+ .to change(Person, :count).by(0)
+ .and change(FormSubmission, :count).by(1)
+
+ expect(FormSubmission.last.person).to eq(existing)
+ end
+
+ it "fails with a friendly error when the form can't identify the respondent" do
+ result = described_class.call(form: form, form_params: params_for(email: ""))
+
+ expect(result.success?).to be(false)
+ expect(result.errors).to include(PublicFormSubmission::IDENTITY_MISSING_MESSAGE)
+ expect(FormSubmission.count).to eq(0)
+ end
+
+ it "sends a confirmation to the submitter and an FYI to admin" do
+ expect { described_class.call(form: form, form_params: params_for) }
+ .to change { Notification.where(kind: "form_submission_confirmation").count }.by(1)
+ .and change { Notification.where(kind: "form_submission_confirmation_fyi").count }.by(1)
+
+ confirmation = Notification.find_by(kind: "form_submission_confirmation")
+ expect(confirmation.recipient_email).to eq("sam@example.com")
+ expect(confirmation.recipient_role).to eq("person")
+ end
+
+ it "records mailing-list consent once when the consent question is answered" do
+ consent_field = create(:form_field, form: form, name: "Email me updates",
+ answer_type: :multi_select_checkbox, field_identifier: "communication_consent")
+ params = params_for.merge(consent_field.id.to_s => [ "Yes, keep me posted" ])
+
+ result = described_class.call(form: form, form_params: params)
+
+ expect(result.person.mailing_list_consent_at).to be_present
+ expect(result.person.mailing_list_consent_source).to include(form.display_name)
+ end
+
+ it "captures a sector 'Other' answer as an OtherResponse, like the other submission paths" do
+ sector_field = create(:form_field, form: form, name: "Who do you serve?",
+ answer_type: :multi_select_checkbox, field_identifier: "additional_sectors")
+ params = params_for.merge(sector_field.id.to_s => [ "Other: Equine therapy" ])
+
+ result = described_class.call(form: form, form_params: params)
+
+ response = result.person.other_responses.sole
+ expect([ response.field_identifier, response.text, response.kind ])
+ .to eq([ "additional_sectors", "Equine therapy", "sector" ])
+ end
+end
diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb
index 40d35d9011..288ed82e98 100644
--- a/spec/views/page_bg_class_alignment_spec.rb
+++ b/spec/views/page_bg_class_alignment_spec.rb
@@ -252,6 +252,10 @@
"app/views/events/callouts/staff.html.erb" => "public",
"app/views/registration_ticket_callouts/show.html.erb" => "public",
+ # ─── public standalone form (pretty URL, no account) ───
+ "app/views/public_forms/show.html.erb" => "public",
+ "app/views/public_forms/thank_you.html.erb" => "public",
+
# ─── bulk payment views ───
"app/views/events/bulk_payment_form_submissions/new.html.erb" => "public",
"app/views/events/bulk_payment_form_submissions/show.html.erb" => "public",
diff --git a/test/mailers/previews/notification_mailer_preview.rb b/test/mailers/previews/notification_mailer_preview.rb
index 068fafa5b5..c304a8a2a1 100644
--- a/test/mailers/previews/notification_mailer_preview.rb
+++ b/test/mailers/previews/notification_mailer_preview.rb
@@ -55,6 +55,38 @@ def bulk_payment_confirmation_fyi
NotificationMailer.bulk_payment_confirmation_fyi(notification)
end
+ def form_submission_confirmation
+ submission = FormSubmission.where(role: "public").order(id: :desc).first ||
+ raise("Need a public FormSubmission to preview (run db:seed:public_forms)")
+
+ notification = find_valid_notification("form_submission_confirmation") ||
+ Notification.create!(
+ noticeable: submission,
+ notification_type: 0,
+ kind: "form_submission_confirmation",
+ recipient_role: "person",
+ recipient_email: submission.person.preferred_email
+ )
+
+ NotificationMailer.form_submission_confirmation(notification)
+ end
+
+ def form_submission_confirmation_fyi
+ submission = FormSubmission.where(role: "public").order(id: :desc).first ||
+ raise("Need a public FormSubmission to preview (run db:seed:public_forms)")
+
+ notification = find_valid_notification("form_submission_confirmation_fyi") ||
+ Notification.create!(
+ noticeable: submission,
+ notification_type: 0,
+ kind: "form_submission_confirmation_fyi",
+ recipient_role: "admin",
+ recipient_email: ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org")
+ )
+
+ NotificationMailer.form_submission_confirmation_fyi(notification)
+ end
+
def idea_submitted
noticeable = StoryIdea.first || WorkshopVariationIdea.first
user = noticeable&.created_by || User.first