Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ This codebase (Rails 8.1)
| Directory | Purpose | Count |
|---|---|---|
| `app/models/` | ActiveRecord models | ~78 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~28 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~29 files |
| `app/jobs/` | SolidQueue background jobs | 3 files |
| `app/models/concerns/` | Shared model modules | 15 concerns |

Expand Down Expand Up @@ -181,6 +181,7 @@ end
### Business Logic

- `EventDashboard` — Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total)
- `EventOrganizationLinkStatus` — Classifies an event's registrations as linked/pending/none by organization resolution, for the registrants filter
- `WorkshopSearchService` — Complex filtering, sorting, pagination with ActionPolicy
- `WorkshopFromIdeaService` — Converts WorkshopIdea to Workshop with asset migration
- `WorkshopVariationFromIdeaService` — Variation creation from ideas
Expand Down
7 changes: 7 additions & 0 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,16 @@ def registrants
scope = scope.scholarship_status(params[:scholarship]) if params[:scholarship].present?
scope = scope.registrant_ids(params[:registrant_ids]) if params[:registrant_ids].present?
scope = scope.registrant_state(params[:state]) if params[:state].present?
scope = scope.registrant_locality(params[:locality]) if params[:locality].present?
scope = scope.registrant_county(params[:county]) if params[:county].present?
scope = scope.registrant_country(params[:country]) if params[:country].present?
scope = scope.registrant_sector(params[:sector]) if params[:sector].present?

if params[:organization_status].present?
registration_ids = EventOrganizationLinkStatus.new(@event).registration_ids_for(params[:organization_status])
scope = scope.where(id: registration_ids)
end

@active_count = scope.active.count
@inactive_count = scope.inactive.count

