Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/contact_us_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/events/public_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 13 additions & 5 deletions app/controllers/form_submissions_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/forms_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions app/controllers/public_forms_controller.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion app/jobs/notification_mailer_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions app/mailers/notification_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions app/models/form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
47 changes: 47 additions & 0 deletions app/models/form_submission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions app/models/notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ class Notification < ApplicationRecord
workshop_log_submitted
workshop_log_submitted_fyi

form_submission_confirmation
form_submission_confirmation_fyi

manual_log
].freeze

Expand Down Expand Up @@ -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" ],
Expand Down
5 changes: 5 additions & 0 deletions app/policies/form_policy.rb
Original file line number Diff line number Diff line change
@@ -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
9 changes: 1 addition & 8 deletions app/services/event_registration_services/bulk_payment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading