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