Skip to content
Merged
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
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@ This codebase (Rails 8.1)
| Directory | Purpose | Count |
|---|---|---|
| `app/models/` | ActiveRecord models | ~80 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~57 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~66 files |
| `app/jobs/` | SolidQueue background jobs | 5 files |
| `app/models/concerns/` | Shared model modules | 16 concerns |

### Presentation

| Directory | Purpose | Count |
|---|---|---|
| `app/controllers/` | Rails controllers (admin/, events/, home/) | ~78 files |
| `app/controllers/` | Rails controllers (admin/, events/, home/) | ~91 files |
| `app/views/` | ERB templates | ~745 files |
| `app/decorators/` | Draper decorators for view logic | ~40 files |
| `app/policies/` | ActionPolicy authorization rules | ~55 files |
Expand Down Expand Up @@ -154,7 +154,7 @@ This codebase (Rails 8.1)

### Namespaces

- **Root level** (~58 controllers): Workshops, stories, resources, events, people, organizations, registration ticket callouts, etc.
- **Root level** (~69 controllers): Workshops, stories, resources, events, people, organizations, registration ticket callouts, etc.
- **`admin/`**: HomeController, AnalyticsController, AhoyActivitiesController
- **`events/`**: Registrations sub-resource (create/destroy + slug-based show at `/registration/:slug`)
- **Devise overrides**: Registrations, Confirmations, Passwords
Expand Down Expand Up @@ -259,6 +259,7 @@ 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`)
- `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
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?

# Honeypot β€” a bot that fills the hidden field is silently bounced.
if params.dig(:public_registration, :website_url).present?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great idea! (and perfect comment).

Any other key we could use instead of website_url? That's a real value we use in other places and could be confusing or conflict at some point.

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
34 changes: 34 additions & 0 deletions app/models/form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,42 @@ 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 (never ""), so the unique index tolerates the many forms with
# none. Input that parameterizes away to nothing ("!!!") is left intact for the
# format validation to reject rather than silently blanked.
def normalize_slug
return if slug.nil?

self.slug = slug.parameterize.presence || slug.presence
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