From 5d17c924877b57d8e49d91328306f8627c9b50df Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 21 Aug 2026 09:52:52 +0200 Subject: [PATCH 1/5] feat(tasks): add temporary rake task to detect and merge codebar auth duplicate members --- Makefile | 9 + lib/tasks/merge_duplicate_members.rake | 417 +++++++++++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 lib/tasks/merge_duplicate_members.rake diff --git a/Makefile b/Makefile index ba910c5c4..35e06fdab 100644 --- a/Makefile +++ b/Makefile @@ -90,5 +90,14 @@ test: ## Run the test suite in parallel bundle exec rake parallel:setup bundle exec parallel_rspec spec/ -n 3 +detect_duplicate_members: ## Detect members duplicated by the codebar auth flow + DB_NAME=$(DUMP_DB) bundle exec rake member:duplicates:detect + +fix_duplicate_members: ## Dry-run merge of duplicate members (set APPLY=1 to execute) + DB_NAME=$(DUMP_DB) bundle exec rake member:duplicates:fix + +verify_duplicate_members: ## Verify no codebar-auth duplicate members remain + DB_NAME=$(DUMP_DB) bundle exec rake member:duplicates:verify + check: ## Run setup checks bundle exec rake setup:check diff --git a/lib/tasks/merge_duplicate_members.rake b/lib/tasks/merge_duplicate_members.rake new file mode 100644 index 000000000..cc5690704 --- /dev/null +++ b/lib/tasks/merge_duplicate_members.rake @@ -0,0 +1,417 @@ +# frozen_string_literal: true +# rubocop:disable all + +# Temporary task to detect and merge members created as duplicates by the +# codebar auth GitHub sign-in flow (codebar/planner#2805). +# +# Usage: +# Local dump: +# make detect_duplicate_members +# make fix_duplicate_members +# make fix_duplicate_members APPLY=1 +# +# Production: +# heroku run rake member:duplicates:detect --app codebar-production +# heroku run rake member:duplicates:fix APPLY=1 --app codebar-production +# +# The task switches to the local dump when DB_NAME is set; otherwise it uses the +# current Rails environment's database (e.g. Heroku DATABASE_URL). + +namespace :member do + namespace :duplicates do + desc 'Detect duplicate members created by the codebar auth flow' + task detect: :environment do + MergeDuplicateMembers.establish_connection! + duplicates = MergeDuplicateMembers::Detector.new.call + MergeDuplicateMembers::Reporter.new(duplicates).print + end + + desc 'Merge duplicate members into their originals (set APPLY=1 to execute)' + task fix: :environment do + MergeDuplicateMembers.establish_connection! + dry_run = ENV['APPLY'] != '1' + duplicates = MergeDuplicateMembers::Detector.new.call + + if duplicates.empty? + puts 'No duplicate members detected.' + next + end + + puts dry_run ? 'DRY RUN — no changes will be made.' : 'APPLYING merges...' + puts + + duplicates.each do |pair| + MergeDuplicateMembers::Merger.new(pair, dry_run: dry_run).call + puts + end + + puts dry_run ? "Run with APPLY=1 to execute these #{duplicates.size} merges." : 'Done.' + end + + desc 'Verify no duplicate members remain after merging' + task verify: :environment do + MergeDuplicateMembers.establish_connection! + duplicates = MergeDuplicateMembers::Detector.new.call + + if duplicates.empty? + puts 'PASS: no duplicate members detected.' + else + puts "FAIL: #{duplicates.size} duplicate member(s) still detected:" + MergeDuplicateMembers::Reporter.new(duplicates).print + exit 1 + end + end + end +end + +module MergeDuplicateMembers + # The merge of the /auth/codebar sign-in flow into planner. + # Duplicates cannot have been created before this point. + CUTOFF_TIME = Time.utc(2026, 8, 6, 15, 25, 11) + + # Email domains that are too generic for the domain-similarity heuristic. + COMMON_DOMAINS = %w[ + gmail.com googlemail.com outlook.com hotmail.com live.com yahoo.com + ymail.com icloud.com me.com mac.com aol.com qq.com 163.com 126.com + foxmail.com protonmail.com proton.me + ].freeze + + # Hard-coded merges for cases the heuristics cannot safely detect. + # Format: [duplicate_member_id, original_member_id] + MANUAL_OVERRIDES = [ + [31_257, 27_714] # Lou Alldis's third account + ].freeze + + class << self + def establish_connection! + return unless ENV['DB_NAME'].present? + + ActiveRecord::Base.establish_connection( + adapter: 'postgresql', + host: ENV.fetch('DB_HOST', 'localhost'), + port: ENV.fetch('DB_PORT', 5432), + database: ENV.fetch('DB_NAME'), + username: ENV.fetch('DB_USER', ''), + password: ENV.fetch('POSTGRES_PASSWORD', '') + ) + end + end + + class Detector + def call + detected = (name_matches + email_matches + firstname_uid_surname_matches + domain_local_matches) + .group_by(&:dup_member_id) + .transform_values { |matches| best_match(matches) } + + apply_manual_overrides(detected) + detected.values.reject { |m| already_merged?(m) }.sort_by { |m| -m.dup_member.id } + end + + private + + def already_merged?(match) + dup = match.dup_member + dup.email.start_with?('duplicate.') || + !AuthService.exists?(member_id: dup.id, provider: 'codebar') + end + + private + + def codebar_members + Member.joins(:auth_services) + .where(auth_services: { provider: 'codebar' }) + .where('members.created_at > ?', CUTOFF_TIME) + end + + def name_matches + codebar_members + .joins(<<~SQL) + JOIN members originals + ON LOWER(TRIM(members.name)) = LOWER(TRIM(originals.name)) + AND LOWER(TRIM(members.surname)) = LOWER(TRIM(originals.surname)) + AND NULLIF(TRIM(members.name), '') IS NOT NULL + AND NULLIF(TRIM(members.surname), '') IS NOT NULL + SQL + .joins("JOIN auth_services original_auth ON original_auth.member_id = originals.id AND original_auth.provider = 'github'") + .where('originals.created_at < members.created_at') + .select("members.id AS dup_member_id, originals.id AS original_member_id, 'name+surname' AS strategy") + .map { |r| Match.new(r.dup_member_id, r.original_member_id, r.strategy) } + end + + def email_matches + codebar_members + .joins('JOIN members originals ON LOWER(TRIM(members.email)) = LOWER(TRIM(originals.email)) AND originals.id != members.id') + .joins("JOIN auth_services original_auth ON original_auth.member_id = originals.id AND original_auth.provider = 'github'") + .select("members.id AS dup_member_id, originals.id AS original_member_id, 'email' AS strategy") + .map { |r| Match.new(r.dup_member_id, r.original_member_id, r.strategy) } + end + + def firstname_uid_surname_matches + codebar_members + .joins('JOIN members originals ON LOWER(TRIM(members.name)) = LOWER(TRIM(originals.name))') + .joins("JOIN auth_services original_auth ON original_auth.member_id = originals.id AND original_auth.provider = 'github'") + .where('NULLIF(TRIM(members.name), \'\') IS NOT NULL') + .where("members.surname IS NULL OR TRIM(members.surname) = ''") + .where('originals.created_at < members.created_at') + .where("LOWER(auth_services.uid) LIKE '%' || LOWER(originals.surname) || '%'") + .select("members.id AS dup_member_id, originals.id AS original_member_id, 'first-name+uid-surname' AS strategy") + .map { |r| Match.new(r.dup_member_id, r.original_member_id, r.strategy) } + end + + def domain_local_matches + non_common_domains = COMMON_DOMAINS.map { |d| "'#{d}'" }.join(',') + + codebar_members + .joins('JOIN members originals ON split_part(members.email, \'@\', 2) ILIKE split_part(originals.email, \'@\', 2)') + .joins("JOIN auth_services original_auth ON original_auth.member_id = originals.id AND original_auth.provider = 'github'") + .where('originals.id != members.id') + .where('originals.created_at < members.created_at') + .where(Arel.sql("split_part(members.email, '@', 2) NOT IN (#{non_common_domains})")) + .where(<<~SQL) + split_part(members.email, '@', 1) ILIKE '%' || split_part(originals.email, '@', 1) || '%' + OR split_part(originals.email, '@', 1) ILIKE '%' || split_part(members.email, '@', 1) || '%' + SQL + .where(<<~SQL) + (SELECT COUNT(*) FROM members_permissions mp WHERE mp.member_id = originals.id) > 0 + OR (SELECT COUNT(*) FROM members_roles mr WHERE mr.member_id = originals.id) > 0 + OR (SELECT COUNT(*) FROM subscriptions s WHERE s.member_id = originals.id) > 0 + OR (SELECT COUNT(*) FROM workshop_invitations wi WHERE wi.member_id = originals.id) > 0 + SQL + .select("members.id AS dup_member_id, originals.id AS original_member_id, 'domain+local-part' AS strategy") + .map { |r| Match.new(r.dup_member_id, r.original_member_id, r.strategy) } + end + + def best_match(matches) + matches.max_by do |m| + orig = m.original_member + [orig.roles.count, orig.subscriptions.count, orig.workshop_invitations.count] + end + end + + def apply_manual_overrides(detected) + MANUAL_OVERRIDES.each do |dup_id, orig_id| + next unless Member.exists?(dup_id) && Member.exists?(orig_id) + + detected[dup_id] = Match.new(dup_id, orig_id, 'manual') + end + end + end + + class Match + attr_reader :dup_member_id, :original_member_id, :strategies + + def initialize(dup_member_id, original_member_id, strategy) + @dup_member_id = dup_member_id + @original_member_id = original_member_id + @strategies = Set[strategy] + end + + def dup_member + @dup_member ||= Member.find(@dup_member_id) + end + + def original_member + @original_member ||= Member.find(@original_member_id) + end + + def merge_strategies + @strategies.to_a.sort.join(', ') + end + end + + class Reporter + def initialize(matches) + @matches = matches + end + + def print + puts format('%-10s %-25s %-35s %-10s %-35s %-25s', 'Dup id', 'Dup name', 'Dup email', 'Orig id', 'Original email', 'Strategies') + puts '-' * 150 + @matches.each do |m| + dup = m.dup_member + orig = m.original_member + name = [dup.name, dup.surname].compact.join(' ') + puts format('%-10s %-25s %-35s %-10s %-35s %-25s', + dup.id, name.truncate(25), dup.email.truncate(35), + orig.id, orig.email.truncate(35), m.merge_strategies) + end + end + end + + class Merger + def initialize(match, dry_run: true) + @match = match + @dry_run = dry_run + end + + def call + dup = @match.dup_member + orig = @match.original_member + + puts "#{dry_run_label}Merging member #{dup.id} (#{dup.email}) into #{orig.id} (#{orig.email})" + + if already_merged?(dup, orig) + puts ' Already merged; skipping.' + return + end + + within_transaction do + move_auth_service(dup, orig) + merge_subscriptions(dup, orig) + merge_workshop_invitations(dup, orig) + merge_invitations(dup, orig) + merge_meeting_invitations(dup, orig) + merge_member_notes(dup, orig) + merge_bans(dup, orig) + merge_eligibility_inquiries(dup, orig) + merge_attendance_warnings(dup, orig) + merge_member_email_deliveries(dup, orig) + merge_testimonials(dup, orig) + merge_feedback_requests(dup, orig) + update_invitation_logs(dup, orig) + deactivate_duplicate(dup, orig) + end + + puts " #{dry_run_label}Done." + end + + private + + def dry_run_label + @dry_run ? '[DRY RUN] ' : '' + end + + def already_merged?(dup, orig) + !Member.exists?(dup.id) || + dup.email.start_with?('duplicate.') || + (AuthService.exists?(member_id: orig.id, provider: 'codebar') && + !AuthService.exists?(member_id: dup.id, provider: 'codebar')) + end + + def within_transaction + if @dry_run + ActiveRecord::Base.transaction do + yield + raise ActiveRecord::Rollback + end + else + ActiveRecord::Base.transaction { yield } + end + end + + def move_auth_service(dup, orig) + auth = AuthService.find_by(member_id: dup.id, provider: 'codebar') + return unless auth + + puts " #{dry_run_label}Moving codebar auth service (#{auth.uid}) to original" + auth.update!(member_id: orig.id) + end + + def merge_subscriptions(dup, orig) + dup.subscriptions.find_each do |sub| + if orig.subscriptions.exists?(group_id: sub.group_id) + puts " #{dry_run_label}Deleting duplicate subscription for group #{sub.group_id}" + sub.destroy! + else + puts " #{dry_run_label}Moving subscription for group #{sub.group_id}" + sub.update!(member_id: orig.id) + end + end + end + + def merge_workshop_invitations(dup, orig) + dup.workshop_invitations.find_each do |wi| + if orig.workshop_invitations.exists?(workshop_id: wi.workshop_id, role: wi.role) + puts " #{dry_run_label}Deleting duplicate workshop invitation #{wi.workshop_id}/#{wi.role}" + wi.destroy! + else + puts " #{dry_run_label}Moving workshop invitation #{wi.workshop_id}/#{wi.role}" + wi.update!(member_id: orig.id) + end + end + end + + def merge_invitations(dup, orig) + dup.invitations.find_each do |inv| + if orig.invitations.exists?(event_id: inv.event_id) + puts " #{dry_run_label}Deleting duplicate invitation for event #{inv.event_id}" + inv.destroy! + else + puts " #{dry_run_label}Moving invitation for event #{inv.event_id}" + inv.update!(member_id: orig.id) + end + end + end + + def merge_meeting_invitations(dup, orig) + dup.meeting_invitations.find_each do |mi| + if orig.meeting_invitations.exists?(meeting_id: mi.meeting_id) + puts " #{dry_run_label}Deleting duplicate meeting invitation #{mi.meeting_id}" + mi.destroy! + else + puts " #{dry_run_label}Moving meeting invitation #{mi.meeting_id}" + mi.update!(member_id: orig.id) + end + end + end + + + + def merge_member_notes(dup, orig) + dup.member_notes.update_all(member_id: orig.id) + end + + def merge_bans(dup, orig) + dup.bans.update_all(member_id: orig.id) + end + + def merge_eligibility_inquiries(dup, orig) + dup.eligibility_inquiries.update_all(member_id: orig.id) + end + + def merge_attendance_warnings(dup, orig) + dup.attendance_warnings.update_all(member_id: orig.id) + end + + def merge_member_email_deliveries(dup, orig) + dup.member_email_deliveries.update_all(member_id: orig.id) + end + + def merge_testimonials(dup, orig) + return unless defined?(Testimonial) + + count = Testimonial.where(member_id: dup.id).update_all(member_id: orig.id) + puts " #{dry_run_label}Moved #{count} testimonial(s)" if count.positive? + end + + def merge_feedback_requests(dup, orig) + count = FeedbackRequest.where(member_id: dup.id).update_all(member_id: orig.id) + puts " #{dry_run_label}Moved #{count} feedback request(s)" if count.positive? + end + + def update_invitation_logs(dup, orig) + count = InvitationLog.where(initiator_id: dup.id).update_all(initiator_id: orig.id) + puts " #{dry_run_label}Updated #{count} invitation log initiator(s)" if count.positive? + end + + def deactivate_duplicate(dup, orig) + dup.auth_services.destroy_all + dup.roles.clear + + new_email = "duplicate.#{dup.id}.merged-into.#{orig.id}@codebar.io" + puts " #{dry_run_label}Renaming duplicate email to #{new_email}" + dup.update_columns(email: new_email) + + note = "Duplicate of member #{orig.id} (#{orig.email}). Merged #{Time.zone.now.iso8601}. Login disabled." + dup.member_notes.create!(note: note, author_id: orig.id) + end + end +end + +class String + def truncate(max_length) + length > max_length ? "#{self[0...max_length - 1]}…" : self + end +end + +# rubocop:enable all From b22a3e6a440946b1ffc0983d2845d2bec536749a Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 21 Aug 2026 10:31:58 +0200 Subject: [PATCH 2/5] fix(duplicates): add created_at guard, remove String monkey-patch, add RunLogger --- .gitignore | 1 + lib/tasks/merge_duplicate_members.rake | 71 ++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index e30b6712d..3e5585fdd 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,4 @@ yarn-debug.log* # Worktrees .worktrees/ +/log/merge_duplicate_members/ diff --git a/lib/tasks/merge_duplicate_members.rake b/lib/tasks/merge_duplicate_members.rake index cc5690704..e86f13338 100644 --- a/lib/tasks/merge_duplicate_members.rake +++ b/lib/tasks/merge_duplicate_members.rake @@ -40,12 +40,22 @@ namespace :member do puts dry_run ? 'DRY RUN — no changes will be made.' : 'APPLYING merges...' puts + logger = MergeDuplicateMembers::RunLogger.new(dry_run: dry_run) + duplicates.each do |pair| - MergeDuplicateMembers::Merger.new(pair, dry_run: dry_run).call + begin + MergeDuplicateMembers::Merger.new(pair, dry_run: dry_run).call + logger.record_merge(dup_id: pair.dup_member_id, orig_id: pair.original_member_id, strategies: pair.merge_strategies, status: 'success') + rescue StandardError => e + logger.record_error(e) + raise + end puts end + log_path = logger.flush puts dry_run ? "Run with APPLY=1 to execute these #{duplicates.size} merges." : 'Done.' + puts "Log: #{log_path}" if log_path end desc 'Verify no duplicate members remain after merging' @@ -95,6 +105,10 @@ module MergeDuplicateMembers password: ENV.fetch('POSTGRES_PASSWORD', '') ) end + + def truncate(string, max_length) + string.length > max_length ? "#{string[0...max_length - 1]}…" : string + end end class Detector @@ -142,6 +156,7 @@ module MergeDuplicateMembers codebar_members .joins('JOIN members originals ON LOWER(TRIM(members.email)) = LOWER(TRIM(originals.email)) AND originals.id != members.id') .joins("JOIN auth_services original_auth ON original_auth.member_id = originals.id AND original_auth.provider = 'github'") + .where('originals.created_at < members.created_at') .select("members.id AS dup_member_id, originals.id AS original_member_id, 'email' AS strategy") .map { |r| Match.new(r.dup_member_id, r.original_member_id, r.strategy) } end @@ -232,12 +247,56 @@ module MergeDuplicateMembers orig = m.original_member name = [dup.name, dup.surname].compact.join(' ') puts format('%-10s %-25s %-35s %-10s %-35s %-25s', - dup.id, name.truncate(25), dup.email.truncate(35), - orig.id, orig.email.truncate(35), m.merge_strategies) + dup.id, MergeDuplicateMembers.truncate(name, 25), MergeDuplicateMembers.truncate(dup.email, 35), + orig.id, MergeDuplicateMembers.truncate(orig.email, 35), m.merge_strategies) end end end + class RunLogger + LOG_DIR = Rails.root.join('log', 'merge_duplicate_members').freeze + + def initialize(dry_run:) + @dry_run = dry_run + @started_at = Time.now.iso8601 + @merges = [] + @errors = [] + end + + def log_path + @log_path ||= LOG_DIR.join("run_#{Time.now.utc.strftime('%Y%m%dT%H%M%SZ')}.json") + end + + def record_merge(dup_id:, orig_id:, strategies:, status:) + return if @dry_run + + @merges << { dup_id: dup_id, orig_id: orig_id, strategies: strategies, status: status } + end + + def record_error(error) + return if @dry_run + + @errors << { message: error.message, backtrace: error.backtrace.first(5) } + end + + def flush + return if @dry_run || (@merges.empty? && @errors.empty?) + + entry = { + timestamp: @started_at, + dry_run: @dry_run, + environment: Rails.env, + merges: @merges, + errors: @errors, + total_merges: @merges.size + } + + FileUtils.mkdir_p(LOG_DIR) + File.write(log_path, JSON.pretty_generate(entry)) + log_path + end + end + class Merger def initialize(match, dry_run: true) @match = match @@ -408,10 +467,4 @@ module MergeDuplicateMembers end end -class String - def truncate(max_length) - length > max_length ? "#{self[0...max_length - 1]}…" : self - end -end - # rubocop:enable all From 77472c6ded1a10db1ee2d9e3c51835a53c56d88f Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 21 Aug 2026 10:31:58 +0200 Subject: [PATCH 3/5] docs: add merge_duplicate_members tool documentation --- docs/merge_duplicate_members.md | 88 +++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/merge_duplicate_members.md diff --git a/docs/merge_duplicate_members.md b/docs/merge_duplicate_members.md new file mode 100644 index 000000000..fc36ece0c --- /dev/null +++ b/docs/merge_duplicate_members.md @@ -0,0 +1,88 @@ +# Merge Duplicate Members + +Temporary tool to detect and merge duplicate members created by the `/auth/codebar` GitHub sign-in flow (codebar/planner#2805). + +## Background + +When the codebar auth app was merged into planner on 2026-08-06, members with a GitHub account whose email differed from their `auth_services` record could not be matched automatically. The codebar auth flow created new accounts instead of linking to existing ones. + +This tool finds those duplicates (by name, email, and auth UID heuristics) and merges their data into the original member. + +## Prerequisites + +A PostgreSQL dump of the production database, accessible as `codebar_production_dump`. + +## Tasks + +### Detect duplicates + +```bash +make detect_duplicate_members +``` + +Dry run — lists all duplicate pairs found, with which detection strategies matched. + +### Fix duplicates (dry run) + +```bash +make fix_duplicate_members +``` + +Shows every step the merger would take, but wraps everything in a transaction that is rolled back. No data is modified. + +### Fix duplicates (execute) + +```bash +make fix_duplicate_members APPLY=1 +``` + +Performs the merge for real inside a transaction. +A JSON log file is written to: + +``` +log/merge_duplicate_members/run_YYYYMMDDTHHMMSSZ.json +``` + +The path is printed after the run completes. + +### Verify + +```bash +make verify_duplicate_members +``` + +Re-detects duplicates and exits `0` when none remain, or `1` with the remaining pairs. + +## Detection strategies + +| Strategy | Description | +|----------|-------------| +| `name+surname` | Exact case-insensitive match on both fields | +| `email` | Exact case-insensitive match on email | +| `first-name+uid-surname` | First name matches; duplicate has no surname, but the codebar auth UID contains the original’s surname | +| `domain+local-part` | Non-generic domain; local parts overlap | + +The tool also applies hard-coded manual overrides for edge cases the heuristics cannot detect. + +## Safety properties + +- **Dry run by default** — must pass `APPLY=1` to change data. +- **Idempotent** — re-running after a successful merge reports no duplicates. +- **Deactivates, not deletes** — duplicates are renamed to `duplicate..merged-into.@codebar.io`, with all auth services and roles removed. Audit history is preserved in a `MemberNote`. +- **Logs every execution** — merge results written to a new per-run JSON file, even on failure. + +## Running in production + +```bash +heroku run rake member:duplicates:detect --app codebar-production +heroku run rake member:duplicates:fix APPLY=1 --app codebar-production +heroku run rake member:duplicates:verify --app codebar-production +``` + +## What to do if a false positive appears + +Duplicate pairs can be added to the `MANUAL_OVERRIDES` array or excluded before execution. If in doubt, err on the side of not merging — the merge does not delete records, but it does permanently move associated data and deactivate the account. + +--- + +*This is a temporary tool. Once all existing duplicates are resolved, it can be removed.* From 056909253109140acd9f14e0383ef17383372b35 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 21 Aug 2026 10:34:01 +0200 Subject: [PATCH 4/5] fix(duplicates): sort by original ID ascending, print count at end of detection output --- docs/merge_duplicate_members.md | 2 +- lib/tasks/merge_duplicate_members.rake | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/merge_duplicate_members.md b/docs/merge_duplicate_members.md index fc36ece0c..e3b99f4d4 100644 --- a/docs/merge_duplicate_members.md +++ b/docs/merge_duplicate_members.md @@ -69,7 +69,7 @@ The tool also applies hard-coded manual overrides for edge cases the heuristics - **Dry run by default** — must pass `APPLY=1` to change data. - **Idempotent** — re-running after a successful merge reports no duplicates. - **Deactivates, not deletes** — duplicates are renamed to `duplicate..merged-into.@codebar.io`, with all auth services and roles removed. Audit history is preserved in a `MemberNote`. -- **Logs every execution** — merge results written to a new per-run JSON file, even on failure. +- **Logs every execution** — merge results written to a new per-run JSON file, even on failure. The log contains only opaque member IDs (`dup_id`, `orig_id`), strategies, and status. No names, emails, UIDs, or other PII. Safe to share or attach to PRs. ## Running in production diff --git a/lib/tasks/merge_duplicate_members.rake b/lib/tasks/merge_duplicate_members.rake index e86f13338..d8b2dc6e4 100644 --- a/lib/tasks/merge_duplicate_members.rake +++ b/lib/tasks/merge_duplicate_members.rake @@ -118,7 +118,7 @@ module MergeDuplicateMembers .transform_values { |matches| best_match(matches) } apply_manual_overrides(detected) - detected.values.reject { |m| already_merged?(m) }.sort_by { |m| -m.dup_member.id } + detected.values.reject { |m| already_merged?(m) }.sort_by { |m| m.original_member.id } end private @@ -250,6 +250,7 @@ module MergeDuplicateMembers dup.id, MergeDuplicateMembers.truncate(name, 25), MergeDuplicateMembers.truncate(dup.email, 35), orig.id, MergeDuplicateMembers.truncate(orig.email, 35), m.merge_strategies) end + puts "\n#{@matches.size} duplicate member(s) detected." end end From 0c062555cf52c107803b6b0b7f96a9836ef7a701 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 21 Aug 2026 10:41:39 +0200 Subject: [PATCH 5/5] feat(duplicates): support DB_URL for direct production connection --- Makefile | 11 +++++++++++ docs/merge_duplicate_members.md | 17 +++++++++++++++++ lib/tasks/merge_duplicate_members.rake | 26 ++++++++++++++++---------- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index 35e06fdab..bddffdb37 100644 --- a/Makefile +++ b/Makefile @@ -99,5 +99,16 @@ fix_duplicate_members: ## Dry-run merge of duplicate members (set APPLY=1 to exe verify_duplicate_members: ## Verify no codebar-auth duplicate members remain DB_NAME=$(DUMP_DB) bundle exec rake member:duplicates:verify +detect_duplicate_members_production: ## Detect duplicates on production DB directly + @read -p "Connect to PRODUCTION database? [y/N] " ans && [ "$$ans" = "y" ] || exit 1 + @DB_URL=$$(heroku config:get DATABASE_URL --app=$(DUMP_APP)) bundle exec rake member:duplicates:detect + +fix_duplicate_members_production: ## Dry-run merge on production DB (APPLY=1 executes) + @read -p "This MODIFIES the PRODUCTION database. Continue? [y/N] " ans && [ "$$ans" = "y" ] || exit 1 + @DB_URL=$$(heroku config:get DATABASE_URL --app=$(DUMP_APP)) bundle exec rake member:duplicates:fix + +verify_duplicate_members_production: ## Verify no duplicates remain on production DB + @DB_URL=$$(heroku config:get DATABASE_URL --app=$(DUMP_APP)) bundle exec rake member:duplicates:verify + check: ## Run setup checks bundle exec rake setup:check diff --git a/docs/merge_duplicate_members.md b/docs/merge_duplicate_members.md index e3b99f4d4..6a45c650c 100644 --- a/docs/merge_duplicate_members.md +++ b/docs/merge_duplicate_members.md @@ -73,6 +73,23 @@ The tool also applies hard-coded manual overrides for edge cases the heuristics ## Running in production +### Option A: local execution (no deploy required) + +You can run the tool locally and connect directly to the Heroku production database using a connection string: + +```bash +make detect_duplicate_members_production # lists duplicates from production +make fix_duplicate_members_production # dry-run against production +make fix_duplicate_members_production APPLY=1 # execute on production +make verify_duplicate_members_production # verify production +``` + +These targets fetch a fresh `DATABASE_URL` from Heroku each time and pass it via `DB_URL`. A confirmation prompt guards the detect and fix targets. A loud `WARNING: Connecting to REMOTE database …` message is printed when `DB_URL` points to a non-localhost host. + +### Option B: run on Heroku dyno + +Deploy the branch first, then run on Heroku: + ```bash heroku run rake member:duplicates:detect --app codebar-production heroku run rake member:duplicates:fix APPLY=1 --app codebar-production diff --git a/lib/tasks/merge_duplicate_members.rake b/lib/tasks/merge_duplicate_members.rake index d8b2dc6e4..eceeda306 100644 --- a/lib/tasks/merge_duplicate_members.rake +++ b/lib/tasks/merge_duplicate_members.rake @@ -94,16 +94,22 @@ module MergeDuplicateMembers class << self def establish_connection! - return unless ENV['DB_NAME'].present? - - ActiveRecord::Base.establish_connection( - adapter: 'postgresql', - host: ENV.fetch('DB_HOST', 'localhost'), - port: ENV.fetch('DB_PORT', 5432), - database: ENV.fetch('DB_NAME'), - username: ENV.fetch('DB_USER', ''), - password: ENV.fetch('POSTGRES_PASSWORD', '') - ) + if ENV['DB_URL'].present? + url = URI.parse(ENV['DB_URL']) + unless %w[localhost 127.0.0.1].include?(url.host) + puts "WARNING: Connecting to REMOTE database #{url.host}" + end + ActiveRecord::Base.establish_connection(ENV['DB_URL']) + elsif ENV['DB_NAME'].present? + ActiveRecord::Base.establish_connection( + adapter: 'postgresql', + host: ENV.fetch('DB_HOST', 'localhost'), + port: ENV.fetch('DB_PORT', 5432), + database: ENV.fetch('DB_NAME'), + username: ENV.fetch('DB_USER', ''), + password: ENV.fetch('POSTGRES_PASSWORD', '') + ) + end end def truncate(string, max_length)