Skip to content
Merged
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
5 changes: 3 additions & 2 deletions 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) | ~29 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~30 files |
| `app/jobs/` | SolidQueue background jobs | 3 files |
| `app/models/concerns/` | Shared model modules | 16 concerns |

Expand Down Expand Up @@ -201,6 +201,7 @@ end

- `EventRegistrationServices::ProcessConfirmation` β€” Registration confirmation flow
- `EventRegistrationServices::PublicRegistration` β€” Public registration handling
- `EventRegistrationReadiness` β€” Computes a registration's lifecycle `status` (`:not_ready` β†’ `:ready` β†’ `:certificate_due` β†’ `:completed`) from a pre-event "event ready" checklist, a post-event "completion work" checklist (attendance, scholarship tasks), and certificate delivery, returning the specific outstanding reasons. Reads payment/certificate state via `Registerable` (`paid_in_full?`, `certificate_sent?`) on both the registration and its `continuing_education_registrations`. Drives the registrants roster's single far-right Status badge column (with a short reason under "Not ready" and a cert-type note under "Certificate pending") and its matching filter
- `ReminderRecipientFilter` β€” Decides which event registrations stay checked on the bulk reminder page given the admin's filters (matches in memory, returns matching ids)
- `MagicTicketCallouts` β€” Code-defined ("magic") ticket callout cards (payment, certificate, scholarship, CE hours, art supplies, forms, handouts, portal, videoconference, FAQ), each with its own visibility rule; rendered through the same `_callout_card` partial as admin-configured `RegistrationTicketCallout`s. Their public show pages live under `app/views/events/callouts/` and are served by `Events::CalloutsController` (slug-authorized, no login)

Expand Down Expand Up @@ -351,7 +352,7 @@ Custom colors defined in `app/frontend/stylesheets/application.tailwind.css`:
| `spec/routing/` | ~13 | Route definition tests |
| `spec/policies/` | ~9 | Authorization policy tests |
| `spec/decorators/` | ~10 | Decorator tests |
| `spec/services/` | ~12 | Service object tests |
| `spec/services/` | ~13 | Service object tests |
| `spec/mailers/` | ~5 | Mailer tests |
| `spec/helpers/` | ~1 | Helper tests |
| `spec/factories/` | ~53 | FactoryBot factory definitions |
Expand Down
27 changes: 25 additions & 2 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def registrants
authorize! @event, to: :registrants?
@event = @event.decorate
scope = @event.event_registrations
.includes(:comments, :organizations, { continuing_education_registrations: [ :professional_license, :allocations ] }, registrant: [ :user, :contact_methods, { avatar_attachment: :blob }, { affiliations: :organization } ])
.includes(:comments, :organizations, :allocations, :scholarships, { continuing_education_registrations: [ :professional_license, :allocations ] }, registrant: [ :user, :contact_methods, { avatar_attachment: :blob }, { affiliations: :organization } ])
.joins(:registrant)
scope = scope.keyword(params[:keyword]) if params[:keyword].present?
scope = scope.payment_status(params[:payment_status]) if params[:payment_status].present?
Expand All @@ -107,10 +107,18 @@ def registrants
scope = scope.active if @status_filter == "active"
end

@event_registrations = scope.order(Arel.sql("people.first_name, people.last_name"))
@event_registrations = scope.order(Arel.sql("people.first_name, people.last_name")).to_a
@dashboard = EventDashboard.new(@event)
@ce_eligible = @event.ce_eligible?

@submitted_org_names = submitted_org_names_for(@event_registrations)
@readiness = @event_registrations.to_h do |registration|
[ registration.id, EventRegistrationReadiness.new(registration) ]
end
if params[:readiness].in?(%w[ not_ready ready certificate_due completed ])
@event_registrations.select! { |r| @readiness[r.id].status.to_s == params[:readiness] }
end

emails = @event_registrations.map { |r| r.registrant.preferred_email&.downcase }.compact
@duplicate_emails = emails.tally.select { |_, count| count > 1 }.keys.to_set

Expand Down Expand Up @@ -541,6 +549,21 @@ def allocated_cents_by_registration(registrations)
.sum(:amount)
end

# Maps registrant person_id => the organization name they typed on the
# registration form (the `agency_name` answer), in one batch query. Drives both
# the roster's Pending/None org chip and the readiness "Organization not linked"
# check, so both read the same resolved answer.
def submitted_org_names_for(registrations)
registration_form = @event.registration_form
field = registration_form&.form_fields&.find_by(field_identifier: "agency_name")
return {} unless field

