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
3 changes: 2 additions & 1 deletion app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ def self.info_email
validates :logo,
content_type: %w[image/png image/jpeg image/webp],
size: { less_than: 5.megabytes }
validates :name, presence: true, length: { maximum: 255 }
normalizes :name, with: ->(name) { name.strip }
validates :name, presence: true, length: { maximum: 255 }, uniqueness: { case_sensitive: false }
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP, message: "must be a valid email address" }, allow_blank: true, length: { maximum: 255 }
validates :organization_type_other, length: { maximum: 255 }
validates :website_url, length: { maximum: 255 }
Expand Down
23 changes: 7 additions & 16 deletions app/services/public_form_submission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,15 @@ def register_for_on_demand_training(submission)

# A close-program submission end-dates the person's affiliations at the named
# organization (see AffiliationServices::CloseProgram). We only auto-process
# when the submitted organization name resolves to exactly one org β€” an exact,
# case-insensitive name match. Anything ambiguous (no match, or two same-named
# orgs) is left for an admin to resolve on the submission's processing panel,
# which runs the same service once they link the organization by hand.
# when the submitted organization name matches an org exactly (case-insensitive).
# An unmatched name is left for an admin to resolve on the submission's processing
# panel, which runs the same service once they link the organization by hand.
def process_close_program(submission)
return unless @form.role == "close_program"
return unless submission.person

answers = submission.answers_by_identifier
organization = sole_organization_named(answers[FormField::ORGANIZATION_NAME_FIELD_IDENTIFIER])
organization = organization_named(answers[FormField::ORGANIZATION_NAME_FIELD_IDENTIFIER])
return unless organization

