From e854c622f9bd354642911d892c5fa0f8ac3b03a0 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 21 Jun 2026 20:56:43 -0400 Subject: [PATCH 1/2] Show recipient job title, agency, and city/state from registration orgs The recipients page now drives each recipient's affiliation line(s) off the org(s) connected to their registration rather than all of their affiliations. For each connected org it shows the registrant's active job title with the agency name and city/state; absent a job affiliation it falls back to a title-less line when they facilitate at that org and the org is active or pending. Multiple connected orgs render multiple lines. Co-Authored-By: Claude Opus 4.8 --- app/services/event_dashboard.rb | 47 ++++++++++++++++ app/views/events/recipients.html.erb | 30 +++++------ spec/requests/events_spec.rb | 24 +++++++-- spec/services/event_dashboard_spec.rb | 78 +++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 20 deletions(-) diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index d752e88cf0..07a1ad18b2 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -59,6 +59,25 @@ def scholarship_applicants .sort_by(&:name) end + # One organization line shown beneath a recipient's name: the org and, for a + # job affiliation, the registrant's title there (nil for the facilitator + # fallback, where the title is deliberately suppressed). + RecipientAffiliation = Struct.new(:organization, :title, keyword_init: true) + + # The organization line(s) under each recipient's name on the recipients page, + # keyed by Person id. Driven by the org(s) connected to the registrant's + # registration (the snapshot taken at registration plus any an admin linked + # later) rather than all of the person's affiliations. For each connected org + # we show the title from the registrant's active, non-facilitator job + # affiliation there; absent one, we fall back to a title-less line when they + # are a facilitator at that org and the org is active or pending. Orgs matching + # neither are omitted, so a recipient can show several lines or none. + def recipient_affiliations_by_registrant + @recipient_affiliations_by_registrant ||= scholarship_applicants.to_h do |person| + [ person.id, recipient_affiliation_rows(person) ] + end + end + # Scholarship-application answers for this event's applicants, keyed by Person # id and de-duplicated to one answer per question. The scholarship section may # be captured on the registration submission (registered with scholarship @@ -660,6 +679,34 @@ def scholarship_applicant_ids @scholarship_applicant_ids ||= active_registrations.where(scholarship_requested: true).pluck(:registrant_id) end + # Org statuses that still warrant showing a facilitator-only fallback line. + FALLBACK_FACILITATOR_STATUSES = %w[Active Pending].freeze + + # Recipient affiliation lines for one person (see #recipient_affiliations_by_registrant). + def recipient_affiliation_rows(person) + affiliations_by_org = person.affiliations.group_by(&:organization_id) + registration_organizations_by_registrant.fetch(person.id, []).filter_map do |organization| + org_affiliations = affiliations_by_org[organization.id] || [] + job = org_affiliations.find { |affiliation| !affiliation.facilitator? && affiliation.active? } + if job + RecipientAffiliation.new(organization: organization, title: job.title) + elsif org_affiliations.any?(&:facilitator?) && FALLBACK_FACILITATOR_STATUSES.include?(organization.organization_status&.name) + RecipientAffiliation.new(organization: organization, title: nil) + end + end + end + + # Organizations connected to each applicant's active registration, sorted by + # name, with addresses and status preloaded for the recipients header. Keyed by + # Person id. + def registration_organizations_by_registrant + @registration_organizations_by_registrant ||= active_registrations + .where(registrant_id: scholarship_applicant_ids) + .includes(organizations: [ :addresses, :organization_status ]) + .index_by(&:registrant_id) + .transform_values { |registration| registration.organizations.sort_by(&:name) } + end + # Registrant (Person) ids behind active registrations that opted into a # shout-out — the candidates for the recipients page shout-out block. def shoutout_registrant_ids diff --git a/app/views/events/recipients.html.erb b/app/views/events/recipients.html.erb index 7f881c0c73..48db17d06c 100644 --- a/app/views/events/recipients.html.erb +++ b/app/views/events/recipients.html.erb @@ -2,7 +2,6 @@ <% answers_by_applicant = @dashboard.scholarship_answers_by_applicant %> <% header_answers = @dashboard.header_answers_by_applicant %> -<% event_date = @event.start_date %>
<%# Top bar: back link + sub-nav, matching the other event pages %> @@ -61,11 +60,10 @@ <%# Prefer the registrant's form answers; fall back to their profile data. %> <% service_area_text = resolve_answer_text(service_area_answer&.form_field, service_area_answer&.submitted_answer).presence || person.sectors.map(&:name).join(", ").presence %> <% age_group_text = resolve_answer_text(age_group_answer&.form_field, age_group_answer&.submitted_answer).presence || person.categories.select { |c| c.category_type&.name == "AgeRange" }.map(&:name).join(", ").presence %> - <%# The affiliation active at the time of the event, excluding facilitator roles. %> - <% recipient_affiliations = person.affiliations.select { |a| - !a.facilitator? && - (event_date.blank? || ((a.start_date.nil? || a.start_date <= event_date) && (a.end_date.nil? || a.end_date >= event_date))) - } %> + <%# Org line(s) from the registrant's registration: the connected org(s), + each with the registrant's job title there (title-less for the + facilitator fallback). %> + <% recipient_affiliations = @dashboard.recipient_affiliations_by_registrant[person.id] || [] %>
" class="scroll-mt-24 bg-white border <%= DomainTheme.border_class_for(:scholarships) %> rounded-xl shadow-sm overflow-hidden break-inside-avoid" @@ -108,18 +106,18 @@ <%= link_to person.name, person_path(person), data: { turbo_frame: "_top" }, class: "text-lg font-bold text-gray-900 hover:underline" %> <% recipient_affiliations.each do |affiliation| %> + <% organization = affiliation.organization %> + <% location = organization.program_location %>
- <% if affiliation.organization %> - <% if allowed_to?(:show?, affiliation.organization) %> - <%= link_to affiliation.organization.name, organization_path(affiliation.organization), - data: { turbo_frame: "_top" }, - class: "font-medium text-gray-700 hover:text-gray-900 hover:underline" %> - <% else %> - <%= affiliation.organization.name %> - <% end %> + <% if allowed_to?(:show?, organization) %> + <%= link_to organization.name, organization_path(organization), + data: { turbo_frame: "_top" }, + class: "font-medium text-gray-700 hover:text-gray-900 hover:underline" %> + <% else %> + <%= organization.name %> <% end %> - <% if affiliation.organization && affiliation.title.present? %>·<% end %> - <% if affiliation.title.present? %><%= affiliation.title %><% end %> + <% if location.present? %>(<%= location %>)<% end %> + <% if affiliation.title.present? %>·<%= affiliation.title %><% end %>
<% end %>
diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index fab6dfbeb0..6363ab6f43 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -1505,19 +1505,35 @@ def submit_agency_name(name) expect(response.body).to include("Mark tasks complete") end - it "shows the non-facilitator affiliation's title and linked organization, excluding facilitator roles" do + it "shows the connected org's job title, linked organization, and city/state" do org = create(:organization, name: "Safe Harbor of Sheboygan") + create(:address, addressable: org, city: "Sheboygan", state: "WI") + registration = event.event_registrations.find_by(registrant: applicant) + create(:event_registration_organization, event_registration: registration, organization: org) create(:affiliation, person: applicant, organization: org, title: "Prevention, Education, and Outreach Specialist", start_date: 1.year.ago) - create(:affiliation, person: applicant, organization: create(:organization, name: "Facilitator Org"), - title: "Facilitator", start_date: 1.year.ago) get recipients_event_path(event) expect(response.body).to include("Prevention, Education, and Outreach Specialist") expect(response.body).to include("Safe Harbor of Sheboygan") expect(response.body).to include(organization_path(org)) - expect(response.body).not_to include("Facilitator Org") + expect(response.body).to include("Sheboygan, WI") + end + + it "falls back to a title-less line for a facilitator at an active/pending connected org" do + org = create(:organization, name: "Facilitator Org", + organization_status: create(:organization_status, name: "Active")) + registration = event.event_registrations.find_by(registrant: applicant) + create(:event_registration_organization, event_registration: registration, organization: org) + create(:affiliation, person: applicant, organization: org, title: "Facilitator", start_date: 1.year.ago) + + get recipients_event_path(event) + + expect(response.body).to include("Facilitator Org") + expect(response.body).to include(organization_path(org)) + # The facilitator title itself is suppressed in the fallback line. + expect(response.body).not_to match(/Facilitator Org.*·\s*Facilitator/m) end it "excludes registrants who did not request a scholarship" do diff --git a/spec/services/event_dashboard_spec.rb b/spec/services/event_dashboard_spec.rb index a65621d865..65d61093df 100644 --- a/spec/services/event_dashboard_spec.rb +++ b/spec/services/event_dashboard_spec.rb @@ -845,4 +845,82 @@ def opt_in(person, text:) expect(dashboard.unallocated_bulk_payment_cents).to eq(0) end end + + describe "#recipient_affiliations_by_registrant" do + let(:event) { create(:event) } + let(:active_status) { create(:organization_status, name: "Active") } + let(:pending_status) { create(:organization_status, name: "Pending") } + let(:suspended_status) { create(:organization_status, name: "Suspended") } + + def applicant_with(organizations) + person = create(:person) + registration = create(:event_registration, event: event, registrant: person, + status: "registered", scholarship_requested: true) + organizations.each { |org| create(:event_registration_organization, event_registration: registration, organization: org) } + person + end + + def rows_for(person) + described_class.new(event).recipient_affiliations_by_registrant[person.id] + end + + it "shows the job title and the org's city/state for an active job affiliation" do + org = create(:organization, name: "Job Org", organization_status: active_status) + create(:address, addressable: org, city: "Austin", state: "TX") + person = applicant_with([ org ]) + create(:affiliation, person: person, organization: org, title: "Director") + + rows = rows_for(person) + expect(rows.map(&:title)).to eq([ "Director" ]) + expect(rows.first.organization).to eq(org) + expect(rows.first.organization.program_location).to eq("Austin, TX") + end + + it "falls back to a title-less line for a facilitator at an active or pending org" do + org = create(:organization, organization_status: pending_status) + person = applicant_with([ org ]) + create(:affiliation, person: person, organization: org, title: "Facilitator") + + rows = rows_for(person) + expect(rows.map(&:title)).to eq([ nil ]) + expect(rows.first.organization).to eq(org) + end + + it "omits a facilitator-only org whose status is neither active nor pending" do + org = create(:organization, organization_status: suspended_status) + person = applicant_with([ org ]) + create(:affiliation, person: person, organization: org, title: "Facilitator") + + expect(rows_for(person)).to be_empty + end + + it "prefers the job affiliation over a facilitator one at the same org" do + org = create(:organization, organization_status: active_status) + person = applicant_with([ org ]) + create(:affiliation, person: person, organization: org, title: "Facilitator") + create(:affiliation, person: person, organization: org, title: "Coordinator") + + expect(rows_for(person).map(&:title)).to eq([ "Coordinator" ]) + end + + it "shows one line per connected org, sorted by name" do + org_b = create(:organization, name: "Beta", organization_status: active_status) + org_a = create(:organization, name: "Alpha", organization_status: active_status) + person = applicant_with([ org_b, org_a ]) + create(:affiliation, person: person, organization: org_a, title: "Lead") + create(:affiliation, person: person, organization: org_b, title: "Aide") + + expect(rows_for(person).map { |row| row.organization.name }).to eq(%w[Alpha Beta]) + end + + it "ignores affiliations whose org is not connected to the registration" do + connected = create(:organization, name: "Connected", organization_status: active_status) + unconnected = create(:organization, name: "Unconnected", organization_status: active_status) + person = applicant_with([ connected ]) + create(:affiliation, person: person, organization: connected, title: "Manager") + create(:affiliation, person: person, organization: unconnected, title: "Volunteer") + + expect(rows_for(person).map { |row| row.organization.name }).to eq(%w[Connected]) + end + end end From f1354086692ebf41820a9087c6b23d79f2803606 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 21 Jun 2026 21:03:31 -0400 Subject: [PATCH 2/2] Render recipient affiliation line as "Title, Organization (City, State)" Lead with the job title, then the agency name and its city/state, per the preferred display order. Co-Authored-By: Claude Opus 4.8 --- app/views/events/recipients.html.erb | 2 +- spec/requests/events_spec.rb | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/views/events/recipients.html.erb b/app/views/events/recipients.html.erb index 48db17d06c..9d07d0c41e 100644 --- a/app/views/events/recipients.html.erb +++ b/app/views/events/recipients.html.erb @@ -109,6 +109,7 @@ <% organization = affiliation.organization %> <% location = organization.program_location %>
+ <% if affiliation.title.present? %><%= affiliation.title %>,<% end %> <% if allowed_to?(:show?, organization) %> <%= link_to organization.name, organization_path(organization), data: { turbo_frame: "_top" }, @@ -117,7 +118,6 @@ <%= organization.name %> <% end %> <% if location.present? %>(<%= location %>)<% end %> - <% if affiliation.title.present? %>·<%= affiliation.title %><% end %>
<% end %>
diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 6363ab6f43..cad80551df 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -1532,8 +1532,9 @@ def submit_agency_name(name) expect(response.body).to include("Facilitator Org") expect(response.body).to include(organization_path(org)) - # The facilitator title itself is suppressed in the fallback line. - expect(response.body).not_to match(/Facilitator Org.*·\s*Facilitator/m) + # The facilitator title itself is suppressed in the fallback line, so no + # "Facilitator, " title prefix precedes the org name. + expect(response.body).not_to include('Facilitator,') end it "excludes registrants who did not request a scholarship" do