FormAnswer.joins(:form_submission)
.where(form_submissions: { person_id: registrations.map(&:registrant_id), form_id: registration_form.id }, form_field_id: field.id)
.pluck(Arel.sql("form_submissions.person_id"), :submitted_answer)
.to_h
end

def event_registrations_csv_string
require "csv"
cost_required = @event.cost_cents.to_i > 0
Expand Down
202 changes: 202 additions & 0 deletions app/services/event_registration_readiness.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
# Decides whether an event registration is "event ready" (the pre-event checklist
# an admin clears before the training) and "completed" (the post-event checklist
# that closes it out), returning the specific failing reasons so the registrants
# roster can render a badge with an explanatory tooltip and the index can filter
# on either state.
#
# Reads only already-loaded associations (organizations, allocations,
# scholarships, continuing_education_registrations) so it adds no per-row queries
# when the roster preloads them.
class EventRegistrationReadiness
def initialize(registration)
@registration = registration
end

STATUS_LABELS = {
not_ready: "Not ready",
ready: "Ready",
certificate_due: "Certificate pending",
completed: "Completed"
}.freeze

# Lifecycle order for sorting the roster's Status column.
STATUS_ORDER = %i[ not_ready ready certificate_due completed ].freeze

def event_ready?
event_ready_issues.empty?
end

# All post-event work done AND the certificate(s) sent.
def completed?
completion_issues.empty?
end

# All post-event work done (attended, scholarship tasks met) β€” i.e. the only
# thing left is sending the certificate(s). This is the admin's "send a
# certificate" queue.
def certifiable?
completion_work_issues.empty?
end