ended = AffiliationServices::CloseProgram.call(
Expand Down Expand Up @@ -145,7 +144,7 @@ def capture_organization(submission)
# core would have to guess at is left for an admin on the submission's linking
# page.
def process_agreement_organization(submission)
organization = sole_organization_named(field_value(FormField::ORGANIZATION_NAME_FIELD_IDENTIFIER))
organization = organization_named(field_value(FormField::ORGANIZATION_NAME_FIELD_IDENTIFIER))
return unless organization

result = OrganizationServices::LinkSubmittedOrganization.call(
Expand All @@ -161,16 +160,8 @@ def process_agreement_organization(submission)
organization
end

# Only an unambiguous name match is auto-processed. Organization names aren't
# unique and the column collates case-insensitively, so two orgs can answer to
# one submitted name β€” and picking the wrong one here would end a facilitator's
# affiliations at the org they actually work for. Ambiguity goes to an admin.
def sole_organization_named(name)
name = name.to_s.strip
return if name.blank?

matches = Organization.where("LOWER(name) = ?", name.downcase)
matches.first if matches.count == 1
def organization_named(name)
Organization.find_by(name: name) if name.present?
end

def field_value(identifier)
Expand Down
13 changes: 13 additions & 0 deletions config/features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4510,3 +4510,16 @@
pro_tips:
- "Logged in, your name and reply email fill in for you under \"From\", so you only write a subject and message."
- "Links that pre-fill a subject (like an affiliation change request) drop you straight into the message box."

- name: "Organization names are unique"
area: people
display_status: admin_facing
released_on: 2026-10-12
pr_number: 2637
action_path: "/organizations/dedupe_index"
summary: >-
Two organizations can no longer share the same name. Names that differ only
by capitalization or extra spaces count as the same name.
pro_tips:
- "To tell apart two real organizations with the same name, add the city or program, e.g. \"Hope House (Oakland)\"."
- "Use Dedupe on the Organizations page to combine records that already exist twice."
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
class AddUniqueNameIndexToOrganizations < ActiveRecord::Migration[8.1]
INDEX_NAME = "index_organizations_on_name"

def up
duplicates = select_rows(<<~SQL)
SELECT MIN(name), GROUP_CONCAT(id ORDER BY id) AS organization_ids
FROM organizations
WHERE name IS NOT NULL
GROUP BY name
HAVING COUNT(*) > 1
SQL

if duplicates.any?
groups = duplicates.map { |name, ids| "#{name} (organizations #{ids})" }
raise ActiveRecord::MigrationError,
"Merge these duplicate organizations at /organizations/dedupe_index before adding #{INDEX_NAME}:\n #{groups.join("\n ")}"
end

add_index :organizations, :name, unique: true, name: INDEX_NAME
end

def down
remove_index :organizations, name: INDEX_NAME, if_exists: true
end
end
1 change: 1 addition & 0 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1477,6 +1477,7 @@
t.integer "windows_type_id"
t.index ["created_by_id"], name: "index_organizations_on_created_by_id"
t.index ["location_id"], name: "index_organizations_on_location_id"
t.index ["name"], name: "index_organizations_on_name", unique: true
t.index ["organization_status_id"], name: "index_organizations_on_organization_status_id"
t.index ["parent_id"], name: "index_organizations_on_parent_id"
t.index ["updated_by_id"], name: "index_organizations_on_updated_by_id"
Expand Down
17 changes: 4 additions & 13 deletions lib/tasks/resolve_resource_organizations.rake
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
#
# For each resource that has an `agency` name but no organization linked yet:
# - match an Organization by exact, case-insensitive name (agency == org.name)
# - exactly one match -> link it
# - no match -> create the Organization, link it
# - more than one match -> ambiguous; skip and report (reconcile by hand)
# - match -> link it
# - no match -> create the Organization, link it
#
# New orgs get status "Unknown" β€” not because the link cares (it doesn't), but
# because Organization requires organization_status_id (org status is legacy and
Expand All @@ -27,7 +26,7 @@ namespace :data do
unknown_status = OrganizationStatus.find_by(name: "Unknown")
raise "OrganizationStatus \"Unknown\" is required to create organizations" if !dry_run && unknown_status.nil?

linked = created = ambiguous = 0
linked = created = 0
created_by_name = {}

ActiveRecord::Base.transaction do
Expand All @@ -36,14 +35,7 @@ namespace :data do
next if name.blank?
key = name.downcase

matches = Organization.where("LOWER(name) = ?", key).to_a
if matches.size > 1
ambiguous += 1
puts " ! resource ##{resource.id} #{resource.title.inspect}: #{matches.size} orgs named #{name.inspect} (ids: #{matches.map(&:id).join(', ')}) β€” skipped"
next
end

if (organization = matches.first)
if (organization = Organization.find_by(name: name))
linked += 1
puts " = resource ##{resource.id}: link existing org ##{organization.id} #{name.inspect}"
elsif created_by_name.key?(key)
Expand All @@ -66,7 +58,6 @@ namespace :data do
puts
puts "linked to existing: #{linked}"
puts "orgs created: #{created}"
puts "ambiguous, skipped: #{ambiguous}"
puts(dry_run ? "\nDRY RUN β€” rolled back, nothing written. Set DRY_RUN=false to execute." : "\nDone.")
end
end
2 changes: 1 addition & 1 deletion spec/factories/organizations.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
FactoryBot.define do
factory :organization do
name { Faker::Company.name.gsub("'", " ") }
sequence(:name) { |n| "#{Faker::Company.name.gsub("'", " ")} #{n}" }
# association :windows_type
# association :location
association :organization_status
Expand Down
16 changes: 16 additions & 0 deletions spec/models/organization_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@
subject { build(:organization) }
it { should validate_presence_of(:name) }
it { should_not validate_presence_of(:organization_status_id) }
it { should validate_uniqueness_of(:name).case_insensitive }

it "strips surrounding whitespace from the name" do
expect(build(:organization, name: " Hope House ").name).to eq("Hope House")
end

it "rejects a name that differs only by case or trailing whitespace" do
create(:organization, name: "Hope House")
expect(build(:organization, name: "hope house ")).not_to be_valid
end

it "is enforced by a unique index when validations are skipped" do
create(:organization, name: "Hope House")
duplicate = build(:organization, name: "HOPE HOUSE")
expect { duplicate.save(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique, /index_organizations_on_name/)
end

it { should allow_value("info@example.com").for(:email) }
it { should allow_value("").for(:email) }
Expand Down
2 changes: 1 addition & 1 deletion spec/requests/dedupable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@
describe "GET dedupe_index" do
it "renders and surfaces candidate groups from the duplicate finder" do
create(:organization, name: "Hope Center")
create(:organization, name: "hope center")
create(:organization, name: "Hope-Center")

get dedupe_index_organizations_path

Expand Down
5 changes: 2 additions & 3 deletions spec/requests/form_submissions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -244,12 +244,11 @@

# A matching affiliation the person holds does not mark it linked β€” only a
# direct link made in the editor does, so it stays Pending (the queue).
create(:affiliation, person: submission.person,
organization: create(:organization, name: "Harbor Family Shelter"))
organization = create(:organization, name: "Harbor Family Shelter")
create(:affiliation, person: submission.person, organization: organization)
get form_submissions_path, headers: frame_headers
expect(response.body).to include("Pending")

organization = create(:organization, name: "Harbor Family Shelter")
submission.link_organization!(organization.id)
get form_submissions_path, headers: frame_headers
# Once linked, the org shows as its own chip (linking to the org) β€” no
Expand Down
6 changes: 3 additions & 3 deletions spec/services/organization_services/duplicate_finder_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def org(attrs = {})
describe "#groups" do
it "groups organizations with the same normalized name" do
a = org(name: "Hope Center")
b = org(name: "hope center")
b = org(name: "Hope-Center")
org(name: "Unrelated")

groups = described_class.new.groups
Expand Down Expand Up @@ -52,14 +52,14 @@ def org(attrs = {})

it "flags a FileMaker code present on only one record" do
org(name: "Sunrise", filemaker_code: "FM123")
org(name: "Sunrise", filemaker_code: nil)
org(name: "Sunrise!", filemaker_code: nil)

expect(described_class.new.groups.first.reasons).to include("FileMaker code on only one record")
end

it "surfaces multiple FileMaker codes as a keep-all conflict" do
org(name: "Rivertown", filemaker_code: "FM1")
org(name: "Rivertown", filemaker_code: "FM2")
org(name: "Rivertown!", filemaker_code: "FM2")

expect(described_class.new.groups.first.reasons)
.to include(a_string_matching(/Multiple FileMaker codes.*merging keeps all/))
Expand Down
24 changes: 0 additions & 24 deletions spec/services/public_form_submission_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -264,20 +264,6 @@ def org_params(overrides = {})
.to include("Facilitator")
end

# Two orgs answering to one submitted name would make the scenario guess
# which one to end affiliations at, so it stays in the admin's queue.
it "leaves an ambiguous name for an admin rather than picking an org" do
form.update!(role: "new_job")
create(:organization, name: "helping hands")

result = described_class.call(form: form, form_params: org_params)

expect(result.person.affiliations).to be_empty
expect(result.form_submission.reload.linked_organization_ids).to be_empty
expect(Organization.where("LOWER(name) = ?", "helping hands").count).to eq(2)
expect(FormSubmission.org_link_status("pending")).to include(result.form_submission)
end

# Fill-blanks, not latest-wins: an agreement runs the admin linking core, so
# a curated value is kept and reported as a conflict on the linking page.
it "does not overwrite the organization's curated profile" do
Expand Down Expand Up @@ -419,15 +405,5 @@ def close_params(org_name: "Sunset Youth Services", date: "2026-06-30", reason:
expect(facilitator.reload.end_date).to be_nil
expect(result.form_submission.linked_organization_ids).to be_empty
end

it "does not auto-process when two organizations share the submitted name" do
create(:organization, name: "Sunset Youth Services")
person = create(:person, user: nil, first_name: "Casey", last_name: "Closing", email: "casey@example.com")
facilitator = create(:affiliation, person: person, organization: organization, title: "Facilitator")

described_class.call(form: form, form_params: close_params)

expect(facilitator.reload.end_date).to be_nil
end
end
end
10 changes: 0 additions & 10 deletions spec/tasks/resolve_resource_organizations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,6 @@ def suppress_output
expect(r1.reload.organization).to eq(r2.reload.organization)
end

it "skips a resource whose agency name is ambiguous across multiple orgs" do
create(:organization, name: "Twins")
create(:organization, name: "Twins")
resource = create(:resource, agency: "Twins")

run_task

expect(resource.reload.organization_id).to be_nil
end

it "leaves an already-linked resource untouched" do
existing = create(:organization, name: "Existing Link")
other = create(:organization, name: "Someplace Else")
Expand Down
2 changes: 1 addition & 1 deletion spec/views/organizations/edit.html.erb_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def org_with_status(name)
Organization.create!(
windows_type: windows_type,
organization_status: OrganizationStatus.find_or_create_by!(name: name),
name: "MyString",
name: "Status org",
description: "MyString",
notes: "MyText"
)
Expand Down
Loading