diff --git a/app/models/form_answer.rb b/app/models/form_answer.rb
index 8210a22406..950924d50f 100644
--- a/app/models/form_answer.rb
+++ b/app/models/form_answer.rb
@@ -11,7 +11,20 @@ class FormAnswer < ApplicationRecord
belongs_to :form_field, optional: true
belongs_to :form_submission
+ # A file-upload answer stores its file on a polymorphic Asset (the same
+ # attachment/validation/display machinery story ideas use). Text answers leave
+ # this nil; submitted_answer still holds the filename so every text-only
+ # display and export shows something readable.
+ has_one :asset, as: :owner, dependent: :destroy
+
def name
"#{question_name_when_answered.presence || form_field&.name}: #{submitted_answer}"
end
+
+ # The attached upload, when this answer is a file-upload answer with a file on
+ # file. nil for text answers or an unanswered file question.
+ def uploaded_file
+ file = asset&.file
+ file if file&.attached?
+ end
end
diff --git a/app/models/form_field.rb b/app/models/form_field.rb
index d0634eea1f..cc6b391876 100644
--- a/app/models/form_field.rb
+++ b/app/models/form_field.rb
@@ -118,7 +118,8 @@ class FormField < ApplicationRecord
:no_user_input,
:multi_select_checkbox,
:group_header,
- :single_select_dropdown
+ :single_select_dropdown,
+ :file_upload
]
enum :input_type, [
@@ -148,6 +149,7 @@ class FormField < ApplicationRecord
"single_select_radio" => "Single select radio",
"single_select_dropdown" => "Single select dropdown",
"multi_select_checkbox" => "Multiple select checkbox",
+ "file_upload" => "File upload",
"no_user_input" => "Informational-only"
}.freeze
diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb
index eaa10e59bf..ca723fbbdb 100644
--- a/app/services/event_registration_services/public_registration.rb
+++ b/app/services/event_registration_services/public_registration.rb
@@ -511,17 +511,42 @@ def save_form_answers(submission)
next unless field
next if field.group_header? || field.field_identifier == "confirm_email"
- text = if raw_value.is_a?(Array)
- raw_value.reject(&:blank?).join(", ")
- else
- raw_value.to_s
- end
+ 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
- record = submission.form_answers.find_or_initialize_by(form_field: field)
- record.update!(submitted_answer: text, 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 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 keeps the filename so text-only views,
+ # exports, and notifications still read. Asset enforces the content type on
+ # save, rolling back the whole submission on an unaccepted file.
+ def attach_uploaded_file(record, raw_value)
+ record.update!(submitted_answer: "")
+ return if raw_value.blank?
+
+ asset = record.asset || record.build_asset
+ asset.file.attach(raw_value)
+ asset.save!
+ record.update!(submitted_answer: asset.file.filename.to_s)
+ 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.
@@ -539,14 +564,7 @@ def save_scholarship_submission(person)
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)
+ persist_answer(submission, field, raw_value)
end
OtherResponses::CaptureFromSubmission.call(submission)
@@ -566,14 +584,7 @@ def save_continuing_education_submission(person)
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)
+ persist_answer(submission, field, raw_value)
end
end
diff --git a/app/services/form_answer_validator.rb b/app/services/form_answer_validator.rb
index aa296ace18..3524055153 100644
--- a/app/services/form_answer_validator.rb
+++ b/app/services/form_answer_validator.rb
@@ -36,6 +36,11 @@ def error_for(field, value)
return "can't be blank" if field.required && blank_answer?(value)
return if value.blank?
+ # A file upload arrives as a signed blob id (or an uploaded file), not text —
+ # the word/character/format/inclusion checks below don't apply. Its content
+ # type is enforced by Asset when the file is attached during submission.
+ return if field.file_upload?
+
if field.number_integer? && value.to_s !~ WHOLE_NUMBER_FORMAT
"must be a whole number"
elsif field.email_field? && value.to_s !~ EMAIL_FORMAT
diff --git a/app/views/events/form_submissions/show.html.erb b/app/views/events/form_submissions/show.html.erb
index 2db18f6126..363b7df0e9 100644
--- a/app/views/events/form_submissions/show.html.erb
+++ b/app/views/events/form_submissions/show.html.erb
@@ -45,7 +45,7 @@
<% section[:fields].each do |field, response| %>
<% end %>
diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb
index e70fb2295c..26df195aa1 100644
--- a/app/views/events/public_registrations/_form_field.html.erb
+++ b/app/views/events/public_registrations/_form_field.html.erb
@@ -174,6 +174,28 @@
<% end %>
<% end %>
+ <% when "file_upload" %>
+ <%# Direct upload: the file streams to storage via JS and the input's value
+ becomes a signed blob id, submitted as a normal param — so the form needs
+ no multipart encoding and the persistence service attaches the blob.
+ Reuses the file_preview controller (image thumbnail / file-type icon). %>
+
+ >
+
+
Accepted: <%= Asset.accepted_types_label %>
+
+
+
<% end %>
<% if field.hint_text.present? %>
diff --git a/app/views/events/public_registrations/show.html.erb b/app/views/events/public_registrations/show.html.erb
index 73646434db..05b12c9cc3 100644
--- a/app/views/events/public_registrations/show.html.erb
+++ b/app/views/events/public_registrations/show.html.erb
@@ -69,7 +69,7 @@
<% response = @responses[field.id] %>
<% end %>
diff --git a/app/views/shared/_form_answer_value.html.erb b/app/views/shared/_form_answer_value.html.erb
new file mode 100644
index 0000000000..c17febfcfc
--- /dev/null
+++ b/app/views/shared/_form_answer_value.html.erb
@@ -0,0 +1,22 @@
+<%# Renders one form answer's value: a file-upload answer shows an inline image
+ preview (or a file link for non-images) plus a download link; every other
+ answer type shows its resolved text. Locals: field, response. %>
+<% if field.file_upload? && (uploaded = response&.uploaded_file) %>
+
+<% elsif field.file_upload? %>
+ —
+<% else %>
+ <%= display_response_text(field, response) %>
+<% end %>
diff --git a/spec/factories/form_fields.rb b/spec/factories/form_fields.rb
index 3446d04415..de04425090 100644
--- a/spec/factories/form_fields.rb
+++ b/spec/factories/form_fields.rb
@@ -13,5 +13,9 @@
required { FormField::NON_INPUT_ANSWER_TYPES.exclude?(answer_type.to_s) }
# Add other attributes based on schema if needed
+
+ trait :file_upload do
+ answer_type { :file_upload }
+ end
end
end
diff --git a/spec/models/form_answer_spec.rb b/spec/models/form_answer_spec.rb
index cc2b36ef6d..f06d11cf80 100644
--- a/spec/models/form_answer_spec.rb
+++ b/spec/models/form_answer_spec.rb
@@ -4,6 +4,27 @@
describe "associations" do
it { should belong_to(:form_field).optional }
it { should belong_to(:form_submission) }
+ it { should have_one(:asset).dependent(:destroy) }
+ end
+
+ describe "#uploaded_file" do
+ let(:form) { create(:form) }
+ let(:submission) { create(:form_submission, form: form) }
+ let(:answer) { create(:form_answer, form_submission: submission, submitted_answer: "sample.png") }
+
+ it "returns nil when no asset is attached" do
+ expect(answer.uploaded_file).to be_nil
+ end
+
+ it "returns the attachment when a file is on file" do
+ asset = answer.build_asset
+ asset.file.attach(io: File.open(Rails.root.join("spec/fixtures/files/sample.png")),
+ filename: "sample.png", content_type: "image/png")
+ asset.save!
+
+ expect(answer.reload.uploaded_file).to be_attached
+ expect(answer.uploaded_file.filename.to_s).to eq("sample.png")
+ end
end
describe "#name" do
diff --git a/spec/models/form_field_spec.rb b/spec/models/form_field_spec.rb
index 4acece44a7..2bdda30643 100644
--- a/spec/models/form_field_spec.rb
+++ b/spec/models/form_field_spec.rb
@@ -48,6 +48,31 @@
field = build(:form_field, form: form, answer_type: :free_form_input_one_line, required: true)
expect(field).to be_valid
end
+
+ it "allows a required file-upload field" do
+ field = build(:form_field, form: form, answer_type: :file_upload, required: true)
+ expect(field).to be_valid
+ end
+ end
+
+ describe "file-upload answer type" do
+ let(:form) { create(:form) }
+ let(:field) { build(:form_field, :file_upload, form: form) }
+
+ it "is a valid answer type" do
+ expect(field).to be_valid
+ expect(field.answer_type).to eq("file_upload")
+ end
+
+ it "collects input but is neither selectable nor free-form text" do
+ expect(field.collects_input?).to be(true)
+ expect(field.selectable?).to be(false)
+ expect(field.free_form_text?).to be(false)
+ end
+
+ it "labels the type in sentence case" do
+ expect(field.answer_type_label).to eq("File upload")
+ end
end
describe "field_identifier uniqueness" do
@@ -94,7 +119,7 @@
it { should define_enum_for(:status).with_values([ :inactive, :active ]) }
it { should define_enum_for(:answer_type).with_values([ :free_form_input_one_line, :free_form_input_paragraph,
:single_select_radio, :no_user_input, :multi_select_checkbox,
- :group_header, :single_select_dropdown ]) }
+ :group_header, :single_select_dropdown, :file_upload ]) }
it { should define_enum_for(:input_type).with_values([ :text_alphanumeric, :number_integer, :number_decimal, :date ]) }
end
diff --git a/spec/requests/events/public_registrations_spec.rb b/spec/requests/events/public_registrations_spec.rb
index 3fa397e963..fefa4745ea 100644
--- a/spec/requests/events/public_registrations_spec.rb
+++ b/spec/requests/events/public_registrations_spec.rb
@@ -630,6 +630,24 @@ def post_with_scholarship(scholarship_answer)
expect(response.body).not_to include("Reworded after submission")
end
+ it "renders an uploaded file answer as an image preview and download link" do
+ upload_field = create(:form_field, :file_upload, form: form, name: "Photo of your creation")
+ submission = FormSubmission.find_by(person: person, form: form)
+ answer = submission.form_answers.create!(form_field: upload_field, submitted_answer: "sample.png")
+ answer.build_asset.tap do |asset|
+ asset.file.attach(io: File.open(Rails.root.join("spec/fixtures/files/sample.png")),
+ filename: "sample.png", content_type: "image/png")
+ asset.save!
+ end
+
+ get event_public_registration_path(event, person_id: person.id)
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include("Photo of your creation")
+ expect(response.body).to include("sample.png")
+ expect(response.body).to include(rails_blob_path(answer.uploaded_file, only_path: true))
+ end
+
context "when the registrant filled out a separate scholarship form" do
let(:scholarship_form) { create(:form, role: "scholarship") }
let!(:scholarship_field) do
diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb
index 8f9a207e5f..a4000b0617 100644
--- a/spec/services/event_registration_services/public_registration_spec.rb
+++ b/spec/services/event_registration_services/public_registration_spec.rb
@@ -955,4 +955,57 @@ def register_with_additional_forms(selections)
expect(FormSubmission.where(person: person, role: "scholarship")).to be_empty
end
end
+
+ describe "file-upload answers" do
+ let!(:upload_field) do
+ form.form_fields.create!(name: "Photo of your creation", answer_type: :file_upload,
+ status: :active, position: 999)
+ end
+
+ def signed_id_for(fixture, content_type)
+ blob = ActiveStorage::Blob.create_and_upload!(
+ io: File.open(Rails.root.join("spec/fixtures/files", fixture)),
+ filename: fixture, content_type: content_type
+ )
+ blob.signed_id
+ end
+
+ it "attaches the uploaded blob to the answer and stores its filename" do
+ params = base_form_params(first_name: "Pat", last_name: "Art", email: "pat@example.com").merge(
+ upload_field.id.to_s => signed_id_for("sample.png", "image/png")
+ )
+
+ result = described_class.call(event: event, registration_form: form, form_params: params)
+
+ expect(result.success?).to be true
+ answer = result.form_submission.form_answers.find_by(form_field: upload_field)
+ expect(answer.uploaded_file).to be_attached
+ expect(answer.uploaded_file.filename.to_s).to eq("sample.png")
+ expect(answer.submitted_answer).to eq("sample.png")
+ end
+
+ it "leaves the answer fileless when nothing was uploaded" do
+ params = base_form_params(first_name: "No", last_name: "File", email: "nofile@example.com").merge(
+ upload_field.id.to_s => ""
+ )
+
+ result = described_class.call(event: event, registration_form: form, form_params: params)
+
+ expect(result.success?).to be true
+ answer = result.form_submission.form_answers.find_by(form_field: upload_field)
+ expect(answer.uploaded_file).to be_nil
+ expect(answer.submitted_answer).to eq("")
+ end
+
+ it "rejects a file whose content type Asset does not accept" do
+ params = base_form_params(first_name: "Bad", last_name: "Type", email: "bad@example.com").merge(
+ upload_field.id.to_s => signed_id_for("sample.txt", "text/plain")
+ )
+
+ result = described_class.call(event: event, registration_form: form, form_params: params)
+
+ expect(result.success?).to be false
+ expect(Person.find_by(email: "bad@example.com")).to be_nil
+ end
+ end
end
diff --git a/spec/services/form_answer_validator_spec.rb b/spec/services/form_answer_validator_spec.rb
index 4969218218..936b6d2e67 100644
--- a/spec/services/form_answer_validator_spec.rb
+++ b/spec/services/form_answer_validator_spec.rb
@@ -77,6 +77,21 @@ def validate(field, value)
end
end
+ describe "file upload" do
+ it "flags a required file question with no upload" do
+ field = create(:form_field, :file_upload, form: form, required: true)
+
+ expect(validate(field, "")).to eq(field.id => "can't be blank")
+ end
+
+ it "accepts a signed blob id and skips the text-shaped checks" do
+ field = create(:form_field, :file_upload, form: form, required: true)
+
+ # A long signed id would trip a character cap, but file uploads have none.
+ expect(validate(field, "a" * 5_000)).to eq({})
+ end
+ end
+
describe "min words / max characters" do
it "surfaces the min-words error" do
field = create(:form_field, form: form, answer_type: :free_form_input_paragraph, min_words: 3)