Skip to content

Create job + Facilitator affiliations on registration and org linking#1809

Merged
maebeale merged 8 commits into
mainfrom
maebeale/dual-org-affiliations
Jun 22, 2026
Merged

Create job + Facilitator affiliations on registration and org linking#1809
maebeale merged 8 commits into
mainfrom
maebeale/dual-org-affiliations

Conversation

@maebeale

@maebeale maebeale commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator

REVIEW NEEDED: 📖 Read — light-logic: contained changes to affiliation creation/dedupe; one new model scope that active delegates to (no behavior change today)

What is the goal of this PR and why is this important?

  • When a person registers for an event — or an admin links an organization to their registration — we should automatically record their affiliation with that org, so the program roster reflects it without manual data entry.
  • Each linking moment now creates up to two affiliations:
    • a job affiliation carrying the title the registrant typed on the form (only when they provided one), and
    • a standing "Facilitator" affiliation.

How did you approach the change?

  • Added AffiliationServices::CreateFromRegistration, a single PORO shared by every entry point so the behavior can't drift:
    • Public registration (PublicRegistration)
    • Link existing org (EventRegistrationsController#select_organization)
    • Create & link org (EventRegistrationsController#create_organization)
  • Facilitator de-dup: the "Facilitator" affiliation is skipped only when an active-or-pending affiliation the model's facilitators scope considers canonical (exactly "Facilitator", trimmed, case-sensitive) already exists with that org — a current one or one dated to a future training that hasn't started yet. An ended Facilitator affiliation does not block a new one (a returning facilitator gets a fresh second affiliation). The job affiliation is created first, so a typed title of exactly "Facilitator" suppresses the duplicate — but a facilitator-ish title like "Lead Facilitator" still gets its own standing "Facilitator" alongside it.
  • active_or_pending scope: added to Affiliation (not-inactive, not-ended; includes future-dated/pending affiliations). active now delegates to it, so existing behavior is unchanged — the scope just names the set the dedupe needs.
  • Atomicity: the job and Facilitator creates run in one transaction, so a job affiliation is never left without its Facilitator (or vice versa).
  • Start dates:
    • The job affiliation is left with no start date — we don't know when the person began that role (they may have been with the org for years before this training), and dating it to registration would misrepresent it.
    • The Facilitator affiliation starts on the first day of the training's month (falling back to the current month when an event has no start date).
  • Title source: all three flows pull the job title from the registrant's position answer on the registration form, reusing the same "primary submission" the org-linking editor displays so the applied title matches what the editor shows.
  • Vocabulary: new code refers to this as the organization position, not "agency". The legacy stored field identifiers (agency_name / agency_position) are localized behind organization-named constants on PublicRegistration pending a future form-field rename.
  • Dev seeds: registration submissions now carry a job title most (~80%) of the time, leaving roughly one in five blank, so dev data exercises both the job + Facilitator and Facilitator-only outcomes.

Anything else to add?

🤖 Generated with Claude Code

@maebeale maebeale force-pushed the maebeale/dual-org-affiliations branch from b938bc8 to 0ef8e8f Compare June 21, 2026 14:24
return unless @job_title
return if active_affiliation_with_title?(@job_title)

create_affiliation(@job_title, start_date: nil)

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: Job affiliation is created with no start date on purpose — registration ≠ when they began this role (they may have been at the org for years), so dating it to today would misrepresent it. The Facilitator affiliation does get a date (first of the training month).

def create_facilitator_affiliation
return if active_affiliation_with_title?(Affiliation::FACILITATOR_TITLE)

create_affiliation(Affiliation::FACILITATOR_TITLE, start_date: facilitator_start_date)

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: Ordering matters: the job affiliation is created first, so a typed title of exactly "Facilitator" satisfies this guard and suppresses a duplicate, while "Lead Facilitator" does not — it still gets its own standing Facilitator affiliation.

# registration form. Uses the same "primary" submission as link_organization
# (the first submission that named an org, else the first), so the title applied
# when linking matches what the editor shows.
def submitted_position(registration)

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: Rebase reconciliation with #1804: derives the title from the same "primary" submission the org-linking editor shows (via registration_submission_entries), so the applied title matches the displayed one. Replaced the old find_submitted_answer helper.

@maebeale maebeale marked this pull request as ready for review June 21, 2026 23:29
@maebeale maebeale force-pushed the maebeale/dual-org-affiliations branch from 0ef8e8f to 383c712 Compare June 21, 2026 23:36
Comment thread app/models/affiliation.rb
.where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", Date.current)
}

scope :active, -> { active_or_pending }

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: active delegates to active_or_pending so behavior is unchanged today; this just names the set the Facilitator dedupe needs. A follow-up PR will tighten active to also require the start date to have arrived (and reclassify call sites), at which point the two scopes diverge.

end

def call
ActiveRecord::Base.transaction do

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: Wrapped both creates in one transaction so a job affiliation is never persisted without its Facilitator (or vice versa) if the second create! raises.

# (future-dated) facilitator affiliations too, so registering for a second
# upcoming training doesn't mint a duplicate.
def active_or_pending_facilitator_affiliation?
@person.affiliations.active_or_pending.facilitators.where(organization: @organization).exists?

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: Checking active_or_pending (not just active) here prevents a duplicate pending Facilitator when someone registers for a second upcoming training before the first has started.

" starting #{start_month}"
"since #{start_month}"
else
"no dates"

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: Dates now always render in parens, including (no dates) when an affiliation has neither start nor end — previously an undated pill showed a bare title with no signal its dates were just unknown. starting became since to read naturally inside the parens.

<p class="font-medium text-gray-800"><%= link_to org.name, person_path(@person, anchor: "affiliations"),
target: "_blank", rel: "noopener",
title: "View #{@person.first_name.presence || @person.full_name}'s affiliations",
class: "after:absolute after:inset-0 hover:underline focus-visible:underline focus:outline-none",

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: Stretched-link pattern: the org-name anchor is the real link (accessible text), and after:absolute after:inset-0 expands its hit area over the whole card. The <li> is relative and the Unlink form is relative z-10 so it stays clickable above the overlay. Opens in a new tab so the in-progress linking flow is preserved.

# Where to send the admin after a successful update. Defaults to the person's
# profile, but returns to the registration org-linking page when the edit was
# opened from one of its linked-org cards (preserving that page's own return_to).
def person_update_return_path

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: Scoped strictly to return_to == "registration_link" so existing flows (incl. the onboarding eyebrow, which never redirected on save) are unaffected — default stays redirect_to @person. The origin is threaded via top-level params (hidden fields in the form), so it survives the POST without touching person_params.

<%# Every jump to the person's affiliations editor (header link, linked-org cards,
other-affiliations card) shares this path so a save returns here — carrying the
origin and this page's own return_to. %>
<% edit_affiliations_path = edit_person_path(@person, anchor: "affiliations", return_to: "registration_link", event_registration_id: @event_registration.id, link_org_return_to: params[:return_to].presence) %>

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: One shared path for all three jumps to the affiliations editor (header link, linked-org cards, other-affiliations card) so they stay in sync — each carries the origin and this page's return_to, opens in the same tab, and a save returns here.

