diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 63ab39883..056f94e3d 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -24,16 +24,6 @@ class CalloutsController < ApplicationController policy.object_src :self end - # Hidden Resource (by title) backing the handout links, in display order. - # Missing ones (e.g. not seeded in an environment) are silently skipped. - HANDOUT_RESOURCE_TITLES = [ - "2-Day AWBW Facilitator Training Worksheets & Handouts", - "AWBW Training Workshop Worksheets", - "Aha Moments", - "Inviting and Responding to Participants' Sharing", - "Letter to Supervisors" - ].freeze - # Payment page: the full allocation ledger with the running balance due, plus # the linked documents (the W-9 from the payment callout's resources, and the # dynamic invoice/receipt) for paid events. diff --git a/app/controllers/events/registrations_controller.rb b/app/controllers/events/registrations_controller.rb index fce43bb95..082ddf477 100644 --- a/app/controllers/events/registrations_controller.rb +++ b/app/controllers/events/registrations_controller.rb @@ -178,7 +178,9 @@ def create_person_for_current_user end def set_event_registration - @event_registration = EventRegistration.find_by!(slug: params[:slug]) + @event_registration = EventRegistration + .includes(event: { registration_ticket_callouts: :resources }) + .find_by!(slug: params[:slug]) end def requires_payment?(registration) diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 7449c9c42..f48e5693b 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -307,10 +307,6 @@ def deletable? !allocations.exists? && !attendance_recorded? && !transferred_out? end - def checked_in? - # checked_in_at.present? - end - # True when the registrant should be granted access to ticket materials # (training links, etc.) even though they haven't paid in full yet. Admins # flip the `intends_to_pay` flag when someone commits to paying after the diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index 1a3b47c63..59b9f0dd1 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -143,16 +143,21 @@ def config_gap?(builtin_key) # A built-in card the event has materialized into an editable row renders from # that row, not from #cards, so we skip it here to avoid double-rendering. + # Reads the (possibly preloaded) association in Ruby rather than a `.pluck`, + # so a preloaded ticket render doesn't issue a second query for the same rows. def materialized?(builtin_key) - @materialized_keys ||= event.registration_ticket_callouts.builtin.pluck(:builtin_key).to_set + @materialized_keys ||= event.registration_ticket_callouts.filter_map(&:builtin_key).to_set @materialized_keys.include?(builtin_key) end # Top card: an action card while a balance is due, a reference card once paid # in full. Its page lists every allocation with the running balance, plus the # linked documents (the W-9, and the invoice/receipt) for paid events. + # Unlike scholarship/CE/videoconference, payment never bypasses its gap in + # preview — a free event truly has no balance to preview, on a real ticket or + # a sample one, so there's nothing to click through. def payment_card - return if config_gap?("payment") + return if self.class.config_gap(event, "payment") due = registration.remaining_cost.to_i.positive? Card.new(icon_class: "fa-solid fa-credit-card", color: due ? "orange" : "blue", title: due ? "Make your payment" : "Payment", diff --git a/app/services/builtin_callouts.rb b/app/services/builtin_callouts.rb index b3046a21a..f34b6e845 100644 --- a/app/services/builtin_callouts.rb +++ b/app/services/builtin_callouts.rb @@ -54,6 +54,16 @@ class BuiltinCallouts "The membership fee covers all facilitators connected to the same program or organization for the calendar year, regardless of the number of facilitators participating. Only one membership fee payment is required per program annually." ] } ].freeze + # Hidden Resource (by title) backing the handout links, in display order. + # Missing ones (e.g. not seeded in an environment) are silently skipped. + HANDOUT_RESOURCE_TITLES = [ + "2-Day AWBW Facilitator Training Worksheets & Handouts", + "AWBW Training Workshop Worksheets", + "Aha Moments", + "Inviting and Responding to Participants' Sharing", + "Letter to Supervisors" + ].freeze + # Default per-resource copy for the built-in handout links, keyed by resource # title. `subtitle` is the short line on the handouts card; `page_content` is # the longer copy shown under the title on the resource's own page. Materialized @@ -82,11 +92,6 @@ class BuiltinCallouts } }.freeze - # builtin_keys this service knows how to materialize. - def self.seedable_keys - new(nil).send(:definitions).map { |definition| definition[:builtin_key] } - end - def self.seed(event) new(event).seed end @@ -348,8 +353,8 @@ def resource_content_customized?(callout, definition) # The training worksheet resources, by title, in the display order the code # card used. Missing ones (not seeded in an environment) are simply skipped. def handout_resources - by_title = Resource.where(title: Events::CalloutsController::HANDOUT_RESOURCE_TITLES).index_by(&:title) - Events::CalloutsController::HANDOUT_RESOURCE_TITLES.filter_map { |title| by_title[title] } + by_title = Resource.where(title: HANDOUT_RESOURCE_TITLES).index_by(&:title) + HANDOUT_RESOURCE_TITLES.filter_map { |title| by_title[title] } end # Renders FAQS to standard
/ disclosures so each question is a diff --git a/app/views/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb index 21e2b7b71..98bfafef3 100644 --- a/app/views/event_registrations/_ticket.html.erb +++ b/app/views/event_registrations/_ticket.html.erb @@ -122,7 +122,9 @@ default; "Show all options" additionally reveals unpublished rows so admins can preview every card. --> <% payment_access = event_registration.payment_access_granted? || @checkout_payment_status == "paid" %> - <% callouts = preview && show_all ? event_registration.event.registration_ticket_callouts : event_registration.event.registration_ticket_callouts.visible %> + <%# Reads the loaded association (not the `.visible` scope) so a preloaded + real-ticket render doesn't issue a second query for the same rows. %> + <% callouts = preview && show_all ? event_registration.event.registration_ticket_callouts : event_registration.event.registration_ticket_callouts.select(&:published?) %> <% callouts.each do |callout| %> <% next if callout.payment_access_gated && !payment_access && !(preview && show_all) %> <% if callout.behavioral_builtin? %> @@ -158,9 +160,7 @@
- <% if event_registration.checked_in? %> - Checked in - <% elsif event_registration.status == "cancelled" %> + <% if event_registration.status == "cancelled" %> Registration cancelled <% if event_registration.event.registerable? %>
<%= button_to "Register again", registration_reactivate_path(event_registration.slug), diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index 188267536..ba6818a9f 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -412,4 +412,94 @@ end end end + + describe "GET /registration/:slug/certificate" do + let(:event) { create(:event, end_date: 2.days.ago) } + let(:registration) { create(:event_registration, event: event, status: "attended") } + + it "renders once the certificate is unlocked" do + get registration_certificate_path(registration.slug) + expect(response).to have_http_status(:success) + end + + it "redirects to the ticket when the certificate isn't unlocked yet" do + registration.update!(status: "registered") + + get registration_certificate_path(registration.slug) + + expect(response).to redirect_to(registration_ticket_path(registration.slug)) + end + end + + describe "POST /registration/:slug/ce/license" do + let(:event) { create(:event) } + + context "with a CE registration on file" do + before do + license = create(:professional_license, person: registration.registrant, number: nil) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + end + + it "saves the license and mirrors it onto the CE form answer" do + post registration_ce_license_path(registration.slug), + params: { license_number: "LIC-999", license_kind: "LCSW", license_issuing_state: "TN" } + + expect(response).to redirect_to(registration_ce_path(registration.slug)) + expect(flash[:notice]).to eq("License saved.") + expect(registration.reload.continuing_education_registrations.first.professional_license.number).to eq("LIC-999") + end + + it "refuses to change the license once the certificate has been issued" do + registration.continuing_education_registrations.first.update!(certificate_sent_at: Time.current) + + post registration_ce_license_path(registration.slug), params: { license_number: "LIC-999" } + + expect(response).to redirect_to(registration_ce_path(registration.slug)) + expect(flash[:alert]).to include("can no longer be changed here") + end + end + + context "when no CE registration exists" do + it "redirects to the CE page" do + post registration_ce_license_path(registration.slug), params: { license_number: "LIC-999" } + + expect(response).to redirect_to(registration_ce_path(registration.slug)) + end + end + end + + describe "POST /registration/:slug/ce/request" do + context "when the event offers CE hours" do + let(:event) { create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 15_000) } + + it "creates a CE registration against a placeholder license and redirects" do + expect(registration.continuing_education_registrations).to be_empty + + post registration_ce_request_path(registration.slug) + + expect(response).to redirect_to(registration_ce_path(registration.slug)) + expect(flash[:notice]).to eq("Continuing education credit requested.") + expect(registration.reload.continuing_education_registrations.count).to eq(1) + end + + it "doesn't create a second CE registration if one already exists" do + license = create(:professional_license, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + + expect { post registration_ce_request_path(registration.slug) } + .not_to change { registration.reload.continuing_education_registrations.count } + end + end + + context "when the event offers no CE hours" do + let(:event) { create(:event) } + + it "redirects without creating a CE registration" do + post registration_ce_request_path(registration.slug) + + expect(response).to redirect_to(registration_ce_path(registration.slug)) + expect(registration.reload.continuing_education_registrations).to be_empty + end + end + end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index bc3d4f8be..e2854544a 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -200,15 +200,28 @@ def offer_ce!(target_event) expect(response.body).to include(sample_payment_event_path(event)) end - it "previews a published behavioral card even when the event config is incomplete" do + it "previews a published scholarship/CE card even when the event config is incomplete" do + no_form = create(:event) + BuiltinCallouts.seed(no_form) + # An event with no scholarship form has a scholarship "config gap" + # (BuiltinCalloutCards.config_gap) that hides the card on a real ticket; + # the preview shows it anyway (via ?options=all, which also flags the + # sample registrant as having requested it) so the admin can see and + # click it while finishing setup. Only scholarship/CE bypass this way — + # payment and videoconference describe the event itself (its real cost, + # its real join link), so there's nothing truthful to preview when the + # event isn't actually configured for them. + no_form.registration_ticket_callouts.find_by(builtin_key: "scholarship").update!(hidden: false) + get sample_ticket_event_path(no_form, options: "all") + expect(response.body).to include(sample_scholarship_event_path(no_form)) + end + + it "still hides the payment card for a free event even with ?options=all" do free = create(:event, cost_cents: 0) BuiltinCallouts.seed(free) - # A free event has a payment "config gap" (BuiltinCalloutCards.config_gap) - # that hides the card on a real ticket; the preview shows it anyway so the - # admin can see and click it while finishing setup. free.registration_ticket_callouts.find_by(builtin_key: "payment").update!(hidden: false) - get sample_ticket_event_path(free) - expect(response.body).to include(sample_payment_event_path(free)) + get sample_ticket_event_path(free, options: "all") + expect(response.body).not_to include(sample_payment_event_path(free)) end it "models a typical registrant by default, hiding scholarship and CE" do