Skip to content
Draft
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
11 changes: 11 additions & 0 deletions app/helpers/event_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,17 @@ def display_response_text(field, response)
text
end

# A small marker shown beside answers that weren't typed on the form but were
# filled in from the member's profile at submission time, so reviewers can tell
# captured profile data apart from what the person actually entered.
def backfilled_indicator(response)
return unless response&.backfilled?

label = "Filled in automatically from the member's profile"
tag.i(class: "fa-solid fa-wand-magic-sparkles text-gray-400 ml-1",

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 From Claude: Single place that renders the profile indicator; returns nil unless response.backfilled?, so every submission view that calls it stays consistent.

title: label, "aria-label": label, role: "img")
end

# Resolve a stored answer to readable text, mapping the sector/category ids
# behind the professional fields to their names. Free-text tokens (e.g. an
# "Other: <text>" answer) aren't ids, so they pass through unchanged. Returns
Expand Down
30 changes: 28 additions & 2 deletions app/services/event_registration_services/bulk_payment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@ def initialize(event:, form:, form_params:, person: nil)
@form = form
@form_params = form_params
@person = person
@backfilled_field_ids = []
@errors = []
end

def call
ActiveRecord::Base.transaction do
person = find_or_create_person
create_phone_contact(person) if field_value("payer_phone").present?
if @person
backfill_payer_fields(@person)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 From Claude: Logged-in branch backfills the hidden payer fields and intentionally skips create_phone_contact, so the signed-in person's existing contact methods are left untouched.

elsif field_value("payer_phone").present?
create_phone_contact(person)
end
submission = create_form_submission(person)
send_notifications(submission, person)
Result.new(success?: true, form_submission: submission, errors: [])
Expand Down Expand Up @@ -56,6 +61,26 @@ def field_value(key)
@form_params[field.id.to_s]
end

# Logged-in payers never see the payer information fields (they're
# logged_out_only), so fill those answers from the signed-in person's record
# to keep the submission self-describing. Their existing contact methods are
# left untouched.
def backfill_payer_fields(person)
{
"payer_first_name" => person.first_name,
"payer_last_name" => person.last_name,
"payer_email" => person.preferred_email,
"payer_phone" => person.phone_number,
"payer_organization" => person.primary_organization&.name
}.each do |key, value|
next if value.blank?
field = @form.form_fields.find_by(field_identifier: key)
next unless field
@form_params[field.id.to_s] = value
@backfilled_field_ids << field.id
end
end

def find_or_create_person
return @person if @person

Expand Down Expand Up @@ -117,7 +142,8 @@ def save_form_answers(submission)
end