maebeale and others added 8 commits June 21, 2026 21:45
When a person registers for an event or an admin links an organization to
their registration, record their affiliation with that org so the program
roster reflects it without manual data entry: a "job" affiliation carrying
the title they typed on the form (when given), plus a standing "Facilitator"
affiliation.

The Facilitator affiliation is skipped only when an active affiliation titled
exactly "Facilitator" already exists, so a job title like "Lead Facilitator"
still gets its own. The job affiliation is left without a start date — we
don't know when the person began that role (they may have been with the org
for years) and dating it to registration would misrepresent that; the
Facilitator affiliation starts on the first of the training's month.

Logic lives in AffiliationServices::CreateFromRegistration, shared by the
public registration service and the admin org-linking actions. Dev seeds now
submit a job title on most (but not all) registration forms so both outcomes
are visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ctive

A Facilitator affiliation dated to a future training is "pending" until its
start date arrives. The dedupe guard only checked currently-active
affiliations, so registering for a second upcoming training (or any flow that
re-ran the service before the first training started) would mint a duplicate
pending Facilitator. Introduce an `active_or_pending` scope and dedupe against
it for both the job-title and Facilitator affiliations.

Also wrap the two creates in one transaction so a job affiliation is never
left without its Facilitator (or vice versa).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The inline affiliation pills omitted the date entirely when an affiliation had
no start/end date, so a bare "Counselor" gave no signal that its dates were
simply unknown. Always render the date in parentheses — "(since June 2025)",
"(until June 2025)", "(March 2024 – June 2025)", or "(no dates)" — so the pill
reads consistently and an undated affiliation is explicit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Affiliations are managed on the Person record (the Unlink confirm dialog even
says so), so the whole registration-linked org card now navigates there —
a stretched link on the org name covers the card, opening the person's
affiliations section in a new tab so the linking flow isn't lost. The Unlink
button is lifted above the overlay so it still works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Affiliations are added/changed on the edit form, so send the card straight
into edit mode (the form's #affiliations section) rather than the read-only
show section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Navigating to the person's edit form is a primary action here, not an aside,
so keep it in the same tab (turbo_frame _top still breaks out of any frame).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clicking a registration-linked org card opens the person's edit form, but
saving previously dumped the admin on the person profile, losing the linking
flow. Thread the origin (return_to=registration_link + event_registration_id,
plus the linking page's own return_to) through the edit link, the form, and
PeopleController#update so a successful save lands back on the org-linking
page. Adds a matching back-link eyebrow on the edit page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The header "Edit affiliations" link and the other-affiliations card already
jumped to the person's affiliations editor, but in a new tab with no way back.
Route all three entry points (header link, linked-org cards, other-affiliations
card) through one shared path that carries the return context, so each opens in
the same tab and a save lands back on the org-linking page. Renamed the header
link "Edit …'s affiliations" and dropped the now-misleading new-tab icons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@maebeale maebeale force-pushed the maebeale/dual-org-affiliations branch from 27ec2b2 to 79550f2 Compare June 22, 2026 01:46
@maebeale maebeale merged commit 2cdafe3 into main Jun 22, 2026
3 checks passed
@maebeale maebeale deleted the maebeale/dual-org-affiliations branch June 22, 2026 02:19
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.

1 participant