# The registration's single lifecycle state for the roster's one Status column
# and its matching filter. Completion is the final state and wins; otherwise an
# outstanding pre-event to-do means "not ready"; a registrant who finished the
# post-event work but still needs a certificate is "certificate due"; and a
# clear pre-event checklist with nothing further done is "ready".
def status

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: Precedence is deliberate: completion wins over the pre-event checklist (a finished registrant shouldn't read "Not ready" just because, say, a balance was never reconciled), then an outstanding pre-event item is "not ready", and a clear checklist is "ready". The roster shows one badge from this and the filter matches it word-for-word.

return :completed if completed?
return :not_ready unless event_ready?
return :certificate_due if certifiable?
:ready
Comment on lines +46 to +50
end

def status_label
STATUS_LABELS.fetch(status)
end

# Sort key for the roster's Status column: lifecycle order first, then the
# reason, so same-status rows group by reason (e.g. all "Payment due" together).
def status_sort_key
"#{STATUS_ORDER.index(status)}|#{status_reason}"
end

# The outstanding items relevant to the current status, for the badge tooltip.
def status_issues
case status
when :not_ready then event_ready_issues
when :certificate_due then certificate_issues
else []
end
end

# The short reason shown under the badge for the current status: a two-word
# pre-event reason when not ready, or which certificate(s) are outstanding when
# certificate-pending. Nil for the ready/completed states.
def status_reason
case status
when :not_ready then event_ready_reason
when :certificate_due then certificate_due_reason
end
end

# Which certificate(s) still need sending, abbreviated for the badge subtext.
def certificate_due_reason
reg_due = !registration_certificate_sent?
ce_due = ce_certificate_pending?
return "Reg + CE" if reg_due && ce_due
return "CE" if ce_due
"Registration" if reg_due
end

# Each pre-event check, in priority order: [ predicate, two-word reason (shown
# under a "Not ready" badge), full description (tooltip) ]. One table keeps the
# short and long forms in sync.
EVENT_READY_CHECKS = [
[ :payment_due?, "Payment due", "Payment due" ],
[ :organization_missing?, "Org validation", "No organization linked" ],
[ :scholarship_uncreated?, "No scholarship", "Scholarship not created" ],
[ :scholarship_tasks_incomplete?, "Tasks incomplete", "Scholarship tasks incomplete" ],
[ :ce_unpaid?, "CE unpaid", "CE not paid" ],
[ :ce_license_missing?, "No license #", "CE license number missing" ]
].freeze

def event_ready_issues
@event_ready_issues ||= failed_event_ready_checks.map { |_, _, full| full }
end

# The two-word reason for the highest-priority outstanding pre-event item,
# shown under the "Not ready" badge. Nil when nothing is outstanding.
def event_ready_reason
failed_event_ready_checks.first&.fetch(1)
end

def completion_issues
completion_work_issues + certificate_issues
end

# Post-event work that must happen before a certificate can be issued.
def completion_work_issues
@completion_work_issues ||= [].tap do |issues|
issues << attendance_issue if attendance_issue
issues << "Scholarship tasks incomplete" if scholarship_tasks_incomplete?
end
end

# The certificate(s) that still need sending once the work above is done.
def certificate_issues
@certificate_issues ||= [].tap do |issues|
issues << "Certificate not sent" unless registration_certificate_sent?
issues << "CE certificate not sent" if ce_certificate_pending?
end
end

private

attr_reader :registration

def failed_event_ready_checks
@failed_event_ready_checks ||= EVENT_READY_CHECKS.select { |predicate, _, _| send(predicate) }
end

def payment_due?
registration.event.cost_cents.to_i > 0 && !registration.paid_in_full?
end

# The organization needs an admin's attention until one is linked to the
# registration β€” whether that's a pending submitted name or nothing at all.
# Once any org is linked, they've made the call, so it's resolved.
def organization_missing?
registration.organizations.empty?
end

def scholarship_uncreated?
registration.scholarship_requested? && !registration.scholarship?
end

def scholarship_tasks_incomplete?
registration.scholarship? && !registration.scholarship_tasks_met?
end

Comment on lines +152 to +159
def ce_unpaid?
registration.ce_credit_requested? && !ce_paid?
end

def ce_license_missing?
registration.ce_credit_requested? && !registration.ce_license_provided?
end

def ce_certificate_pending?
registration.ce_credit_requested? && !ce_certificate_sent?
end

# Post-event criteria are only met by a full "attended". "incomplete_attendance"
# explicitly does not satisfy them; everything else means they never showed.
def attendance_issue
return nil if registration.attended?
registration.status == "incomplete_attendance" ? "Attendance incomplete" : "Did not attend"
end

# The admin-created CE billing records for this registration (preloaded on the
# roster). Their payment + certificate state drives the CE readiness checks.
def ce_registrations
registration.continuing_education_registrations
end

# CE is paid once every CE registration is paid in full. A requested-but-not-yet
# -created CE registration counts as unpaid (nothing to pay against yet).
def ce_paid?

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: These three are deliberate stubs β€” CE-paid and both certificate_sent_at fields don't exist yet. Until they land, "Completed" reads false for everyone (by design). Flip each to the real predicate when its feature merges; completion should require both the registration and CE certificate-sent fields.

ce_registrations.any? && ce_registrations.all?(&:paid_in_full?)
end

# The registration's own completion certificate (certificate_sent_at, via
# Registerable#certificate_sent?).
def registration_certificate_sent?
registration.certificate_sent?
end

# CE certificates are sent once every CE registration's certificate has been
# sent. No CE registration yet means nothing has been issued.
def ce_certificate_sent?
ce_registrations.any? && ce_registrations.all?(&:certificate_sent?)
end
end
29 changes: 29 additions & 0 deletions app/views/event_registrations/_readiness_badge.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<%# Single status pill for a registration's lifecycle: Not ready (pre-event work
outstanding) β†’ Ready (cleared for the event) β†’ Certificate due (post-event
work done, certificate still to send) β†’ Completed. When there are outstanding
items, the tooltip lists them; `subtext` shows a short reason under the badge.
Locals:
status - Symbol (:not_ready, :ready, :certificate_due, :completed)
label - display text for the status
issues - array of outstanding-condition strings (tooltip, when any)
subtext - optional short reason rendered under the badge %>
<%
subtext = local_assigns[:subtext]
style, icon, subtext_color = case status
when :completed then [ "bg-green-50 text-green-700 border-green-200", "fa-flag-checkered", "text-green-600" ]
when :certificate_due then [ "bg-purple-50 text-purple-700 border-purple-200", "fa-certificate", "text-purple-600" ]
when :ready then [ "bg-blue-50 text-blue-700 border-blue-200", "fa-circle-check", "text-blue-600" ]
else [ "bg-amber-50 text-amber-700 border-amber-200", "fa-circle-exclamation", "text-amber-600" ]
end
tooltip = issues.any? ? issues.join(" Β· ") : label
%>
<div class="inline-flex flex-col items-center gap-0.5">
<span class="inline-flex items-center gap-1.5 whitespace-nowrap rounded-full text-xs font-medium border px-3 py-0.5 <%= style %>"
title="<%= tooltip %>">
<i class="fas <%= icon %>"></i>
<span><%= label %></span>
</span>
<% if subtext.present? %>
<span class="text-[0.65rem] font-medium <%= subtext_color %>"><%= subtext %></span>
<% end %>
</div>
Loading