record = submission.form_answers.find_or_initialize_by(form_field: field)
record.update!(submitted_answer: text, question_name_when_answered: field.name)
record.update!(submitted_answer: text, question_name_when_answered: field.name,
backfilled: @backfilled_field_ids.include?(field.id))
end
end
end
Expand Down
60 changes: 59 additions & 1 deletion app/services/event_registration_services/public_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def initialize(event:, form:, form_params:, scholarship_requested: false, person
@person = person
@scholarship_form = scholarship_form
@scholarship_params = scholarship_params || {}
@backfilled_field_ids = []
@errors = []
end

Expand All @@ -54,6 +55,8 @@ def call

assign_tags(person, organization)

backfill_profile_fields(person) if @person

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 From Claude: Placed after the create_*/assign_tags block on purpose: those guards read the original @form_params, so backfilling here records the data as answers without re-running address/contact creation on the person's own profile data.


existing = @event.event_registrations.find_by(registrant: person)
if existing
existing.update!(scholarship_requested: true) if @scholarship_requested
Expand Down Expand Up @@ -120,6 +123,60 @@ def resolve_names
end
end

# Logged-in registrants don't see the profile fields they already have on
# file (they're hidden as logged_out_only), so those answers would otherwise
# be missing from the submission. Capture them from the person's record so
# the submission reflects all the data known at the time it was submitted.
# Only fields left blank by the registrant are filled, and the person's own
# records (addresses, contacts) are not touched.
def backfill_profile_fields(person)
profile_field_values(person).each do |key, value|
next if value.blank?
field = @form.form_fields.find_by(field_identifier: key)
next unless field
next if @form_params[field.id.to_s].present?
@form_params[field.id.to_s] = value

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 From Claude: Track which fields were profile-sourced (vs typed) so save_form_answers can set backfilled per answer. Only blanks are filled, so a typed value is never reflagged.

@backfilled_field_ids << field.id
end
end

def profile_field_values(person)
{
"first_name" => person.legal_first_name.presence || person.first_name,
"last_name" => person.last_name,
"primary_email" => person.email,
"primary_email_type" => person.email_type&.capitalize,
"nickname" => (person.first_name if person.legal_first_name.present?),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 From Claude: Reverses resolve_names: when a legal name is on file the person goes by a nickname, so first_name field gets the legal name and nickname gets the preferred first_name. Skipped (blank) otherwise.

"pronouns" => person.pronouns,
"secondary_email" => person.email_2,
"secondary_email_type" => person.email_2_type&.capitalize
}.merge(address_field_values(person)).merge(phone_field_values(person))
end

def address_field_values(person)
return {} unless person.addresses.exists?

address = person.addresses.find_by(primary: true) || person.addresses.first
{
"mailing_street" => address.street_address,
"mailing_address_type" => address.address_type&.capitalize,
"mailing_city" => address.city,
"mailing_state" => address.state,
"mailing_zip" => address.zip_code
}
end

def phone_field_values(person)
phones = person.contact_methods.where(kind: :phone)
phone = phones.find_by(primary: true, inactive: false) || phones.where(inactive: false).first || phones.first
return {} unless phone

{
"phone" => phone.value,
"phone_type" => phone.contact_type&.capitalize
}
end

def find_or_create_person
return @person if @person

Expand Down Expand Up @@ -377,7 +434,8 @@ def save_form_answers(submission)
end

record = submission.form_answers.find_or_initialize_by(form_field: field)
record.update!(submitted_answer: text, question_name_when_answered: field.name)
record.update!(submitted_answer: text, question_name_when_answered: field.name,
backfilled: @backfilled_field_ids.include?(field.id))
end
end

Expand Down
2 changes: 1 addition & 1 deletion app/views/events/public_registrations/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
<div class="<%= field.grid_span_class %> py-2">
<dt class="rich-label text-xs font-medium text-gray-500 uppercase tracking-wide"><%= form_label_html(display_question_label(field, response)) %></dt>
<dd class="mt-1 text-sm text-gray-900">
<%= display_response_text(field, response) %>
<%= display_response_text(field, response) %><%= backfilled_indicator(response) %>
</dd>
</div>
<% end %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/form_submissions/_submission.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<div class="py-2">
<dt class="text-xs font-medium text-gray-500 uppercase tracking-wide"><%= display_question_label(field, response) %></dt>
<dd class="mt-1 text-sm text-gray-900">
<%= response&.submitted_answer.presence || tag.span("—", class: "text-gray-400") %>
<%= response&.submitted_answer.presence || tag.span("—", class: "text-gray-400") %><%= backfilled_indicator(response) %>
</dd>
</div>
<% end %>
Expand Down
9 changes: 9 additions & 0 deletions db/migrate/20260622121824_add_backfilled_to_form_answers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class AddBackfilledToFormAnswers < ActiveRecord::Migration[8.1]
def up
add_column :form_answers, :backfilled, :boolean, default: false, null: false
end

def down
remove_column :form_answers, :backfilled, if_exists: true
end
end
3 changes: 2 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.1].define(version: 2026_06_21_213947) do
ActiveRecord::Schema[8.1].define(version: 2026_06_22_121824) do
create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "action_text_rich_text_id", null: false
t.datetime "created_at", null: false
Expand Down Expand Up @@ -578,6 +578,7 @@
end

create_table "form_answers", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.boolean "backfilled", default: false, null: false
t.datetime "created_at", null: false
t.integer "form_field_id"
t.bigint "form_submission_id", null: false
Expand Down
5 changes: 5 additions & 0 deletions spec/factories/form_answers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,10 @@
association :form_field
submitted_answer { Faker::Lorem.sentence }
question_name_when_answered { nil }
backfilled { false }

