Skip to content

Add a registration deduper + fix polymorphic reassignment in the deduper engine - #2467

Merged
maebeale merged 4 commits into
mainfrom
maebeale/registration-deduper
Sep 1, 2026
Merged

Add a registration deduper + fix polymorphic reassignment in the deduper engine#2467
maebeale merged 4 commits into
mainfrom
maebeale/registration-deduper

Conversation

@maebeale

@maebeale maebeale commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

🤖 suggested review level: 5 Inspect 🔬 new feature on shared machinery plus a correctness fix to ModelDeduper that changes merge behavior for all five deduper models

Lets admins find and merge duplicate registrations — the same person signed up for one event under two different people records (often a re-typed name) — without a developer. Along the way it fixes a latent data-corruption bug in the shared merge engine.

Registration deduper

  • The (registrant_id, event_id) unique index blocks one person registering for the same event twice, so a duplicate registration only happens when a real person is registered under two different Person records. This surfaces and consolidates those.
  • Wired into the existing config-driven Dedupable deduper (same as people/orgs/workshops) — no new merge engine, views, policy, or Stimulus.
  • EventRegistrationServices::DuplicateFinder — clusters same-event registrations whose registrants share a name (nickname/legal-variant aware), email/email_2, or FileMaker code; flags mismatched FileMaker codes as a caution.
  • Two entry points (admins only): a "Dedupe" link on the global Event registrations index, and a "Dedupe registrations" bulk action on an event's registrants page that scopes the suggested duplicates to that event and returns there.
  • Non-blocking preview note: merging combines the registrations only — the two people stay separate. Merging across differently-spelled registrants is intentional (that's the common cause of the duplicate).

Engine fix (affects all deduper models)

  • merge_join reassigned a polymorphic has_many … as: child (allocations, comments, bookmarks) by foreign-key id alone, so it could move — and corrupt — another type's rows that happened to share the deleted record's id.
  • Now every polymorphic join is scoped by its *_type column, so a merge only ever touches this model's own rows. Counts/preview/merge all go through one join_references helper.
  • Regression test added (model_deduper_spec): a same-id row of a different type is left untouched.

Notes for the reviewer

  • Registration's financial allocations and comments now combine onto the kept registration correctly (the reason the fix was needed).
  • Scholarships and CE registrations carry onto the kept registration; a new after_merge hook re-credits any moved scholarship to the kept registrant so its recipient matches its allocation (they differ only because a duplicate is a second registrant record). Runs inside the merge transaction.
  • ModelDeduper's unhandled_references safety net still blocks (and explains) any child it genuinely can't move.
  • Specs: finder unit spec + an Event registrations section in dedupable_spec.rb (surfacing, preview, merge/reassign, authorization) + the engine regression test.
  • Added a Features & tips entry (registration area).

🤖 Generated with Claude Code

@maebeale
maebeale marked this pull request as ready for review August 31, 2026 21:20
@maebeale maebeale changed the title Add a registration deduper (shared Dedupable pattern) Add a registration deduper + fix polymorphic reassignment in the deduper engine Aug 31, 2026
# A polymorphic `has_many … as:` (allocations, comments) is scoped by its
# `*_type` column too, so a merge only moves this model's own rows and can't
# steal another type's rows that happen to share the deleted record's id.
type_column = assoc.type.to_s if assoc.options[:as]

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: Load-bearing behavior change for all five deduper models, not just registrations: polymorphic has_many … as: children (allocations, comments, bookmarks) were previously reassigned by FK id alone. This adds the *_type scope so a merge can only touch this model's own rows — worth a close look since it changes existing people/org/workshop merges too.

@maebeale

Copy link
Copy Markdown
Collaborator Author

🤖 From Claude: What happens to associated records on a registration merge (D = deleted, K = kept)

Short version: nothing with money attached is lost — it all reassigns to K.

Everything moves to the kept registration

  • Scholarships — linked through their allocation (Scholarship has_one :allocation, as: :source; the allocation's allocatable is the registration). The merge repoints that allocation to K, so the scholarship record is untouched and now funds K. No scholarship is destroyed.
  • CE registrationscontinuing_education_registrations.event_registration_id has no unique index, so every CE reg on D moves to K wholesale. Their own allocations (payments) and comments hang off the CE reg, so they ride along. Even if K and D each have a CE reg for the same license, both survive on K — nothing is cascade-deleted.
  • Payments — payments are the source of allocations, never the allocatable. The merge only moves the allocatable side, so payment records are untouched.
  • Attendance times, affiliations, comments — plain moves to K (no unique index on the FK to collapse anything).

Only exact duplicates collapse (no money involved)

  • Checklist completions — unique [event_registration_id, step]: a step both had marked stays marked once on K.
  • Organization links — unique [event_registration_id, organization_id]: a shared org link stays once on K (the org itself is fine).

One caveat — cross-registrant scholarship mismatch
Because a registration duplicate only exists when the two registrations have different registrants, a moved scholarship ends up with its recipient still pointing at D's person while its allocation now sits on K (a different person). The merge itself won't complain (it moves the allocation with update_all, skipping validations), but Scholarship#recipient_must_match_allocation_registrant would fail the next time someone edits and saves that scholarship. Merging the two people in the People deduper realigns the recipient and clears it — which is why the preview nudges you to do that too.

@maebeale
maebeale requested a review from jmilljr24 August 31, 2026 21:38
@maebeale

Copy link
Copy Markdown
Collaborator Author

🤖 From Claude: Open question — should the cross-registrant scholarship mismatch be fixed automatically?

When merging registrations with different registrants, I could re-point any moved scholarship's recipient to the kept registration's registrant so there's no lingering mismatch — a small addition to the merge_keeper/finder path. That would make the registration merge self-consistent on its own, without requiring the People merge as a follow-up.

Trade-off: it silently changes who a scholarship is credited to, which the People merge would otherwise do explicitly. Happy to implement it either way — flagging the decision.

@maebeale maebeale changed the title Add a registration deduper + fix polymorphic reassignment in the deduper engine JM: Add a registration deduper + fix polymorphic reassignment in the deduper engine Aug 31, 2026
@maebeale
maebeale force-pushed the maebeale/registration-deduper branch from cdbde5e to 13f1635 Compare August 31, 2026 21:41
maebeale and others added 2 commits August 31, 2026 23:23
Facilitators end up with the same person registered for one event under
two different people records (a duplicate person, a re-typed sign-up).
Wire EventRegistration into the existing config-driven deduper so admins
can find and merge those without a developer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merging reassigned a polymorphic `has_many … as:` child (allocations,
comments, bookmarks) by foreign-key id alone, so it could move — and
corrupt — another type's rows that happened to share the deleted
record's id. Scope every polymorphic join by its `*_type` column so a
merge only ever touches this model's own rows.

Surfaced by the new registration deduper: EventRegistration's financial
allocations and comments are both polymorphic children that must combine
onto the kept registration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@maebeale
maebeale force-pushed the maebeale/registration-deduper branch from 13f1635 to e996fea Compare September 1, 2026 03:24

@jmilljr24 jmilljr24 left a comment

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.

This worked really well!

Two comments, questions.

  1. Is there an intended way to get to the deduper from the event management pages? I only found the link from event-reg index which isn't the primary flow for events. I wasn't sure how surfaced you wanted this feature.
  2. I could be mistaken but it didn't seem like form submissions were carried over from the deleted. Could be intentional, just didn't want that to slip through if the intent was to keep them.

@maebeale

maebeale commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author
  1. adding, ty!
  2. i've got a followup pr related to form submission handling.

Justin noted the deduper was only reachable from the global registrations
index, not the event-management flow admins actually work in. Add a
"Dedupe registrations" bulk action on the event registrants page that
scopes the suggested duplicates to that event and returns the eyebrow to
its registrants list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@maebeale

maebeale commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 From Claude: Thanks Justin — both answered:

1. Getting to it from event management — good call, it was only on the global registrations index. Just pushed a "Dedupe registrations" entry to the Bulk actions menu on an event's registrants page. From there it's scoped to that one event (the suggested-duplicate groups only show that event's registrations) and the eyebrow returns to the event's registrants list. The global index link stays for the cross-event view.

2. Form submissions — intentional, and nothing is lost. A FormSubmission belongs to the person + event + form; it has no event_registration_id, so it was never owned by the registration row being deleted. It stays on the registrant and consolidates when you merge the two people in the People deduper (the same follow-up the preview nudges you toward for scholarships). The one loose end is the soft link some submissions keep in metadata.linked_registration_ids (a JSON array, not an FK) — that can dangle at the deleted registration's id after a merge. Happy to have the merge rewrite those to the kept registration if you'd like it tidied up; flagging rather than silently expanding scope.

A registration merge already carries the deleted reg's scholarship and CE
registration onto the keeper, but a scholarship kept crediting the deleted
registrant — so its recipient no longer matched its allocation (the two
registrations have different registrants by definition). Add an after_merge
hook to Dedupable and use it to re-credit any scholarship now on the keeper
to the keeper's registrant. CE delegates its registrant to the registration,
so it follows automatically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@maebeale
maebeale merged commit 793cc64 into main Sep 1, 2026
3 checks passed
@maebeale
maebeale deleted the maebeale/registration-deduper branch September 1, 2026 15:33
@maebeale maebeale changed the title JM: Add a registration deduper + fix polymorphic reassignment in the deduper engine Add a registration deduper + fix polymorphic reassignment in the deduper engine Sep 1, 2026
@maebeale

maebeale commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

@jmilljr24 just an update -- i'm not going to submit the 2nd pr bc i don't think it's needed.

Submissions are not tied to Registrations, they are just tied to the Person, so a Submission is only shown on the Registrants table if its parent Person is the Registration's parent Person.

If someone dedupes registrations that have two different parent people, then the new single reg will still only show one submission. Deduping those two people will then move the form into that reg's row for visibility.

@jmilljr24

Copy link
Copy Markdown
Collaborator

@jmilljr24 just an update -- i'm not going to submit the 2nd pr bc i don't think it's needed.

Submissions are not tied to Registrations, they are just tied to the Person, so a Submission is only shown on the Registrants table if its parent Person is the Registration's parent Person.

If someone dedupes registrations that have two different parent people, then the new single reg will still only show one submission. Deduping those two people will then move the form into that reg's row for visibility.

Sounds good. Thanks for checking on it.

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