Auto-fill org type/website/address when linking from a registration, and keep a record of it - #2140
Auto-fill org type/website/address when linking from a registration, and keep a record of it#2140maebeale wants to merge 15 commits into
Conversation
| notice = "#{flash_safe(organization.name)} #{verb}." | ||
| notice += " Saved from the form: #{saved.to_sentence}." if saved.any? | ||
| notice | ||
| end |
There was a problem hiding this comment.
🤖 From Claude: Flash messages render with html_safe, so anything registrant-typed that lands in one has to be escaped here — a form answer containing a script tag would otherwise run in the admin browser.
| zip = zip_code&.strip | ||
| return if zip.blank? | ||
| addresses.find do |address| | ||
| address.street_address.to_s.strip.casecmp?(street) && address.zip_code.to_s.strip.casecmp?(zip) |
There was a problem hiding this comment.
🤖 From Claude: This last-resort match crosses city boundaries, so it needs the ZIP as corroboration — street alone would merge two real offices on "1 Main St" in different towns. Requiring both means it only fires for a genuinely respelled city.
| updates[:city] = @city if existing.city.blank? | ||
| updates[:state] = @state if @state.present? && existing.state.blank? | ||
| updates[:street_address] = @street_address if @street_address.present? && (@overwrite || existing.street_address.blank?) | ||
| updates[:zip_code] = @zip_code if @zip_code.present? && (@overwrite || existing.zip_code.blank?) |
There was a problem hiding this comment.
🤖 From Claude: State is the one address field that can't be defaulted — Address validates its presence — so a submission without one saves no new address instead of failing the whole registration. A matching address is still updated, and the raw answers stay on the form submission for an admin to enter by hand.
87c871b to
6ae1d1c
Compare
6ae1d1c to
139d20c
Compare
|
@jmilljr24 this was on the spreadsheet: "Can the organization type and website url be automatically populated when we create and link the org?" |
| def form_filled_labels | ||
| Array(form_filled_fields) |
There was a problem hiding this comment.
The column name form_filled_fields was a bit confusing on first read (I assumed it was saving the filled out answers). Disregard this comment if I'm wrong but it appears the column is storing the labels and this method just ensures an empty array is returned if the column is null.
Could the column be named form_filled_labels to be more clear its purpose? Then this method can still be called to return the empty array vs nil.
| def form_filled_labels | |
| Array(form_filled_fields) | |
| def form_filled_labels | |
| Array(super) |
There was a problem hiding this comment.
🤖 From Claude: You read it right, and the rename is in (7663a6cb9) — the column stored the names of what changed ("website", "ZIP on the Austin work address"), never the submitted answers, which stay on the form submission. It is now form_filled_labels, and the method survives as an override of the generated reader (Array(super)), so a link the form never filled still answers [] while the column itself stays NULL.
One thing worth a look: I did it as a separate rename_column migration rather than editing the original add_column, since that one has already run in dev DBs on this branch. If you would rather this ship as a single clean migration, say the word and I will fold it into the original.
…a registration The admin "create and link org" flow created the org with only name + Active status, so orgs made this way started with blank type/website even though the registration submission carried those answers — unlike the self-service registration path, which syncs them. Extract the type/website population out of PublicRegistration into a shared OrganizationServices::SyncProfile and call it from create_organization with overwrite: false, so new orgs get seeded and existing orgs only have blanks filled (never clobbering curated values). Address + the affiliation-to-address link were already handled by UpsertAddress + CreateFromRegistration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the fill-blanks profile sync to the select-existing org path (not just
create-and-link), and make both surfaces report what happened so an admin can
reconcile by hand:
- SyncProfile now returns a Result (filled columns + conflicts) and delegates
conflict detection to a new read-only OrganizationServices::ProfileDiff.
- Both link actions set a flash notice ("Saved from the form: type, website,
work address") and, when a submitted answer differs from a value already on
the org, a flash warning naming the kept-vs-form values.
- The linking page shows a persistent per-org note flagging type/website
answers that differ from the org's saved profile (survives the transient
flash), so the discrepancy stays visible until resolved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the linking-flow discrepancy reporting to the work address and make the address fill-blanks (like type/website) so a conflicting saved address is kept and surfaced rather than silently overwritten: - ProfileDiff now also compares the submitted street/ZIP/country against the org's same-city/state address; ProfileDiff is the single source for the flash warning and the persistent per-org note (SyncProfile no longer computes conflicts, just reports what it filled). - UpsertAddress gains overwrite: (admin flow passes false → fills only blank fields; public flow keeps the default overwrite). - When the submission carried no address, link the affiliation to the org's sole address so it's still anchored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…duplicating When a submission reuses an existing address's street (in the same city) but reformats the state or adds a country we didn't have, update that address instead of spawning a duplicate. - Add OrganizationServices::AddressMatcher: same city, prefer a state match, fall back to a same-street match. Used by both UpsertAddress and ProfileDiff so they target the same record. - UpsertAddress fills a blank country/zip/street per the overwrite flag and treats state as fill-only (never flips a state already on file). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flash messages render with html_safe, so the form answers folded into the
org-linking warning reached admins as raw HTML — a registrant could put a
script tag in their agency website and have it run in an admin's session.
The linking notice also claimed "work address" from the mere presence of a
submitted city, so it could credit an address save while the warning beside it
said those same fields were not applied. UpsertAddress now reports what it
actually wrote (created / which fields changed) and the notice names the city.
Matching gains a last-resort street + ZIP fallback across cities so a respelled
city ("St. Louis" / "Saint Louis") updates the address on file instead of
duplicating it; city joins state as fill-only, so the saved value is kept and
the difference is surfaced for an admin to reconcile.
Memoize the submission entries the linking actions read half a dozen times per
request: 66 -> 41 queries for one select_organization.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
addresses.street_address and zip_code are NOT NULL with no default, so a registrant who named an org and gave a city but skipped the street or ZIP took down the whole public registration with a NotNullViolation — and the admin linking action with it. Store those blank instead. State can't be papered over the same way (Address validates it), so a submission without one now saves no new address rather than failing the registration over an optional answer. A matching address is still updated, the answers remain on the form submission, and the linking page still shows them for an admin to enter by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Linking an org that already exists can't be assumed to be the org the registrant wrote about — an admin also links extra orgs by hand — so filling its blank type/website from the form wrote one org's answers onto another. Seed only an org we create from the submitted name, and report what an existing org doesn't carry (blank as well as conflicting) so the admin enters it deliberately. Scope the flash warning and the per-card note the same way: to the submission that names the org, falling back to the sole submission only when there's exactly one linked org, which still covers a typo'd name resolved to a differently-spelled record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Filling an org's blanks from the form is the useful behavior, but the flash saying so is gone by the next page load, leaving no trace that the org's website/type/address came from a registrant rather than from curation. Persist the labels on the registration-org link so the linking page keeps showing them, and do it from the public flow too, which is where most of these fills actually happen. An org created from the submission records nothing: everything on it came from the form, so there is no change to flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Linking an extra organization by hand no longer stamps it with the title the registrant typed about a different org, matching how the profile and address answers are already attributed per org. Also pick the same-street address when an org has several in one city (state alone was rewriting the wrong office into a duplicate), and report a profile column as filled only when the write actually changed it, so a re-registration with unchanged answers stops leaving a permanent "filled from the form" note on the linking page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Browsers don't trim text inputs, so a registrant's padded street/ZIP was stored verbatim. ProfileDiff stripped only the submitted side, so the very next comparison flagged the address as differing from the answer it came from — the linking page showed a persistent "not applied" note, and the link flashed the same warning, for an address that was applied. Trim on write in UpsertAddress, and strip both sides in ProfileDiff so legacy padded rows compare cleanly too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
form_filled_labels and record_form_fills were only exercised through the request and public-registration specs, so the merge/de-dup contract and the skip-the-write guard weren't pinned anywhere directly. Each example is mutation-checked: dropping the guard, replacing instead of merging, dropping uniq, or returning the raw column each fail at least one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The linking page re-derived which submission described which org on every request, so an org linked under a name the registrant didn't type lost its discrepancy note as soon as a second org was linked — the sole-submission fallback that paired them stops applying at two. Recording the pairing when the link is made keeps the note for as long as the link exists, and also survives an admin renaming the org afterwards.
Pinning is what keeps the linking page's discrepancy note alive, so it can't be conditioned on the submission having written something: an org whose every answer conflicts is written nothing, and it's the one whose note matters most. Same for an org the submission created — it skips the 'filled from the form' note (no prior value to change) but is pinned like any other.
0c868f0 to
cfaf100
Compare
form_filled_fields read as though it stored the registrant's submitted values; it stores the names of what changed. Renaming it to form_filled_labels lets the reader keep its Array() nil-guard by overriding the generated one, so callers still get [] from a link the form never filled.
🤖 suggested review level: 5 Inspect 🔬 four POROs, shared controller helpers, a new column, one XSS fix, and address-matching semantics that decide when two saved addresses are the same place
Why
When an admin links an org to a registration, the submitted org profile answers (type, website, address) weren't applied — and there was no signal about what happened or whether the form's answers matched the org. Admins couldn't tell that a form said "School" while the org is saved as "501c3", or that the submitted address differs from the one on file.
What
Both link paths — Create and link and Link existing (
{create,select}_organization):agency_type,website_url, and the work address (street/city/state/ZIP/country) from the submission — never clobbering curated values."Acme linked. Saved from the form: type, website, and work address in Austin.", or"ZIP and country on the Austin work address"when an existing one was filled, or nothing at all when the submission changed nothing.event_registration_organizations.form_filled_fields, written by the public flow too — that's where most fills happen), and the type/website/address answers that differ from the org's saved profile.Semantics
submission_entry_for): the submission whose typed org name matches the org, else — only when there's exactly one submission and one linked org — that sole entry, which covers a registrant who named no org and an admin resolving a typo'd "Acme Inc" to the saved "Acme Corporation". An extra org an admin links by hand matches nothing, so another org's answers are never written onto it or reported against it.overwrite:flags onSyncProfile/UpsertAddress).AddressMatcher) tries same city + state, then same city + street, then — across cities — same street + same ZIP. The last one reunites an address whose city was respelled between submissions ("St. Louis" / "Saint Louis") without merging two real offices that share a street name in different towns. City and state on file are never flipped: the saved value stays and the difference is flagged for an admin.Security
Flash messages render with
html_safe, so registrant-typed form answers folded into the linking warning reached admins as raw HTML. All dynamic values in the notice and warning are now escaped.New / changed
event_registration_organizations.form_filled_fields(json) +EventRegistrationOrganization#form_filled_labels/#record_form_fills— persistent provenance for what a submission wrote onto an org.OrganizationServices::SyncProfile— fill-blanks type/website sync; returns filled labels. Extracted fromPublicRegistration.OrganizationServices::ProfileDiff— read-only comparison →Discrepancylist (type + website + address).OrganizationServices::AddressMatcher— shared street/ZIP-aware address matcher.OrganizationServices::UpsertAddress— gainsoverwrite:, matcher-based lookup, and aResult(address,created,filled,saved_label) so callers report what was actually saved.The linking actions memoize the form-submission entries they read repeatedly: 66 → 41 queries per
select_organization.Tests
SyncProfile,ProfileDiff(incl. address + city),AddressMatcher(cross-city ZIP fallback, and that a differing ZIP does not merge), andUpsertAddress(overwrite: false, dedup, whatfilledreports, skipped street/ZIP/state).PublicRegistrationspec: a registrant's answers are recorded on the registration's org link.EventRegistrationOrganizationmodel spec:form_filled_labels/record_form_fills— the merge-and-de-dup contract, and that a resubmission of what's already recorded issues no write.Anything else to add?
addresses.street_addressandzip_codeareNOT NULLwith no default, andcity/statealso have presence validations — but the registration form's address answers are individually optional. A registrant who named an org and gave a city but skipped the ZIP took down the whole public registration with aNotNullViolation(and the admin linking action with it). This predated the branch; fixed here since the flow is being reworked anyway:🤖 Generated with Claude Code