trait :backfilled do
backfilled { true }
end
end
end
26 changes: 26 additions & 0 deletions spec/requests/events/bulk_payments_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,32 @@ def payer_params
end
end

describe "POST create as a signed-in payer" do
let(:admin) { create(:user, :admin, :with_person) }

before do
# Mirror the seeded form: payer fields are hidden from signed-in users, so
# they are neither rendered nor validated and must be backfilled.
[ org_field, payer_first_name_field, payer_last_name_field, payer_email_field ].each do |f|
f.update!(visibility: :logged_out_only, required: false)
end
admin.person.update!(first_name: "Signed", last_name: "Inn")
end

it "records the payer fields from the signed-in person" do
post event_bulk_payment_path(event),
params: { bulk_payment: { form_fields: { payment_method_field.id.to_s => "Check" } } }

expect(response).to have_http_status(:redirect)
submission = FormSubmission.last
expect(submission.person).to eq(admin.person)
answers = submission.answers_by_identifier
expect(answers["payer_first_name"]).to eq("Signed")
expect(answers["payer_last_name"]).to eq("Inn")
expect(answers["payer_email"]).to eq(admin.person.preferred_email)
end
end

describe "GET new with the seeded bulk payment form" do
let(:seeded_form) do
FormBuilderService.new(name: "Bulk Payment", sections: %i[bulk_payment], role: "bulk_payment").call
Expand Down
13 changes: 13 additions & 0 deletions spec/requests/form_submissions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@
expect(response.body).to include("Organization")
expect(response.body).to include("AWBW")
end

it "marks a backfilled answer with the profile indicator icon" do
typed = create(:form_field, form: submission.form, name: "Typed")
from_profile = create(:form_field, form: submission.form, name: "From profile")
create(:form_answer, form_submission: submission, form_field: typed, submitted_answer: "Typed value")
create(:form_answer, :backfilled, form_submission: submission, form_field: from_profile,
submitted_answer: "Profile value")

get form_submission_path(submission)

expect(response.body).to include("fa-wand-magic-sparkles")
expect(response.body).to include("Filled in automatically from the member&#39;s profile")
end
end

context "when arriving from the bulk payments index" do
Expand Down
58 changes: 58 additions & 0 deletions spec/services/event_registration_services/bulk_payment_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,64 @@ def base_form_params(overrides = {})
end
end

describe "logged-in payer" do
let(:person) do
create(:person, :with_organization, first_name: "Logged", last_name: "In",
user: create(:user, email: "loggedin@example.com"))
end

before do
person.contact_methods.create!(kind: :phone, value: "555-987-6543", contact_type: "personal", primary: true)
end

def logged_in_params
{
field_id("payment_method") => "Check",
field_id("number_of_attendees") => "2"
}
end

it "records the payer fields from the logged-in person's data" do
result = described_class.call(
event: event,
form: form,
form_params: logged_in_params,
person: person
)

expect(result.success?).to be true
submission = result.form_submission
expect(submission.person).to eq(person)

answers = submission.answers_by_identifier
expect(answers["payer_first_name"]).to eq("Logged")
expect(answers["payer_last_name"]).to eq("In")
expect(answers["payer_email"]).to eq("loggedin@example.com")
expect(answers["payer_phone"]).to eq("555-987-6543")
expect(answers["payer_organization"]).to eq(person.primary_organization.name)
end

it "flags the backfilled answers as backfilled and leaves typed ones unflagged" do
result = described_class.call(
event: event,
form: form,
form_params: logged_in_params,
person: person
)

answers = result.form_submission.form_answers.includes(:form_field).index_by { |a| a.form_field.field_identifier }
expect(answers["payer_first_name"]).to be_backfilled
expect(answers["payer_email"]).to be_backfilled
expect(answers["payment_method"]).not_to be_backfilled
end

it "does not alter the logged-in person's existing phone contact methods" do
expect {
described_class.call(event: event, form: form, form_params: logged_in_params, person: person)
}.not_to change { person.contact_methods.where(kind: :phone).count }
end
end

describe "notifications" do
it "sends the payer confirmation and the staff FYI" do
expect(NotificationServices::CreateNotification).to receive(:call).with(
Expand Down
Loading