Skip to content

JM: Prevent duplicate organizations with the same name - #2637

Open
maebeale wants to merge 3 commits into
mainfrom
maebeale/organization-name-unique-index
Open

maebeale wants to merge 3 commits into
mainfrom
maebeale/organization-name-unique-index

Conversation

@maebeale

@maebeale maebeale commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 suggested review level: 3 Read 📖 unique index + validation, migration aborts on existing dupes

Organizations can no longer be saved twice under the same name, so the deduper stops refilling.

  • Index is case-insensitive — utf8mb4_unicode_ci treats Hope House, hope house, and Hope House as the same name. The model validation (case_sensitive: false) matches it.
  • Migration aborts if dupes exist — it lists each group with ids; merge them at /organizations/dedupe_index first. Same pattern as Prevent duplicate people with the same name and email #2632.
  • Prod has 6 duplicate groups today — one is 26 placeholder rows named "Delete". Clear them before this deploys.
  • Removed now-impossible "ambiguous name" branches — form auto-processing (PublicFormSubmission) and the resolve_resource_organizations backfill no longer handle two orgs with one name.
  • Factory names get a sequence suffix — avoids random Faker collisions.
  • Legitimately distinct same-name orgs need a disambiguator in the name (e.g. city).

🤖 Generated with Claude Code

@maebeale
maebeale marked this pull request as ready for review October 12, 2026 16:12
@maebeale
maebeale requested a review from jmilljr24 October 12, 2026 16:18
@maebeale
maebeale force-pushed the maebeale/organization-name-unique-index branch from 499f8a8 to 88e314d Compare October 12, 2026 16:22
@maebeale maebeale changed the title Prevent duplicate organizations with the same name JM: Prevent duplicate organizations with the same name Oct 12, 2026
@maebeale
maebeale force-pushed the maebeale/organization-name-unique-index branch 5 times, most recently from 26e00cc to 011d549 Compare October 12, 2026 23:41
Comment thread app/models/organization.rb Outdated
content_type: %w[image/png image/jpeg image/webp],
size: { less_than: 5.megabytes }
validates :name, presence: true, length: { maximum: 255 }
NAME_INDEX = "index_organizations_on_name".freeze

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Whats this for?

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: Only the spec used it, so it’s gone now and the spec names the index directly.

The validation handles normal saves; the index is the backstop for two simultaneous saves or writes that skip validation, and that spec checks the backstop.

@maebeale
maebeale force-pushed the maebeale/organization-name-unique-index branch 4 times, most recently from cafb6c6 to c932536 Compare October 14, 2026 12:19
@maebeale
maebeale requested a review from jmilljr24 October 14, 2026 12:25
@maebeale
maebeale force-pushed the maebeale/organization-name-unique-index branch 3 times, most recently from b4cd373 to dba9672 Compare October 14, 2026 14:26
@maebeale

maebeale commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

@jmilljr24 you ok w my running this in production to dedupe the 31 "DELETE" organizations?
Screenshot 2026-10-14 at 10 51 50 AM

# Merge every organization named "Delete" into the lowest-id one, the same way the
# admin dedupe page does (Dedupable#dedupe_perform + OrganizationsController#dedupe_config).
# Paste into a Rails console. Dry run by default; set DRY_RUN = false to execute.
DRY_RUN = true

orgs = Organization.where(name: "Delete").order(:id).to_a
keep, *dupes = orgs
puts "Keeping ##{keep.id}; merging #{dupes.size}: #{dupes.map(&:id).join(", ")}"

deduper = ModelDeduper.new(model_class: Organization, logger: Logger.new($stdout), dry_run: false)

blocked = dupes.filter_map do |dupe|
  gaps = deduper.unhandled_references(dupe)
  "##{dupe.id}: #{gaps.map { |g| "#{g[:table]}.#{g[:column]}" }.join(", ")}" if gaps.any?
end
abort "Unhandled references, aborting:\n#{blocked.join("\n")}" if blocked.any?

dupes.each do |dupe|
  moved = deduper.reassignment_counts(dupe)
  puts "##{dupe.id} → ##{keep.id} #{moved.presence || "(nothing to move)"}"
end

if DRY_RUN
  puts "DRY RUN — nothing changed."
else
  ActiveRecord::Base.transaction do
    dupes.each do |dupe|
      keep.reload
      keep.filemaker_code = Organization.join_filemaker_codes(keep.filemaker_code, dupe.filemaker_code)
      signature = AddressDeduper.primary_signature(keep, dupe)
      deduper.merge(keep, dupe)
      AddressDeduper.new(keep, primary_signature: signature).call
      keep.save! if keep.changed?
    end
  end
  puts "Done. Remaining \"Delete\" orgs: #{Organization.where(name: "Delete").pluck(:id).inspect}"
end

maebeale and others added 3 commits October 14, 2026 12:06
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Duplicate organizations could be saved freely, which is why the deduper keeps
finding them. A unique index closes the gap for every create path (including
races), and the model validation turns it into a form error. The column's
case-insensitive collation means "Hope House" and "hope house " collide too.
The migration refuses to run while duplicates exist so they get merged first.

With names unique, the "two orgs share this name" branches in form
auto-processing and the resource-org backfill can no longer happen, so they go.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nothing in the app rescues the duplicate-name error, so the constant only
existed for the spec.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@maebeale
maebeale force-pushed the maebeale/organization-name-unique-index branch from dba9672 to 3d56352 Compare October 14, 2026 16:12
@jmilljr24

Copy link
Copy Markdown
Collaborator

@jmilljr24 you ok w my running this in production to dedupe the 31 "DELETE" organizations? Screenshot 2026-10-14 at 10 51 50 AM

# Merge every organization named "Delete" into the lowest-id one, the same way the
# admin dedupe page does (Dedupable#dedupe_perform + OrganizationsController#dedupe_config).
# Paste into a Rails console. Dry run by default; set DRY_RUN = false to execute.
DRY_RUN = true

orgs = Organization.where(name: "Delete").order(:id).to_a
keep, *dupes = orgs
puts "Keeping ##{keep.id}; merging #{dupes.size}: #{dupes.map(&:id).join(", ")}"

deduper = ModelDeduper.new(model_class: Organization, logger: Logger.new($stdout), dry_run: false)

blocked = dupes.filter_map do |dupe|
  gaps = deduper.unhandled_references(dupe)
  "##{dupe.id}: #{gaps.map { |g| "#{g[:table]}.#{g[:column]}" }.join(", ")}" if gaps.any?
end
abort "Unhandled references, aborting:\n#{blocked.join("\n")}" if blocked.any?

dupes.each do |dupe|
  moved = deduper.reassignment_counts(dupe)
  puts "##{dupe.id} → ##{keep.id} #{moved.presence || "(nothing to move)"}"
end

if DRY_RUN
  puts "DRY RUN — nothing changed."
else
  ActiveRecord::Base.transaction do
    dupes.each do |dupe|
      keep.reload
      keep.filemaker_code = Organization.join_filemaker_codes(keep.filemaker_code, dupe.filemaker_code)
      signature = AddressDeduper.primary_signature(keep, dupe)
      deduper.merge(keep, dupe)
      AddressDeduper.new(keep, primary_signature: signature).call
      keep.save! if keep.changed?
    end
  end
  puts "Done. Remaining \"Delete\" orgs: #{Organization.where(name: "Delete").pluck(:id).inspect}"
end

I'm 99% confident it's fine to do but its their data and I think it's worth the quick question to ask them to scan the list first before we do it on their behalf. We don't know why they labeled them "delete" even though we could assume.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants