diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb index 81c363342f..ca9832ed15 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -118,12 +118,14 @@ def show end # Scope submissions to this event so a person registered for multiple - # events that share the same form sees only the answers for this one. + # events that share the same form sees only the answers for this one. A + # returning registrant appends a new submission each time, so default to the + # most recent — their freshest answers — when none is named. submissions = @registration_form.form_submissions.where(person: person, event: @event) @form_submission = if params[:form_submission_id].present? submissions.find_by(id: params[:form_submission_id]) else - submissions.first + submissions.order(:created_at).last end unless @form_submission redirect_to event_path(@event), alert: "No registration form submission found." diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index 3e8050bf86..b7415b2bb4 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -107,7 +107,7 @@ def call send_notifications(existing) end organization_link = connect_organization(existing, organization) - submission = update_form_submission(person) + submission = create_form_submission(person) organization_link&.record_form_submission(submission) save_scholarship_submission(person) save_continuing_education_submission(person) @@ -494,16 +494,6 @@ def create_form_submission(person) submission end - def update_form_submission(person) - submission = FormSubmission.find_or_create_by!(person: person, form: @registration_form, role: "registration", event: @event) do |record| - record.event = @event - end - save_form_answers(submission) - OtherResponses::CaptureFromSubmission.call(submission) - Quotes::CaptureFromSubmission.call(submission) - submission - end - def save_form_answers(submission) @form_params.each do |field_id, raw_value| field = @registration_form.form_fields.find_by(id: field_id) diff --git a/app/views/events/public_registrations/new.html.erb b/app/views/events/public_registrations/new.html.erb index a958acaee0..9f15c83d66 100644 --- a/app/views/events/public_registrations/new.html.erb +++ b/app/views/events/public_registrations/new.html.erb @@ -94,7 +94,7 @@ <% end %> - <%= form_with url: event_public_registration_path(@event), method: :post, local: true, class: "space-y-2", data: { turbo: !@event.cost_cents.to_i.positive? } do |f| %> + <%= form_with url: event_public_registration_path(@event), method: :post, local: true, class: "space-y-2", data: { turbo: !@event.cost_cents.to_i.positive?, controller: "submit-once", "submit-once-submitting-text-value": "Registering…" } do |f| %> <% if @scholarship %> diff --git a/config/features.yml b/config/features.yml index bf94271fa9..ed93a01108 100644 --- a/config/features.yml +++ b/config/features.yml @@ -39,6 +39,20 @@ action_path: /organizations pr_number: 2464 +- name: "Repeat registration submissions no longer create duplicates" + area: registration + display_status: user_facing + released_on: 2026-08-31 + summary: >- + If someone submits an event registration more than once — two submissions in a + row, or coming back to register again later — they now stay a single person with + one registration for that event, instead of appearing as duplicates on your + roster. Every submission is still saved, so no answers are lost. + pr_number: 2465 + pro_tips: + - "The registrant's confirmation page shows their most recent submission." + - "Each submission is kept in full, so you can see what changed between them." + - name: "Set who a workshop log is credited to" area: content display_status: admin_facing diff --git a/spec/requests/events/public_registrations_spec.rb b/spec/requests/events/public_registrations_spec.rb index 4c7f89be12..4f960cc8b0 100644 --- a/spec/requests/events/public_registrations_spec.rb +++ b/spec/requests/events/public_registrations_spec.rb @@ -250,6 +250,24 @@ def post_with_scholarship(scholarship_answer) expect(response.body).to include("Minimum of 5 words.") end + # A double-click used to fire two POSTs and create duplicate people and + # registrations — worst on paid events, which opt out of Turbo (turbo: false) + # and so lose even Turbo's own submitter disabling. + it "guards the form against double-submit on a free event" do + get new_event_public_registration_path(event) + + expect(response.body).to include('data-controller="submit-once"') + end + + it "guards the form against double-submit on a paid event that opts out of Turbo" do + event.update!(cost_cents: 5_000) + + get new_event_public_registration_path(event) + + expect(response.body).to include('data-controller="submit-once"') + expect(response.body).to include('data-turbo="false"') + end + it "surfaces the CE deadlines on the continuing education section" do ce = FormBuilderService.new(name: "CE", sections: %i[continuing_education], role: "continuing_education").call event.event_forms.create!(form: ce, role: "continuing_education") @@ -735,6 +753,21 @@ def identity_answers end end + # A returning registrant appends a new submission each time, so the default + # view (no form_submission_id) must land on their freshest answers. + it "defaults to the most recently created submission when the registrant re-registered" do + older = FormSubmission.find_by(person: person, form: form) + older.form_answers.create!(form_field: essay_field, submitted_answer: "my older reasons") + newer = create(:form_submission, person: person, form: form, event: event) + newer.form_answers.create!(form_field: essay_field, submitted_answer: "my newer reasons") + + get event_public_registration_path(event, person_id: person.id) + + expect(response).to have_http_status(:success) + expect(response.body).to include("my newer reasons") + expect(response.body).not_to include("my older reasons") + end + it "renders header and field-label HTML unescaped on the response page" do create(:form_field, form: form, answer_type: :group_header, name: "Your details") create(:form_field, form: form, answer_type: :free_form_input_one_line, name: "Name", required: false) diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index 98cefa9f97..fde82e850f 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -610,6 +610,29 @@ def register_with_organization_type(value) end end + describe "re-submitting the registration form with the same name and email" do + let(:params) { base_form_params(first_name: "Rae", last_name: "Fox", email: "rae@example.com") } + + it "reuses the same person rather than creating a duplicate" do + described_class.call(event: event, registration_form: form, form_params: params) + + expect { + described_class.call(event: event, registration_form: form, form_params: params) + }.not_to change(Person, :count) + + expect(Person.where(email: "rae@example.com").count).to eq(1) + end + + it "keeps a single event registration but appends a second form submission" do + described_class.call(event: event, registration_form: form, form_params: params) + described_class.call(event: event, registration_form: form, form_params: params) + + person = Person.find_by!(email: "rae@example.com") + expect(event.event_registrations.where(registrant: person).count).to eq(1) + expect(person.form_submissions.where(form: form, role: "registration", event: event).count).to eq(2) + end + end + describe "an answer longer than its database column" do # `city` (like the other mapped person/address columns) is a varchar(255). # A longer answer must surface as a form error, not an ActiveRecord::ValueTooLong @@ -1135,48 +1158,55 @@ def signed_id_for(fixture, content_type) expect(Person.find_by(email: "badid@example.com")).to be_nil end - # submitted_answer is only a cache of the attachment's name. Pinned across - # every transition so a second writer that skips FormAnswer#sync_uploaded_filename! - # fails here rather than silently desyncing the two. - it "keeps submitted_answer equal to the attached filename through upload, replace, and blank re-submit" do + # submitted_answer is only a cache of the attachment's name. Each re-submission + # is its own FormSubmission, so its answer's submitted_answer must equal its own + # attachment — a writer that skips FormAnswer#sync_uploaded_filename! fails here + # rather than silently desyncing the two. + it "keeps each submission's submitted_answer equal to its own attached filename" do params = base_form_params(first_name: "Sync", last_name: "Check", email: "sync@example.com") in_sync = lambda do |result| answer = result.form_submission.form_answers.find_by(form_field: upload_field) expect(answer.submitted_answer).to eq(answer.uploaded_file&.filename.to_s) - answer + result.form_submission end - uploaded = in_sync.call(described_class.call( + png = in_sync.call(described_class.call( event: event, registration_form: form, form_params: params.merge(upload_field.id.to_s => signed_id_for("sample.png", "image/png")) )) - expect(uploaded.submitted_answer).to eq("sample.png") - - replaced = in_sync.call(described_class.call( + pdf = in_sync.call(described_class.call( event: event, registration_form: form, form_params: params.merge(upload_field.id.to_s => signed_id_for("sample.pdf", "application/pdf")) )) - expect(replaced.submitted_answer).to eq("sample.pdf") - - blanked = in_sync.call(described_class.call( + blank = in_sync.call(described_class.call( event: event, registration_form: form, form_params: params.merge(upload_field.id.to_s => "") )) - expect(blanked.submitted_answer).to eq("sample.pdf") + + expect([ png, pdf, blank ].map(&:id).uniq.size).to eq(3) + expect(png.form_answers.find_by(form_field: upload_field).submitted_answer).to eq("sample.png") + expect(pdf.form_answers.find_by(form_field: upload_field).submitted_answer).to eq("sample.pdf") + expect(blank.form_answers.find_by(form_field: upload_field).submitted_answer).to eq("") end - it "keeps the file already on the answer when a re-submission leaves the field blank" do + it "leaves the earlier submission's file intact when a blank re-submission adds a fileless one" do params = base_form_params(first_name: "Re", last_name: "Sub", email: "resub@example.com").merge( upload_field.id.to_s => signed_id_for("sample.png", "image/png") ) - described_class.call(event: event, registration_form: form, form_params: params) + first = described_class.call(event: event, registration_form: form, form_params: params) result = described_class.call(event: event, registration_form: form, form_params: params.merge(upload_field.id.to_s => "")) 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.submitted_answer).to eq("sample.png") + expect(result.form_submission).not_to eq(first.form_submission) + + original_answer = first.form_submission.form_answers.find_by(form_field: upload_field) + expect(original_answer.uploaded_file).to be_attached + expect(original_answer.submitted_answer).to eq("sample.png") + + blank_answer = result.form_submission.form_answers.find_by(form_field: upload_field) + expect(blank_answer.uploaded_file).to be_nil + expect(blank_answer.submitted_answer).to eq("") end end end