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
6 changes: 4 additions & 2 deletions app/controllers/events/public_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
12 changes: 1 addition & 11 deletions app/services/event_registration_services/public_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion app/views/events/public_registrations/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
</div>
<% 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 %>
<input type="hidden" name="scholarship_requested" value="true">
Expand Down
14 changes: 14 additions & 0 deletions config/features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions spec/requests/events/public_registrations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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: "<strong>Your details</strong>")
create(:form_field, form: form, answer_type: :free_form_input_one_line, name: "<em>Name</em>", required: false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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