Expand Down
10 changes: 10 additions & 0 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ class EventRegistration < ApplicationRecord
conditions[:state] = state if state.present?
joins(registrant: :addresses).where(addresses: conditions).distinct
}
scope :registrant_locality, ->(locality) {
joins(registrant: :addresses)
.where(addresses: { inactive: false, locality: locality })
.distinct
}
scope :registrant_country, ->(country) {
joins(registrant: :addresses)
.where(addresses: { inactive: false, country: country })
.distinct
}
scope :registrant_sector, ->(sector_id) {
joins(registrant: :sectorable_items)
.where(sectorable_items: { sector_id: sector_id })
Expand Down
12 changes: 12 additions & 0 deletions app/services/event_dashboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,18 @@ def countries
.sort
end

# Localities (e.g. urban / rural / suburban) on file across active registrants'
# active addresses, sorted by name — the option list for the registrants filter.
def localities
@localities ||= Address
.active
.where(addressable_type: "Person", addressable_id: registrant_ids)
.where.not(locality: [ nil, "" ])
.distinct
.pluck(:locality)
.sort
end

# Distinct registrant count per country.
def country_counts
@country_counts ||= Address
Expand Down
93 changes: 93 additions & 0 deletions app/services/event_organization_link_status.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Classifies an event's registrations by how their organization is resolved,
# mirroring the badge logic in `events/_registrants_results` so the registrants
# filter and the displayed pills agree:
#
# - linked: the registration has at least one linked organization
# - pending: the registrant submitted an agency name that doesn't match any
# linked organization (needs admin attention)
# - none: no linked organization and no submitted agency name
#
# The states are not mutually exclusive — a registration can be both linked and
# pending — so each option returns everyone matching that predicate.
class EventOrganizationLinkStatus
STATUSES = %w[linked pending none].freeze

def initialize(event)
@event = event
end

# Registration ids matching the given status ("linked"/"pending"/"none").
# Returns [] for an unrecognized status so callers can scope to nothing.
def registration_ids_for(status)
case status.to_s
when "linked" then linked_registration_ids
when "pending" then pending_registration_ids
when "none" then none_registration_ids
else []
end
end

private

attr_reader :event

def linked_registration_ids
registrations.select { |registration| linked_names(registration).present? }.map(&:id)
end

def pending_registration_ids
registrations.select { |registration| needs_linking?(registration) }.map(&:id)
end

def none_registration_ids
registrations.select do |registration|
linked_names(registration).blank? && submitted_name(registration).blank?
end.map(&:id)
end

def needs_linking?(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: Overlap is intentional — a registration with a linked org and a non-matching submitted agency name counts as both Linked and Pending, matching the per-row badges. Each filter option returns everyone in that state rather than a single exclusive bucket.

name = submitted_name(registration)
name.present? && linked_names(registration).exclude?(name.downcase)
end

def linked_names(registration)
linked_names_by_registration.fetch(registration.id, [])
end

def submitted_name(registration)
submitted_names_by_registrant[registration.registrant_id].to_s.strip
end

def registrations
@registrations ||= event.event_registrations.pluck(:id, :registrant_id)
.map { |id, registrant_id| Registration.new(id, registrant_id) }
end

# Linked organization names (lowercased) keyed by registration id.
def linked_names_by_registration
@linked_names_by_registration ||= EventRegistrationOrganization
.joins(:organization)
.where(event_registration_id: registrations.map(&:id))
.pluck(:event_registration_id, Arel.sql("organizations.name"))
.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(registration_id, name), memo|
memo[registration_id] << name.to_s.strip.downcase
end
end

# Agency name submitted on the registration form, keyed by registrant (person) id.
def submitted_names_by_registrant
return @submitted_names_by_registrant if defined?(@submitted_names_by_registrant)
form = event.registration_form
field = form&.form_fields&.find_by(field_identifier: "agency_name")
@submitted_names_by_registrant = if field
FormAnswer.joins(:form_submission)
.where(form_submissions: { person_id: registrations.map(&:registrant_id), form_id: form.id }, form_field_id: field.id)
.pluck(Arel.sql("form_submissions.person_id"), :submitted_answer)
.to_h
else
{}
end
end

Registration = Struct.new(:id, :registrant_id)
end
198 changes: 120 additions & 78 deletions app/views/events/_registrants_search.html.erb
Original file line number Diff line number Diff line change
@@ -1,90 +1,132 @@
<%= form_with url: registrants_event_path(@event), method: :get,
data: { controller: "collection", turbo_frame: "registrants_results" },
autocomplete: "off",
class: "flex flex-col md:flex-row md:flex-wrap md:items-end gap-4 mb-6" do %>
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm mb-6">
<%= form_with url: registrants_event_path(@event), method: :get,
data: { controller: "collection", turbo_frame: "registrants_results" },
autocomplete: "off",
class: "flex flex-col gap-4" do %>

<%= hidden_field_tag :status_filter, params[:status_filter].presence || "active" %>
<%= hidden_field_tag :status_filter, params[:status_filter].presence || "active" %>

<div class="w-full md:flex-1">
<%= label_tag :keyword, "Keyword", class: "block text-sm font-medium text-gray-700 mb-1" %>
<div class="relative">
<%= text_field_tag :keyword, params[:keyword],
<%# Row 1: keyword, organization status, scholarship, payment, attendance %>
<div class="flex flex-col md:flex-row md:flex-wrap md:items-end gap-4">
<div class="w-full md:flex-1 md:min-w-[16rem]">
<%= label_tag :keyword, "Keyword", class: "block text-sm font-medium text-gray-700 mb-1" %>
<div class="relative">
<%= text_field_tag :keyword, params[:keyword],
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none",
placeholder: "Name, email, phone, city, or organization" %>
<button type="submit"
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 hover:text-blue-600">
<i class="fa fa-search"></i>
</button>
</div>
</div>

<div class="w-full md:w-48">
<%= label_tag :organization_status, "Organization status", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= select_tag :organization_status,
options_for_select(
[ [ "Linked", "linked" ], [ "Pending", "pending" ], [ "None", "none" ] ],
params[:organization_status]
),
include_blank: "All",
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
</div>

<% if @event.cost_cents.to_i > 0 %>
<div class="w-full md:w-48">
<%= label_tag :scholarship, "Scholarship", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= select_tag :scholarship,
options_for_select(
[ [ "All recipients", "yes" ], [ "Tasks complete", "complete" ], [ "Tasks not complete", "incomplete" ] ],
params[:scholarship]
),
include_blank: "All registrants",
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none",
placeholder: "Name, email, phone, city, or organization" %>
<button type="submit"
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 hover:text-blue-600">
<i class="fa fa-search"></i>
</button>
</div>
</div>
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
</div>

<div class="w-full md:w-48">
<%= label_tag :attendance_status, "Attendance status", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= select_tag :attendance_status,
options_for_select(
EventRegistration::ATTENDANCE_STATUSES.map { |s| [EventRegistration.new(status: s).attendance_status_label, s] },
params[:attendance_status]
),
include_blank: "All statuses",
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
</div>
<div class="w-full md:w-48">
<%= label_tag :payment_status, "Payment", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= select_tag :payment_status,
options_for_select(
[ [ "Due", "unpaid" ], [ "Paid", "paid" ], [ "Intends to pay", "intends_to_pay" ] ],
params[:payment_status]
),
include_blank: "Any payment status",
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
</div>
<% end %>

<% if @event.cost_cents.to_i > 0 %>
<div class="w-full md:w-48">
<%= label_tag :payment_status, "Payment", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= select_tag :payment_status,
options_for_select(
[ [ "Due", "unpaid" ], [ "Paid", "paid" ], [ "Intends to pay", "intends_to_pay" ] ],
params[:payment_status]
),
include_blank: "Any payment status",
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
<div class="w-full md:w-48">
<%= label_tag :attendance_status, "Attendance status", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= select_tag :attendance_status,
options_for_select(
EventRegistration::ATTENDANCE_STATUSES.map { |s| [EventRegistration.new(status: s).attendance_status_label, s] },
params[:attendance_status]
),
include_blank: "All statuses",
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
</div>
</div>

<div class="w-full md:w-48">
<%= label_tag :scholarship, "Scholarship", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= select_tag :scholarship,
options_for_select(
[ [ "All recipients", "yes" ], [ "Tasks complete", "complete" ], [ "Tasks not complete", "incomplete" ] ],
params[:scholarship]
),
include_blank: "All registrants",
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
</div>
<% end %>
<%# Row 2: state, locality, county, country %>
<div class="flex flex-col md:flex-row md:flex-wrap md:items-end gap-4">
<% if @dashboard.states.any? %>
<div class="w-full md:w-48">
<%= label_tag :state, "State", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= select_tag :state,
options_for_select(@dashboard.states, params[:state]),
include_blank: "All states",
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
</div>
<% end %>

<% if @dashboard.states.any? %>
<div class="w-full md:w-48">
<%= label_tag :state, "State", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= select_tag :state,
options_for_select(@dashboard.states, params[:state]),
include_blank: "All states",
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
</div>
<% end %>
<% if @dashboard.localities.any? %>
<div class="w-full md:w-48">
<%= label_tag :locality, "Locality", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= select_tag :locality,
options_for_select(@dashboard.localities, params[:locality]),
include_blank: "All localities",
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
</div>
<% end %>

<% if @dashboard.counties.any? %>
<div class="w-full md:w-48">
<%= label_tag :county, "County", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= select_tag :county,
options_for_select(
@dashboard.counties.map { |state, county| [ "#{state} - #{county}", "#{state}|#{county}" ] },
params[:county]
),
include_blank: "All counties",
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
<% if @dashboard.counties.any? %>
<div class="w-full md:w-48">
<%= label_tag :county, "County", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= select_tag :county,
options_for_select(
@dashboard.counties.map { |state, county| [ "#{state} - #{county}", "#{state}|#{county}" ] },
params[:county]
),
include_blank: "All counties",
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
</div>
<% end %>

<% if @dashboard.countries.any? %>
<div class="w-full md:w-48">
<%= label_tag :country, "Country", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= select_tag :country,
options_for_select(@dashboard.countries, params[:country]),
include_blank: "All countries",
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
</div>
<% end %>

<div class="w-full md:w-auto md:ml-auto flex items-end justify-end">
<%= link_to "Clear filters", registrants_event_path(@event),
class: "btn btn-utility-outline",
data: { action: "collection#clearAndSubmit" } %>
</div>
</div>
<% end %>

<div class="w-full md:w-auto flex justify-end">
<%= link_to "Clear filters", registrants_event_path(@event),
class: "btn btn-utility-outline",
data: { action: "collection#clearAndSubmit" } %>
</div>
<% end %>
</div>
Loading