Skip to content
Open
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
147 changes: 138 additions & 9 deletions app/controllers/event_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,12 @@ def select_organization
@person = @event_registration.registrant
organization = Organization.find(params[:organization_id])

# Apply the org details the form captured only when the admin opted in — linking an
# established org shouldn't silently rewrite its address/website.
entries = registration_submission_entries(@event_registration)
answers = submitted_agency_answers(entries, organization.name)
apply_submitted_org_details(organization, answers) if params[:apply_form_details] == "1"

AffiliationServices::CreateFromRegistration.call(
person: @person,
organization: organization,
Expand All @@ -236,17 +242,33 @@ def create_organization
# button can't be used to create an arbitrary org — it only resolves a pending
# submitted name. A specific name is passed when there are several submissions;
# otherwise default to the first submitted name.
submitted_names = submitted_agency_names(@event_registration)
entries = registration_submission_entries(@event_registration)
submitted_names = entries.filter_map { |entry| entry[:org_name].presence&.strip }.uniq { |name| name.downcase }
requested = params[:organization_name].presence
name = requested ? submitted_names.find { |submitted| submitted.casecmp?(requested) } : submitted_names.first
if name.blank?
redirect_to link_organization_event_registration_path(@event_registration, return_to: params[:return_to].presence), alert: "No submitted organization name to create from."
return
end

# Reuse an existing org with that name rather than creating a duplicate.
# The other org details the registrant typed on the same submission, applied when the
# admin leaves the populate toggle on (checked by default for create-and-link).
answers = submitted_agency_answers(entries, name)
apply = params[:apply_form_details] == "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: Create-and-link now honors the populate toggle: on (default) builds the org from the form (or populates an existing same-name org); off creates it bare / links only. Suggested-match and create-and-link toggles default on in the view, the manual search box defaults off.


# Reuse an existing org with that name rather than creating a duplicate. A new org is
# built from the form when populating, else created bare; an existing org is populated
# only when the toggle is on (never silently).
existing = Organization.where("LOWER(name) = ?", name.strip.downcase).first
organization = existing || Organization.create!(name: name.strip, organization_status: OrganizationStatus.find_by(name: "Active"))
organization =
if existing
existing
elsif apply
build_organization_from_answers(name, answers)
else
Organization.create!(name: name.strip, organization_status: OrganizationStatus.find_by(name: "Active"))
end
apply_submitted_org_details(organization, answers) if existing && apply

AffiliationServices::CreateFromRegistration.call(
person: @person,
Expand Down Expand Up @@ -414,12 +436,15 @@ def registration_submission_entries(registration)
entries
end

# Distinct, non-blank org names the registrant typed across their registration-form
# submissions (case-insensitive dedupe, first spelling wins).
def submitted_agency_names(registration)
registration_submission_entries(registration)
.filter_map { |entry| entry[:org_name].presence&.strip }
.uniq { |name| name.downcase }
# The agency answers (org details + position) the registrant submitted for this org,
# keyed by field_identifier. Uses the submission that named this org, else the first
# that named any org (registrants normally have a single submission), so a resolved
# org and affiliation can carry what they typed. {} when nothing was submitted.
def submitted_agency_answers(entries, name)
entry = entries.find { |e| e[:org_name].present? && e[:org_name].strip.casecmp?(name.to_s.strip) } ||
entries.find { |e| e[:org_name].present? } ||
entries.first
entry&.fetch(:submission)&.answers_by_identifier || {}
end
Comment on lines +443 to 448
Comment on lines +443 to 448

# The job title/position the registrant typed for their organization on the
Expand All @@ -431,4 +456,108 @@ def submitted_position(registration)
primary = entries.find { |entry| entry[:org_name].present? } || entries.first
primary && primary[:position]
end

# Apply the org info the registrant submitted to an org we already have, when the
# admin opts in (the apply_form_details checkbox). Website and organization type
# overwrite the org's values (each logged to Ahoy). The address is additive — a new
# record — unless one with the same street exists, which we refresh instead of duplicating.
def apply_submitted_org_details(organization, answers)
return if answers.blank?

overwrite_org_field(organization, :website_url, answers["agency_website"])
overwrite_org_type(organization, answers["agency_type"])
apply_submitted_address(organization, answers)
end

# Overwrite a single org column with the submitted value — an explicit admin opt-in —
# and log the change to Ahoy. No-op when the form had no value or it already matches.
def overwrite_org_field(organization, field, submitted)
value = submitted.to_s.strip
return if value.blank? || organization.public_send(field).to_s == value

previous = organization.public_send(field)
organization.update!(field => value)
Ahoy::Tracker.new(user: current_user).track(
"update.organization",
resource_type: "Organization",
resource_id: organization.id,
resource_title: organization.name,
change: field.to_s,
from: previous,
to: value,
reason: "registration_form_apply"
)
end

# Overwrite the org's type from the submitted type name, resolving it to an
# OrganizationType record and logging the change to Ahoy. No-op when the form had no
# type, it names no known type, or it already matches.
def overwrite_org_type(organization, submitted)
type = submitted_organization_type(submitted)
return if type.nil? || organization.organization_type_id == type.id

previous = organization.organization_type&.name
organization.update!(organization_type: type)
Ahoy::Tracker.new(user: current_user).track(
"update.organization",
resource_type: "Organization",
resource_id: organization.id,
resource_title: organization.name,
change: "organization_type",
from: previous,
to: type.name,
reason: "registration_form_apply"
)
end

# The OrganizationType matching a submitted type name (case-insensitive), or nil when
# blank or unrecognized. The form submits a type name from OrganizationType.published_names.
def submitted_organization_type(submitted)
name = submitted.to_s.strip
return if name.blank?

OrganizationType.where("LOWER(name) = ?", name.downcase).first
end

# Creates an Active org from a submitted name, carrying over the org-level details
# the registrant typed: type, website (stored as typed; see Organization#website_link_url),
# and a primary work address.
def build_organization_from_answers(name, answers)
organization = Organization.create!(
name: name.strip,
organization_status: OrganizationStatus.find_by(name: "Active"),
organization_type: submitted_organization_type(answers["agency_type"]),
website_url: answers["agency_website"].presence&.strip
)
apply_submitted_address(organization, answers)
organization
end

# Add the submitted address as a new primary "work" record with an "Unknown" locality
# (the form doesn't capture one). Additive — but if the org already has an address with
# the same street, refresh that one (e.g. fill in a city it was missing) rather than
# duplicating it. The chosen record becomes primary and any others are demoted, leaving
# them active so prior addresses aren't lost. Skipped unless city and state are present
# (the Address model requires them); street_address and zip_code are NOT NULL columns,
# so a missing one is stored as "" rather than nil.
def apply_submitted_address(organization, answers)

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: Address is additive (new primary) but de-duped by street — a same-street record is refreshed (city/zip filled in) rather than duplicated. Demoted prior addresses stay active so history is kept. Website + type instead overwrite via overwrite_org_field, each Ahoy-logged.

city = answers["agency_city"].presence&.strip
state = answers["agency_state"].presence&.strip
return if city.blank? || state.blank?

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: Address needs both city and state (the model validates presence of both + locality), so a partial address is skipped instead of raising — matching PublicRegistration's hardcoded locality: "Unknown", address_type: "work".


street = answers["agency_street"].to_s.strip
zip = answers["agency_zip"].to_s.strip
match = street.present? && organization.addresses.find { |a| a.street_address.to_s.strip.casecmp?(street) }

address = if match
match.update!(city: city, state: state, zip_code: zip, primary: true, inactive: false)
match
else
organization.addresses.create!(
street_address: street, city: city, state: state, zip_code: zip,
locality: "Unknown", address_type: "work", primary: true
)
end
organization.addresses.where.not(id: address.id).update_all(primary: false)
end
end
16 changes: 8 additions & 8 deletions app/services/event_registration_services/public_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,8 @@ def create_mailing_address(person)

if existing
existing.update!(
street_address: field_value("mailing_street"),
zip_code: field_value("mailing_zip"),
street_address: field_value("mailing_street").to_s,
zip_code: field_value("mailing_zip").to_s,
primary: true,
inactive: false
)
Expand All @@ -246,10 +246,10 @@ def create_mailing_address(person)
person.addresses.where(primary: true).update_all(primary: false, inactive: true)

person.addresses.create!(
street_address: field_value("mailing_street"),
street_address: field_value("mailing_street").to_s,
city: new_city,
state: new_state,
zip_code: field_value("mailing_zip"),
zip_code: field_value("mailing_zip").to_s,
country: field_value("mailing_country")&.strip,
locality: "Unknown",
address_type: field_value("mailing_address_type")&.downcase || "unknown",
Expand Down Expand Up @@ -327,8 +327,8 @@ def create_agency_address(organization)

if existing
existing.update!(
street_address: field_value("agency_street"),
zip_code: field_value("agency_zip"),
street_address: field_value("agency_street").to_s,

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: Latent-bug fix: street/zip are NOT NULL columns but the form leaves them optional, so a city-only address answer used to 500 with a NotNullViolation. .to_s stores "" instead of nil (the model only validates city/state/locality).

zip_code: field_value("agency_zip").to_s,
primary: existing.primary? || make_primary,
inactive: false
)
Expand All @@ -337,10 +337,10 @@ def create_agency_address(organization)
end

organization.addresses.create!(
street_address: field_value("agency_street"),
street_address: field_value("agency_street").to_s,
city: new_city,
state: new_state,
zip_code: field_value("agency_zip"),
zip_code: field_value("agency_zip").to_s,
country: field_value("agency_country")&.strip,
locality: "Unknown",
address_type: "work",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<%# Toggle: when checked, linking this org also applies the org info from the
registrant's form — overwrites the website and type, and adds the submitted address
as the new primary (refreshing a same-street one instead of duplicating). Defaults
on for a create-and-link or a suggested match (very likely the same org), off for the
manual search box (often an unrelated established org). The caller passes `checked`.
Lives inside each link form so it submits with that form without any JS. %>
<% checked = local_assigns.fetch(:checked, true) %>
<label class="flex items-center gap-2 text-xs text-gray-600 cursor-pointer">
<%= check_box_tag :apply_form_details, "1", checked, class: "rounded border-gray-300 text-blue-900 focus:ring-blue-500" %>
Also populate this org's address, website, and type from the form answers
</label>
63 changes: 38 additions & 25 deletions app/views/event_registrations/link_organization.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -121,18 +121,23 @@
<%= @creatable_org_names.size > 1 ? "These organizations the registrant entered aren't in the database yet. Create each in the database and link it in one step:" : "The registrant entered this organization on their form, but it's not in the database yet. Create the organization in the database and link it in one step:" %>
</p>
<% @creatable_org_names.each do |creatable_name| %>
<div class="flex items-stretch gap-2 mb-2">
<div class="flex-1 min-w-0 flex items-center rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800">
<span class="truncate"><%= creatable_name %></span>
<%= form_with url: create_organization_event_registration_path(@event_registration),
method: :post,
data: { turbo_frame: "_top" },
class: "mb-2" do %>
<%= hidden_field_tag :organization_name, creatable_name %>
<%= hidden_field_tag :return_to, params[:return_to] %>
<div class="flex items-stretch gap-2">
<div class="flex-1 min-w-0 flex items-center rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800">
<span class="truncate"><%= creatable_name %></span>
</div>
<%= submit_tag "Create and link",
class: "shrink-0 w-48 text-center rounded-md px-6 py-2 font-semibold #{btn_solid} focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition cursor-pointer" %>
</div>
<%= button_to create_organization_event_registration_path(@event_registration, return_to: params[:return_to]),
method: :post,
params: { organization_name: creatable_name },
form: { class: "shrink-0", data: { turbo_frame: "_top" } },
class: "shrink-0 w-48 text-center rounded-md px-6 py-2 font-semibold #{btn_solid} focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition cursor-pointer" do %>
Create and link
<% end %>
</div>
<div class="mt-2">
<%= render "apply_form_details_checkbox", checked: true %>
</div>
<% end %>
<% end %>
<p class="text-sm text-gray-600 mb-2 mt-4">Or, add an existing organization instead:</p>
<% end %>
Expand All @@ -144,13 +149,18 @@
<%= form_with url: select_organization_event_registration_path(@event_registration),
method: :post,
data: { turbo_frame: "_top" },
class: "flex items-center justify-between bg-blue-50 border border-blue-200 rounded-lg p-3" do %>
class: "bg-blue-50 border border-blue-200 rounded-lg p-3" do %>
<%= hidden_field_tag :organization_id, org.id %>
<%= hidden_field_tag :return_to, params[:return_to] %>
<div class="min-w-0">
<p class="font-medium text-gray-800"><%= org.name %><% if org.city_state.present? %> <span class="text-xs text-gray-500">· <%= org.city_state %></span><% end %></p>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="font-medium text-gray-800"><%= org.name %><% if org.city_state.present? %> <span class="text-xs text-gray-500">· <%= org.city_state %></span><% end %></p>
</div>
<%= submit_tag "Link organization", class: "shrink-0 rounded-md bg-blue-900 px-4 py-1.5 text-white text-sm font-medium hover:bg-blue-800 transition cursor-pointer" %>
</div>
<div class="mt-2">
<%= render "apply_form_details_checkbox", checked: true %>
</div>
<%= submit_tag "Link organization", class: "rounded-md bg-blue-900 px-4 py-1.5 text-white text-sm font-medium hover:bg-blue-800 transition cursor-pointer" %>
<% end %>
<% end %>
</div>
Expand All @@ -159,18 +169,21 @@
<%= form_with url: select_organization_event_registration_path(@event_registration),
method: :post,
data: { turbo_frame: "_top" },
class: "flex items-stretch gap-2" do %>
class: "space-y-2" do %>
<%= hidden_field_tag :return_to, params[:return_to] %>
<div class="flex-1 min-w-0">
<%= select_tag :organization_id,
options_from_collection_for_select([], :id, :name),
include_blank: true,
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none",
data: { controller: "remote-select", remote_select_model_value: "organization" },
prompt: "Type to search organizations…" %>
<div class="flex items-stretch gap-2">
<div class="flex-1 min-w-0">
<%= select_tag :organization_id,
options_from_collection_for_select([], :id, :name),
include_blank: true,
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none",
data: { controller: "remote-select", remote_select_model_value: "organization" },
prompt: "Type to search organizations…" %>
</div>
<%= submit_tag "Link organization",
class: "shrink-0 w-48 text-center rounded-md px-6 py-2 font-semibold #{create_is_primary ? btn_outline : btn_solid} focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition cursor-pointer" %>
</div>
<%= submit_tag "Link organization",
class: "shrink-0 w-48 text-center rounded-md px-6 py-2 font-semibold #{create_is_primary ? btn_outline : btn_solid} focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition cursor-pointer" %>
<%= render "apply_form_details_checkbox", checked: false %>
<% end %>
</div>
</div>
Expand Down
Loading