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 %> - - + <%= render "shared/honeypot", scope: "contact_us" %> <% if @from_story_share %> diff --git a/app/views/events/bulk_payment_form_submissions/new.html.erb b/app/views/events/bulk_payment_form_submissions/new.html.erb index dc6730feb3..1937482a35 100644 --- a/app/views/events/bulk_payment_form_submissions/new.html.erb +++ b/app/views/events/bulk_payment_form_submissions/new.html.erb @@ -65,9 +65,7 @@ data: { controller: "bulk-payment-attendees", action: "submit->bulk-payment-attendees#serialize", turbo: false }, class: "space-y-2" do |f| %> - + <%= render "shared/honeypot", scope: "bulk_payment" %> <% fields_by_identifier = @form_fields.select(&:field_identifier).index_by(&:field_identifier) diff --git a/app/views/events/public_registrations/new.html.erb b/app/views/events/public_registrations/new.html.erb index 719e2f3ed9..c654e4b919 100644 --- a/app/views/events/public_registrations/new.html.erb +++ b/app/views/events/public_registrations/new.html.erb @@ -101,10 +101,7 @@ <% end %> - <%# Honeypot %> - + <%= render "shared/honeypot", scope: "public_registration" %> <%# Per-field widths flow into a 12-column grid; full-width fields and headers take a whole row %> <% diff --git a/app/views/form_submissions/form_submissions_results.html.erb b/app/views/form_submissions/form_submissions_results.html.erb new file mode 100644 index 0000000000..e145c81a94 --- /dev/null +++ b/app/views/form_submissions/form_submissions_results.html.erb @@ -0,0 +1,58 @@ +<%= turbo_frame_tag :form_submissions_results do %> +
+ + <%= pluralize(@form_submissions.total_entries, "submission") %> + + <% if @form %> + for <%= @form.display_name %> + <% end %> +
+ + <% if @form_submissions.any? %> +
+ + + + + + + <% unless @person %><% end %> + + + + + + <% @form_submissions.each do |submission| %> + + + + + <% unless @person %><% end %> + + + + <% end %> + +
FormRoleEventPersonSubmitted
<%= submission.form&.display_name %><%= submission.role&.humanize %> + <% if submission.resolved_event %> + <%= submission.resolved_event.name %> + <% elsif submission.role == "public" %> + + Public form + + <% else %> + + <% end %> + <%= submission.person&.name %><%= submission.created_at.to_fs(:long) %> + <%= link_to "View", form_submission_path(submission, return_to: "form_submissions", person_id: submission.person_id, form_id: @form&.id), data: { turbo_frame: "_top" }, class: "text-blue-600 hover:underline" %> +
+
+ + + <% else %> +
+ +

No form submissions found.

+
+ <% end %> +<% end %> diff --git a/app/views/form_submissions/index.html.erb b/app/views/form_submissions/index.html.erb index 9f5fecf283..d2bf480093 100644 --- a/app/views/form_submissions/index.html.erb +++ b/app/views/form_submissions/index.html.erb @@ -1,8 +1,14 @@ <% content_for(:page_bg_class, "admin-only bg-blue-100") %> -
+
- <% if @person %> + <% if params[:return_to] == "forms" && @form %> +
+ <%= link_to forms_path(anchor: "form_#{@form.id}"), class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700" do %> + Forms + <% end %> +
+ <% elsif @person %>
<%= link_to edit_person_path(@person), class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700" do %> Edit <%= @person.name %> @@ -10,58 +16,44 @@
<% end %> -
-