From d25e0e18afa1e0b8656d361ea55f72f0da7a4e52 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 11 Jul 2026 08:37:18 -0400 Subject: [PATCH 01/14] Fix 500 in rich text asset mentions for decorated records (#1961) * Fix 500 in rich text asset mentions for decorated records The rhino editor embedded the *decorator's* signed GlobalID, so the mentions endpoint located an EventDecorator and called .rich_text_assets on a record type that doesn't own the association, raising NoMethodError. Emit the model's sgid and guard the association lookup so unsupported records return an empty list. Co-Authored-By: Claude Opus 4.8 (1M context) * Use Draper.undecorate to unwrap decorated records Per review: Draper.undecorate is the built-in for this and reads cleaner than the inline decorated? check. Also apply it in the controller. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../rich_text_asset_mentions_controller.rb | 4 +-- app/helpers/rhino_editor_helper.rb | 5 ++-- .../requests/rich_text_asset_mentions_spec.rb | 26 +++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 spec/requests/rich_text_asset_mentions_spec.rb diff --git a/app/controllers/rich_text_asset_mentions_controller.rb b/app/controllers/rich_text_asset_mentions_controller.rb index d5fbedbd37..d93ed4d889 100644 --- a/app/controllers/rich_text_asset_mentions_controller.rb +++ b/app/controllers/rich_text_asset_mentions_controller.rb @@ -2,9 +2,9 @@ class RichTextAssetMentionsController < ApplicationController skip_verify_authorized def index # authorize! - record = GlobalID::Locator.locate_signed(params[:sgid]) + record = Draper.undecorate(GlobalID::Locator.locate_signed(params[:sgid])) - unless record + unless record.respond_to?(:rich_text_assets) render json: [] and return end diff --git a/app/helpers/rhino_editor_helper.rb b/app/helpers/rhino_editor_helper.rb index ccb59c7d2b..abfad9bcc5 100644 --- a/app/helpers/rhino_editor_helper.rb +++ b/app/helpers/rhino_editor_helper.rb @@ -1,9 +1,10 @@ module RhinoEditorHelper # custom rhino editor with stimulus controller attached to edit raw source html def rhino_editor(form, base_attribute_name, label: nil, hint: nil) + object = Draper.undecorate(form.object) rhino_attr = :"rhino_#{base_attribute_name}" field_id = form.field_id(rhino_attr) - value = form.object.public_send(rhino_attr) + value = object.public_send(rhino_attr) label_tag = if label.present? @@ -70,7 +71,7 @@ def rhino_editor(form, base_attribute_name, label: nil, hint: nil) data: { blob_url_template: rails_service_blob_url(":signed_id", ":filename"), direct_upload_url: rails_direct_uploads_url, - model_sgid: form.object.persisted? ? form.object.to_sgid.to_s : nil + model_sgid: object.persisted? ? object.to_sgid.to_s : nil } ) diff --git a/spec/requests/rich_text_asset_mentions_spec.rb b/spec/requests/rich_text_asset_mentions_spec.rb new file mode 100644 index 0000000000..60b7977c90 --- /dev/null +++ b/spec/requests/rich_text_asset_mentions_spec.rb @@ -0,0 +1,26 @@ +require "rails_helper" + +RSpec.describe "/rich_text_asset_mentions", type: :request do + let(:admin) { create(:user, :admin) } + + before { sign_in admin } + + describe "GET /index" do + it "returns an empty list when the sgid resolves to a decorated record that owns no rich text assets" do + event = create(:event) + sgid = event.decorate.to_sgid.to_s + + get rich_text_asset_mentions_path(format: :json, sgid: sgid, query: "1") + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to eq([]) + end + + it "returns an empty list when the sgid cannot be located" do + get rich_text_asset_mentions_path(format: :json, sgid: "bogus", query: "1") + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to eq([]) + end + end +end From f04dc94cadb34af74971fa89eca6bc9196f6550c Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 11 Jul 2026 08:41:20 -0400 Subject: [PATCH 02/14] Add signed-in user to Honeybadger fault context (#1962) Honeybadger faults didn't identify the affected user, so triage relied on decoding request params. Attach user_id/user_email as context on every authenticated request. Co-authored-by: Claude Opus 4.8 (1M context) --- app/controllers/application_controller.rb | 7 +++++++ spec/requests/honeybadger_context_spec.rb | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 spec/requests/honeybadger_context_spec.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e3135d7434..e59fa26901 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,6 +2,7 @@ class ApplicationController < ActionController::Base before_action :authenticate_user! # ensures only logged-in users can access pages before_action :track_user_with_ahoy, unless: :devise_controller? before_action :set_current_user # for AhoyTrackable in models + before_action :set_honeybadger_context # so faults name the affected user before_action :set_paper_trail_whodunnit before_action :preload_current_user_associations @@ -73,4 +74,10 @@ def preload_current_user_associations def set_current_user Current.user = current_user if user_signed_in? # needed for Ahoy tracking in models end + + def set_honeybadger_context + return unless user_signed_in? + + Honeybadger.context(user_id: current_user.id, user_email: current_user.email) + end end diff --git a/spec/requests/honeybadger_context_spec.rb b/spec/requests/honeybadger_context_spec.rb new file mode 100644 index 0000000000..c74c54c61a --- /dev/null +++ b/spec/requests/honeybadger_context_spec.rb @@ -0,0 +1,21 @@ +require "rails_helper" + +RSpec.describe "Honeybadger context", type: :request do + before { allow(Honeybadger).to receive(:context) } + + it "records the signed-in user so faults name who hit them" do + user = create(:user) + + sign_in user + get root_path + + expect(Honeybadger).to have_received(:context) + .with(hash_including(user_id: user.id, user_email: user.email)) + end + + it "does not set user context for anonymous visitors" do + get root_path + + expect(Honeybadger).not_to have_received(:context).with(hash_including(:user_id)) + end +end From 4dba2966e26fbc970a7f19d8678d6e75489fa2fc Mon Sep 17 00:00:00 2001 From: Justin <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:44:18 -0400 Subject: [PATCH 03/14] use remote select on variation author (#1964) --- .../workshop_variations_controller.rb | 1 - app/views/workshop_variations/_form.html.erb | 26 ++++++++++--------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/app/controllers/workshop_variations_controller.rb b/app/controllers/workshop_variations_controller.rb index 38c9d126db..9f42ac9ffe 100644 --- a/app/controllers/workshop_variations_controller.rb +++ b/app/controllers/workshop_variations_controller.rb @@ -123,7 +123,6 @@ def update # end def set_form_variables - @people = Person.order(Arel.sql("LOWER(first_name), LOWER(last_name)")) @workshop = @workshop_variation.workshop || (Workshop.find_by(id: params[:workshop_id]) if params[:workshop_id].present?) @workshop_variation_idea = WorkshopVariationIdea.find_by(id: params[:workshop_variation_idea_id]) if params[:workshop_variation_idea_id].present? @workshop_variation.build_primary_asset if @workshop_variation.primary_asset.blank? diff --git a/app/views/workshop_variations/_form.html.erb b/app/views/workshop_variations/_form.html.erb index d5a5f690ff..38da45f91a 100644 --- a/app/views/workshop_variations/_form.html.erb +++ b/app/views/workshop_variations/_form.html.erb @@ -105,18 +105,20 @@ <% if params[:admin] == "true" %> <%= f.input :position, as: :integer, label: "Ordering", input_html: { class: "w-24" } %> <% end %> -
- <%= f.input :author_id, - as: :select, - label: (f.object.author ? ( - link_to "Variation author", - person_path(f.object.author), - class: "hover:underline") : "Variation author").html_safe, - collection: @people.map { |p| [ p.full_name_with_email, p.id ] }, - selected: (f.object.author_person&.id || current_user.person_id), - input_html: { class: "w-full rounded-lg px-3 py-2", - data: { searchable_select_target: "select" } } %> -
+ <%= f.input :author_id, + as: :select, + label: (f.object.author ? ( + link_to "Variation author", + person_path(f.object.author), + class: "hover:underline") : "Variation author").html_safe, + collection: f.object.author_person.present? ? [[ f.object.author_person.remote_search_label[:label], f.object.author_person.id ]] : [], + include_blank: true, + input_html: { + data: { + controller: "remote-select", + remote_select_model_value: "person" + } + } %> <% end %> From 0e5f1569aa410f8c87806d7c028fb4a66b9b6c74 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 11 Jul 2026 08:47:34 -0400 Subject: [PATCH 04/14] JM: Surface onboarding day-attendance checkmarks on the registration edit form (#1932) * Surface onboarding day-attendance checkmarks on the registration edit form The per-day attendance checkboxes only lived on the Onboarding matrix, so an admin editing a single registration couldn't mark days without leaving the page. Surface the same checkmarks beside the status dropdown, and mirror onboarding's behavior so toggling a day rolls the attendance status forward/back (client-side, respecting deliberate inactive states). DAY_FIELDS were already permitted params, so the boxes save with the form. Co-Authored-By: Claude Opus 4.8 (1M context) * Move the days-attended checkmarks into the registration meta strip Group them inline with the "View ticket" / "View submission" links instead of under the status dropdown. The attendance-status controller now spans the whole card so the checkmarks still drive the status select. Co-Authored-By: Claude Opus 4.8 (1M context) * Right-align the days-attended group with its label stacked above Push it to the right edge of the meta strip and stack the label over the day chips, per the requested layout. Co-Authored-By: Claude Opus 4.8 (1M context) * Wait for the save redirect before asserting the persisted status The derivation spec reloaded the record immediately after clicking Save, racing the submit round-trip and occasionally reading the pre-save status. Assert the redirect landed first, matching the sibling persistence spec. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../attendance_status_controller.js | 25 +++++++- app/views/event_registrations/_form.html.erb | 35 ++++++++++-- spec/system/event_registration_edit_spec.rb | 57 +++++++++++++++++++ 3 files changed, 109 insertions(+), 8 deletions(-) diff --git a/app/frontend/javascript/controllers/attendance_status_controller.js b/app/frontend/javascript/controllers/attendance_status_controller.js index 62a4a6b5d5..007416009b 100644 --- a/app/frontend/javascript/controllers/attendance_status_controller.js +++ b/app/frontend/javascript/controllers/attendance_status_controller.js @@ -4,9 +4,30 @@ import { Controller } from "@hotwired/stimulus" // Swaps the colored icon inside the status select as the dropdown changes. The // select looks like an ordinary form field (not the roster's autosaving chip), // so an "Unsaved" hint is shown until the form is saved. +// +// The per-day attendance checkboxes drive the status the same way the Onboarding +// matrix does: toggling a day rolls the status forward/back (registered → +// incomplete_attendance → attended), but only while the status is an active one — +// deliberate manual states (cancelled, no_show, transferred_out) are never +// overridden. Mirrors EventRegistration#sync_attendance_status_to_days!. export default class extends Controller { - static targets = ["select", "icon", "dirty"] - static values = { colors: Object, icons: Object, initial: String } + static targets = ["select", "icon", "dirty", "day"] + static values = { colors: Object, icons: Object, initial: String, activeStatuses: Array, dayCount: Number } + + // Recompute the status from how many day checkboxes are checked, then reflect it + // in the dropdown (which re-renders the icon and the unsaved hint via update()). + deriveFromDays() { + if (!this.activeStatusesValue.includes(this.selectTarget.value)) return + + const checked = this.dayTargets.filter((day) => day.checked).length + let derived = "incomplete_attendance" + if (checked === 0) derived = "registered" + else if (checked >= this.dayCountValue) derived = "attended" + + if (this.selectTarget.value === derived) return + this.selectTarget.value = derived + this.update() + } update() { const status = this.selectTarget.value diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb index f14d44e550..1095f8a73c 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -31,7 +31,15 @@ } %> <% current_icon_color = status_icon_colors[f.object.status] || "text-gray-500" %> <% current_icon = status_icons[f.object.status] || "fa-question" %> -
+ <%# The attendance-status controller spans the whole card so the per-day + checkmarks in the meta strip below can drive the status select above. %> +
<%= person_profile_button(f.object.registrant, subtitle: f.object.registrant.preferred_email) %> @@ -76,11 +84,7 @@
<% end %> -
+
Registration status <% end %> <% end %> + + <%# ---- Per-day attendance — the same checkmarks as the Onboarding + matrix. Toggling a day rolls the status select above forward/back + (see the attendance-status controller), and the boxes save with the + form. ---- %> +
+ Days attended +
+ <% (1..f.object.event.day_count).each do |day| %> + + <% end %> +
+
diff --git a/spec/system/event_registration_edit_spec.rb b/spec/system/event_registration_edit_spec.rb index c245dce65a..12e7bb9ddd 100644 --- a/spec/system/event_registration_edit_spec.rb +++ b/spec/system/event_registration_edit_spec.rb @@ -182,6 +182,63 @@ end end + describe "per-day attendance checkboxes" do + it "renders a checkbox per event day, reflecting stored state, and persists changes" do + registration.update!(completed_day_1: true) + + sign_in(admin) + visit edit_event_registration_path(registration) + + # The event spans 3 days, so Days 1-3 show (and no Day 4). + expect(page).to have_field("Day 1", checked: true) + expect(page).to have_field("Day 2", checked: false) + expect(page).to have_field("Day 3", checked: false) + expect(page).to have_no_field("Day 4") + + check "Day 2", allow_label_click: true + click_on "Save changes" + + expect(page).to have_current_path(registrants_event_path(event)) + expect(registration.reload.completed_day_1).to be(true) + expect(registration.completed_day_2).to be(true) + end + + it "derives the attendance status from the checked days and saves both together" do + sign_in(admin) + visit edit_event_registration_path(registration) + + badge = "[data-attendance-status-target='dirty']" + expect(page).to have_no_css(badge, visible: true) + + # Checking every day rolls the status forward to Attended (mirrors onboarding). + check "Day 1", allow_label_click: true + check "Day 2", allow_label_click: true + check "Day 3", allow_label_click: true + + expect(page).to have_css(badge, visible: true, text: "Unsaved") + expect(page).to have_select("event_registration[status]", selected: "Attended") + + click_on "Save changes" + + # Wait for the save round-trip to land before reading the database. + expect(page).to have_current_path(registrants_event_path(event)) + expect(registration.reload.status).to eq("attended") + expect(registration.completed_day_count).to eq(3) + end + + it "leaves an inactive status untouched when days are toggled" do + registration.update!(status: "cancelled") + + sign_in(admin) + visit edit_event_registration_path(registration) + + check "Day 1", allow_label_click: true + + # Cancelled is a deliberate manual state, so toggling a day never overrides it. + expect(page).to have_select("event_registration[status]", selected: "Cancelled") + end + end + describe "notifications box" do it "lists notifications sent to the registrant" do create(:notification, From d68c01679bbe7a17d17637bae16abc27a637a499 Mon Sep 17 00:00:00 2001 From: Justin <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:22:57 -0400 Subject: [PATCH 05/14] add author to select if present (#1965) --- app/views/workshop_variations/_form.html.erb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/workshop_variations/_form.html.erb b/app/views/workshop_variations/_form.html.erb index 38da45f91a..b7c14b83c4 100644 --- a/app/views/workshop_variations/_form.html.erb +++ b/app/views/workshop_variations/_form.html.erb @@ -112,6 +112,7 @@ person_path(f.object.author), class: "hover:underline") : "Variation author").html_safe, collection: f.object.author_person.present? ? [[ f.object.author_person.remote_search_label[:label], f.object.author_person.id ]] : [], + selected: f.object.author_person&.id || current_user.person_id, include_blank: true, input_html: { data: { From 1016b33b0b46692646767812de6047f6eaab3721 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 11 Jul 2026 19:27:32 -0400 Subject: [PATCH 06/14] CE cutover: reroute intake/display, drop ce_* columns (PR 2) (#1917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * CE cutover: reroute intake/display onto the new models, drop ce_* columns Completes the continuing-education cutover started by the foundation models (#1916). Reroutes registration intake, the public callout, and every read site (callouts, onboarding, reminders, CSV) off the flat EventRegistration#ce_* columns and onto ContinuingEducationRegistration / ProfessionalLicense, then drops the ce_credit_requested / ce_hours_requested / ce_license_number columns. Reconciled with #1833 (registrants-index CE column + filter), which merged to main while this branch was in flight and built the same scaffolding on the old columns: - Kept #1833's scaffolding (CE column, dropdown filter, column toggles, layout, onboarding columns, bulk-reminder filter, CSV columns, eyebrow nav) and repointed every CE data read to the new models. - EventRegistration.ce_status is now a derived scope over the CE registrations (needs_license / requested / paid / issued / not_issued), replacing the old ce_credit_requested-based buckets; the roster + reminder dropdowns follow it. - EventRegistration#ce_status_label and the aggregators (ce_requested?, ce_hours_total, ce_amount_owed_cents, ce_license_provided?, ce_paid_in_full?) read the new models; the registrants column gates on a CE record existing rather than the dropped boolean (the "requested, no record yet" state is gone now that intake creates the record). - ReminderRecipientFilter's CE matching flows through the shared ce_status scope (its per-record matchers were redundant with main's scope approach). Migration timestamped to run after the foundation migrations now in main. Co-Authored-By: Claude Opus 4.8 * CE: scholarship-style card, ce_requested flag, and a CE edit page Make continuing education mirror scholarships: - Add a stored ce_requested flag on event_registration (joins the other *_requested flags); intake sets it; the registration edit form drives it. - The CE card shows a Requested toggle only when no CE registration exists; saving it on creates the record (against the selected/only license, else a placeholder), with a flash. Once a record exists the card shows a summary + Issued/Not-issued pill + an Edit link, gated on the event being ce_eligible?. - New ContinuingEducationRegistrationsController + policy + edit page: edit license (promoting a placeholder in place), hours, cost; a Certificate issued toggle (certificate_sent_at); and removal guarded against payments. - Rename the derived ce_requested? (record-exists) to ce_registered? to free the name for the column; readers/views key off whichever they mean. Co-Authored-By: Claude Opus 4.8 * Move CE hours-offered/cost fields under the calendar description These configure whether an event grants CE and at what cost, so they belong with the event's details rather than buried in the registration-ticket callouts panel. The CE-hours ticket card (its label + details text) stays in the callouts panel. Co-Authored-By: Claude Opus 4.8 * Align scholarship/CE card status chips with a consistent grantor row The CE card always has three rows (cost·hrs / License / chip) but the scholarship card showed its funder line only when a grant existed, so its status chip sat a row higher and the two orange chips didn't line up. Always render the scholarship card's middle row — "Grantor: " when funded, an empty same-height spacer (padding) when not — so both chips align. Co-Authored-By: Claude Opus 4.8 * CE card: only show the license selector when there's a real choice A single-license registrant was still shown a one-option license dropdown. Only render the selector when the registrant has more than one license on file; with one the controller uses it, with none it creates an empty (placeholder) license to fill in later. Co-Authored-By: Claude Opus 4.8 * CE card: drop the license dropdown; pick license on create, show it after The create state is now just the Requested toggle. Saving creates the CE registration against the registrant's existing license (or an empty placeholder), and the selected license is shown in the card summary and editable on the CE registration's own page. Co-Authored-By: Claude Opus 4.8 * CE card: drop the 'Saving creates the CE registration' hint Co-Authored-By: Claude Opus 4.8 * CE toggle: amber-while-pending/teal track + teal card highlight when on Reuse (and simplify) the ce-credit-requested Stimulus controller so the CE Requested toggle behaves like scholarship's: the track turns amber while the choice is pending, teal once stored on, gray when off — and the whole card gets a teal ring while it's on. Drops the controller's dead targets (license/ hours/details fields were removed earlier), so it's no longer orphaned. Co-Authored-By: Claude Opus 4.8 * Restyle the CE registration edit page to match the scholarship form Wrap it in the continuing-education-themed (teal) outer card, use the shared centered event page header (icon + title + event + registrant), and split the fields, certificate, and removal into teal section cards with icon chips — mirroring the scholarship edit layout. Co-Authored-By: Claude Opus 4.8 * Align the org/scholarship/CE card bottom actions on one row The three cards in the registration row are equal-height (grid), but their bottom elements floated at different heights. Make each card a flex column and pin its bottom action (Connect organization link, scholarship chip, CE chip) with mt-auto so they line up. Drops the now-unneeded scholarship grantor spacer since mt-auto handles the alignment. Co-Authored-By: Claude Opus 4.8 * Block deleting a registration whose CE registration has payments Mirrors the CE-registration removal guard: deleting the registration would cascade away a paid CE registration and its allocations, so refuse it with a flash directing the admin to revert the payment first. Co-Authored-By: Claude Opus 4.8 * Let a registrant request CE from the public callout A registrant who didn't opt into CE at registration can now request it from the CE callout: a Request CE credit button sets ce_requested and creates the CE registration (placeholder license; the number is entered next). Co-Authored-By: Claude Opus 4.8 * Manage a person's professional licenses on the person edit page Add a Professional licenses section (cocoon nested fields: number, type, issuing state, expiry) with add/remove rows, mirroring affiliations/contact methods. A license whose CE registrations carry payments can't be removed — the remove control is replaced with a note, and a prepended before_destroy guard backstops it so the dependent: :destroy cascade can't wipe paid CE. Co-Authored-By: Claude Opus 4.8 * Move professional licenses below Background; drop the credentials field Place the Professional licenses section right after Background on the person edit form, with the fields and Add license button inside one bordered card. Remove the now-redundant free-text Credentials input (licenses capture this); the credentials column and its profile display toggle are untouched. Co-Authored-By: Claude Opus 4.8 * Show license type(s) as the profile credential suffix Replace the removed free-text credentials field as the source of the profile name suffix: when 'Show credentials' is on, append the person's distinct professional-license types (e.g. ', LMFT, LCSW') after their name. Keeps the profile_show_credentials toggle; the credentials column is now unused for display. Co-Authored-By: Claude Opus 4.8 * Group the registrant CE callout into labeled sections The callout body was an unanchored stack of bordered blocks. Split it into "Your CE credit", "Your professional license", and "Details" sections so it reads top-to-bottom, lead with Hours/Cost and trail the status pill, and align the Save button to the input height. Co-Authored-By: Claude Opus 4.8 * Flip the registrant license to read-only with an edit link Once a license number is on file, show it read-only with an Edit link rather than always exposing the form. The link reloads with ?editing=license to flip in the form (a plain full-page nav, no JS), and saving redirects back to the read-only view. Surface a clearer "Needs license #" status and a prompt when no number is on file yet. Co-Authored-By: Claude Opus 4.8 * Fix unclosed filter div and read CE hours from the registration The CE-status filter rewrite accidentally dropped the closing tag for the registrants search Row 2 wrapper, so the browser auto-closed it at the form boundary and distorted the filter row. Restore it. The ticket card subtitle read hours from the event default, diverging from every other registrant-facing surface (CE page, onboarding, CSV) once an admin customizes a registrant's hours. Read the registration's own total instead. Co-Authored-By: Claude Fable 5 * Render the license Expires field through simple_form The Expires date field was hand-rolled with f.label + f.date_field while the sibling fields use simple_form (wrapper: false), so its label weight and the raw native date input didn't match the rest of the row. Render it as an HTML5 date input via simple_form so the label and control line up with the others. Co-Authored-By: Claude Fable 5 * Add issuing state + expiry to the CE license forms The CE callout (registrant-facing) and CE registration edit (admin) pages only captured the license type and number, while the person edit form captures the full license (type, number, issuing state, expiry). Surface the same two extra fields on both CE surfaces so a registrant or admin can complete the license in the place they're already working, instead of bouncing to the person page. assign_license now persists issuing_state and expires_on alongside number/kind. Co-Authored-By: Claude Fable 5 * Match the card prompts and retire the CE-credit Stimulus controller - Add scholarship link was fuchsia/text-sm; recolor to grey/text-xs so it matches the sibling Connect organization prompt. - The CE card's empty (toggle-only) state had no bottom prompt, leaving it barren next to the other two — add a grey hint pinned to the bottom. CE has no separate award page (saving with Requested on creates the record), so it's a hint rather than a link. - The CE box no longer needs JS: drop the ce-credit-requested Stimulus controller (and its index.js registration / AGENTS.md count) along with the data-controller wiring, and recolor the Requested toggle's checked state. Co-Authored-By: Claude Fable 5 * Add deliberate "Add CE registration" flow + universal number formatter Add CE registration flow (mirrors scholarship's new/create): - New/create actions + routes + policy; a "+ Add CE registration" link on the registration card opens a prefilled new form in a new tab and returns to the registration. The Requested toggle still auto-creates a stub on save; this is the alternative where the admin fills license/hours/cost up front. - Extract the CE-details form fields into a shared _details_section partial used by both new and edit. Universal number formatter: - Replace ContinuingEducationRegistration.format_hours (CE-specific) and the EventsHelper#ce_hours_display wrapper with a NumberFormatter PORO + a generic plain_number helper, mirroring MoneyFormatter/dollars_from_cents. Any model, service, or view can now format a trailing-zero-free number one way. Co-Authored-By: Claude Fable 5 * Add a license picker to the CE registration form When the registrant already holds licenses, the new/edit CE form now shows a License dropdown: pick an existing one to use it as-is, or "Create new license" to add one from the typed fields. Plain server-rendered select (no JS), so the fields stay the source of truth for a new/edited license while the dropdown only chooses which record to write — a CE registration still never exists without a license. With no license on file there's no picker and the fields create the first one, as before. Co-Authored-By: Claude Fable 5 * Tidy the CE registration form: one-line license row, optional markers, atomic create - Lay the four license fields in a single 4-col grid row (widen the form to max-w-3xl) so Expires no longer wraps; mark Issuing state + Expires "(optional)". - Make create atomic: a brand-new license is a build that persists with the CE registration in one transaction, so opening (then abandoning) the new form never leaves a stray placeholder license, and a failed save rolls back both. update is likewise transactional. Co-Authored-By: Claude Fable 5 * Unify the CE status badge across every surface The registrants index, CE callout, and CE card each computed their own CE status pill with drifting labels and colors. Consolidate onto one decorator method so the lifecycle reads identically everywhere: Requested -> License # needed -> $X due -> Pending -> Issued, with Pending blue, Issued green, and every actionable/in-progress state amber. The "$X due" state shows the real outstanding balance (cost net of payments), so it persists until paid rather than flipping on the first dollar. ?admin=true on the callout previews the post-payment Pending state without recording a payment, gated on edit access. Co-Authored-By: Claude Opus 4.8 * Tint registration-edit card icons by content + retire scholarship-requested controller Color each card's section icon via section_icon_class(domain, active?) so a card with content reads as filled and an empty one stays muted — applied across the organizations, scholarship, shout-out, comments, payments, and communications cards. Drop the scholarship-requested Stimulus controller in favor of a plain peer-checked toggle, and align card chips/min-heights so the cards line up. Co-Authored-By: Claude Opus 4.8 * Live-populate the CE license fields from the picked license Selecting a license in the CE form's dropdown now loads that license's type, number, state, and expiry into the fields (a ce-license-picker Stimulus controller reads each option's data attributes); "Create new license" clears them. Saving therefore edits whichever license is selected in place, so assign_license now writes the typed fields onto the picked existing license rather than just linking to it. Co-Authored-By: Claude Fable 5 * Load picked CE license into the fields + add scholarship edit↔callout cross-links Selecting a license in the CE picker now populates the type/number/state/expiry fields from that license (new ce-license-picker Stimulus controller), so the fields always describe the selected license — and saving corrects that license in place rather than only switching to it. Mirrors the CE edit↔callout pattern onto scholarships: an admin-only sky jump-link each way, plus a return_to=scholarship eyebrow so an admin lands back on the edit page (registrants keep the ticket). Co-Authored-By: Claude Opus 4.8 * Drop the unused credentials column from people The free-text credentials field is no longer edited (form input removed) or displayed — the profile credential suffix now derives from professional-license types via Person#license_credentials. Drop the column and its permit param; the separate profile_show_credentials toggle is kept. Co-Authored-By: Claude Opus 4.8 * Scope inline PR comments to reviewer-worthy flags, not every push Commenting on every push produced noise on routine changes. Reserve inline diff comments for things a reviewer genuinely needs flagged. Co-Authored-By: Claude Opus 4.8 * Mirror the saved CE license number into the form answer The public license POST stripped the number twice — once inside assign_license and again to feed record_ce_license_answer. assign_license already normalizes it, so read the saved value back instead of re-stripping the raw param. Co-Authored-By: Claude Opus 4.8 * List the ce_license_picker Stimulus controller in AGENTS It was added in the CE cutover but missing from the controller list. Co-Authored-By: Claude Opus 4.8 * Make CE license issuing-state a US-states dropdown Match the public registration form's state field on both the CE callout and the person license fields, and mark issuing state / expiry optional. Co-Authored-By: Claude Opus 4.8 * Person license form: US states dropdown + grey date placeholder - Issuing state is now a US-states + + class="sr-only peer"> + + Requested + + + <%# Two ways to create the record, mirroring the scholarship card: the + Requested toggle above auto-creates a stub on save, while "Add CE + registration" opens the full new form (license/hours/cost) in a new tab + and returns here. %> +
+ <%= link_to new_continuing_education_registration_path(allocatable_sgid: event_registration.to_sgid.to_s, return_to: "registration"), + class: "inline-flex items-center gap-1.5 self-start rounded-md px-2 py-1 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700", + target: "_blank", rel: "noopener" do %> + + Add CE registration + + <% end %> +
+ <% else %> + <% license = ce_registration.professional_license %> +
+
+ <%= dollars_from_cents(ce_registration.cost_cents) %> + · + <%= plain_number(ce_registration.hours) || "0" %> hrs + <%= link_to edit_continuing_education_registration_path(ce_registration, return_to: "registration"), + class: "ml-auto inline-flex items-center gap-1.5 text-xs font-medium text-gray-500 hover:text-gray-700 hover:underline", + target: "_blank", rel: "noopener" do %> + Edit + + <% end %> +
+ +

+ License: + <% if license&.number_known? %> + <%= license.name %> + <% else %> + Needs license + <% end %> +

+ + <%# Pinned to the card's bottom so it lines up with the scholarship card's + chip and the organizations card's "Connect organization" link. %> +
+ <%= render "event_registrations/ce_status_badge", registration: event_registration %> +
+
+ <% end %> +
+ diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb index 1095f8a73c..783acbabec 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -117,7 +117,7 @@ <% if f.object.slug.present? %> <%= link_to registration_ticket_path(f.object.slug), - class: "group inline-flex items-center gap-1.5 font-medium #{DomainTheme.text_class_for(:event_registrations, intensity: 600)} hover:underline", + class: "group inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-2.5 py-1 font-medium shadow-sm #{DomainTheme.text_class_for(:event_registrations, intensity: 600)} hover:bg-gray-50 transition-colors", target: "_blank", data: { turbo_frame: "_top" } do %> @@ -133,7 +133,7 @@ title: "#{form_name} — submitted #{ts.strftime('%b %-d, %Y')}", data: { turbo_frame: "_top" } do %> - View submission<%= " ##{i + 1}" if submissions.size > 1 %> + View form submission<%= " ##{i + 1}" if submissions.size > 1 %> <% end %> <% end %> @@ -164,16 +164,21 @@ <% connected_org_ids = f.object.organizations.map(&:id) %> <% addable_orgs = active_orgs.reject { |org| connected_org_ids.include?(org.id) } %>
-
+
- + -

Registration organizations

+ <%# Keep "Registration-linked" intact on line one (no break at the hyphen) and + let "organizations" wrap to line two in the narrow card header. %> +

Registration-linked organizations

-
-
> +
+ <%# Nudge the chips up by the org card's extra header line (its title wraps + to two lines) so the first chip's top lines up with the scholarship + card's "$100". %> +
>
<% f.object.organizations.sort_by(&:name).each do |org| %>
<% if f.object.payment_unresolved? %> @@ -327,7 +273,7 @@ <%# ---- Comments ---- %>
- +

Registration comments

@@ -376,7 +322,7 @@ their profile via nested attributes. ---- %>
- +

Shout out

@@ -390,7 +336,7 @@ value="1" <%= "checked" if f.object.shoutout? %> class="sr-only peer"> - + Feature on the recipients page diff --git a/app/views/event_registrations/_payment_history.html.erb b/app/views/event_registrations/_payment_history.html.erb index 0986c050eb..73708d1ef9 100644 --- a/app/views/event_registrations/_payment_history.html.erb +++ b/app/views/event_registrations/_payment_history.html.erb @@ -8,7 +8,7 @@ <% return if cost_cents <= 0 && event_registration.allocations.empty? %>
- +

Registration payments and allocations

diff --git a/app/views/event_registrations/_scholarship.html.erb b/app/views/event_registrations/_scholarship.html.erb index dd80e30f14..665f0cf253 100644 --- a/app/views/event_registrations/_scholarship.html.erb +++ b/app/views/event_registrations/_scholarship.html.erb @@ -2,18 +2,15 @@ not create or destroy an award on its own. Awarding is a deliberate action via "Add scholarship". On save, the controller cleans up an unrequested scholarship only when it is an empty stub (no amount, tasks incomplete). ---- %> -<% scholarship_active = scholarship.present? || event_registration.scholarship_requested %> -
" - data-controller="scholarship-requested" - data-scholarship-requested-initial-value="<%= event_registration.scholarship_requested %>"> +
- +

Scholarship

-
+
<% unless scholarship %> <% end %> <% if scholarship %> -
+
<%= dollars_from_cents(scholarship.amount_cents) %> <%= link_to edit_scholarship_path(scholarship, return_to: "registration"), @@ -43,7 +38,7 @@ <% if scholarship.grant&.funder_name.present? %>

- Funded by + Grantor: <% if scholarship.grant.donor %> <%= link_to scholarship.grant.funder_name, polymorphic_path(scholarship.grant.donor), class: "font-medium text-gray-700 hover:underline" %> @@ -53,7 +48,9 @@

<% end %> -
+ <%# Pinned to the card's bottom so it lines up with the CE card's chip and + the organizations card's "Connect organization" link. %> +
<% if scholarship.tasks_completed? %> @@ -68,11 +65,11 @@
<% else %> -
+
<%= link_to new_scholarship_path(allocatable_sgid: event_registration.to_sgid.to_s, return_to: "registration"), - class: "inline-flex items-center gap-1.5 text-sm font-medium #{DomainTheme.text_class_for(:scholarships, intensity: 600)} hover:underline", + class: "inline-flex items-center gap-1.5 self-start rounded-md px-2 py-1 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700", target: "_blank", rel: "noopener" do %> - + Add scholarship <% end %> diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 96f08bc12d..8d981534f7 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -224,6 +224,27 @@ class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> + <%# Continuing education — "CE hours offered" above 0 makes the event + CE-eligible (the gate for the CE card on registrations); the total cost + is what a registrant pays for credit (stored in cents). %> +
+ +
+ + +
+

Edit the CE hours ticket card and its details below, under Registration ticket callouts.

+
+
<%# RIGHT: Autoshow display options %> @@ -513,19 +534,6 @@ title_placeholder: "CE hours", content_help: "CE requirements, payment, sign-in rules, and the post-training evaluation — shown on its own page linked from the ticket. Accepts basic HTML — bold, italics, links, lists, headings, and line breaks.", content_placeholder: "e.g.

AWBW is approved by CAMFT…

Before the training

  • Email your license number
" %> -
- - -
<% when :event_details %> <%= render "events/builtin_callout_card", f: f, card: card, label_field: :event_details_label, content_field: :event_details, diff --git a/app/views/events/_registrants_results.html.erb b/app/views/events/_registrants_results.html.erb index ba7c5aa8e5..0db3449d7e 100644 --- a/app/views/events/_registrants_results.html.erb +++ b/app/views/events/_registrants_results.html.erb @@ -293,37 +293,19 @@ <% if ce_col %> - <%# CE progression mirrors scholarship's colors. Until a CE registration - record exists it's just "Requested"; once it does, walk through - license → payment → paid. "Create" when CE wasn't requested. %> - <% cer = registration.continuing_education_registrations.first %> - <% if !registration.ce_credit_requested? %> - <% ce_classes = "bg-gray-50 text-gray-400 border-gray-200" %> - <% ce_text = "Create" %> - <% ce_title = "No CE credit requested" %> - <% elsif cer.nil? %> - <% ce_classes = "bg-amber-50 text-amber-700 border-amber-200" %> - <% ce_text = "Requested" %> - <% ce_title = "CE requested — no registration record yet" %> - <% elsif !cer.professional_license&.number_known? %> - <% ce_classes = "bg-amber-50 text-amber-700 border-amber-200" %> - <% ce_text = "No license #" %> - <% ce_title = "CE license number not provided" %> - <% elsif !cer.paid_in_full? %> - <% ce_classes = "bg-blue-50 text-blue-700 border-blue-200" %> - <% ce_text = "Filed" %> - <% ce_title = "CE license filed — #{dollars_from_cents(cer.remaining_cost)} due" %> + <%# Canonical CE badge (Requested → License # needed → $X due → + Pending → Issued). "Create" when CE isn't in play yet. %> + <% if registration.decorate.ce_status_badge.nil? %> + <%= link_to edit_event_registration_path(registration, return_to: "registrants"), + title: "No CE credit requested", + class: "inline-flex items-center gap-1.5 whitespace-nowrap rounded-full text-xs font-medium border px-5 py-0.5 bg-gray-50 text-gray-400 border-gray-200 transition hover:opacity-80 hover:shadow-sm", + data: { turbo_frame: "_top" } do %> + Create + + <% end %> <% else %> - <% ce_classes = "bg-green-50 text-green-700 border-green-200" %> - <% ce_text = "Recipient" %> - <% ce_title = "CE paid" %> - <% end %> - <%= link_to edit_event_registration_path(registration, return_to: "registrants"), - title: ce_title, - class: "inline-flex items-center gap-1.5 whitespace-nowrap rounded-full text-xs font-medium border px-5 py-0.5 #{ce_classes} transition hover:opacity-80 hover:shadow-sm", - data: { turbo_frame: "_top" } do %> - <%= ce_text %> - + <%= render "event_registrations/ce_status_badge", registration: registration, + href: edit_event_registration_path(registration, return_to: "registrants") %> <% end %> <% end %> diff --git a/app/views/events/_registrants_search.html.erb b/app/views/events/_registrants_search.html.erb index b73053d9a8..f637393060 100644 --- a/app/views/events/_registrants_search.html.erb +++ b/app/views/events/_registrants_search.html.erb @@ -36,7 +36,7 @@ <% if @ce_eligible %> <%= render "events/filter_select", param: :ce_status, label: "CE status", - options: [ [ "Requested", "requested" ], [ "License not provided", "license_not_provided" ], [ "Hours not provided", "hours_not_provided" ], [ "Paid", "paid" ] ], + options: [ [ "Needs license", "needs_license" ], [ "Requested", "requested" ], [ "Paid", "paid" ], [ "Issued", "issued" ], [ "Not issued", "not_issued" ] ], selected: params[:ce_status], blank: "Any CE status", field_class: field_class %> <% end %> diff --git a/app/views/events/_reminder_recipient_filters.html.erb b/app/views/events/_reminder_recipient_filters.html.erb index 3491a19764..8ed5a1121d 100644 --- a/app/views/events/_reminder_recipient_filters.html.erb +++ b/app/views/events/_reminder_recipient_filters.html.erb @@ -47,7 +47,7 @@ <% if @ce_eligible %> <%= render "events/filter_select", param: :ce_status, label: "CE status", - options: [ [ "Requested", "requested" ], [ "License not provided", "license_not_provided" ], [ "Hours not provided", "hours_not_provided" ], [ "Paid", "paid" ] ], + options: [ [ "Needs license", "needs_license" ], [ "Requested", "requested" ], [ "Paid", "paid" ], [ "Issued", "issued" ], [ "Not issued", "not_issued" ] ], selected: params[:ce_status], blank: "Any CE status", field_class: field_class %> <% end %> diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 25423fdb6f..0407867625 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -1,55 +1,211 @@ <% content_for(:page_bg_class, "public") %> <% content_for(:page_title, "#{@event.ce_hours_details_label} — #{@event.title}") %> -<%= render layout: "events/callouts/callout_page", locals: { title: @event.ce_hours_details_label } do %> - <% if @event_registration.ce_credit_requested? %> -
-

Status

- <% if @event_registration.paid_in_full? %> - - Paid - - <% else %> - - Requested - - <% end %> -
- -
-
-
Hours requested
- <% if @event_registration.ce_hours_requested.present? %> -
<%= @event_registration.ce_hours_requested %>
- <% else %> -
We don't have your requested hours yet.
+<% ce_registration = @event_registration.continuing_education_registrations.first %> + +<%# + Reached from the admin CE registration edit page (return_to=ce_registration): + point the eyebrow back there instead of the registrant's ticket. Gated on edit + access so a registrant who lands on this URL still gets the default ticket back. +%> + +<% callout_eyebrow = if params[:return_to] == "ce_registration" && ce_registration && allowed_to?(:edit?, ce_registration) + { back_path: edit_continuing_education_registration_path(ce_registration), back_label: "Back to CE registration" } + else + {} + end %> + +<%= render layout: "events/callouts/callout_page", locals: { title: @event.ce_hours_details_label, **callout_eyebrow } do %> + <% if @event_registration.ce_registered? %> + <% license = ce_registration&.professional_license %> + <% license_kind = license&.kind %> + <% license_number = license&.number %> + <% license_issuing_state = license&.issuing_state %> + <% license_expires_on = license&.expires_on %> + <% license_on_file = license&.number_known? %> + <%# + ?admin=true lets an admin preview the post-payment ("Pending") state of the + badge without recording a real payment. Gated on edit access so a registrant + can't fake it by editing the URL. + %> + <% simulate_ce_paid = params[:admin] == "true" && ce_registration && allowed_to?(:edit?, ce_registration) %> + <%# + Admin-only jump to the management surface for this CE registration. Hidden + from registrants; opens in a new tab so the registrant view is kept. + %> + <% if ce_registration && allowed_to?(:edit?, ce_registration) %> +
+ <%= link_to edit_continuing_education_registration_path(ce_registration), target: "_blank", rel: "noopener", + class: "inline-flex items-center gap-1 text-xs rounded-full border px-2 py-0.5 bg-sky-100 text-sky-700 border-sky-200 hover:bg-sky-200 transition" do %> + + Edit CE registration + <% end %>
-
-
Cost<% if @event_registration.ce_hours_requested.present? %> (<%= @event_registration.ce_hours_requested %> × $<%= EventRegistration::CE_HOURLY_RATE_DOLLARS %>)<% end %>
-
<%= @event_registration.ce_hours_requested.present? ? dollars_from_cents(@event_registration.ce_amount_owed_cents) : "—" %>
-
-
-
License number
- <% if @event_registration.ce_license_provided? %> -
<%= @event_registration.ce_license_number %>
+ <% end %> + <%= turbo_frame_tag "license_section" do %> +
+

Your CE credit

+ +
+
+
Hours
+
<%= plain_number(@event_registration.ce_hours_total) || "—" %>
+
+ +
+
Cost
+
<%= dollars_from_cents(@event_registration.ce_amount_owed_cents) %>
+
+ +
+
Status
+
<%= render "event_registrations/ce_status_badge", registration: @event_registration, simulate_paid: simulate_ce_paid %>
+
+
+
+ + <%# + Registrants supply (or correct) their license type + number here. With a + number on file we show it read-only with an Edit link; that link reloads the + page with ?editing=license to flip in the form (a plain full-page nav, no + JS). Saving POSTs and redirects back to the read-only view. With nothing on + file the form shows straight away. + %> + <% editing_license = params[:editing] == "license" %> + + <%# + Once the certificate is issued the license is locked — it's the credential + the certificate was issued under. Read-only, no Edit link; admins correct it + on the admin CE edit page. + %> + <% license_locked = ce_registration.certificate_sent_at.present? %> + +
+
+

Your professional license

+ + <% if license_on_file && !editing_license && !license_locked %> + <%= link_to registration_ce_path(@event_registration.slug, editing: "license", return_to: params[:return_to].presence), + class: "shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-50" do %> + + Edit + <% end %> + <% end %> +
+ + <% if license_locked || (license_on_file && !editing_license) %> + <%# Each license field on its own row, labels in a fixed column so the values line up. %> +
+
+
License type
+
<%= license_kind.presence || "—" %>
+
+ +
+
License number
+
<%= license_number %>
+
+ +
+
Issuing state
+
<%= license_issuing_state.presence || "—" %>
+
+ +
+
Expires
+
<%= license_expires_on&.to_fs(:long) || "—" %>
+
+
+ <% if license_locked %> +

+ + Your CE certificate has been issued, so your license is locked. Contact us if it needs correcting. +

+ <% end %> <% else %> -
We don't have your license number on file yet.
- <% end %> -
-
+ <%= form_with url: registration_ce_license_path(@event_registration.slug), method: :post, + class: "mt-3" do |form| %> +
+
+ - <% if @event_registration.ce_hours_requested.blank? || !@event_registration.ce_license_provided? %> -
- We don't have all of your CE details yet. Please <%= link_to "email us", contact_us_path, class: "underline font-medium" %> with your license number and the number of CE hours you'd like. -
+ <%= form.text_field :license_kind, value: license_kind, id: "license_kind", + placeholder: "e.g. LCSW", required: true, + class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+ +
+ + + <%= form.text_field :license_number, value: license_number, id: "license_number", + placeholder: "e.g. 12345", required: true, + class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+ +
+ + + <%# + US states dropdown, matching the public registration form's state field. + Greyed while blank (placeholder) and darkened once a state is picked — by + form logic on the stored value, no JS. + %> + + +
+ +
+ + + <%# + Date inputs render their mm/dd/yyyy hint in the input's own text colour + (no ::placeholder), so grey it while empty and darken once a date is set. + %> + + <%= form.date_field :license_expires_on, value: license_expires_on, id: "license_expires_on", + class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm #{license_expires_on.present? ? "text-gray-900" : "text-gray-400"} shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ +
+ <%= form.submit "Save changes", class: "shrink-0 rounded-lg border border-transparent bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %> + + <% if license_on_file %> + <%= link_to "Cancel", registration_ce_path(@event_registration.slug, return_to: params[:return_to].presence), + class: "shrink-0 px-2 py-2 text-sm font-medium text-gray-500 hover:text-gray-700" %> + <% end %> +
+ + <% unless license_on_file %> +

We need your license type and number to issue your CE certificate.

+ <% end %> + +

Acceptance of CE hours is determined by each state board; we can't guarantee yours will accept them.

+ <% end %> + <% end %> +
<% end %> <% else %> -

You haven't requested continuing education credit for this training. CE hours are available for an additional fee — reach out to request credit.

+

You haven't requested continuing education credit for this training. CE hours are available for an additional fee.

+ <%= form_with url: registration_ce_request_path(@event_registration.slug), method: :post, data: { turbo: false }, class: "mt-4" do |form| %> + <%= form.submit "Request CE credit", class: "rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %> + <% end %> <% end %> <% if @event.ce_hours_details.present? %> -
- <%= form_label_html(@event.ce_hours_details) %> -
+
+

Details

+
<%= form_label_html(@event.ce_hours_details) %>
+
<% end %> <% end %> diff --git a/app/views/events/callouts/scholarship.html.erb b/app/views/events/callouts/scholarship.html.erb index 7ff8e0b178..0f6260a572 100644 --- a/app/views/events/callouts/scholarship.html.erb +++ b/app/views/events/callouts/scholarship.html.erb @@ -1,10 +1,21 @@ <% content_for(:page_bg_class, "public") %> <% content_for(:page_title, "Scholarship — #{@event.title}") %> +<%# Reached from the admin scholarship edit page (return_to=scholarship): point the + eyebrow back there instead of the registrant's ticket. Gated on edit access so a + registrant who lands on this URL still gets the default ticket back. %> +<% from_scholarship_edit = params[:return_to] == "scholarship" && @scholarship && allowed_to?(:edit?, @scholarship) %>
- <%= link_to registration_ticket_path(@event_registration.slug), - class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %> - Back to ticket + <% if from_scholarship_edit %> + <%= link_to edit_scholarship_path(@scholarship), + class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %> + Back to scholarship + <% end %> + <% else %> + <%= link_to registration_ticket_path(@event_registration.slug), + class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %> + Back to ticket + <% end %> <% end %>
@@ -24,6 +35,19 @@
+ <% if @scholarship && allowed_to?(:edit?, @scholarship) %> + <%# Admin-only jump to the management surface for this scholarship. Hidden from + registrants; opens in a new tab so the registrant view is kept. %> +
+ <%= link_to edit_scholarship_path(@scholarship), target: "_blank", rel: "noopener", + class: "inline-flex items-center gap-1 text-xs rounded-full border px-2 py-0.5 bg-sky-100 text-sky-700 border-sky-200 hover:bg-sky-200 transition" do %> + + Edit scholarship + + <% end %> +
+ <% end %> + <% if @scholarship %> <% grant = @scholarship.grant %>
diff --git a/app/views/events/onboarding/_row.html.erb b/app/views/events/onboarding/_row.html.erb index 19ee6ad93f..6177e81b70 100644 --- a/app/views/events/onboarding/_row.html.erb +++ b/app/views/events/onboarding/_row.html.erb @@ -205,7 +205,7 @@ <% when :ce_requested %> - <% ce_requested = registration.ce_credit_requested? %> + <% ce_requested = registration.ce_requested? %> " data-sort-value="<%= ce_requested ? 1 : 0 %>"> <%= link_to edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:opacity-80", title: "Edit CE details", data: { turbo_frame: "_top" } do %> <% if ce_requested %> @@ -217,20 +217,20 @@ <% when :ce_hours %> - <% ce_hours = registration.ce_hours_requested.to_i %> + <% ce_hours = registration.ce_hours_total.to_f %> <%= ce_hours.positive? ? "text-gray-800" : "text-gray-400" %>" data-sort-value="<%= ce_hours %>"> - <%= link_to (ce_hours.positive? ? ce_hours : "—"), edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:underline #{ce_hours.positive? ? "text-gray-800" : "text-gray-400"}", data: { turbo_frame: "_top" } %> + <%= link_to (ce_hours.positive? ? plain_number(ce_hours) : "—"), edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:underline #{ce_hours.positive? ? "text-gray-800" : "text-gray-400"}", data: { turbo_frame: "_top" } %> <% when :ce_amount %> <% ce_cents = registration.ce_amount_owed_cents %> <%= ce_cents.positive? ? "text-gray-800" : "text-gray-400" %>" data-sort-value="<%= ce_cents %>"> - <%= link_to (ce_cents.positive? ? dollars_from_cents(ce_cents) : "—"), edit_event_registration_path(registration, return_to: "onboarding"), title: "CE owed (hours × $#{EventRegistration::CE_HOURLY_RATE_DOLLARS})", class: "hover:underline #{ce_cents.positive? ? "text-gray-800" : "text-gray-400"}", data: { turbo_frame: "_top" } %> + <%= link_to (ce_cents.positive? ? dollars_from_cents(ce_cents) : "—"), edit_event_registration_path(registration, return_to: "onboarding"), title: "CE cost", class: "hover:underline #{ce_cents.positive? ? "text-gray-800" : "text-gray-400"}", data: { turbo_frame: "_top" } %> <% when :ce_license %> - <% ce_license = registration.ce_license_number %> - text-sm" data-sort-value="<%= ce_license.to_s.downcase %>"> + <% ce_license = registration.ce_license_numbers.join(", ") %> + text-sm" data-sort-value="<%= ce_license.downcase %>"> <%= link_to (ce_license.presence || "—"), edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:underline #{ce_license.present? ? "text-gray-700" : "text-gray-400"}", data: { turbo_frame: "_top" } %> diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb index 7bec3c4bb7..9605cfe1ca 100644 --- a/app/views/events/public_registrations/_form_field.html.erb +++ b/app/views/events/public_registrations/_form_field.html.erb @@ -78,10 +78,10 @@ data and store none of their own, so fall back to those when present. %> <% radio_options = dynamic_form_field_options(field) || field.form_field_answer_options.includes(:answer_option).map { |ffao| [ ffao.answer_option.name, ffao.answer_option.name ] } %> - <% has_specify = radio_options.any? { |option_label, _option_value| specify_placeholder(option_label, field).present? } %> + <% has_specify = radio_options.any? { |option_label, _option_value| specify_placeholder(option_label).present? } %> <%= tag.div class: "flex flex-wrap gap-2.5 mt-1", data: (has_specify ? { controller: "specify-option", action: "change->specify-option#update" } : {}) do %> <% radio_options.each do |option_label, option_value, option_description| %> - <% placeholder = specify_placeholder(option_label, field) %> + <% placeholder = specify_placeholder(option_label) %> <% is_specify = placeholder.present? %>
<% else %> diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index dc99406a5e..e2c8ff349f 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -351,11 +351,6 @@ focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50" }, hint: "Maximum 250 characters" %> - - <%= f.input :credentials, - label: "Credentials", - input_html: { class: "w-full" }, - wrapper_html: { class: "w-full" } %>
@@ -375,6 +370,23 @@
+
+
+ Professional licenses +
+
+

Used for continuing-education credit. Once a license has CE registrations it can't be removed, and only an admin can edit it.

+ <%= f.simple_fields_for :professional_licenses do |license_form| %> + <%= render "professional_licenses/professional_license_fields", f: license_form %> + <% end %> + <%= link_to_add_association "➕ Add license", + f, + :professional_licenses, + partial: "professional_licenses/professional_license_fields", + class: "btn btn-secondary-outline" %> +
+
+
@@ -402,7 +414,7 @@ collection: us_time_zone_fundamentals, selected: user_form.object.time_zone.presence || "Pacific Time (US & Canada)", include_blank: false, - hint: "Event times and other dates will be shown in this timezone.", + hint: timezone_visibility_hint(user_form.object), input_html: { class: "w-full" }, wrapper_html: { class: "w-full sm:flex-1" } %> <% end %> diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index 4857fe4d07..a6ae611b70 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -64,7 +64,7 @@

- <%= @person.name %><% if @person.credentials.present? && @person.profile_show_credentials? %>, <%= @person.credentials %><% end %> + <%= @person.name %><% if @person.profile_show_credentials? && @person.license_credentials.present? %>, <%= @person.license_credentials %><% end %> <% if @person.pronouns.present? && @person.profile_show_pronouns? %> (<%= @person.pronouns %>) <% end %> diff --git a/app/views/professional_licenses/_professional_license_fields.html.erb b/app/views/professional_licenses/_professional_license_fields.html.erb new file mode 100644 index 0000000000..5d6ee69ee0 --- /dev/null +++ b/app/views/professional_licenses/_professional_license_fields.html.erb @@ -0,0 +1,82 @@ +<%# A license is editable by an admin, or by its holder while it has no CE + registrations. Once CE registrations exist it locks to admins — shown read-only + with admin-only styling. (Today the whole person form is admin-only, so the + locked branch only surfaces once PersonPolicy opens editing to owners.) + simple_fields_for still emits the hidden id in the locked branch, so the record + submits unchanged. %> +<% editable = f.object.new_record? || allowed_to?(:edit?, f.object) %> +<% if editable %> +
+
+
+ <%= f.input :number, + wrapper: false, + label: "License number", + input_html: { placeholder: "e.g. 90210", class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" } %> +
+ +
+ <%= f.input :kind, + wrapper: false, + label: "Type", + input_html: { placeholder: "e.g. LMFT", class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" } %> +
+ +
+ <%= f.input :issuing_state, + as: :select, + collection: us_states, + include_blank: true, + wrapper: false, + label: "Issuing state", + input_html: { class: "w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" } %> +
+ +
+ <%= f.input :expires_on, + as: :date, + html5: true, + wrapper: false, + label: "Expires", + input_html: { class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" } %> +
+ +
+ <% if f.object.new_record? || f.object.removable? %> + <%= link_to_remove_association "Remove", + f, + class: "text-sm text-gray-400 hover:text-red-600 underline whitespace-nowrap" %> + <% else %> + Has CE registrations — can't remove + <% end %> +
+
+
+<% else %> +
+
+
+
+
License number
+
<%= f.object.number.presence || "—" %>
+
+
+
Type
+
<%= f.object.kind.presence || "—" %>
+
+
+
Issuing state
+
<%= f.object.issuing_state.presence || "—" %>
+
+
+
Expires
+
<%= f.object.expires_on&.to_fs(:long) || "—" %>
+
+
+ + Admin only + +
+

Locked because this license has CE registrations — only an admin can edit it.

+
+<% end %> diff --git a/app/views/scholarships/edit.html.erb b/app/views/scholarships/edit.html.erb index 27a3311abb..c478953e97 100644 --- a/app/views/scholarships/edit.html.erb +++ b/app/views/scholarships/edit.html.erb @@ -36,6 +36,17 @@ <% end %> <% end %>
+ <% if @allocatable.respond_to?(:event) %> + <%# Admin jump to the registrant-facing scholarship callout (what the registrant + sees). Opens in a new tab so the edit context is kept; matches the sky + admin-link convention and the CE edit page's "Registrant's CE page". %> + <%= link_to registration_scholarship_path(@allocatable.slug, return_to: "scholarship"), target: "_blank", rel: "noopener", + class: "inline-flex items-center gap-1 text-xs rounded-full border px-2 py-0.5 bg-sky-100 text-sky-700 border-sky-200 hover:bg-sky-200 transition" do %> + + Registrant's scholarship page + + <% end %> + <% end %> <%= link_to "View", scholarship_path(@scholarship), class: "text-sm text-gray-500 hover:text-gray-700" %> <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700" %>
diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index ce76bbb7b8..3b2f00607c 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -105,7 +105,7 @@ collection: us_time_zone_fundamentals, selected: f.object.time_zone.presence || "Pacific Time (US & Canada)", include_blank: false, - hint: "Event times and other dates will be shown in this timezone.", + hint: timezone_visibility_hint(f.object), input_html: { class: "w-full" }, wrapper_html: { class: "w-full" } %> <% end %> diff --git a/config/routes.rb b/config/routes.rb index b107a16572..b08eb45f06 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -84,6 +84,8 @@ get "registration/:slug/payment", to: "events/callouts#payment", as: :registration_payment get "registration/:slug/certificate", to: "events/callouts#certificate", as: :registration_certificate get "registration/:slug/ce", to: "events/callouts#ce", as: :registration_ce + post "registration/:slug/ce/license", to: "events/callouts#update_ce_license", as: :registration_ce_license + post "registration/:slug/ce/request", to: "events/callouts#request_ce", as: :registration_ce_request get "registration/:slug/forms", to: "events/callouts#forms", as: :registration_forms get "registration/:slug/handouts", to: "events/callouts#handouts", as: :registration_handouts get "registration/:slug/resource/:resource_id", to: "events/callouts#resource", as: :registration_resource @@ -117,6 +119,9 @@ resources :scholarships, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do member { patch :toggle_tasks } end + resources :continuing_education_registrations, only: [ :new, :create, :edit, :update, :destroy ] do + member { patch :toggle_certificate } + end resources :discounts, only: [ :create, :show, :destroy ] do collection do post :allocation_form diff --git a/db/migrate/20260629122601_remove_ce_columns_from_event_registrations.rb b/db/migrate/20260629122601_remove_ce_columns_from_event_registrations.rb new file mode 100644 index 0000000000..029e38859d --- /dev/null +++ b/db/migrate/20260629122601_remove_ce_columns_from_event_registrations.rb @@ -0,0 +1,16 @@ +class RemoveCeColumnsFromEventRegistrations < ActiveRecord::Migration[8.1] + # CE is now tracked as ContinuingEducationRegistration records, so these flat + # columns are obsolete. No data to preserve (the CE form was never used in + # production). + def up + remove_column :event_registrations, :ce_credit_requested, if_exists: true + remove_column :event_registrations, :ce_hours_requested, if_exists: true + remove_column :event_registrations, :ce_license_number, if_exists: true + end + + def down + add_column :event_registrations, :ce_credit_requested, :boolean, null: false, default: false unless column_exists?(:event_registrations, :ce_credit_requested) + add_column :event_registrations, :ce_hours_requested, :integer unless column_exists?(:event_registrations, :ce_hours_requested) + add_column :event_registrations, :ce_license_number, :string unless column_exists?(:event_registrations, :ce_license_number) + end +end diff --git a/db/migrate/20260629173817_add_ce_requested_to_event_registrations.rb b/db/migrate/20260629173817_add_ce_requested_to_event_registrations.rb new file mode 100644 index 0000000000..8d294a7fc9 --- /dev/null +++ b/db/migrate/20260629173817_add_ce_requested_to_event_registrations.rb @@ -0,0 +1,14 @@ +class AddCeRequestedToEventRegistrations < ActiveRecord::Migration[8.1] + # The CE "Requested" intent flag, mirroring scholarship_requested / w9_requested + # / invoice_requested. It's the toggle the registrant/admin sets; the + # ContinuingEducationRegistration record is the fulfillment, created from it. + def up + unless column_exists?(:event_registrations, :ce_requested) + add_column :event_registrations, :ce_requested, :boolean, null: false, default: false + end + end + + def down + remove_column :event_registrations, :ce_requested, if_exists: true + end +end diff --git a/db/migrate/20260630133153_remove_credentials_from_people.rb b/db/migrate/20260630133153_remove_credentials_from_people.rb new file mode 100644 index 0000000000..fb0886729c --- /dev/null +++ b/db/migrate/20260630133153_remove_credentials_from_people.rb @@ -0,0 +1,12 @@ +class RemoveCredentialsFromPeople < ActiveRecord::Migration[8.1] + # The free-text credentials field is no longer edited or displayed — the profile + # credential suffix now derives from the person's professional-license types + # (Person#license_credentials). The profile_show_credentials toggle stays. + def up + remove_column :people, :credentials, if_exists: true + end + + def down + add_column :people, :credentials, :string unless column_exists?(:people, :credentials) + end +end diff --git a/db/migrate/20260630220947_scope_professional_license_uniqueness_to_kind.rb b/db/migrate/20260630220947_scope_professional_license_uniqueness_to_kind.rb new file mode 100644 index 0000000000..856a7c646a --- /dev/null +++ b/db/migrate/20260630220947_scope_professional_license_uniqueness_to_kind.rb @@ -0,0 +1,10 @@ +class ScopeProfessionalLicenseUniquenessToKind < ActiveRecord::Migration[8.1] + # A license is identified by kind + number, not number alone, so "LMFT 12345" + # and "LCSW 12345" are distinct. Widen the per-person unique index. + def change + remove_index :professional_licenses, [ :person_id, :number ], unique: true, + name: "index_professional_licenses_on_person_and_number" + add_index :professional_licenses, [ :person_id, :kind, :number ], unique: true, + name: "index_professional_licenses_on_person_kind_number" + end +end diff --git a/db/schema.rb b/db/schema.rb index 28fba433c2..d0dc66e1bc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -479,9 +479,7 @@ end create_table "event_registrations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.boolean "ce_credit_requested", default: false, null: false - t.integer "ce_hours_requested" - t.string "ce_license_number" + t.boolean "ce_requested", default: false, null: false t.datetime "certificate_sent_at" t.string "checkout_session_id" t.boolean "completed_day_1", default: false, null: false @@ -968,7 +966,6 @@ t.boolean "blog_contributor", default: false, null: false t.datetime "created_at", null: false t.integer "created_by_id" - t.string "credentials" t.date "date_of_birth" t.string "display_name_preference" t.string "email" @@ -1034,7 +1031,7 @@ t.datetime "updated_at", null: false t.bigint "updated_by_id" t.index ["created_by_id"], name: "index_professional_licenses_on_created_by_id" - t.index ["person_id", "number"], name: "index_professional_licenses_on_person_and_number", unique: true + t.index ["person_id", "kind", "number"], name: "index_professional_licenses_on_person_kind_number", unique: true t.index ["person_id"], name: "index_professional_licenses_on_person_id" t.index ["updated_by_id"], name: "index_professional_licenses_on_updated_by_id" end diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index 3986c176ab..87f3cc530d 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -155,8 +155,8 @@ .update_all(subtitle: "Payments are due no more than three weeks after your registration date. " \ "Training details will be sent after payments are received.") -# The CE-interest "magic question": a single Yes/No whose answer drives the -# resulting registration's ce_credit_requested flag (see +# The CE-interest "magic question": a single Yes/No whose "Yes" creates the +# registration's ContinuingEducationRegistration (see # EventRegistrationServices::PublicRegistration). Seeded straight onto the form # with its own section so the form builder's add/remove-section logic leaves it # alone, and carrying the well-known field_identifier the service keys off. A @@ -769,10 +769,11 @@ # so she can reach her training materials (the intends_to_pay scenario). Pairs # with Amy on this same event, who DOES have payments, for side-by-side review. if facilitator_training + facilitator_training.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) [ - { person: amy_person, status: "registered", scholarship_requested: true, w9_requested: true, invoice_requested: true, ce_credit_requested: true }, + { person: amy_person, status: "registered", scholarship_requested: true, w9_requested: true, invoice_requested: true, ce_credit_requested: true, ce_license_number: "LMFT 90210" }, { person: maria_j, status: "registered", invoice_requested: true, ce_credit_requested: true, intends_to_pay: true }, - { person: anna_g, status: "attended", ce_credit_requested: true, intends_to_pay: true }, + { person: anna_g, status: "attended", ce_credit_requested: true, intends_to_pay: true, ce_license_number: "LCSW 11223", ce_status: "issued" }, { person: mario_j, status: "registered" }, { person: kim_d, status: "cancelled" }, { person: aisha_person, status: "registered", intends_to_pay: true } @@ -788,8 +789,9 @@ # Angel Garcia: registered, no form (no user) # Linda Williams: no_show (no user) if trauma_training + trauma_training.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) [ - { person: sarah_s, status: "registered", invoice_requested: true, ce_credit_requested: true }, + { person: sarah_s, status: "registered", invoice_requested: true, ce_credit_requested: true, ce_license_number: "LPCC 44556" }, { person: jessica_b, status: "registered", scholarship_requested: true, ce_credit_requested: true }, { person: angel_g, status: "registered" }, { person: linda_w, status: "no_show" } @@ -858,9 +860,18 @@ # existing DB (find_or_initialize no longer recreates these registrations). registration.w9_requested = data[:w9_requested] || false registration.invoice_requested = data[:invoice_requested] || false - registration.ce_credit_requested = data[:ce_credit_requested] || false registration.intends_to_pay = data[:intends_to_pay] || false registration.save! + + # CE opt-in becomes a ContinuingEducationRegistration against the registrant's + # license (a placeholder when no number is seeded). Hours come from the event. + if data[:ce_credit_requested] && registration.continuing_education_registrations.none? + license = ProfessionalLicense.find_or_create_for(person: data[:person], number: data[:ce_license_number]) + ce_registration = registration.continuing_education_registrations.create!(professional_license: license) + registration.update_column(:ce_requested, true) + # "issued" in the seed data means the CE certificate was delivered. + ce_registration.mark_certificate_sent! if data[:ce_status] == "issued" + end end # Connect each multi-affiliation registrant's registration to a single one of diff --git a/spec/decorators/event_registration_decorator_spec.rb b/spec/decorators/event_registration_decorator_spec.rb index 837167f75e..c808420493 100644 --- a/spec/decorators/event_registration_decorator_spec.rb +++ b/spec/decorators/event_registration_decorator_spec.rb @@ -74,4 +74,87 @@ expect(reason).to include("an attendance outcome on record") end end + + describe "#ce_status_badge" do + subject(:badge) { registration.decorate.ce_status_badge(**opts) } + + let(:registration) { create(:event_registration) } + let(:opts) { {} } + + def add_ce(number: "LIC-1", cost_cents: 15_000) + license = create(:professional_license, person: registration.registrant, number: number) + create(:continuing_education_registration, event_registration: registration, + professional_license: license, cost_cents: cost_cents) + end + + def pay(cer, amount) + payment = create(:payment, person: registration.registrant, amount_cents: amount, amount_cents_remaining: nil) + create(:allocation, source: payment, allocatable: cer, amount: amount) + end + + context "when CE isn't in play" do + it { is_expected.to be_nil } + end + + context "when requested but no CE registration exists yet" do + before { registration.update!(ce_requested: true) } + + it "is an amber Requested badge" do + expect(badge.label).to eq("Requested") + expect(badge.classes).to include("amber") + end + end + + context "when a CE registration sits on a placeholder license" do + before do + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant)) + end + + it "is an amber License # needed badge" do + expect(badge.label).to eq("License # needed") + expect(badge.classes).to include("amber") + end + end + + context "when the license is on file but unpaid" do + before { add_ce(cost_cents: 15_000) } + + it "shows the balance due in amber" do + expect(badge.label).to eq("$150 due") + expect(badge.classes).to include("amber") + end + + context "with simulate_paid" do + let(:opts) { { simulate_paid: true } } + + it "previews the blue Pending state" do + expect(badge.label).to eq("Pending") + expect(badge.classes).to include("blue") + end + end + end + + context "when paid in full but not issued" do + before { pay(add_ce(cost_cents: 15_000), 15_000) } + + it "is a blue Pending badge" do + expect(badge.label).to eq("Pending") + expect(badge.classes).to include("blue") + end + end + + context "when the certificate has been issued" do + before do + cer = add_ce(cost_cents: 15_000) + pay(cer, 15_000) + cer.mark_certificate_sent! + end + + it "is a green Issued badge" do + expect(badge.label).to eq("Issued") + expect(badge.classes).to include("green") + end + end + end end diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 7b7c3681c1..8533528d3e 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -53,6 +53,21 @@ end end + describe "#timezone_visibility_hint" do + let(:me) { create(:user) } + let(:other) { create(:user) } + + before { allow(helper).to receive(:current_user).and_return(me) } + + it "uses second person when the form is about the current user" do + expect(helper.timezone_visibility_hint(me)).to eq("You will see times and dates in this timezone.") + end + + it "uses 'User' when an admin edits someone else" do + expect(helper.timezone_visibility_hint(other)).to eq("User will see times and dates in this timezone.") + end + end + describe "#dollars_from_cents" do it "drops the cents for whole-dollar amounts and adds thousands separators" do expect(helper.dollars_from_cents(150_000)).to eq("$1,500") diff --git a/spec/helpers/event_helper_spec.rb b/spec/helpers/event_helper_spec.rb index 8426f5f677..d06f3bbbe5 100644 --- a/spec/helpers/event_helper_spec.rb +++ b/spec/helpers/event_helper_spec.rb @@ -19,13 +19,6 @@ expect(helper.specify_placeholder("Other reasons")).to be_nil expect(helper.specify_placeholder(nil)).to be_nil end - - it "returns the CE hours placeholder for 'Yes' only on the CE field" do - ce_field = FormField.new(field_identifier: "ce_credit_interest") - other_field = FormField.new(field_identifier: "interested_in_more") - expect(helper.specify_placeholder("Yes", ce_field)).to eq("How many CE hours?") - expect(helper.specify_placeholder("Yes", other_field)).to be_nil - end end describe "#specify_option_selected?" do diff --git a/spec/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb index e90ab505d1..1af98e0860 100644 --- a/spec/models/continuing_education_registration_spec.rb +++ b/spec/models/continuing_education_registration_spec.rb @@ -12,6 +12,41 @@ expect(ce_reg).not_to be_valid expect(ce_reg.errors[:professional_license]).to include("must belong to the registrant") end + + describe "cost_not_below_allocations" do + it "allows setting cost above allocations" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + payment = create(:payment, amount_cents: 3_000, amount_cents_remaining: 3_000) + create(:allocation, source: payment, allocatable: ce_reg, amount: 3_000) + + ce_reg.cost_cents = 5_000 + expect(ce_reg).to be_valid + end + + it "allows setting cost equal to allocations" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + payment = create(:payment, amount_cents: 5_000, amount_cents_remaining: 5_000) + create(:allocation, source: payment, allocatable: ce_reg, amount: 5_000) + + ce_reg.cost_cents = 5_000 + expect(ce_reg).to be_valid + end + + it "rejects setting cost below allocations" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + payment = create(:payment, amount_cents: 5_000, amount_cents_remaining: 5_000) + create(:allocation, source: payment, allocatable: ce_reg, amount: 5_000) + + ce_reg.cost_cents = 3_000 + expect(ce_reg).not_to be_valid + expect(ce_reg.errors[:cost_cents]).to include("can't be less than the amount already allocated ($50)") + end + + it "allows setting cost on create even without allocations" do + ce_reg = build(:continuing_education_registration, cost_cents: 10_000) + expect(ce_reg).to be_valid + end + end end describe "hours and cost defaults from the event" do @@ -186,5 +221,28 @@ def scholarship_for(reg, amount) expect(queries).to be_empty expect(preloaded.paid_in_full?).to be(true) end + + it "reports discounted? and discount_sum like an event registration" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + expect(ce_reg.discounted?).to be(false) + expect(ce_reg.discount_sum).to eq(0) + + create(:allocation, source: create(:discount, amount_cents: 4_000), allocatable: ce_reg, amount: 4_000) + + expect(ce_reg.discounted?).to be(true) + expect(ce_reg.discount_sum).to eq(4_000) + end + + it "labels payment status Due → Partial → Paid" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + expect(ce_reg.payment_status_label).to eq("Due") + + payment = create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000) + create(:allocation, source: payment, allocatable: ce_reg, amount: 4_000) + expect(ce_reg.payment_status_label).to eq("Partial") + + create(:allocation, source: payment, allocatable: ce_reg, amount: 6_000) + expect(ce_reg.payment_status_label).to eq("Paid") + end end end diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index 6376fcef6b..f947d0194a 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -323,41 +323,68 @@ def registration_with_scholarship end describe ".ce_status" do - let!(:complete_ce) do - create(:event_registration, event: event, ce_credit_requested: true, ce_license_number: "ABC123", ce_hours_requested: 3).tap do |r| - create(:allocation, source: create(:payment, amount_cents: event.cost_cents, amount_cents_remaining: event.cost_cents), - allocatable: r, amount: event.cost_cents) + let(:ce_cost) { 15_000 } + # Known license, fully paid (certificate not yet issued). + let!(:paid_ce) do + create(:event_registration, event: event).tap do |r| + cer = create(:continuing_education_registration, event_registration: r, cost_cents: ce_cost) + create(:allocation, source: create(:payment, amount_cents: ce_cost, amount_cents_remaining: ce_cost), + allocatable: cer, amount: ce_cost) end end - let!(:missing_ce) { create(:event_registration, event: event, ce_credit_requested: true) } - let!(:no_ce) { create(:event_registration, event: event, ce_credit_requested: false) } + # Known license, unpaid. + let!(:requested_ce) do + create(:event_registration, event: event).tap do |r| + create(:continuing_education_registration, event_registration: r, cost_cents: ce_cost) + end + end + # CE registration sitting on a placeholder (numberless) license. + let!(:needs_license_ce) do + create(:event_registration, event: event).tap do |r| + license = create(:professional_license, :placeholder, person: r.registrant) + create(:continuing_education_registration, event_registration: r, professional_license: license, cost_cents: ce_cost) + end + end + # Certificate delivered. + let!(:issued_ce) do + create(:event_registration, event: event).tap do |r| + create(:continuing_education_registration, event_registration: r, cost_cents: ce_cost, certificate_sent_at: Time.current) + end + end + let!(:no_ce) { create(:event_registration, event: event) } - it "maps 'requested' to anyone who asked for CE credit" do - results = EventRegistration.ce_status("requested") - expect(results).to include(complete_ce, missing_ce) - expect(results).not_to include(no_ce) + it "maps 'needs_license' to CE on a placeholder license" do + results = EventRegistration.ce_status("needs_license") + expect(results).to include(needs_license_ce) + expect(results).not_to include(paid_ce, requested_ce, no_ce) end - it "maps 'license_not_provided' to CE requests missing a license number" do - results = EventRegistration.ce_status("license_not_provided") - expect(results).to include(missing_ce) - expect(results).not_to include(complete_ce, no_ce) + it "maps 'paid' to fully paid CE registrations" do + results = EventRegistration.ce_status("paid") + expect(results).to include(paid_ce) + expect(results).not_to include(requested_ce, no_ce) end - it "maps 'hours_not_provided' to CE requests missing hours" do - results = EventRegistration.ce_status("hours_not_provided") - expect(results).to include(missing_ce) - expect(results).not_to include(complete_ce, no_ce) + it "maps 'requested' to CE registrations not yet paid" do + results = EventRegistration.ce_status("requested") + expect(results).to include(requested_ce, needs_license_ce) + expect(results).not_to include(paid_ce, no_ce) end - it "maps 'paid' to CE requests that are paid in full" do - results = EventRegistration.ce_status("paid") - expect(results).to include(complete_ce) - expect(results).not_to include(missing_ce, no_ce) + it "maps 'issued' to CE registrations with a delivered certificate" do + results = EventRegistration.ce_status("issued") + expect(results).to include(issued_ce) + expect(results).not_to include(requested_ce, no_ce) + end + + it "maps 'not_issued' to CE registrations without a delivered certificate" do + results = EventRegistration.ce_status("not_issued") + expect(results).to include(paid_ce, requested_ce) + expect(results).not_to include(issued_ce, no_ce) end it "returns an unfiltered relation for unknown values" do - expect(EventRegistration.ce_status("bogus")).to include(complete_ce, missing_ce, no_ce) + expect(EventRegistration.ce_status("bogus")).to include(paid_ce, requested_ce, no_ce) end end @@ -718,33 +745,66 @@ def registration_with_scholarship describe "continuing education" do let(:reg) { create(:event_registration) } + def add_ce(number: "LIC-123", hours: 4, cost_cents: 15_000) + license = create(:professional_license, person: reg.registrant, number: number) + create(:continuing_education_registration, event_registration: reg, professional_license: license, hours: hours, cost_cents: cost_cents) + end + describe "#ce_amount_owed_cents" do - it "multiplies requested hours by the default hourly rate" do - reg.ce_hours_requested = 4 - expect(reg.ce_amount_owed_cents).to eq(4 * EventRegistration::CE_HOURLY_RATE_DOLLARS * 100) + it "sums the cost across the registration's CE registrations" do + add_ce(cost_cents: 10_000) + expect(reg.ce_amount_owed_cents).to eq(10_000) end - it "is zero when no hours are requested" do - reg.ce_hours_requested = nil + it "is zero when no CE is requested" do expect(reg.ce_amount_owed_cents).to eq(0) end end + describe "#ce_registered?" do + it "is true only once a CE registration exists" do + expect(reg).not_to be_ce_registered + add_ce + expect(reg.reload).to be_ce_registered + end + end + describe "#ce_license_provided?" do - it "is true only when a license number is present" do - reg.ce_license_number = "LIC-123" - expect(reg).to be_ce_license_provided - reg.ce_license_number = "" - expect(reg).not_to be_ce_license_provided + it "is true only when every CE registration has a known license number" do + add_ce(number: "LIC-123") + expect(reg.reload).to be_ce_license_provided + end + + it "is false when a CE registration sits on a placeholder license" do + add_ce(number: nil) + expect(reg.reload).not_to be_ce_license_provided + end + end + + describe "#ce_amount_due_cents" do + it "is the cost not yet covered by payments, floored at zero" do + cer = add_ce(cost_cents: 15_000) + expect(reg.ce_amount_due_cents).to eq(15_000) + + payment = create(:payment, person: reg.registrant, amount_cents: 6_000, amount_cents_remaining: nil) + create(:allocation, source: payment, allocatable: cer, amount: 6_000) + expect(reg.reload.ce_amount_due_cents).to eq(9_000) + end + + it "is zero once fully paid" do + cer = add_ce(cost_cents: 15_000) + payment = create(:payment, person: reg.registrant, amount_cents: 15_000, amount_cents_remaining: nil) + create(:allocation, source: payment, allocatable: cer, amount: 15_000) + expect(reg.reload.ce_amount_due_cents).to eq(0) end end - describe "ce_hours_requested validation" do - it "rejects negative or non-integer hours but allows nil" do - reg.ce_hours_requested = nil - expect(reg).to be_valid - reg.ce_hours_requested = -1 - expect(reg).not_to be_valid + describe "#ce_certificate_issued?" do + it "is true only once every CE registration's certificate is sent" do + cer = add_ce + expect(reg.reload).not_to be_ce_certificate_issued + cer.mark_certificate_sent! + expect(reg.reload).to be_ce_certificate_issued end end end diff --git a/spec/models/form_field_spec.rb b/spec/models/form_field_spec.rb index b937d699b0..4acece44a7 100644 --- a/spec/models/form_field_spec.rb +++ b/spec/models/form_field_spec.rb @@ -392,12 +392,12 @@ def selectable_field(type:, option_names:) expect(field.answer_inclusion_error("Foundation/Funder: ACME")).to eq("has an invalid selection") end - it "accepts the CE question's field-scoped 'Yes: ' specify answer" do + it "treats the CE question's 'Yes' as a plain choice (hours come from the event)" do field = selectable_field(type: :single_select_radio, option_names: %w[Yes No]) field.update!(field_identifier: "ce_credit_interest") expect(field.answer_inclusion_error("Yes")).to be_nil - expect(field.answer_inclusion_error("Yes: 6")).to be_nil expect(field.answer_inclusion_error("No")).to be_nil + expect(field.answer_inclusion_error("Yes: 6")).to eq("has an invalid selection") end it "treats a bare 'Yes' as a plain choice on other fields" do diff --git a/spec/models/professional_license_spec.rb b/spec/models/professional_license_spec.rb index 9ed1565e62..ad43d763b6 100644 --- a/spec/models/professional_license_spec.rb +++ b/spec/models/professional_license_spec.rb @@ -45,11 +45,60 @@ end describe "validations" do - it "rejects a duplicate number for the same person" do - create(:professional_license, person: person, number: "DUP") - dup = build(:professional_license, person: person, number: "DUP") + it "rejects a duplicate kind + number for the same person" do + create(:professional_license, person: person, kind: "LMFT", number: "DUP") + dup = build(:professional_license, person: person, kind: "LMFT", number: "DUP") expect(dup).not_to be_valid end + + it "allows (and persists) the same number under a different kind" do + create(:professional_license, person: person, kind: "LMFT", number: "DUP") + + expect { + create(:professional_license, person: person, kind: "LCSW", number: "DUP") + }.to change(described_class, :count).by(1) + end + end + + describe "removal guard" do + let(:license) { create(:professional_license, person: person, number: "GUARD-1") } + + def attach_ce(paid: false) + registration = create(:event_registration, registrant: person) + ce = create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 10_000) + return ce unless paid + create(:allocation, source: create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000), + allocatable: ce, amount: 10_000) + ce + end + + it "is removable with no CE registrations" do + expect(license).to be_removable + expect { license.destroy }.to change(described_class, :count).by(-1) + end + + it "refuses to destroy when an unpaid CE registration exists" do + attach_ce(paid: false) + expect(license.reload).not_to be_removable + expect { license.destroy }.not_to change(described_class, :count) + end + + it "refuses to destroy when a CE registration carries payments" do + attach_ce(paid: true) + expect(license.reload).not_to be_removable + expect { license.destroy }.not_to change(described_class, :count) + end + end + + describe "#used_for_ce?" do + let(:license) { create(:professional_license, person: person, number: "USED-1") } + + it "is false without CE registrations and true once one exists" do + expect(license).not_to be_used_for_ce + registration = create(:event_registration, registrant: person) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + expect(license.reload).to be_used_for_ce + end end end diff --git a/spec/policies/professional_license_policy_spec.rb b/spec/policies/professional_license_policy_spec.rb new file mode 100644 index 0000000000..13668c33d1 --- /dev/null +++ b/spec/policies/professional_license_policy_spec.rb @@ -0,0 +1,59 @@ +require "rails_helper" + +RSpec.describe ProfessionalLicensePolicy, type: :policy do + let(:owner) { create(:user, :with_person) } + let(:admin) { create(:user, :admin) } + let(:other) { create(:user, :with_person) } + + let(:license) { create(:professional_license, person: owner.person, number: "POL-1") } + + def policy_for(user:, record: license) + described_class.new(record, user: user) + end + + def attach_ce + registration = create(:event_registration, registrant: owner.person) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + end + + describe "#edit? / #update?" do + context "an admin" do + it "may always edit, even with CE registrations" do + expect(policy_for(user: admin)).to be_allowed_to(:edit?) + attach_ce + expect(policy_for(user: admin, record: license.reload)).to be_allowed_to(:edit?) + end + end + + context "the holder (owner)" do + it "may edit a license with no CE registrations" do + expect(policy_for(user: owner)).to be_allowed_to(:edit?) + expect(policy_for(user: owner)).to be_allowed_to(:update?) + end + + it "may not edit once the license has CE registrations" do + attach_ce + expect(policy_for(user: owner, record: license.reload)).not_to be_allowed_to(:edit?) + end + end + + context "an unrelated user" do + it "may not edit" do + expect(policy_for(user: other)).not_to be_allowed_to(:edit?) + end + end + end + + describe "#destroy?" do + it "is allowed for the owner only when the license has no CE registrations" do + expect(policy_for(user: owner)).to be_allowed_to(:destroy?) + attach_ce + expect(policy_for(user: owner, record: license.reload)).not_to be_allowed_to(:destroy?) + end + + it "is blocked even for an admin once a CE registration exists" do + attach_ce + expect(policy_for(user: admin, record: license.reload)).not_to be_allowed_to(:destroy?) + end + end +end diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb new file mode 100644 index 0000000000..581969594e --- /dev/null +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -0,0 +1,178 @@ +require "rails_helper" + +RSpec.describe "ContinuingEducationRegistrations", type: :request do + let(:admin) { create(:user, :admin) } + let(:event) { create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 12_000) } + let(:registration) { create(:event_registration, event: event, ce_requested: true) } + let(:ce_registration) do + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant)) + end + + describe "as an admin" do + before { sign_in admin } + + it "renders the edit page" do + get edit_continuing_education_registration_path(ce_registration) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Edit CE registration") + end + + it "updates hours, cost, and fills the placeholder license type, number, state + expiry in place" do + license = ce_registration.professional_license + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "4.5", cost_dollars: "90", license_kind: "LMFT", + license_number: "555", license_issuing_state: "CA", license_expires_on: "2027-01-31" } } + + ce_registration.reload + expect(ce_registration.hours).to eq(4.5) + expect(ce_registration.cost_cents).to eq(9_000) + # Placeholder is filled in place rather than orphaned. + expect(ce_registration.professional_license).to eq(license) + expect(license.reload).to have_attributes(kind: "LMFT", number: "555", + issuing_state: "CA", expires_on: Date.new(2027, 1, 31)) + end + + it "edits the same license in place when correcting a typo (no new record)" do + license = create(:professional_license, person: registration.registrant, kind: "LCSW", number: "11223") + ce_registration.update!(professional_license: license) + + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", license_kind: "LCSW", license_number: "11224" } } + }.not_to change(ProfessionalLicense, :count) + + expect(ce_registration.reload.professional_license).to eq(license) + expect(license.reload.number).to eq("11224") + end + + it "links to the registrant's existing license when the typed number already matches one" do + ce_registration + other = create(:professional_license, person: registration.registrant, kind: "LMFT", number: "99887") + + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", license_kind: "LMFT", license_number: "99887" } } + }.not_to change(ProfessionalLicense, :count) + + expect(ce_registration.reload.professional_license).to eq(other) + end + + it "marks the certificate issued and back to not issued" do + patch toggle_certificate_continuing_education_registration_path(ce_registration) + expect(ce_registration.reload.certificate_sent_at).to be_present + + patch toggle_certificate_continuing_education_registration_path(ce_registration) + expect(ce_registration.reload.certificate_sent_at).to be_nil + end + + it "renders the new page for a registration" do + get new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Add CE registration") + end + + it "creates a CE registration with license, hours, and cost, and sets the flag" do + registration + + expect { + post continuing_education_registrations_path, + params: { allocatable_sgid: registration.to_sgid.to_s, + continuing_education_registration: { hours: "4.5", cost_dollars: "90", license_kind: "LMFT", + license_number: "555", license_issuing_state: "CA", license_expires_on: "2027-01-31" } } + }.to change { registration.continuing_education_registrations.count }.by(1) + + ce = registration.continuing_education_registrations.last + expect(response).to redirect_to(edit_event_registration_path(registration)) + expect(ce.hours).to eq(4.5) + expect(ce.cost_cents).to eq(9_000) + expect(ce.professional_license).to have_attributes(kind: "LMFT", number: "555", + issuing_state: "CA", expires_on: Date.new(2027, 1, 31)) + expect(registration.reload.ce_requested).to be(true) + end + + it "creates no license just from opening the new form" do + registration + expect { + get new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s) + }.not_to change(ProfessionalLicense, :count) + end + + it "leaves no orphan license when the create fails validation" do + registration + params = { allocatable_sgid: registration.to_sgid.to_s, + continuing_education_registration: { hours: "-5", license_kind: "LMFT", license_number: "555" } } + + expect { + post continuing_education_registrations_path, params: params + }.to change(ProfessionalLicense, :count).by(0) + expect(ContinuingEducationRegistration.count).to eq(0) + expect(response).to have_http_status(:unprocessable_content) + end + + it "renders the license picker on new when the registrant holds more than one license" do + create(:professional_license, person: registration.registrant, kind: "LMFT", number: "111") + create(:professional_license, person: registration.registrant, kind: "LCSW", number: "222") + get new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s) + expect(response.body).to include("professional_license_id") + expect(response.body).to include("Create new license") + end + + it "omits the license picker when the registrant has a single license" do + create(:professional_license, person: registration.registrant, kind: "LMFT", number: "111") + get new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s) + expect(response.body).not_to include("professional_license_id") + end + + it "points the registration at a picked license and edits it in place from the fields" do + a = create(:professional_license, person: registration.registrant, kind: "LMFT", number: "111") + b = create(:professional_license, person: registration.registrant, kind: "LCSW", number: "222") + ce_registration.update!(professional_license: a) + + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { professional_license_id: b.id, + license_kind: "LCSW", license_number: "222-B", license_issuing_state: "NY", hours: "6", cost_dollars: "120" } } + + expect(ce_registration.reload.professional_license).to eq(b) + expect(b.reload).to have_attributes(kind: "LCSW", number: "222-B", issuing_state: "NY") + expect(a.reload).to have_attributes(kind: "LMFT", number: "111") + end + + it "creates a new license when 'Create new license' is picked" do + a = create(:professional_license, person: registration.registrant, kind: "LMFT", number: "111") + ce_registration.update!(professional_license: a) + + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { professional_license_id: "new", + license_kind: "LPCC", license_number: "333", hours: "6", cost_dollars: "120" } } + }.to change(ProfessionalLicense, :count).by(1) + + expect(ce_registration.reload.professional_license).to have_attributes(kind: "LPCC", number: "333") + expect(a.reload).to have_attributes(kind: "LMFT", number: "111") + end + + it "removes a CE registration with no payments and clears the flag" do + ce_registration + delete continuing_education_registration_path(ce_registration) + expect(ContinuingEducationRegistration.exists?(ce_registration.id)).to be(false) + expect(registration.reload.ce_requested).to be(false) + end + + it "refuses to remove a CE registration that has payments" do + create(:allocation, source: create(:payment, amount_cents: 12_000, amount_cents_remaining: 12_000), + allocatable: ce_registration, amount: 12_000) + + delete continuing_education_registration_path(ce_registration) + + expect(ContinuingEducationRegistration.exists?(ce_registration.id)).to be(true) + expect(flash[:alert]).to match(/has payments/) + end + end + + it "forbids non-admins" do + sign_in create(:user) + get edit_continuing_education_registration_path(ce_registration) + expect(response).not_to have_http_status(:ok) + end +end diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index a22e5714d6..2a6b171a8c 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -34,6 +34,17 @@ expect(response.body).not_to include(existing_registration.registrant.first_name) end + it "filters registrations by ce_status" do + needs_license = create(:event_registration) + placeholder = create(:professional_license, :placeholder, person: needs_license.registrant) + create(:continuing_education_registration, event_registration: needs_license, professional_license: placeholder) + + get event_registrations_path(ce_status: "needs_license") + expect(response).to have_http_status(:success) + expect(response.body).to include(needs_license.registrant.first_name) + expect(response.body).not_to include(existing_registration.registrant.first_name) + end + it "exports CSV with headers and data only (no captions)" do get event_registrations_path, params: { format: :csv } @@ -305,20 +316,26 @@ def toggle_day(field, value) expect(existing_registration.reload.event_id).to eq(new_event.id) end - it "updates the CE credit requested flag" do + it "creates a CE registration stub when the ce_requested flag is set" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 12_000) patch event_registration_path(existing_registration), - params: { event_registration: { ce_credit_requested: "1" } } + params: { event_registration: { status: existing_registration.status, ce_requested: "1" } } - expect(existing_registration.reload.ce_credit_requested).to be(true) + ce_registration = existing_registration.reload.continuing_education_registrations.first + expect(ce_registration).to be_present + # Hours/cost default from the event; the license is a placeholder until set. + expect(ce_registration.hours).to eq(6) + expect(ce_registration.professional_license.number).to be_nil end - it "updates the CE hours and license number" do + it "creates the CE registration against the registrant's existing license" do + event.update!(ce_hours_offered: 6) + license = create(:professional_license, person: existing_registration.registrant, number: "LIC-987") + patch event_registration_path(existing_registration), - params: { event_registration: { ce_credit_requested: "1", ce_hours_requested: "5", ce_license_number: "LIC-987" } } + params: { event_registration: { status: existing_registration.status, ce_requested: "1" } } - existing_registration.reload - expect(existing_registration.ce_hours_requested).to eq(5) - expect(existing_registration.ce_license_number).to eq("LIC-987") + expect(existing_registration.reload.continuing_education_registrations.first.professional_license).to eq(license) end it "sets the shout-out flag and stores the shout-out text on the registrant" do @@ -452,6 +469,18 @@ def unrequest(registration) expect(flash[:alert]).to include("can't be deleted") end + + it "refuses to delete a registration whose CE registration has payments" do + ce = create(:continuing_education_registration, event_registration: existing_registration, cost_cents: 12_000) + create(:allocation, source: create(:payment, amount_cents: 12_000, amount_cents_remaining: 12_000), + allocatable: ce, amount: 12_000) + + expect { + delete event_registration_path(existing_registration) + }.not_to change(EventRegistration, :count) + + expect(flash[:alert]).to match(/has payments/) + end end describe "organization linking" do diff --git a/spec/requests/events/bulk_reminders_spec.rb b/spec/requests/events/bulk_reminders_spec.rb index 091d9a2029..37126a731b 100644 --- a/spec/requests/events/bulk_reminders_spec.rb +++ b/spec/requests/events/bulk_reminders_spec.rb @@ -77,7 +77,7 @@ def checked?(body, registration) it "returns to the picker after save" do patch event_registration_path(jane), - params: { return_to: "preview_reminder", event_registration: { ce_credit_requested: "1" } } + params: { return_to: "preview_reminder", event_registration: { intends_to_pay: "1" } } expect(response).to redirect_to(preview_reminder_event_path(event)) end diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index d1d64e25b9..a2f04159da 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -232,6 +232,29 @@ expect(response.body).not_to include("Review your form responses") end + + it "shows an admin edit-scholarship link and a back-to-scholarship eyebrow when reached from there" do + scholarship = create(:scholarship, amount_cents: 75_000) + create(:allocation, source: scholarship, allocatable: registration, amount: 75_000) + + sign_in create(:user, :with_person, super_user: true) + get registration_scholarship_path(registration.slug, return_to: "scholarship") + + expect(response.body).to include("Edit scholarship") + expect(response.body).to include("Back to scholarship") + expect(response.body).to include(edit_scholarship_path(scholarship)) + end + + it "keeps the default ticket eyebrow for a registrant even with the scholarship origin" do + scholarship = create(:scholarship, amount_cents: 75_000) + create(:allocation, source: scholarship, allocatable: registration, amount: 75_000) + + get registration_scholarship_path(registration.slug, return_to: "scholarship") + + expect(response.body).to include("Back to ticket") + expect(response.body).not_to include("Back to scholarship") + expect(response.body).not_to include("Edit scholarship") + end end describe "GET /registration/:slug/faq" do @@ -292,20 +315,139 @@ describe "GET /registration/:slug/ce" do let!(:registration) { create(:event_registration, event: event, registrant: user.person) } - it "shows status, cost, and the license number on file" do - registration.update!(ce_credit_requested: true, ce_hours_requested: 6, ce_license_number: "LIC123") + it "shows status, cost, and the license number on file, read-only with an edit link" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) get registration_ce_path(registration.slug) expect(response).to have_http_status(:success) - expect(response.body).to include("Requested") - expect(response.body).to include("Hours requested") + # License on file but unpaid → the badge shows the balance due. + expect(response.body).to include("$150 due") + expect(response.body).to include("Hours") expect(response.body).to include("$150") expect(response.body).to include("LIC123") + # Read-only by default: the form is not rendered, just an Edit link that flips to it. + expect(response.body).to include("editing=license") + expect(response.body).not_to include("Save changes") + end + + it "flips to the editable form when reached with ?editing=license" do + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) + get registration_ce_path(registration.slug, editing: "license") + expect(response.body).to include("Save changes") + expect(response.body).to include("Cancel") end - it "notes when the license number is not yet on file" do - registration.update!(ce_credit_requested: true, ce_hours_requested: 6, ce_license_number: nil) + it "shows blank license fields and a needs-license prompt when nothing is on file yet" do + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) get registration_ce_path(registration.slug) - expect(response.body).to include("We don't have your license number on file yet.") + expect(response.body).to include("License type") + expect(response.body).to include("Your license number") + expect(response.body).to include("License # needed") + expect(response.body).to include("We need your license type and number") + expect(response.body).to include("Save changes") + # Nothing on file yet, so there's no read-only value to edit or cancel back to. + expect(response.body).not_to include("editing=license") + expect(response.body).not_to include(">Cancel<") + end + + it "shows an admin jump link to the CE registration only to admins" do + ce = create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant), hours: 6) + + get registration_ce_path(registration.slug) + expect(response.body).not_to include(edit_continuing_education_registration_path(ce)) + + sign_in create(:user, :with_person, super_user: true) + get registration_ce_path(registration.slug) + expect(response.body).to include(edit_continuing_education_registration_path(ce)) + end + + it "lets an admin preview the paid (Pending) state with ?admin=true" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) + + sign_in create(:user, :with_person, super_user: true) + get registration_ce_path(registration.slug, admin: "true") + expect(response.body).to include("Pending") + expect(response.body).not_to include("$150 due") + end + + it "ignores ?admin=true for a registrant (no access)" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) + + get registration_ce_path(registration.slug, admin: "true") + expect(response.body).to include("$150 due") + expect(response.body).not_to include("Pending") + end + + it "points the eyebrow back to the CE registration when reached from there" do + ce = create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant), hours: 6) + + sign_in create(:user, :with_person, super_user: true) + get registration_ce_path(registration.slug, return_to: "ce_registration") + expect(response.body).to include("Back to CE registration") + expect(response.body).to include(edit_continuing_education_registration_path(ce)) + end + + it "keeps the default ticket eyebrow for a registrant even with the ce_registration origin" do + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant), hours: 6) + + get registration_ce_path(registration.slug, return_to: "ce_registration") + expect(response.body).to include("Back to ticket") + expect(response.body).not_to include("Back to CE registration") + end + end + + describe "POST /registration/:slug/ce/license" do + let!(:registration) { create(:event_registration, event: event, registrant: user.person) } + + it "saves the license type, number, issuing state, and expiry entered on the callout" do + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) + + post registration_ce_license_path(registration.slug), + params: { license_kind: "LMFT", license_number: "7788", + license_issuing_state: "CA", license_expires_on: "2027-01-31" } + + expect(response).to redirect_to(registration_ce_path(registration.slug)) + saved = registration.continuing_education_registrations.first.professional_license + expect(saved.kind).to eq("LMFT") + expect(saved.number).to eq("7788") + expect(saved.issuing_state).to eq("CA") + expect(saved.expires_on).to eq(Date.new(2027, 1, 31)) + end + + it "refuses to change the license once the certificate is issued" do + license = create(:professional_license, person: registration.registrant, kind: "LMFT", number: "111") + create(:continuing_education_registration, event_registration: registration, professional_license: license, + hours: 6, certificate_sent_at: Time.current) + + post registration_ce_license_path(registration.slug), params: { license_kind: "LCSW", license_number: "999" } + + expect(license.reload).to have_attributes(kind: "LMFT", number: "111") + expect(flash[:alert]).to match(/certificate has been issued/) + end + end + + describe "POST /registration/:slug/ce/request" do + let(:event) { create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 12_000) } + let!(:registration) { create(:event_registration, event: event, registrant: user.person, ce_requested: false) } + + it "opts the registrant into CE and creates the registration" do + expect { + post registration_ce_request_path(registration.slug) + }.to change { registration.reload.continuing_education_registrations.count }.from(0).to(1) + + expect(registration.ce_requested).to be(true) + expect(response).to redirect_to(registration_ce_path(registration.slug)) end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 9bdbf61f43..c2dce966e6 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -202,15 +202,15 @@ def offer_ce!(target_event) describe "GET /ce_hours" do let(:event) { create(:event, :published, :publicly_visible) } - it "renders the CE hours page when details are present" do - event.update!(ce_hours_details_label: "Continuing education", ce_hours_details: "

Email your license number

") + it "renders the CE hours page when the event is CE-eligible" do + event.update!(ce_hours_offered: 6, ce_hours_details_label: "Continuing education", ce_hours_details: "

Email your license number

") get ce_hours_event_path(event) expect(response).to have_http_status(:ok) expect(response.body).to include("Continuing education") expect(response.body).to include("Email your license number") end - it "redirects to the event when details are blank" do + it "redirects to the event when the event is not CE-eligible" do get ce_hours_event_path(event) expect(response).to redirect_to(event_path(event)) end @@ -1018,25 +1018,30 @@ def submit_agency_name(name) end describe "GET /events/:id/registrants with the CE status filter" do - let(:event) { create(:event, cost_cents: 1_000) } - let(:complete_person) { create(:person, first_name: "Complete", last_name: "Person") } - let(:missing_person) { create(:person, first_name: "Missing", last_name: "Person") } + let(:event) { offer_ce!(create(:event, cost_cents: 1_000)) } + let(:paid_person) { create(:person, first_name: "Paid", last_name: "Person") } + let(:needs_person) { create(:person, first_name: "Needs", last_name: "License") } let(:none_person) { create(:person, first_name: "Noce", last_name: "Person") } - let!(:complete_reg) do - reg = create(:event_registration, event: event, registrant: complete_person, - ce_credit_requested: true, ce_license_number: "ABC123", ce_hours_requested: 3) - create(:allocation, source: create(:payment, amount_cents: 1_000, amount_cents_remaining: 1_000), - allocatable: reg, amount: 1_000) + # Known license, fully paid. + let!(:paid_reg) do + reg = create(:event_registration, event: event, registrant: paid_person) + cer = create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000) + create(:allocation, source: create(:payment, amount_cents: 15_000, amount_cents_remaining: 15_000), + allocatable: cer, amount: 15_000) reg end - let!(:missing_reg) { create(:event_registration, event: event, registrant: missing_person, ce_credit_requested: true) } - let!(:none_reg) { create(:event_registration, event: event, registrant: none_person, ce_credit_requested: false) } - - before do - offer_ce!(event) - sign_in admin + # CE registration sitting on a placeholder (numberless) license. + let!(:needs_reg) do + reg = create(:event_registration, event: event, registrant: needs_person) + create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, + professional_license: create(:professional_license, :placeholder, person: needs_person)) + reg end + # No CE registration. + let!(:none_reg) { create(:event_registration, event: event, registrant: none_person) } + + before { sign_in admin } it "shows the CE status column and filter when the event offers CE" do get registrants_event_path(event) @@ -1053,29 +1058,23 @@ def submit_agency_name(name) expect(response.body).to include('data-column-toggle-group-value="ce"') end - it "filters to all CE requests" do + it "filters to CE registrations not yet paid" do get registrants_event_path(event, ce_status: "requested") - expect(response.body).to include("Complete Person") - expect(response.body).to include("Missing Person") + expect(response.body).to include("Needs License") + expect(response.body).not_to include("Paid Person") expect(response.body).not_to include("Noce Person") end - it "filters to CE requests missing a license number" do - get registrants_event_path(event, ce_status: "license_not_provided") - expect(response.body).to include("Missing Person") - expect(response.body).not_to include("Complete Person") - end - - it "filters to CE requests missing hours" do - get registrants_event_path(event, ce_status: "hours_not_provided") - expect(response.body).to include("Missing Person") - expect(response.body).not_to include("Complete Person") + it "filters to CE registrations on a placeholder license" do + get registrants_event_path(event, ce_status: "needs_license") + expect(response.body).to include("Needs License") + expect(response.body).not_to include("Paid Person") end - it "filters to paid CE requests" do + it "filters to paid CE registrations" do get registrants_event_path(event, ce_status: "paid") - expect(response.body).to include("Complete Person") - expect(response.body).not_to include("Missing Person") + expect(response.body).to include("Paid Person") + expect(response.body).not_to include("Needs License") end it "does not crash on an invalid ce_status" do @@ -1083,9 +1082,9 @@ def submit_agency_name(name) expect(response).to have_http_status(:ok) end - it "hides CE entirely when the event's registration form doesn't offer CE" do + it "hides CE entirely when the event does not offer CE" do plain_event = create(:event) - create(:event_registration, event: plain_event, ce_credit_requested: true) + create(:event_registration, event: plain_event) get registrants_event_path(plain_event) expect(response.body).not_to include("CE status") end @@ -1093,7 +1092,7 @@ def submit_agency_name(name) it "includes a CE status column in the CSV export" do get registrants_event_path(event, format: :csv) expect(response.body).to include("CE status") - expect(response.body).to include("Incomplete") + expect(response.body).to include("Needs license") end end @@ -1109,42 +1108,47 @@ def ce_chip_text Nokogiri::HTML(response.body).at_css('td[data-column-toggle-col="ce"]')&.text&.squish end - it "shows Create when CE was not requested" do - create(:event_registration, event: event, registrant: person, ce_credit_requested: false) + it "shows Create when no CE registration exists" do + create(:event_registration, event: event, registrant: person) get registrants_event_path(event) expect(ce_chip_text).to eq("Create") end - it "shows Requested when requested but no CE registration record exists yet" do - create(:event_registration, event: event, registrant: person, ce_credit_requested: true) - get registrants_event_path(event) - expect(ce_chip_text).to eq("Requested") - end - - it "shows No license # once a CE record exists without a license number" do - reg = create(:event_registration, event: event, registrant: person, ce_credit_requested: true) + it "shows License # needed once a CE record exists without a license number" do + reg = create(:event_registration, event: event, registrant: person) create(:continuing_education_registration, event_registration: reg, professional_license: create(:professional_license, :placeholder, person: person)) get registrants_event_path(event) - expect(ce_chip_text).to eq("No license #") + expect(ce_chip_text).to eq("License # needed") end - it "shows Filed once a license is on file but the CE balance is unpaid" do - reg = create(:event_registration, event: event, registrant: person, ce_credit_requested: true) + it "shows the balance due once a license is on file but the CE balance is unpaid" do + reg = create(:event_registration, event: event, registrant: person) create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, professional_license: create(:professional_license, person: person)) get registrants_event_path(event) - expect(ce_chip_text).to eq("Filed") + expect(ce_chip_text).to eq("$150 due") + end + + it "shows Pending when the CE balance is paid but the certificate isn't issued" do + reg = create(:event_registration, event: event, registrant: person) + cer = create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, + professional_license: create(:professional_license, person: person)) + create(:allocation, source: create(:payment, amount_cents: 15_000, amount_cents_remaining: 15_000), + allocatable: cer, amount: 15_000) + get registrants_event_path(event) + expect(ce_chip_text).to eq("Pending") end - it "shows Recipient when the CE balance is paid" do - reg = create(:event_registration, event: event, registrant: person, ce_credit_requested: true) + it "shows Issued once the CE certificate has been delivered" do + reg = create(:event_registration, event: event, registrant: person) cer = create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, professional_license: create(:professional_license, person: person)) create(:allocation, source: create(:payment, amount_cents: 15_000, amount_cents_remaining: 15_000), allocatable: cer, amount: 15_000) + cer.mark_certificate_sent! get registrants_event_path(event) - expect(ce_chip_text).to eq("Recipient") + expect(ce_chip_text).to eq("Issued") end end diff --git a/spec/requests/people_professional_licenses_spec.rb b/spec/requests/people_professional_licenses_spec.rb new file mode 100644 index 0000000000..bf94569c00 --- /dev/null +++ b/spec/requests/people_professional_licenses_spec.rb @@ -0,0 +1,122 @@ +require "rails_helper" + +RSpec.describe "People professional licenses", type: :request do + let(:admin) { create(:user, :admin) } + + before { sign_in admin } + + it "renders the professional licenses section on the edit page" do + person = create(:person) + create(:professional_license, person: person, number: "LMFT 90210") + + get edit_person_path(person) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Professional licenses") + expect(response.body).to include("LMFT 90210") + end + + it "adds a license through the person form" do + person = create(:person) + + expect { + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { number: "LCSW 11223", kind: "LCSW", issuing_state: "CA" } } + } } + }.to change { person.professional_licenses.count }.by(1) + + expect(person.professional_licenses.last.number).to eq("LCSW 11223") + end + + it "ignores a blank license row" do + person = create(:person) + + expect { + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { number: "", kind: "", issuing_state: "", expires_on: "" } } + } } + }.not_to change { person.professional_licenses.count } + end + + it "removes a license with no CE registrations" do + person = create(:person) + license = create(:professional_license, person: person, number: "GONE-1") + + expect { + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { id: license.id, _destroy: "1" } } + } } + }.to change { person.professional_licenses.count }.by(-1) + end + + it "keeps a license whose CE registration has no payments" do + person = create(:person) + license = create(:professional_license, person: person, number: "UNPAID-1") + registration = create(:event_registration, registrant: person) + create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 10_000) + + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { id: license.id, _destroy: "1" } } + } } + + expect(ProfessionalLicense.exists?(license.id)).to be(true) + end + + it "keeps a license whose CE registration has payments" do + person = create(:person) + license = create(:professional_license, person: person, number: "PAID-1") + registration = create(:event_registration, registrant: person) + ce = create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 10_000) + create(:allocation, source: create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000), + allocatable: ce, amount: 10_000) + + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { id: license.id, _destroy: "1" } } + } } + + expect(ProfessionalLicense.exists?(license.id)).to be(true) + end + + # The person form is admin-only today, so the per-license guard is dormant. These + # simulate the future state where PersonPolicy lets an owner reach the form, to + # prove the server-side backstop (ProfessionalLicensePolicy) holds before we flip it. + context "when a non-admin owner can reach the form (simulating the future PersonPolicy)" do + let(:owner) { create(:user, :with_person) } + let(:person) { owner.person } + + before do + sign_in owner + allow_any_instance_of(PersonPolicy).to receive(:edit?).and_return(true) + allow_any_instance_of(PersonPolicy).to receive(:update?).and_return(true) + end + + it "ignores edits to a CE-tied license but applies edits to an unlocked one" do + locked = create(:professional_license, person: person, number: "LOCKED-1", kind: "LMFT") + registration = create(:event_registration, registrant: person) + create(:continuing_education_registration, event_registration: registration, professional_license: locked) + unlocked = create(:professional_license, person: person, number: "OPEN-1", kind: "LCSW") + + patch person_path(person), params: { person: { + professional_licenses_attributes: { + "0" => { id: locked.id, kind: "HACKED" }, + "1" => { id: unlocked.id, kind: "LPCC" } + } + } } + + expect(locked.reload.kind).to eq("LMFT") + expect(unlocked.reload.kind).to eq("LPCC") + end + + it "blocks deleting a CE-tied license" do + locked = create(:professional_license, person: person, number: "LOCKED-2") + registration = create(:event_registration, registrant: person) + create(:continuing_education_registration, event_registration: registration, professional_license: locked) + + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { id: locked.id, _destroy: "1" } } + } } + + expect(ProfessionalLicense.exists?(locked.id)).to be(true) + end + end +end diff --git a/spec/requests/people_profile_flags_spec.rb b/spec/requests/people_profile_flags_spec.rb index 61bc7bf565..f90b057041 100644 --- a/spec/requests/people_profile_flags_spec.rb +++ b/spec/requests/people_profile_flags_spec.rb @@ -65,12 +65,12 @@ end describe "#profile_show_credentials" do - before { person.update!(credentials: "LCSW") } + before { create(:professional_license, person: person, kind: "LCSW", number: "11223") } context "when false" do before { person.update!(profile_show_credentials: false) } - it "hides credentials on own profile" do + it "hides the license credentials on own profile" do sign_in owner_user get person_path(person) expect(response.body).not_to include("LCSW") @@ -80,13 +80,13 @@ context "when true" do before { person.update!(profile_show_credentials: true) } - it "shows credentials as a suffix on own profile" do + it "shows the license type as a suffix on own profile" do sign_in owner_user get person_path(person) expect(response.body).to include("LCSW") end - it "shows credentials when admin views profile" do + it "shows the license type when admin views profile" do sign_in admin get person_path(person) expect(response.body).to include("LCSW") diff --git a/spec/services/event_registration_readiness_spec.rb b/spec/services/event_registration_readiness_spec.rb index 9ce53d2111..2e778dab1a 100644 --- a/spec/services/event_registration_readiness_spec.rb +++ b/spec/services/event_registration_readiness_spec.rb @@ -88,16 +88,20 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) end context "continuing education" do - it "flags CE as unpaid when CE credit is requested (no CE payment is tracked yet)" do + it "flags CE as unpaid when CE credit is requested" do pay(registration, 1000) - registration.update!(ce_credit_requested: true, ce_license_number: "LIC123") + registration.update!(ce_requested: true) + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 5_000) expect(readiness.event_ready_issues).to include("CE not paid") end it "flags a missing CE license number when CE credit is requested" do pay(registration, 1000) - registration.update!(ce_credit_requested: true, ce_license_number: nil) + registration.update!(ce_requested: true) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 5_000) expect(readiness.event_ready_issues).to include("CE license number missing") end @@ -152,7 +156,7 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) end it "flags the CE certificate as unsent when CE credit was requested" do - registration.update!(status: "attended", ce_credit_requested: true) + registration.update!(status: "attended", ce_requested: true) expect(readiness.completion_issues).to include("CE certificate not sent") end @@ -271,7 +275,7 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) end it "names both certificates when CE credit was requested" do - registration.update!(ce_credit_requested: true) + registration.update!(ce_requested: true) expect(readiness.certificate_due_reason).to eq("Reg + CE") end diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index f0b503d5ee..30b1db22c2 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -532,67 +532,79 @@ def register_with_agency_type(value) field end - def register_with_ce(answer) + let!(:ce_license_field) do + form.form_fields.create!( + name: "License number", + answer_type: :free_form_input_one_line, + status: :active, + position: (form.form_fields.maximum(:position) || 0) + 1, + required: false, + field_identifier: described_class::CE_LICENSE_NUMBER_IDENTIFIER, + section: "continuing_education", + visibility: :always_ask + ) + end + + def register_with_ce(answer, license: nil) params = base_form_params(first_name: "Cy", last_name: "Reed", email: "cy@example.com") params = params.merge(ce_field.id.to_s => answer) unless answer.nil? + params = params.merge(ce_license_field.id.to_s => license) if license described_class.call(event: event, registration_form: form, form_params: params) end - it "toggles ce_credit_requested on when answered Yes" do + it "creates a CE registration when answered Yes" do result = register_with_ce("Yes") - expect(result.event_registration.ce_credit_requested).to be true + expect(result.event_registration.continuing_education_registrations.count).to eq(1) end - it "leaves ce_credit_requested off when answered No" do + it "creates no CE registration when answered No" do result = register_with_ce("No") - expect(result.event_registration.ce_credit_requested).to be false + expect(result.event_registration.continuing_education_registrations).to be_empty end - it "leaves ce_credit_requested off when unanswered" do + it "creates no CE registration when unanswered" do result = register_with_ce(nil) - expect(result.event_registration.ce_credit_requested).to be false + expect(result.event_registration.continuing_education_registrations).to be_empty end - it "toggles ce_credit_requested on for an existing registration that answers Yes" do + it "creates a CE registration for an existing registration that answers Yes" do person = create(:person, first_name: "Cy", last_name: "Reed", email: "cy@example.com") - existing = create(:event_registration, event: event, registrant: person, ce_credit_requested: false) + existing = create(:event_registration, event: event, registrant: person) result = register_with_ce("Yes") expect(result.event_registration).to eq(existing) - expect(existing.reload.ce_credit_requested).to be true + expect(existing.reload.ce_requested).to be(true) + expect(existing.continuing_education_registrations.count).to eq(1) end - it "saves the hours folded into a 'Yes: ' specify answer" do - result = register_with_ce("Yes: 6") - expect(result.event_registration.ce_credit_requested).to be true - expect(result.event_registration.ce_hours_requested).to eq(6) - end + it "does not duplicate a CE registration when the existing one already has one" do + person = create(:person, first_name: "Cy", last_name: "Reed", email: "cy@example.com") + existing = create(:event_registration, event: event, registrant: person, ce_requested: true) + license = create(:professional_license, :placeholder, person: person) + create(:continuing_education_registration, event_registration: existing, professional_license: license) - it "leaves ce_hours_requested nil when Yes carries no hours" do - result = register_with_ce("Yes") - expect(result.event_registration.ce_hours_requested).to be_nil - end + register_with_ce("Yes") - it "leaves ce_hours_requested nil when answered No" do - result = register_with_ce("No") - expect(result.event_registration.ce_hours_requested).to be_nil + expect(existing.reload.continuing_education_registrations.count).to eq(1) end - it "ignores non-numeric hours in the specify answer" do - result = register_with_ce("Yes: lots") - expect(result.event_registration.ce_credit_requested).to be true - expect(result.event_registration.ce_hours_requested).to be_nil + it "records the typed license number on the CE registration's license" do + result = register_with_ce("Yes", license: "LMFT 555") + license = result.event_registration.continuing_education_registrations.first.professional_license + expect(license.number).to eq("LMFT 555") + expect(license.person).to eq(result.event_registration.registrant) end - it "saves the hours onto an existing registration that answers Yes" do - person = create(:person, first_name: "Cy", last_name: "Reed", email: "cy@example.com") - existing = create(:event_registration, event: event, registrant: person, ce_credit_requested: false) - - register_with_ce("Yes: 4") + it "uses a placeholder license when no number is given" do + result = register_with_ce("Yes") + expect(result.event_registration.continuing_education_registrations.first.professional_license.number).to be_nil + end - expect(existing.reload.ce_credit_requested).to be true - expect(existing.ce_hours_requested).to eq(4) + it "takes the CE hours from the event" do + event.update!(ce_hours_offered: 6) + result = register_with_ce("Yes") + expect(result.event_registration.continuing_education_registrations.first.hours).to eq(6) end end diff --git a/spec/services/event_revenue_report_spec.rb b/spec/services/event_revenue_report_spec.rb index 2420a17a88..0de79b0009 100644 --- a/spec/services/event_revenue_report_spec.rb +++ b/spec/services/event_revenue_report_spec.rb @@ -10,14 +10,16 @@ let!(:reg1) { create(:event_registration, event: event, registrant: person1, status: "registered") } let!(:reg2) do - create(:event_registration, event: event, registrant: person2, status: "registered", - ce_credit_requested: true, ce_hours_requested: 3) + create(:event_registration, event: event, registrant: person2, status: "registered", ce_requested: true) end before do + # reg2's projected CE fee: a $75 CE registration. + create(:continuing_education_registration, event_registration: reg2, cost_cents: 7_500) + # A cancelled registration whose money/CE must be ignored everywhere. - cancelled = create(:event_registration, event: event, registrant: create(:person), status: "cancelled", - ce_credit_requested: true, ce_hours_requested: 6) + cancelled = create(:event_registration, event: event, registrant: create(:person), status: "cancelled", ce_requested: true) + create(:continuing_education_registration, event_registration: cancelled, cost_cents: 12_000) create(:allocation, source: create(:payment, amount_cents: 5_000, amount_cents_remaining: 5_000), allocatable: cancelled, amount: 5_000) diff --git a/spec/services/magic_ticket_callouts_spec.rb b/spec/services/magic_ticket_callouts_spec.rb index 1498d8cc69..7acb7067fe 100644 --- a/spec/services/magic_ticket_callouts_spec.rb +++ b/spec/services/magic_ticket_callouts_spec.rb @@ -75,31 +75,25 @@ def card(reg, title) end it "shows the CE card only when the registrant requested CE credit" do - event.update!(ce_hours_details: "6 hours") expect(card_titles(registration)).not_to include(event.ce_hours_details_label) - registration.update!(ce_credit_requested: true) - expect(card_titles(registration)).to include(event.ce_hours_details_label) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + expect(card_titles(registration.reload)).to include(event.ce_hours_details_label) end - it "shows an amber 'what's needed' CE badge until complete, then a teal amount due" do - registration.update!(ce_credit_requested: true, ce_hours_requested: nil, ce_license_number: nil) - both = card(registration, event.ce_hours_details_label) - expect(both.theme).to eq(DomainTheme.swatch("teal")) - expect(both.subtitle).to eq("Continuing education credit") - expect(both.badge).to eq("Hours & license number needed") - expect(both.badge_classes).to be_nil - - registration.update!(ce_hours_requested: 6, ce_license_number: nil) - license = card(registration, event.ce_hours_details_label) - expect(license.subtitle).to eq("6 hours") - expect(license.badge).to eq("$150 · License number needed") - expect(license.badge_classes).to be_nil - - registration.update!(ce_hours_requested: nil, ce_license_number: "LIC123") - expect(card(registration, event.ce_hours_details_label).badge).to eq("Hours needed") - - registration.update!(ce_hours_requested: 6, ce_license_number: "LIC123") - complete = card(registration, event.ce_hours_details_label) + it "shows a 'license needed' CE badge until provided, then a teal amount due" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + + needs = card(registration.reload, event.ce_hours_details_label) + expect(needs.theme).to eq(DomainTheme.swatch("teal")) + expect(needs.subtitle).to eq("6 hours") + expect(needs.badge).to eq("$150 · License number needed") + expect(needs.badge_classes).to be_nil + + license.update!(number: "LIC123") + complete = card(registration.reload, event.ce_hours_details_label) expect(complete.subtitle).to eq("6 hours") expect(complete.badge).to eq("$150 due") expect(complete.badge_classes).to include("teal") @@ -133,10 +127,12 @@ def card(reg, title) it "places payment first and FAQ last in the full ordering" do event.update!(facilitator_training: true, event_details: "Bring supplies", - ce_hours_details: "6 hours", + ce_hours_details: "6 hours", ce_hours_offered: 6, videoconference_url: "https://example.zoom.us/j/123", start_date: 3.days.ago, end_date: 2.days.ago) - registration.update!(status: "attended", scholarship_requested: true, ce_credit_requested: true) + registration.update!(status: "attended", scholarship_requested: true) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license) expect(card_titles(registration)).to eq([ "Make your payment", "Certificate of completion", diff --git a/spec/services/number_formatter_spec.rb b/spec/services/number_formatter_spec.rb new file mode 100644 index 0000000000..d1b4b6b650 --- /dev/null +++ b/spec/services/number_formatter_spec.rb @@ -0,0 +1,17 @@ +require "rails_helper" + +RSpec.describe NumberFormatter do + describe ".plain" do + it "drops insignificant trailing zeros" do + expect(described_class.plain(6.0)).to eq("6") + expect(described_class.plain(1.5)).to eq("1.5") + expect(described_class.plain(0.25)).to eq("0.25") + expect(described_class.plain(BigDecimal("6"))).to eq("6") + end + + it "returns nil for a blank input" do + expect(described_class.plain(nil)).to be_nil + expect(described_class.plain("")).to be_nil + end + end +end diff --git a/spec/services/reminder_recipient_filter_spec.rb b/spec/services/reminder_recipient_filter_spec.rb index 140d5cdf31..b19f039392 100644 --- a/spec/services/reminder_recipient_filter_spec.rb +++ b/spec/services/reminder_recipient_filter_spec.rb @@ -167,32 +167,42 @@ def matched(params, registrations) end context "CE status" do - # Requested CE, supplied both license and hours, and paid in full. + # Requested CE, supplied a license, and paid the CE balance in full. let!(:complete) do registration(first_name: "Complete").tap do |r| - r.update!(ce_credit_requested: true, ce_license_number: "ABC123", ce_hours_requested: 3) - create(:allocation, allocatable: r, amount: 10_000) + license = create(:professional_license, person: r.registrant, number: "ABC123") + ce_reg = create(:continuing_education_registration, event_registration: r, professional_license: license, hours: 4) + payment = create(:payment, amount_cents: ce_reg.cost_cents, amount_cents_remaining: ce_reg.cost_cents) + create(:allocation, source: payment, allocatable: ce_reg, amount: ce_reg.cost_cents) end end - # Requested CE but missing license and hours, unpaid. - let!(:missing) { registration(first_name: "Missing").tap { |r| r.update!(ce_credit_requested: true) } } - # Did not request CE at all. - let!(:no_ce) { registration(first_name: "None").tap { |r| r.update!(ce_license_number: nil, ce_hours_requested: nil) } } - let(:regs) { [ complete, missing, no_ce ] } - - it "filters CE requested" do - expect(matched({ ce_status: "requested" }, regs)).to eq([ complete.id, missing.id ].to_set) + # CE on a placeholder license, unpaid. + let!(:missing) do + registration(first_name: "Missing").tap do |r| + license = create(:professional_license, :placeholder, person: r.registrant) + create(:continuing_education_registration, event_registration: r, professional_license: license, hours: 4) + end + end + # CE with a license on file but the balance unpaid. + let!(:unpaid_known) do + registration(first_name: "Unpaid").tap do |r| + license = create(:professional_license, person: r.registrant, number: "XYZ789") + create(:continuing_education_registration, event_registration: r, professional_license: license, hours: 4) + end end + # No CE registration at all. + let!(:no_ce) { registration(first_name: "None") } + let(:regs) { [ complete, missing, unpaid_known, no_ce ] } - it "filters CE license not provided (only among CE requesters)" do - expect(matched({ ce_status: "license_not_provided" }, regs)).to eq([ missing.id ].to_set) + it "filters CE not yet paid" do + expect(matched({ ce_status: "requested" }, regs)).to eq([ missing.id, unpaid_known.id ].to_set) end - it "filters CE hours not provided (only among CE requesters)" do - expect(matched({ ce_status: "hours_not_provided" }, regs)).to eq([ missing.id ].to_set) + it "filters CE on a placeholder license" do + expect(matched({ ce_status: "needs_license" }, regs)).to eq([ missing.id ].to_set) end - it "filters CE paid (requested CE and paid in full)" do + it "filters CE paid (CE balance paid in full)" do expect(matched({ ce_status: "paid" }, regs)).to eq([ complete.id ].to_set) end end diff --git a/spec/system/event_registration_edit_spec.rb b/spec/system/event_registration_edit_spec.rb index 12e7bb9ddd..fa3f0ba634 100644 --- a/spec/system/event_registration_edit_spec.rb +++ b/spec/system/event_registration_edit_spec.rb @@ -117,7 +117,7 @@ visit edit_event_registration_path(registration) within("section", text: "Scholarship") do - expect(page).to have_text("Funded by") + expect(page).to have_text("Grantor:") expect(page).to have_link("Acme Foundation", href: organization_path(organization)) end end @@ -136,7 +136,7 @@ end end - it "omits the funder line when the scholarship has no grant" do + it "shows no grantor text (just a spacer) when the scholarship has no grant" do scholarship = create(:scholarship, recipient: registration.registrant, amount_cents: 1_000) create(:allocation, source: scholarship, allocatable: registration, amount: 1_000) @@ -144,7 +144,7 @@ visit edit_event_registration_path(registration) within("section", text: "Scholarship") do - expect(page).to have_no_text("Funded by") + expect(page).to have_no_text("Grantor:") end end end diff --git a/spec/system/public_registration_form_submission_spec.rb b/spec/system/public_registration_form_submission_spec.rb index f44722ef95..74102fcb1a 100644 --- a/spec/system/public_registration_form_submission_spec.rb +++ b/spec/system/public_registration_form_submission_spec.rb @@ -73,8 +73,9 @@ email_2: "robin.alt@example.com") registration = event.event_registrations.find_by!(registrant: person) - expect(registration).to have_attributes(scholarship_requested: false, ce_credit_requested: true, + expect(registration).to have_attributes(scholarship_requested: false, w9_requested: true, invoice_requested: false) + expect(registration.continuing_education_registrations.count).to eq(1) answers = answers_by_identifier(registration_form.form_submissions.find_by!(person: person)) expect(answers).to include( diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index b298be1428..5cd9ed7c65 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -172,6 +172,8 @@ "app/views/category_types/edit.html.erb" => "admin-only bg-blue-100", "app/views/community_news/edit.html.erb" => "admin-only bg-blue-100", "app/views/event_registrations/edit.html.erb" => "admin-only bg-blue-100", + "app/views/continuing_education_registrations/edit.html.erb" => "admin-only bg-blue-100", + "app/views/continuing_education_registrations/new.html.erb" => "admin-only bg-blue-100", "app/views/events/edit.html.erb" => "admin-only bg-blue-100", "app/views/forms/edit.html.erb" => "admin-only bg-blue-100", "app/views/forms/edit_sections.html.erb" => "admin-only bg-blue-100", From d25bf0cc2541f3ddc6c659b9ab091b401b7bb306 Mon Sep 17 00:00:00 2001 From: Justin <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:40:39 -0400 Subject: [PATCH 07/14] Ce part 3 (#1966) * remove ce requested any request for ce was followed by an automatic ce record creation so no need for the boolean. * add ce reg form role * do not guard on 1 * add ce form role to edit/new * remove check for ce offered, just use form * add basic ce form selection * css toggle cost and hour fields for one form * add spec * add ce payment via stripe * add payment/discount info to callout * add specs * rubocop * brakeman --- ...uing_education_registrations_controller.rb | 2 - .../event_registrations_controller.rb | 51 +- app/controllers/events/callouts_controller.rb | 63 +- .../events/public_registrations_controller.rb | 36 +- app/controllers/events_controller.rb | 4 +- .../event_registration_decorator.rb | 3 +- .../controllers/section_filter_controller.js | 4 +- app/helpers/events_helper.rb | 1 - app/models/concerns/pay_charge_extensions.rb | 34 +- app/models/event.rb | 4 + app/models/event_form.rb | 3 +- app/models/event_registration.rb | 6 +- app/services/event_registration_readiness.rb | 6 +- .../public_registration.rb | 57 +- app/services/form_builder_service.rb | 31 +- .../_continuing_education.html.erb | 27 +- app/views/events/_form.html.erb | 591 ++++++++++-------- app/views/events/callouts/ce.html.erb | 37 +- app/views/events/onboarding/_row.html.erb | 12 - .../events/public_registrations/new.html.erb | 22 + .../events/public_registrations/show.html.erb | 51 ++ app/views/forms/edit.html.erb | 2 +- app/views/forms/new.html.erb | 8 +- config/brakeman.ignore | 148 +++-- config/routes.rb | 1 + ...e_ce_requested_from_event_registrations.rb | 15 + db/schema.rb | 3 +- db/seeds/dev/events_management.rb | 77 +-- .../event_registration_decorator_spec.rb | 9 - spec/factories/event_forms.rb | 4 + .../concerns/pay_charge_extensions_spec.rb | 58 ++ ...continuing_education_registrations_spec.rb | 6 +- spec/requests/event_registrations_spec.rb | 22 - spec/requests/events/callouts_spec.rb | 103 +++ spec/requests/events/registrations_spec.rb | 4 +- spec/requests/events_spec.rb | 1 - .../event_registration_readiness_spec.rb | 9 +- .../public_registration_spec.rb | 65 +- spec/services/event_revenue_report_spec.rb | 4 +- .../ce_form_toggle_on_event_edit_spec.rb | 32 + ...ublic_registration_form_submission_spec.rb | 36 +- 41 files changed, 1046 insertions(+), 606 deletions(-) create mode 100644 db/migrate/20260711193528_remove_ce_requested_from_event_registrations.rb create mode 100644 spec/system/ce_form_toggle_on_event_edit_spec.rb diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb index 6f32951e34..3faa969518 100644 --- a/app/controllers/continuing_education_registrations_controller.rb +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -20,7 +20,6 @@ def create ActiveRecord::Base.transaction do apply_ce_params(@ce_registration) @ce_registration.save! - @event_registration.update_column(:ce_requested, true) end redirect_to edit_event_registration_path(@ce_registration.event_registration), notice: "CE registration created.", status: :see_other rescue ActiveRecord::RecordInvalid @@ -55,7 +54,6 @@ def destroy registration = @ce_registration.event_registration @ce_registration.destroy! - registration.update_column(:ce_requested, false) redirect_to edit_event_registration_path(registration), notice: "CE registration removed.", status: :see_other end diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index e50b05480a..3dccb72f18 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -60,7 +60,6 @@ def create authorize! @event_registration if @event_registration.save - reconcile_ce_registration if @event_registration.event&.ce_eligible? respond_to do |format| format.html { redirect_to confirm_event_registration_path(@event_registration, return_to: params[:return_to]) @@ -88,10 +87,7 @@ def update @event_registration.notifications.select(&:new_record?).each { |n| n.recipient_email = recipient_email } if @event_registration.save - reconcile_ce_registration if @event_registration.event&.ce_eligible? - # Prefer the CE flash ("CE registration created/removed") when reconcile set - # one; a blocked toggle-off sets flash[:alert], which survives independently. - notice = flash[:notice].presence || "Registration was successfully updated." + notice = "Registration was successfully updated." respond_to do |format| format.turbo_stream format.html { @@ -327,56 +323,11 @@ def toggle_checklist_step(step, completed) end end - # Keep the registration's CE record in step with the `ce_requested` flag (set on - # the edit form / at intake), mirroring how a scholarship is awarded from its - # "Requested" toggle. Requested + none yet → create a stub against the chosen - # license (or the registrant's only license, else a placeholder); license/hours/ - # cost/certificate are then edited on the CE edit page. Un-requested → remove it, - # admins only, and never one that carries payments (the flag is restored and the - # admin is told to revert the payment first). Sets a flash describing what changed. - def reconcile_ce_registration - if @event_registration.ce_requested? - create_ce_registration_stub - else - remove_ce_registration - end - end - - def create_ce_registration_stub - return if @event_registration.continuing_education_registrations.exists? - - @event_registration.continuing_education_registrations.create!(professional_license: ce_license_for_create) - flash[:notice] = "CE registration created." - end - - def remove_ce_registration - registrations = @event_registration.continuing_education_registrations - return if registrations.none? || !allowed_to?(:manage?, with: EventRegistrationPolicy) - - if registrations.any? { |registration| registration.allocations.exists? } - @event_registration.update_column(:ce_requested, true) - flash[:alert] = "Can't remove CE — it has payments. Revert the payment first." - return - end - - registrations.destroy_all - flash[:notice] = "CE registration removed." - end - - # License a brand-new CE registration attaches to: the registrant's existing - # license, else an empty placeholder (number pending). Which license is used - # (and its number) is then changeable on the CE registration's edit page. - def ce_license_for_create - @event_registration.registrant.professional_licenses.first || - ProfessionalLicense.find_or_create_for(person: @event_registration.registrant) - end - # Strong parameters def event_registration_params params.require(:event_registration).permit( :event_id, :registrant_id, :status, :scholarship_requested, - :ce_requested, :shoutout, :intends_to_pay, :expected_payment_method, diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index b652cae3c5..253d997144 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -60,6 +60,23 @@ def scholarship # CE hours status: hours, amount owed, and license number. def ce + case params[:checkout] + when "success" + flash.now[:notice] = "Your CE payment was successful." + when "cancelled" + flash.now[:alert] = "CE payment was cancelled. You can try again whenever you're ready." + end + end + + # Pay the CE balance via Stripe Checkout. + def pay_ce + ce_registration = @event_registration.continuing_education_registrations.first + unless ce_registration&.remaining_cost&.positive? + redirect_to registration_ce_path(@event_registration.slug), alert: "No CE payment is due." + return + end + + redirect_to_ce_stripe_checkout(ce_registration) end # Public license entry from the CE callout (type, number, issuing state, and @@ -96,7 +113,6 @@ def update_ce_license def request_ce return redirect_to(registration_ce_path(@event_registration.slug)) unless @event.ce_eligible? - @event_registration.update!(ce_requested: true) unless @event_registration.continuing_education_registrations.exists? license = ProfessionalLicense.find_or_create_for(person: @event_registration.registrant) @event_registration.continuing_education_registrations.create!(professional_license: license) @@ -161,13 +177,11 @@ def set_event @event = @event_registration.event end - # Keep the registrant's form submission in step with a license number entered - # on the callout, so the registration record shows the same value. A no-op - # when the form, field, or submission isn't present. + # Update form submission if ce record is updated via callout def record_ce_license_answer(number) - form = @event.registration_form + form = @event.continuing_education_form field = form&.form_fields&.find_by(field_identifier: "ce_license_number") - submission = form&.form_submissions&.find_by(person: @event_registration.registrant) + submission = form&.form_submissions&.find_by(person: @event_registration.registrant, role: "continuing_education") return unless field && submission answer = submission.form_answers.find_or_initialize_by(form_field: field) @@ -205,5 +219,42 @@ def resource_card(icon:, title:, subtitle:, href:, target: "_blank", trailing_ic MagicTicketCallouts::Card.new(icon_class: icon, color: "blue", title: title, subtitle: subtitle, href: href, target: target, trailing_icon: trailing_icon) end + + def redirect_to_ce_stripe_checkout(ce_registration) + person = @event_registration.registrant + amount = ce_registration.remaining_cost + + person.set_payment_processor :stripe + + checkout_session = person.payment_processor.checkout( + mode: "payment", + metadata: { + ce_registration_id: ce_registration.id, + event_registration_id: @event_registration.id, + event_id: @event.id + }, + payment_intent_data: { + metadata: { + ce_registration_id: ce_registration.id, + event_registration_id: @event_registration.id, + event_id: @event.id + }, + description: "CE Hours: #{@event.title}" + }, + line_items: [ { + price_data: { + currency: "usd", + product_data: { name: "CE Hours: #{@event.title}" }, + unit_amount: amount + }, + quantity: 1 + } ], + success_url: registration_ce_url(@event_registration.slug, checkout: "success"), + cancel_url: registration_ce_url(@event_registration.slug, checkout: "cancelled") + ) + + @event_registration.update!(checkout_session_id: checkout_session.id) + redirect_to checkout_session.url, allow_other_host: true, status: :see_other + end end end diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb index 0645472d04..a4ea0e5979 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -21,6 +21,7 @@ def new @form_fields = visible_form_fields @scholarship = scholarship_mode? @scholarship_form = @event.scholarship_form if @scholarship + @continuing_education_form = @event.continuing_education_form @event = @event.decorate end @@ -35,17 +36,23 @@ def create @registration_form = registration_form @scholarship = scholarship_mode? @scholarship_form = @event.scholarship_form if @scholarship + @continuing_education_form = @event.continuing_education_form all_params = params.dig(:public_registration, :form_fields)&.to_unsafe_h || {} - registration_params, scholarship_params = split_form_params(all_params) + registration_params, scholarship_params, continuing_education_params = split_form_params(all_params) @field_errors = validate_required_fields(registration_params) if @scholarship_form scholarship_fields = @scholarship_form.form_fields.where.not(answer_type: :group_header).to_a @field_errors = @field_errors.merge(FormAnswerValidator.call(scholarship_fields, scholarship_params)) end + if @continuing_education_form + ce_fields = @continuing_education_form.form_fields.where.not(answer_type: :group_header).to_a + @field_errors = @field_errors.merge(FormAnswerValidator.call(ce_fields, continuing_education_params)) + end if @field_errors.any? @form_fields = visible_form_fields + @continuing_education_form = @event.continuing_education_form @event = @event.decorate flash.now[:alert] = "Your registration is not complete yet. Scroll down to check for any errors or missing information." render :new, status: :unprocessable_content @@ -61,7 +68,9 @@ def create scholarship_requested: @scholarship, person: current_user&.person, scholarship_form: @scholarship_form, - scholarship_params: scholarship_params + scholarship_params: scholarship_params, + continuing_education_form: @continuing_education_form, + continuing_education_params: continuing_education_params ) if result.success? @@ -76,6 +85,7 @@ def create end else @form_fields = visible_form_fields + @continuing_education_form = @event.continuing_education_form @event = @event.decorate flash.now[:error] = result.errors.join(", ") flash.now[:alert] = "Your registration is not complete yet. Scroll down to check for any errors or missing information." @@ -120,6 +130,7 @@ def show @responses = @form_submission.form_answers.index_by(&:form_field_id) load_scholarship_submission(person) + load_continuing_education_submission(person) @event = @event.decorate end @@ -190,6 +201,19 @@ def load_scholarship_submission(person) @scholarship_responses = application.answers_by_field_id end + def load_continuing_education_submission(person) + ce_form = @event.continuing_education_form + return unless ce_form + + submission = ce_form.form_submissions.find_by(person: person, event: @event, role: "continuing_education") + return unless submission + + @continuing_education_submission = submission + @continuing_education_form = ce_form + @continuing_education_fields = ce_form.form_fields.reorder(position: :asc) + @continuing_education_responses = submission.form_answers.index_by(&:form_field_id) + end + def scholarship_mode? params[:scholarship_requested] == "true" end @@ -204,7 +228,13 @@ def split_form_params(all_params) scholarship = all_params.slice(*scholarship_field_ids) end - [ registration, scholarship ] + continuing_education = {} + if @continuing_education_form + ce_field_ids = @continuing_education_form.form_fields.pluck(:id).map(&:to_s) + continuing_education = all_params.slice(*ce_field_ids) + end + + [ registration, scholarship, continuing_education ] end def visible_form_fields diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index f0f822cff8..e8eaf11cbe 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -670,7 +670,7 @@ def onboarding_csv_row(registration, cost_required, day_count, include_ce = fals row << onboarding_scholarship_tasks_csv(registration) if include_ce ce_hours = registration.ce_hours_total - row << (registration.ce_requested? ? "Yes" : "No") + row << (registration.ce_registered? ? "Yes" : "No") row << (ce_hours.positive? ? helpers.plain_number(ce_hours) : "") row << (registration.ce_amount_owed_cents.positive? ? helpers.dollars_from_cents(registration.ce_amount_owed_cents) : "") row << registration.ce_license_numbers.join("; ") @@ -700,6 +700,7 @@ def assign_event_forms(event) assign_event_form(event, :registration, params.dig(:event, :registration_form_id)) assign_event_form(event, :scholarship, params.dig(:event, :scholarship_form_id)) assign_event_form(event, :bulk_payment, params.dig(:event, :bulk_payment_form_id)) + assign_event_form(event, :continuing_education, params.dig(:event, :continuing_education_form_id)) end def assign_event_form(event, role, form_id) @@ -719,6 +720,7 @@ def set_form_variables @registration_forms = Form.standalone.where(role: "registration").order(:name) @scholarship_forms = Form.standalone.where(role: "scholarship").order(:name) @bulk_payment_forms = Form.standalone.where(role: "bulk_payment").order(:name) + @continuing_education_forms = Form.standalone.where(role: "continuing_education").order(:name) @categories_grouped = Category .includes(:category_type) diff --git a/app/decorators/event_registration_decorator.rb b/app/decorators/event_registration_decorator.rb index 82200f9203..82374fdae6 100644 --- a/app/decorators/event_registration_decorator.rb +++ b/app/decorators/event_registration_decorator.rb @@ -15,8 +15,7 @@ class EventRegistrationDecorator < ApplicationDecorator # `simulate_paid:` lets the CE callout's ?admin=true preview the post-payment state # without recording a payment. def ce_status_badge(simulate_paid: false) - return unless ce_requested? || ce_registered? - return ce_badge("Requested", "fa-solid fa-clock", :amber) unless ce_registered? + return unless ce_registered? return ce_badge("Issued", "fa-solid fa-circle-check", :green) if ce_certificate_issued? return ce_badge("License # needed", "fa-solid fa-id-card", :amber) unless ce_license_provided? return ce_badge("Pending", "fa-solid fa-hourglass-half", :blue) if ce_paid_in_full? || simulate_paid diff --git a/app/frontend/javascript/controllers/section_filter_controller.js b/app/frontend/javascript/controllers/section_filter_controller.js index 1055151363..fb3c441e60 100644 --- a/app/frontend/javascript/controllers/section_filter_controller.js +++ b/app/frontend/javascript/controllers/section_filter_controller.js @@ -15,8 +15,10 @@ export default class extends Controller { el.classList.toggle("hidden", sectionRole !== "scholarship") } else if (role === "bulk_payment") { el.classList.toggle("hidden", sectionRole !== "bulk_payment") + } else if (role === "continuing_education") { + el.classList.toggle("hidden", sectionRole !== "continuing_education") } else { - el.classList.toggle("hidden", sectionRole === "scholarship" || sectionRole === "bulk_payment") + el.classList.toggle("hidden", sectionRole === "scholarship" || sectionRole === "bulk_payment" || sectionRole === "continuing_education") } }) } diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 2871b44ba8..1c6d6fe302 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -53,7 +53,6 @@ def onboarding_columns(event) columns << { key: "funder", label: "Scholarship grant", kind: :funder, sortable: true, align: "left", toggle: "funder" } columns << { key: "scholarship_tasks_completed", label: "Scholarship tasks done", kind: :scholarship_tasks, sortable: true, align: "center", toggle: "scholarship_tasks_completed" } if event.ce_eligible? - columns << { key: "ce_requested", label: "CE requested", kind: :ce_requested, sortable: true, align: "center", toggle: "ce_requested" } columns << { key: "ce_hours", label: "CE hours", kind: :ce_hours, sortable: true, align: "center", toggle: "ce_hours" } columns << { key: "ce_amount", label: "CE amount", kind: :ce_amount, sortable: true, align: "center", toggle: "ce_amount" } columns << { key: "ce_license", label: "License #", kind: :ce_license, sortable: true, align: "center", toggle: "ce_license" } diff --git a/app/models/concerns/pay_charge_extensions.rb b/app/models/concerns/pay_charge_extensions.rb index 54cadffe59..a5dc7d44dd 100644 --- a/app/models/concerns/pay_charge_extensions.rb +++ b/app/models/concerns/pay_charge_extensions.rb @@ -12,7 +12,9 @@ def create_external_processor_payment return if ExternalProcessorPayment.exists?(pay_charge_id: id) return unless object["paid"] == true - if (event_registration_id = metadata["event_registration_id"]) + if (ce_registration_id = metadata["ce_registration_id"]) + create_ce_payment(ce_registration_id) + elsif (event_registration_id = metadata["event_registration_id"]) create_event_registration_payment(event_registration_id) elsif (form_submission_id = metadata["form_submission_id"]) create_bulk_payment(form_submission_id) @@ -54,6 +56,36 @@ def create_event_registration_payment(event_registration_id) registration.update!(payment_unresolved: false) end + def create_ce_payment(ce_registration_id) + ce_registration = ContinuingEducationRegistration.find_by(id: ce_registration_id) + return unless ce_registration + + person = ce_registration.event_registration&.registrant + return unless person + + payment = ExternalProcessorPayment.create!( + stripe_charge_id: processor_id, + external_origin: false, + person: person, + amount_cents: amount, + amount_cents_remaining: amount, + currency: currency, + pay_charge_id: id, + metadata: metadata.merge(stripe_charge: object) + ) + + remaining_needed = ce_registration.remaining_cost + allocation_amount = [ amount, remaining_needed ].min + + if allocation_amount > 0 + Allocation.create!( + source: payment, + allocatable: ce_registration, + amount: allocation_amount + ) + end + end + def create_bulk_payment(form_submission_id) submission = FormSubmission.find_by(id: form_submission_id) return unless submission&.role == "bulk_payment" diff --git a/app/models/event.rb b/app/models/event.rb index 604acd7a5c..69f8e0e62f 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -151,6 +151,10 @@ def bulk_payment_form forms.find_by(event_forms: { role: "bulk_payment" }) end + def continuing_education_form + forms.find_by(event_forms: { role: "continuing_education" }) + end + def active_registration_for(person) return nil unless person event_registrations.active.find_by(registrant_id: person.id) diff --git a/app/models/event_form.rb b/app/models/event_form.rb index 4f2f6bddae..5ffb4c88b8 100644 --- a/app/models/event_form.rb +++ b/app/models/event_form.rb @@ -2,7 +2,7 @@ class EventForm < ApplicationRecord belongs_to :event belongs_to :form - ROLES = %w[registration scholarship bulk_payment].freeze + ROLES = %w[registration scholarship bulk_payment continuing_education].freeze validates :role, presence: true, inclusion: { in: ROLES } validates :form_id, uniqueness: { scope: [ :event_id, :role ] } @@ -10,4 +10,5 @@ class EventForm < ApplicationRecord scope :registration, -> { where(role: "registration") } scope :scholarship, -> { where(role: "scholarship") } scope :bulk_payment, -> { where(role: "bulk_payment") } + scope :continuing_education, -> { where(role: "continuing_education") } end diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 2b48dc6537..b65673f356 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -382,10 +382,8 @@ def cost_cents # CE is now tracked as one or more ContinuingEducationRegistration records, # each against a professional license. These aggregate across them so callers # (callouts, onboarding, CSV) read a single registration-level figure. - # - # `ce_requested?` is the stored intent flag (column); `ce_registered?` is whether - # a CE registration record actually exists. They align in the normal flow (the - # toggle creates the record), but the readers below key off the record. + # `ce_registered?` is whether a CE registration record actually exists — + # the readers below key off the record(s). def ce_registered? if ce_registrations_in_memory? return continuing_education_registrations.any? diff --git a/app/services/event_registration_readiness.rb b/app/services/event_registration_readiness.rb index 37343012bb..4b42953aff 100644 --- a/app/services/event_registration_readiness.rb +++ b/app/services/event_registration_readiness.rb @@ -158,15 +158,15 @@ def scholarship_tasks_incomplete? end def ce_unpaid? - registration.ce_requested? && !ce_paid? + registration.ce_registered? && !ce_paid? end def ce_license_missing? - registration.ce_requested? && !registration.ce_license_provided? + registration.ce_registered? && !registration.ce_license_provided? end def ce_certificate_pending? - registration.ce_requested? && !ce_certificate_sent? + registration.ce_registered? && !ce_certificate_sent? end # Post-event criteria are only met by a full "attended". "incomplete_attendance" diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index a069a765c9..1719e172da 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -28,13 +28,16 @@ class PublicRegistration ORGANIZATION_POSITION_IDENTIFIER = "agency_position".freeze def self.call(event:, registration_form:, form_params:, scholarship_requested: false, person: nil, - scholarship_form: nil, scholarship_params: {}) + scholarship_form: nil, scholarship_params: {}, + continuing_education_form: nil, continuing_education_params: {}) new(event:, registration_form:, form_params:, scholarship_requested:, person:, - scholarship_form:, scholarship_params:).call + scholarship_form:, scholarship_params:, + continuing_education_form:, continuing_education_params:).call end def initialize(event:, registration_form:, form_params:, scholarship_requested: false, person: nil, - scholarship_form: nil, scholarship_params: {}) + scholarship_form: nil, scholarship_params: {}, + continuing_education_form: nil, continuing_education_params: {}) @event = event @registration_form = registration_form @form_params = form_params @@ -42,6 +45,8 @@ def initialize(event:, registration_form:, form_params:, scholarship_requested: @person = person @scholarship_form = scholarship_form @scholarship_params = scholarship_params || {} + @continuing_education_form = continuing_education_form + @continuing_education_params = continuing_education_params || {} @errors = [] end @@ -66,7 +71,6 @@ def call existing = @event.event_registrations.find_by(registrant: person) if existing existing.update!(scholarship_requested: true) if @scholarship_requested - existing.update!(ce_requested: true) if ce_credit_requested? create_ce_registration(existing, person) existing.update!(w9_requested: true) if w9_requested? existing.update!(invoice_requested: true) if invoice_requested? @@ -79,6 +83,7 @@ def call connect_organization(existing, organization) submission = update_form_submission(person) save_scholarship_submission(person) + save_continuing_education_submission(person) return Result.new(success?: true, event_registration: existing, form_submission: submission, errors: []) end @@ -87,6 +92,7 @@ def call connect_organization(event_registration, organization) submission = create_form_submission(person) save_scholarship_submission(person) + save_continuing_education_submission(person) send_notifications(event_registration) @@ -382,7 +388,6 @@ def create_event_registration(person) @event.event_registrations.create!( registrant: person, scholarship_requested: @scholarship_requested, - ce_requested: ce_credit_requested?, w9_requested: w9_requested?, invoice_requested: invoice_requested?, expected_payment_method: field_value("payment_method")&.strip.presence @@ -401,13 +406,23 @@ def create_ce_registration(event_registration, person) event_registration.continuing_education_registrations.create!(professional_license: license) end - # True when the registrant answered "Yes" to the seeded CE-interest question. def ce_credit_requested? - field_value(CE_CREDIT_INTEREST_IDENTIFIER).to_s.strip.casecmp?("yes") + return false unless @continuing_education_form + + ce_field_value(CE_CREDIT_INTEREST_IDENTIFIER).to_s.strip.casecmp?("yes") end def ce_license_number - field_value(CE_LICENSE_NUMBER_IDENTIFIER)&.strip.presence + return nil unless @continuing_education_form + + ce_field_value(CE_LICENSE_NUMBER_IDENTIFIER)&.strip.presence + end + + def ce_field_value(key) + field = @continuing_education_form.form_fields.find_by(field_identifier: key) + return nil unless field + + @continuing_education_params[field.id.to_s] end # The "Additional forms" question is a multi-select, so its submitted value is @@ -483,6 +498,32 @@ def save_scholarship_submission(person) end end + def save_continuing_education_submission(person) + return unless @continuing_education_form && @continuing_education_params.present? + return unless ce_credit_requested? + + submission = FormSubmission.find_or_create_by!( + person: person, form: @continuing_education_form, role: "continuing_education", event: @event + ) do |record| + record.event = @event + end + + @continuing_education_params.each do |field_id, raw_value| + field = @continuing_education_form.form_fields.find_by(id: field_id) + next unless field + next if field.group_header? + + text = if raw_value.is_a?(Array) + raw_value.reject(&:blank?).join(", ") + else + raw_value.to_s + end + + record = submission.form_answers.find_or_initialize_by(form_field: field) + record.update!(submitted_answer: text, question_name_when_answered: field.name) + end + end + def send_notifications(event_registration) registrant_email = event_registration.registrant.preferred_email diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb index 5144d3dfa0..dd5e727c10 100644 --- a/app/services/form_builder_service.rb +++ b/app/services/form_builder_service.rb @@ -11,6 +11,7 @@ class FormBuilderService person_background: { label: "Person background", method: :build_person_background_fields }, professional_info: { label: "Professional info", method: :build_professional_info_fields }, marketing: { label: "Marketing", method: :build_marketing_fields }, + continuing_education: { label: "Continuing education", method: :build_continuing_education_fields }, scholarship: { label: "Scholarship", method: :build_scholarship_fields }, payment: { label: "Payment", method: :build_payment_fields }, consent: { label: "Consent", method: :build_consent_fields }, @@ -51,6 +52,7 @@ def call person_background: %w[racial_ethnic_identity], professional_info: %w[primary_sector_single additional_sectors primary_age_group additional_age_group], marketing: %w[referral_source training_motivation interested_in_more], + continuing_education: %w[ce_credit_interest ce_license_number], scholarship: %w[scholarship_eligibility scholarship_contribution impact_description implementation_plan additional_comments], payment: %w[payment_method], consent: %w[communication_consent], @@ -65,6 +67,7 @@ def call person_background: [ "Background Information" ], professional_info: [ "Professional Information" ], marketing: [ "Marketing" ], + continuing_education: [ "Continuing education" ], scholarship: [ "Scholarship Application", "Your goals", "Anything else?" ], payment: [ "Payment Information" ], consent: [ "Consent" ], @@ -92,6 +95,10 @@ def call "What motivated you to sign up for AWBW's Facilitator Training?", "Are you interested in learning more about upcoming trainings or resources?" ], + continuing_education: [ + "Do you seek Continuing Education (CE) hours for this training?", + "If seeking CE hours, what is your LMFT, LCSW, LPCC or LEP license number?" + ], scholarship: [ "I and/or my organization cannot afford the full training cost and need a scholarship to attend.", "How much are you (and/or your organization) able to pay for this training?", @@ -123,6 +130,7 @@ def self.section_field_names(key) person_background: %w[background], professional_info: %w[professional], marketing: %w[marketing], + continuing_education: %w[continuing_education], scholarship: %w[scholarship], payment: %w[payment], consent: %w[consent], @@ -138,7 +146,8 @@ def self.applicable_section_keys(role) case role when "scholarship" then key == :scholarship when "bulk_payment" then key == :bulk_payment - else key != :scholarship && key != :bulk_payment + when "continuing_education" then key == :continuing_education + else key != :scholarship && key != :bulk_payment && key != :continuing_education end end end @@ -291,6 +300,7 @@ def self.reorder_to_page_order!(form, section_order) "background" => :logged_out_only, "professional" => :answers_on_file, "marketing" => :answers_on_file, + "continuing_education" => :always_ask, "payment" => :always_ask, "scholarship" => :always_ask, "consent" => :answers_on_file, @@ -496,6 +506,25 @@ def build_marketing_fields(form, position) position end + def build_continuing_education_fields(form, position) + position = add_header(form, position, "Continuing education", group: "continuing_education") + + position = add_field(form, position, + "Do you seek Continuing Education (CE) hours for this training?", + :single_select_radio, + key: "ce_credit_interest", group: "continuing_education", required: false, + options: %w[Yes No]) + position = add_field(form, position, + "If seeking CE hours, what is your LMFT, LCSW, LPCC or LEP license number?", + :free_form_input_one_line, + key: "ce_license_number", group: "continuing_education", required: false, + subtitle: "Acceptance of continuing education hours is determined by each individual state board separately, " \ + "and AWBW cannot guarantee your specific state board will accept them. " \ + "Participants are responsible for confirming whether the hours meet the requirements " \ + "for their specific license and state.") + position + end + def build_scholarship_fields(form, position) position = add_header(form, position, "Scholarship Application", group: "scholarship") diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb index a6d56e56b7..04f3d56270 100644 --- a/app/views/event_registrations/_continuing_education.html.erb +++ b/app/views/event_registrations/_continuing_education.html.erb @@ -1,11 +1,10 @@ -<%# ---- Continuing education — mirrors the scholarship card. "Requested" is a plain - flag saved with the form; saving it on (when none exists) creates a CE - registration stub, which is then filled in on its own edit page. Once a record - exists the toggle is gone and we show the record + an Edit link. Only rendered - for CE-eligible events. ---- %> +<%# ---- Continuing education — mirrors the scholarship card. CE registration + records are created on the dedicated CE form (license/hours/cost). This card + links to that form when no record exists, or shows the record + Edit link + once one does. Only rendered for CE-eligible events. ---- %>
- +

Continuing education

@@ -13,21 +12,7 @@
<% unless ce_registration %> - - - <%# Two ways to create the record, mirroring the scholarship card: the - Requested toggle above auto-creates a stub on save, while "Add CE - registration" opens the full new form (license/hours/cost) in a new tab - and returns here. %> + <%# Open the full new form (license/hours/cost) in a new tab and return here. %>
<%= link_to new_continuing_education_registration_path(allocatable_sgid: event_registration.to_sgid.to_s, return_to: "registration"), class: "inline-flex items-center gap-1.5 self-start rounded-md px-2 py-1 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700", diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 8d981534f7..3834aad7f8 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -8,6 +8,7 @@
+
@@ -22,8 +23,10 @@ "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> + <%= f.input :facilitator_training, as: :boolean, label: "Facilitator training event", wrapper_html: { class: "mb-0!" } %>
+ <%= f.input :pre_title, label: "Pre-title", hint: "Displays above the title", @@ -38,14 +41,14 @@ <% if allowed_to?(:manage?, @event) %>
<%= render "shared/visibility_info", flags: %i[published featured publicly_visible publicly_featured public_registration_enabled] %> +
<%= visibility_flag_input f, :published %> <%= visibility_flag_input f, :featured %> <%= visibility_flag_input f, :publicly_visible %> <%= visibility_flag_input f, :publicly_featured %> -
- <%= visibility_flag_input f, :public_registration_enabled, label: "Publicly registerable" %> -
+ +
<%= visibility_flag_input f, :public_registration_enabled, label: "Publicly registerable" %>
<% end %> @@ -58,97 +61,83 @@ id="event_details_button" class="flex items-center justify-between cursor-pointer w-full bg-gray-500 text-white hover:bg-gray-300 px-3 py-2 rounded-t-md" data-action="dropdown#toggle" - data-dropdown-payload-param='[{"event_details":"hidden"}, {"event_details_arrow":"rotate-180"}, {"event_details_button":"rounded-t-md rounded-md"}]'> + data-dropdown-payload-param='[{"event_details":"hidden"}, {"event_details_arrow":"rotate-180"}, {"event_details_button":"rounded-t-md rounded-md"}]' + > Event details
- <%# LEFT: Date/time, cost + pre-date text, videoconference + location %> -
-
-
-

Start

-
- - <%= f.text_field :start_date_date, + <%# LEFT: Date/time, cost + pre-date text, videoconference + location %> +
+
+
+

Start

+ +
<%= f.text_field :start_date_date, type: "date", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", - required: true %> -
-
- - <%= f.text_field :start_date_time, + required: true %>
+
<%= f.text_field :start_date_time, type: "time", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", - required: true %> -
- <% if f.object.errors[:start_date].any? %> -

Start date - <%= f.object.errors[:start_date].join(", ") %>

- <% end %> -
+ required: true %>
-
-

End

-
- - <%= f.text_field :end_date_date, + <% if f.object.errors[:start_date].any? %> +

Start date <%= f.object.errors[:start_date].join(", ") %>

+ <% end %> +
+ +
+

End

+ +
<%= f.text_field :end_date_date, type: "date", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", - required: true %> -
-
- - <%= f.text_field :end_date_time, + required: true %>
+
<%= f.text_field :end_date_time, type: "time", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", - required: true %> -
- <% if f.object.errors[:end_date].any? %> -

End date - <%= f.object.errors[:end_date].join(", ") %>

- <% end %> -
+ required: true %>
-
-

Registration closed

- <% rc_default = @event.registration_close_date || event_registration_close_default(@event) %> -
- - <%= f.text_field :registration_close_date_date, + <% if f.object.errors[:end_date].any? %> +

End date <%= f.object.errors[:end_date].join(", ") %>

+ <% end %> +
+ +
+

Registration closed

+ <% rc_default = @event.registration_close_date || event_registration_close_default(@event) %> + +
+ + + <%= f.text_field :registration_close_date_date, type: "date", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", value: rc_default.strftime("%Y-%m-%d") %> -
-
- - <%= f.text_field :registration_close_date_time, +
+ +
<%= f.text_field :registration_close_date_time, type: "time", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", - value: rc_default.strftime("%H:%M") %> -
- <% if f.object.errors[:registration_close_date].any? %> -

- Registration close date - <%= f.object.errors[:registration_close_date].join(", ") %> -

- <% end %> -
-
+ value: rc_default.strftime("%H:%M") %>
-
-
-
- <%= f.label :cost, "Event Cost", class: "block font-medium" %> - Enter $0 if the event is free + <% if f.object.errors[:registration_close_date].any? %> +

Registration close date <%= f.object.errors[:registration_close_date].join(", ") %>

+ <% end %> +
-
- $ +
+
+
<%= f.label :cost, "Event Cost", class: "block font-medium" %>Enter $0 if the event is free
+ +
+ $ - <%= f.text_field :cost, + <%= f.text_field :cost, type: "number", step: "0.01", min: "0", @@ -157,26 +146,23 @@ "if(this.value.includes('.')) { var parts = this.value.split('.'); if(parts[1] && parts[1].length > 2) { this.value = parts[0] + '.' + parts[1].substring(0, 2); } }", class: "w-full rounded border-gray-300 shadow-sm pl-8 pr-3 py-2 focus:ring-blue-500 focus:border-blue-500" %> -
+
- <% if f.object.errors[:cost].any? %> -

Cost - <%= f.object.errors[:cost].join(", ") %>

- <% end %> -
+ <% if f.object.errors[:cost].any? %> +

Cost <%= f.object.errors[:cost].join(", ") %>

+ <% end %> +
-
- <%= f.input :pre_date_text, +
<%= f.input :pre_date_text, label: "Pre-date text", input_html: { class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", placeholder: "e.g. Upcoming Workshop" - } %> -
-
+ } %>
+
-
- <%= f.input :videoconference_url, +
+ <%= f.input :videoconference_url, label: "Videoconference URL", as: :url, input_html: { @@ -184,8 +170,8 @@ class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> -
- <%= f.input :location_id, +
+ <%= f.input :location_id, label: "Location", collection: @locations, label_method: :name, @@ -195,26 +181,27 @@ class: "ts-flat w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", data: { searchable_select_target: "select" }, } %> -
-
+
+
-
- <%= f.input :videoconference_label, +
+ <%= f.input :videoconference_label, label: "Videoconference label", input_html: { placeholder: "Virtual event", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> - <%= f.input :videoconference_passcode, + + <%= f.input :videoconference_passcode, label: "Videoconference passcode", hint: "Shown alongside the join link. The meeting ID/code is read from the URL automatically.", input_html: { placeholder: "e.g. 123456", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> -
+
- <%= f.input :short_description, + <%= f.input :short_description, label: "Short description", as: :text, hint: "Plain-text blurb for the \"Add to calendar\" links. Calendars don't render HTML — leave blank to fall back to the page description.", @@ -223,51 +210,33 @@ placeholder: "Short description for calendar entries…", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> - - <%# Continuing education — "CE hours offered" above 0 makes the event - CE-eligible (the gate for the CE card on registrations); the total cost - is what a registrant pays for credit (stored in cents). %> -
- -
- -
-

Edit the CE hours ticket card and its details below, under Registration ticket callouts.

-
- -
- <%# RIGHT: Autoshow display options %> -
-

Display options

-

Toggle which details appear on the event page.
Fields with no value will be ignored.

-
- <% tz = Time.zone %> - <% tz_abbr = @event.start_date.present? ? @event.start_date.in_time_zone(tz).strftime("%Z") : Time.current.strftime("%Z") %> - <%= f.input :autoshow_pre_date_text, as: :boolean, label: ("Pre-date text" + (@event.pre_date_text.present? ? " #{h @event.pre_date_text}" : "")).html_safe %> - <%= f.input :autoshow_date, as: :boolean, label: ("Date " + (@event.start_date.present? ? @event.start_date.in_time_zone(tz).strftime("%A, %B %-d, %Y") : Time.current.strftime("%A, %B %-d, %Y")) + "").html_safe %> - <%= f.input :autoshow_time, as: :boolean, label: ("Time " + (@event.start_date.present? && @event.end_date.present? ? @event.start_date.in_time_zone(tz).strftime("%-l:%M %P") + " – " + @event.end_date.in_time_zone(tz).strftime("%-l:%M %P") : "3:00 pm – 5:00 pm") + " " + tz_abbr + "").html_safe %> - <% cost_preview = @event.decorate.labelled_cost %> - <%= f.input :autoshow_cost, as: :boolean, label: ("Cost " + (cost_preview.present? ? h(cost_preview) : ""Cost: $X" or "Free event"") + "").html_safe %> - <%= f.input :autoshow_location, as: :boolean, label: ("Location" + (@event.location.present? ? " #{h @event.location.name}" : "")).html_safe %> - <% vc_domain = @event.videoconference_url.present? ? @event.decorate.videoconference_domain : nil %> - <%= f.input :autoshow_videoconference_link, as: :boolean, label: ("Videoconference link " + (vc_domain.present? ? "Join on #{vc_domain}" : "Join on [Domain]") + "").html_safe %> - <% vc_label = @event.videoconference_label.presence || "Virtual event" %> - <%= f.input :autoshow_videoconference_label, as: :boolean, label: ("Videoconference label #{h vc_label}").html_safe %> - <%= f.input :autoshow_registration_close, as: :boolean, label: ("Registration close Registration closes " + (@event.registration_close_date.present? ? @event.registration_close_date.in_time_zone(tz).strftime("%B %-d, %Y %-l:%M %P") : 2.days.from_now.in_time_zone(tz).strftime("%B %-d, %Y %-l:%M %P")) + " " + tz_abbr + "").html_safe %> - <%= f.input :autoshow_registration, as: :boolean, label: "Registration button Register + calendar links".html_safe %> -
-
+ <%# RIGHT: Autoshow display options %> +
+

Display options

+

Toggle which details appear on the event page.
Fields with no value will be ignored.

+ +
+ <% tz = Time.zone %> + <% tz_abbr = @event.start_date.present? ? @event.start_date.in_time_zone(tz).strftime("%Z") : Time.current.strftime("%Z") %> + + <%= f.input :autoshow_pre_date_text, as: :boolean, label: ("Pre-date text" + (@event.pre_date_text.present? ? " #{h @event.pre_date_text}" : "")).html_safe %> + <%= f.input :autoshow_date, as: :boolean, label: ("Date " + (@event.start_date.present? ? @event.start_date.in_time_zone(tz).strftime("%A, %B %-d, %Y") : Time.current.strftime("%A, %B %-d, %Y")) + "").html_safe %> + <%= f.input :autoshow_time, as: :boolean, label: ("Time " + (@event.start_date.present? && @event.end_date.present? ? @event.start_date.in_time_zone(tz).strftime("%-l:%M %P") + " – " + @event.end_date.in_time_zone(tz).strftime("%-l:%M %P") : "3:00 pm – 5:00 pm") + " " + tz_abbr + "").html_safe %> + + <% cost_preview = @event.decorate.labelled_cost %> + <%= f.input :autoshow_cost, as: :boolean, label: ("Cost " + (cost_preview.present? ? h(cost_preview) : ""Cost: $X" or "Free event"") + "").html_safe %> + <%= f.input :autoshow_location, as: :boolean, label: ("Location" + (@event.location.present? ? " #{h @event.location.name}" : "")).html_safe %> + + <% vc_domain = @event.videoconference_url.present? ? @event.decorate.videoconference_domain : nil %> + <%= f.input :autoshow_videoconference_link, as: :boolean, label: ("Videoconference link " + (vc_domain.present? ? "Join on #{vc_domain}" : "Join on [Domain]") + "").html_safe %> + <% vc_label = @event.videoconference_label.presence || "Virtual event" %> + <%= f.input :autoshow_videoconference_label, as: :boolean, label: ("Videoconference label #{h vc_label}").html_safe %> + <%= f.input :autoshow_registration_close, as: :boolean, label: ("Registration close Registration closes " + (@event.registration_close_date.present? ? @event.registration_close_date.in_time_zone(tz).strftime("%B %-d, %Y %-l:%M %P") : 2.days.from_now.in_time_zone(tz).strftime("%B %-d, %Y %-l:%M %P")) + " " + tz_abbr + "").html_safe %> + <%= f.input :autoshow_registration, as: :boolean, label: "Registration button Register + calendar links".html_safe %> +
+
@@ -275,29 +244,32 @@
<% if allowed_to?(:manage?, @event) %> -
+
- <%# On a failed save the event_forms aren't persisted, so re-read the - admin's registration choices from the submitted params instead of - the (now-empty) associations. %> + <%# + On a failed save the event_forms aren't persisted, so re-read the + admin's registration choices from the submitted params instead of + the (now-empty) associations. + %> <% resubmitted = params[:event].present? %> +
<% current_reg_form = @event.registration_form %> + <% default_form_id = if resubmitted params.dig(:event, :registration_form_id).presence&.to_i elsif current_reg_form @@ -307,45 +279,57 @@ else @registration_forms.find { |rf| rf.name == "Short Event Registration" }&.id end %> + -

- <%= link_to "View / Edit forms", forms_path, class: "text-blue-600 hover:text-blue-800 underline", target: "_blank" %> -

+ +

<%= link_to "View / Edit forms", forms_path, class: "text-blue-600 hover:text-blue-800 underline", target: "_blank" %>

Public registration +
<% if @event.public_registration_enabled? %> - Enabled + + Enabled <% else %> - Disabled + + Disabled <% end %> - Change + + + Change +
+ <% if @event.event_forms.registration.exists? %> <%= link_to new_event_public_registration_path(@event), class: "inline-flex items-center gap-1 text-sm text-blue-700 hover:text-blue-900 underline mt-2", target: "_blank" do %> - View public registration page + + View public registration page <% end %> <% end %>
@@ -353,114 +337,174 @@
Registration options +
- +

Skip the registration form for logged-in users. Logged-out visitors still use the form.

+
- +

Add an at-a-glance list of dates, time, platform/location, fee, and deadline above the registration form. Pulled from this event's details.

+
<%= f.label :hint_dates, "Dates note", class: "block text-xs font-medium text-gray-700" %> + <%= f.text_field :hint_dates, placeholder: "e.g. must attend both days", class: "w-full rounded border-gray-300 shadow-sm px-3 py-1.5 text-sm focus:ring-blue-500 focus:border-blue-500" %>
-
- <%= f.label :hint_times, "Time note", class: "block text-xs font-medium text-gray-700" %> - <%= f.text_field :hint_times, + +
<%= f.label :hint_times, "Time note", class: "block text-xs font-medium text-gray-700" %><%= f.text_field :hint_times, placeholder: "e.g. both days", - class: "w-full rounded border-gray-300 shadow-sm px-3 py-1.5 text-sm focus:ring-blue-500 focus:border-blue-500" %> -
+ class: "w-full rounded border-gray-300 shadow-sm px-3 py-1.5 text-sm focus:ring-blue-500 focus:border-blue-500" %>
+
<%= f.label :hint_registration_cost, "Fee note", class: "block text-xs font-medium text-gray-700" %> + <%= f.text_field :hint_registration_cost, placeholder: "e.g. due within 3 weeks of registration", class: "w-full rounded border-gray-300 shadow-sm px-3 py-1.5 text-sm focus:ring-blue-500 focus:border-blue-500" %> +
+ +

Shown in grey beside the Dates, Time, and Fee rows on the details panel.

-

Shown in grey beside the Dates, Time, and Fee rows on the details panel.

-
- <% if @scholarship_forms.any? %> -
- <% if @scholarship_forms.size == 1 %> - <% single_form = @scholarship_forms.first %> -
+ <% end %>
@@ -474,9 +518,11 @@ id="page_content_button" class=" flex items-center justify-between cursor-pointer w-full - bg-gray-500 text-white hover:bg-gray-300 px-3 py-2 rounded-md" + bg-gray-500 text-white hover:bg-gray-300 px-3 py-2 rounded-md + " data-action="dropdown#toggle" - data-dropdown-payload-param='[{"page_content":"hidden"}, {"page_content_arrow":"rotate-180"}, {"page_content_button":"rounded-md rounded-t-md"}]'> + data-dropdown-payload-param='[{"page_content":"hidden"}, {"page_content_arrow":"rotate-180"}, {"page_content_button":"rounded-md rounded-t-md"}]' + > Event show page @@ -488,43 +534,50 @@ <%= rhino_editor(f, :header, label: "Header content") %> <% if f.object.errors[:rhino_header].present? %> -
- Field <%= f.object.errors[:rhino_header].first %> -
+
Field <%= f.object.errors[:rhino_header].first %>
<% end %>
-
- <%= rhino_editor(f, :description, label: "Description") %> -
+
<%= rhino_editor(f, :description, label: "Description") %>
<%# ---- REGISTRATION TICKET CALLOUTS (built-in + custom ticket call-outs) ---- %> - <%# ?expand=callouts (from the ticket's "Edit event" link) auto-opens this - section via a dropdown expand target; scroll-mt keeps the anchor clear of - any sticky chrome. %> + <%# + ?expand=callouts (from the ticket's "Edit event" link) auto-opens this + section via a dropdown expand target; scroll-mt keeps the anchor clear of + any sticky chrome. + %> +
<%# diff --git a/app/views/events/onboarding/_row.html.erb b/app/views/events/onboarding/_row.html.erb index 6177e81b70..511d8ea42a 100644 --- a/app/views/events/onboarding/_row.html.erb +++ b/app/views/events/onboarding/_row.html.erb @@ -204,18 +204,6 @@ data: { turbo_frame: "_top" } %> - <% when :ce_requested %> - <% ce_requested = registration.ce_requested? %> - " data-sort-value="<%= ce_requested ? 1 : 0 %>"> - <%= link_to edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:opacity-80", title: "Edit CE details", data: { turbo_frame: "_top" } do %> - <% if ce_requested %> - Yes - <% else %> - No - <% end %> - <% end %> - - <% when :ce_hours %> <% ce_hours = registration.ce_hours_total.to_f %> <%= ce_hours.positive? ? "text-gray-800" : "text-gray-400" %>" data-sort-value="<%= ce_hours %>"> diff --git a/app/views/events/public_registrations/new.html.erb b/app/views/events/public_registrations/new.html.erb index 586cc518c5..d16835d352 100644 --- a/app/views/events/public_registrations/new.html.erb +++ b/app/views/events/public_registrations/new.html.erb @@ -156,6 +156,28 @@

<% end %> + <% if @continuing_education_form && @continuing_education_form.form_fields.any? %> +
+

+ <%= @continuing_education_form.display_name %> +

+ <%= render "forms/header", form: @continuing_education_form, event: @event %> +
+ <% @continuing_education_form.form_fields.reorder(position: :asc).each do |field| %> + <% if field.group_header? %> + <% next if field.name.to_s.strip.casecmp?(@continuing_education_form.display_name.to_s.strip) %> + <%= render "events/public_registrations/group_header", field: field %> + <% else %> + <% submitted_value = params.dig(:public_registration, :form_fields, field.id.to_s) %> +
+ <%= render "events/public_registrations/form_field", field: field, value: submitted_value %> +
+ <% end %> + <% end %> +
+
+ <% end %> +
<%= button_tag type: "submit", class: "w-full inline-flex items-center justify-center gap-2.5 rounded-xl bg-blue-900 px-6 py-3.5 text-white font-semibold text-lg shadow-lg shadow-blue-900/20 transition hover:bg-blue-800 hover:-translate-y-0.5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 cursor-pointer" do %> diff --git a/app/views/events/public_registrations/show.html.erb b/app/views/events/public_registrations/show.html.erb index bc6d0e2d53..73646434db 100644 --- a/app/views/events/public_registrations/show.html.erb +++ b/app/views/events/public_registrations/show.html.erb @@ -132,4 +132,55 @@
<% end %> + + <%# ---- CONTINUING EDUCATION CARD — only when the registrant filled out the + separate continuing_education form (its own role: "continuing_education" + submission). ---- %> + <% if @continuing_education_submission %> +
+ + <%# Card Header %> +
+
+ +
+

Continuing education

+

<%= @continuing_education_form.display_name %>

+
+
+
+
+ + <%# Card Body %> +
+ <%= render "forms/header", form: @continuing_education_form, event: @event %> + +
+ <% @continuing_education_fields.each do |field| %> + <% next if field.name.to_s.strip.casecmp?(@continuing_education_form.display_name.to_s.strip) %> + + <% if field.group_header? %> +
+

+ <%= form_label_html(field.name) %> +

+
+ <% else %> + <% response = @continuing_education_responses[field.id] %> +
+
<%= form_label_html(display_question_label(field, response)) %>
+
<%= display_response_text(field, response) %>
+
+ <% end %> + <% end %> +
+ +
+

+ Submitted on <%= @continuing_education_submission.created_at.strftime("%B %d, %Y at %l:%M %P") %> +

+
+
+
+ <% end %>
diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb index 107ea464be..564dd204d3 100644 --- a/app/views/forms/edit.html.erb +++ b/app/views/forms/edit.html.erb @@ -79,7 +79,7 @@
<%= f.select :role, - [["General", ""], ["Registration", "registration"], ["Scholarship", "scholarship"], ["Bulk Payment", "bulk_payment"]], + [["General", ""], ["Registration", "registration"], ["Scholarship", "scholarship"], ["Bulk Payment", "bulk_payment"], ["Continuing Education", "continuing_education"]], { selected: @form.role || "" }, class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 text-sm" %>
diff --git a/app/views/forms/new.html.erb b/app/views/forms/new.html.erb index 2cee1b8fd9..f24898f771 100644 --- a/app/views/forms/new.html.erb +++ b/app/views/forms/new.html.erb @@ -43,6 +43,7 @@ +
@@ -57,7 +58,12 @@
<% FormBuilderService::SECTIONS.each do |key, section| %> - <% section_role = key == :scholarship ? "scholarship" : key == :bulk_payment ? "bulk_payment" : "other" %> + <% section_role = case key + when :scholarship then "scholarship" + when :bulk_payment then "bulk_payment" + when :continuing_education then "continuing_education" + else "other" + end %> <% field_names = FormBuilderService.section_field_names(key) %>
diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 7d8b4112e0..785b764f14 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -15,7 +15,7 @@ "type": "controller", "class": "EventsController", "method": "create", - "line": 123, + "line": 458, "file": "app/controllers/events_controller.rb", "rendered": { "name": "events/show", @@ -34,6 +34,63 @@ ], "note": "Stakeholder requirement for admin to add google analytics" }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 2, + "fingerprint": "343336d81aa87ab12862c5f9ca2b0f9ac4155488c053c1ce9c7ed27c456e3dba", + "check_name": "CrossSiteScripting", + "message": "Unescaped parameter value", + "file": "app/views/events/confirm_reminder.html.erb", + "line": 58, + "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", + "code": "EventMailer.event_registration_reminder(selected_reminder_registrations.first, :custom_message => params[:custom_message].to_s, :custom_subject => params[:custom_subject].to_s).html_part.body.decoded", + "render_path": [ + { + "type": "controller", + "class": "EventsController", + "method": "confirm_reminder", + "line": 397, + "file": "app/controllers/events_controller.rb", + "rendered": { + "name": "events/confirm_reminder", + "file": "app/views/events/confirm_reminder.html.erb" + } + } + ], + "location": { + "type": "template", + "template": "events/confirm_reminder" + }, + "user_input": "params[:custom_message].to_s", + "confidence": "Weak", + "cwe_id": [ + 79 + ], + "note": "Admin-only reminder confirmation. Same as preview_reminder: the raw value is the server-rendered email HTML; the embedded custom message is sanitized via reminder_message_html (SafeListSanitizer) and the custom subject is shown escaped, separately, so no unsanitized user input reaches the page." + }, + { + "warning_type": "Redirect", + "warning_code": 18, + "fingerprint": "6b0a218f30eb40af7b6cf4ec15de35003eb42e1cea35ecd6acb69139643a338b", + "check_name": "Redirect", + "message": "Possible unprotected redirect", + "file": "app/controllers/events/callouts_controller.rb", + "line": 257, + "link": "https://brakemanscanner.org/docs/warning_types/redirect/", + "code": "redirect_to(EventRegistration.find_by!(:slug => params[:slug]).registrant.payment_processor.checkout(:mode => \"payment\", :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :payment_intent_data => ({ :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :description => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :line_items => ([{ :price_data => ({ :currency => \"usd\", :product_data => ({ :name => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :unit_amount => ce_registration.remaining_cost }), :quantity => 1 }]), :success_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"success\"), :cancel_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"cancelled\")).url, :allow_other_host => true, :status => :see_other)", + "render_path": null, + "location": { + "type": "method", + "class": "Events::CalloutsController", + "method": "redirect_to_ce_stripe_checkout" + }, + "user_input": "EventRegistration.find_by!(:slug => params[:slug]).registrant.payment_processor.checkout(:mode => \"payment\", :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :payment_intent_data => ({ :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :description => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :line_items => ([{ :price_data => ({ :currency => \"usd\", :product_data => ({ :name => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :unit_amount => ce_registration.remaining_cost }), :quantity => 1 }]), :success_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"success\"), :cancel_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"cancelled\")).url", + "confidence": "Weak", + "cwe_id": [ + 601 + ], + "note": "known redirect for stripe" + }, { "warning_type": "Cross-Site Scripting", "warning_code": 2, @@ -49,7 +106,7 @@ "type": "controller", "class": "EventsController", "method": "create", - "line": 123, + "line": 458, "file": "app/controllers/events_controller.rb", "rendered": { "name": "events/show", @@ -75,7 +132,7 @@ "check_name": "Redirect", "message": "Possible unprotected redirect", "file": "app/controllers/people_controller.rb", - "line": 119, + "line": 148, "link": "https://brakemanscanner.org/docs/warning_types/redirect/", "code": "redirect_to(Person.find(params[:id]).payment_processor.checkout(:mode => \"payment\", :metadata => ({ :person_id => Person.find(params[:id]).id }), :payment_intent_data => ({ :metadata => ({ :person_id => Person.find(params[:id]).id }) }), :line_items => ([{ :price_data => ({ :currency => \"usd\", :product_data => ({ :name => \"Donation to A Window Between Worlds\" }), :unit_amount => (((params[:amount].to_i * 100).to_i or 1000)) }), :quantity => 1 }]), :success_url => person_url(Person.find(params[:id]), :checkout => \"success\"), :cancel_url => person_url(Person.find(params[:id]), :checkout => \"cancelled\")).url, :allow_other_host => true, :status => :see_other)", "render_path": null, @@ -93,49 +150,49 @@ }, { "warning_type": "Mass Assignment", - "warning_code": 70, - "fingerprint": "abea946803acd0f0fe12c3bcd54e9b5b04d04f78ff6672e38a6cb8db02e3b5eb", - "check_name": "MassAssignment", - "message": "Specify exact keys allowed for mass assignment instead of using `permit!` which allows any keys", - "file": "app/controllers/reports_controller.rb", - "line": 263, + "warning_code": 105, + "fingerprint": "a750b09d4d42ef567df595f7a9035933dc1178c533bf8ae8f8986ae8e837c6b8", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/forms_controller.rb", + "line": 138, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", - "code": "params[:quotes].permit!", + "code": "params.require(:form).permit(:name, :role, :header, :hide_answered_person_questions, :hide_answered_form_questions, :form_fields_attributes => ([:id, :name, :answer_type, :required, :subtitle, :hint_text, :field_identifier, :section, :position, :visibility, :one_time, :width, :min_words, :max_characters, :_destroy, { :form_field_answer_options_attributes => ([:id, :option_name, :_destroy]) }]))", "render_path": null, "location": { "type": "method", - "class": "ReportsController", - "method": "quotes_params" + "class": "FormsController", + "method": "form_params" }, - "user_input": null, + "user_input": ":role", "confidence": "Medium", "cwe_id": [ 915 ], - "note": "" + "note": "admin only" }, { "warning_type": "Mass Assignment", - "warning_code": 105, - "fingerprint": "a750b09d4d42ef567df595f7a9035933dc1178c533bf8ae8f8986ae8e837c6b8", - "check_name": "PermitAttributes", - "message": "Potentially dangerous key allowed for mass assignment", - "file": "app/controllers/forms_controller.rb", - "line": 138, + "warning_code": 70, + "fingerprint": "abea946803acd0f0fe12c3bcd54e9b5b04d04f78ff6672e38a6cb8db02e3b5eb", + "check_name": "MassAssignment", + "message": "Specify exact keys allowed for mass assignment instead of using `permit!` which allows any keys", + "file": "app/controllers/reports_controller.rb", + "line": 263, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", - "code": "params.require(:form).permit(:name, :role, :header, :hide_answered_person_questions, :hide_answered_form_questions, :form_fields_attributes => ([:id, :name, :answer_type, :required, :subtitle, :hint_text, :field_identifier, :section, :position, :visibility, :one_time, :width, :min_words, :max_characters, :_destroy, { :form_field_answer_options_attributes => ([:id, :option_name, :_destroy]) }]))", + "code": "params[:quotes].permit!", "render_path": null, "location": { "type": "method", - "class": "FormsController", - "method": "form_params" + "class": "ReportsController", + "method": "quotes_params" }, - "user_input": ":role", + "user_input": null, "confidence": "Medium", "cwe_id": [ 915 ], - "note": "admin only" + "note": "" }, { "warning_type": "Cross-Site Scripting", @@ -152,7 +209,7 @@ "type": "controller", "class": "EventsController", "method": "create", - "line": 123, + "line": 458, "file": "app/controllers/events_controller.rb", "rendered": { "name": "events/show", @@ -178,7 +235,7 @@ "check_name": "CrossSiteScripting", "message": "Unescaped model attribute", "file": "app/views/notifications/show.html.erb", - "line": 190, + "line": 196, "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", "code": "Notification.find(params[:id]).email_body_html", "render_path": [ @@ -204,42 +261,7 @@ 79 ], "note": "email_body_html is the server-rendered HTML of a notification email, generated from trusted mailer templates rather than user-supplied free text. It is rendered intentionally as a preview on notifications/show, which is gated to admin-or-owner (NotificationPolicy#show?), so no untrusted input reaches the page." - }, - { - "warning_type": "Cross-Site Scripting", - "warning_code": 2, - "fingerprint": "343336d81aa87ab12862c5f9ca2b0f9ac4155488c053c1ce9c7ed27c456e3dba", - "check_name": "CrossSiteScripting", - "message": "Unescaped parameter value", - "file": "app/views/events/confirm_reminder.html.erb", - "line": 53, - "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", - "code": "EventMailer.event_registration_reminder(selected_reminder_registrations.first, :custom_message => params[:custom_message].to_s, :custom_subject => params[:custom_subject].to_s).html_part.body.decoded", - "render_path": [ - { - "type": "controller", - "class": "EventsController", - "method": "confirm_reminder", - "line": 316, - "file": "app/controllers/events_controller.rb", - "rendered": { - "name": "events/confirm_reminder", - "file": "app/views/events/confirm_reminder.html.erb" - } - } - ], - "location": { - "type": "template", - "template": "events/confirm_reminder" - }, - "user_input": "params[:custom_message].to_s", - "confidence": "Weak", - "cwe_id": [ - 79 - ], - "note": "Admin-only reminder confirmation. Same as preview_reminder: the raw value is the server-rendered email HTML; the embedded custom message is sanitized via reminder_message_html (SafeListSanitizer) and the custom subject is shown escaped, separately, so no unsanitized user input reaches the page." } ], - "brakeman_version": "8.0.4", - "updated": "2026-06-19 10:05:46 -0400" + "brakeman_version": "8.0.5" } diff --git a/config/routes.rb b/config/routes.rb index b08eb45f06..00eea12ffe 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -86,6 +86,7 @@ get "registration/:slug/ce", to: "events/callouts#ce", as: :registration_ce post "registration/:slug/ce/license", to: "events/callouts#update_ce_license", as: :registration_ce_license post "registration/:slug/ce/request", to: "events/callouts#request_ce", as: :registration_ce_request + post "registration/:slug/ce/pay", to: "events/callouts#pay_ce", as: :registration_ce_pay get "registration/:slug/forms", to: "events/callouts#forms", as: :registration_forms get "registration/:slug/handouts", to: "events/callouts#handouts", as: :registration_handouts get "registration/:slug/resource/:resource_id", to: "events/callouts#resource", as: :registration_resource diff --git a/db/migrate/20260711193528_remove_ce_requested_from_event_registrations.rb b/db/migrate/20260711193528_remove_ce_requested_from_event_registrations.rb new file mode 100644 index 0000000000..c4165fb9dd --- /dev/null +++ b/db/migrate/20260711193528_remove_ce_requested_from_event_registrations.rb @@ -0,0 +1,15 @@ +class RemoveCeRequestedFromEventRegistrations < ActiveRecord::Migration[8.1] + # `ce_requested` was the intent flag mirrored by the existence of a + # ContinuingEducationRegistration record. Every flow that set it also created + # (or destroyed) the record in the same transaction, so `ce_registered?` is + # the same signal — the column is redundant and is dropped here. + def up + remove_column :event_registrations, :ce_requested, if_exists: true + end + + def down + unless column_exists?(:event_registrations, :ce_requested) + add_column :event_registrations, :ce_requested, :boolean, null: false, default: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index d0dc66e1bc..32646d8031 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_09_184214) do +ActiveRecord::Schema[8.1].define(version: 2026_07_11_193528) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -479,7 +479,6 @@ end create_table "event_registrations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.boolean "ce_requested", default: false, null: false t.datetime "certificate_sent_at" t.string "checkout_session_id" t.boolean "completed_day_1", default: false, null: false diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index 87f3cc530d..19a9959749 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -37,11 +37,20 @@ ).call end +unless Form.standalone.find_by(role: "continuing_education") + FormBuilderService.new( + name: "Continuing Education Request", + sections: %i[continuing_education], + role: "continuing_education" + ).call +end + puts "Creating Events with shared forms…" admin_user = User.find_by(email: "umberto.user@example.com") registration_form = Form.standalone.find_by!(role: "registration") scholarship_form = Form.standalone.find_by!(role: "scholarship") bulk_payment_form = Form.standalone.find_by!(role: "bulk_payment") +continuing_education_form = Form.standalone.find_by!(role: "continuing_education") # Seed an example header (admin-authored HTML intro shown under the form title on # the public registration page) onto the base registration form, mirroring the @@ -155,71 +164,6 @@ .update_all(subtitle: "Payments are due no more than three weeks after your registration date. " \ "Training details will be sent after payments are received.") -# The CE-interest "magic question": a single Yes/No whose "Yes" creates the -# registration's ContinuingEducationRegistration (see -# EventRegistrationServices::PublicRegistration). Seeded straight onto the form -# with its own section so the form builder's add/remove-section logic leaves it -# alone, and carrying the well-known field_identifier the service keys off. A -# follow-on license-number question mirrors the real form's CE block. -ce_identifier = EventRegistrationServices::PublicRegistration::CE_CREDIT_INTEREST_IDENTIFIER -if registration_form.form_fields.where(field_identifier: ce_identifier).none? - next_position = (registration_form.form_fields.maximum(:position) || 0) + 1 - registration_form.form_fields.create!( - name: "Continuing education", - answer_type: :group_header, - status: :active, - position: next_position, - required: false, - section: "continuing_education", - visibility: :always_ask - ) - ce_field = registration_form.form_fields.create!( - name: "Do you seek Continuing Education (CE) hours for this training?", - answer_type: :single_select_radio, - status: :active, - position: next_position + 1, - required: false, - field_identifier: ce_identifier, - section: "continuing_education", - visibility: :always_ask, - width: :full - ) - %w[Yes No].each_with_index do |opt, idx| - ao = AnswerOption.find_or_create_by!(name: opt) { |a| a.position = idx } - ce_field.form_field_answer_options.create!(answer_option: ao) - end - registration_form.form_fields.create!( - name: "If seeking CE hours, what is your LMFT, LCSW, LPCC or LEP license number?", - answer_type: :free_form_input_one_line, - status: :active, - position: next_position + 2, - required: false, - field_identifier: "ce_license_number", - section: "continuing_education", - visibility: :always_ask, - width: :full, - subtitle: "Acceptance of continuing education hours is determined by each individual state board separately, " \ - "and AWBW cannot guarantee your specific state board will accept them. Participants are responsible " \ - "for confirming whether the hours meet the requirements for their specific license and state." - ) -end - -# Bring an already-seeded CE block up to the current wording (these only run when -# the field exists from an earlier seed, since the create above skips them then). -# Idempotent — each clause only matches the prior default copy. -registration_form.form_fields - .where(field_identifier: ce_identifier, name: "Do you plan to use Continuing Education (CE) hours for this course?") - .update_all(name: "Do you seek Continuing Education (CE) hours for this training?") -registration_form.form_fields - .where(field_identifier: ce_identifier, - subtitle: "CE hours are available for select trainings. Let us know and our team will follow up with details.") - .update_all(subtitle: nil) -registration_form.form_fields - .where(field_identifier: "ce_license_number", subtitle: [ nil, "" ]) - .update_all(subtitle: "Acceptance of continuing education hours is determined by each individual state board separately, " \ - "and AWBW cannot guarantee your specific state board will accept them. Participants are responsible " \ - "for confirming whether the hours meet the requirements for their specific license and state.") - # The "Additional forms" question: a multi-select whose checked options drive the # resulting registration's invoice_requested / w9_requested flags (see # EventRegistrationServices::PublicRegistration). The digital ticket reads those @@ -267,7 +211,7 @@ # the order the real form uses. Idempotent: re-running lands on the same order. registration_section_order = %w[ person_identifier person_contact_info professional background marketing - continuing_education payment additional_forms consent + payment additional_forms consent ] registration_section_rank = registration_section_order.each_with_index.to_h registration_form.form_fields.reorder(:position).to_a.each_with_index @@ -868,7 +812,6 @@ if data[:ce_credit_requested] && registration.continuing_education_registrations.none? license = ProfessionalLicense.find_or_create_for(person: data[:person], number: data[:ce_license_number]) ce_registration = registration.continuing_education_registrations.create!(professional_license: license) - registration.update_column(:ce_requested, true) # "issued" in the seed data means the CE certificate was delivered. ce_registration.mark_certificate_sent! if data[:ce_status] == "issued" end diff --git a/spec/decorators/event_registration_decorator_spec.rb b/spec/decorators/event_registration_decorator_spec.rb index c808420493..49bb4337cf 100644 --- a/spec/decorators/event_registration_decorator_spec.rb +++ b/spec/decorators/event_registration_decorator_spec.rb @@ -96,15 +96,6 @@ def pay(cer, amount) it { is_expected.to be_nil } end - context "when requested but no CE registration exists yet" do - before { registration.update!(ce_requested: true) } - - it "is an amber Requested badge" do - expect(badge.label).to eq("Requested") - expect(badge.classes).to include("amber") - end - end - context "when a CE registration sits on a placeholder license" do before do create(:continuing_education_registration, event_registration: registration, diff --git a/spec/factories/event_forms.rb b/spec/factories/event_forms.rb index 3f17a9fb12..277bcec170 100644 --- a/spec/factories/event_forms.rb +++ b/spec/factories/event_forms.rb @@ -11,5 +11,9 @@ trait :scholarship do role { "scholarship" } end + + trait :continuing_education do + role { "continuing_education" } + end end end diff --git a/spec/models/concerns/pay_charge_extensions_spec.rb b/spec/models/concerns/pay_charge_extensions_spec.rb index bf9a82df2f..831a77151e 100644 --- a/spec/models/concerns/pay_charge_extensions_spec.rb +++ b/spec/models/concerns/pay_charge_extensions_spec.rb @@ -58,6 +58,64 @@ end end + describe "CE payment via ce_registration_id metadata" do + let(:ce_registration) do + create(:continuing_education_registration, event_registration: registration, cost_cents: 15_00) + end + + let(:charge_object) do + { + "id" => "ch_ce_456", + "object" => "charge", + "amount" => 10_00, + "currency" => "usd", + "paid" => true, + "metadata" => { "ce_registration_id" => ce_registration.id, "event_registration_id" => registration.id }, + "refunds" => { "object" => "list", "data" => [], "has_more" => false } + } + end + + let!(:pay_charge) do + Pay::Charge.create!( + customer: pay_customer, + processor_id: "ch_ce_456", + amount: 10_00, + amount_refunded: 0, + currency: "usd", + object: charge_object, + metadata: { "ce_registration_id" => ce_registration.id, "event_registration_id" => registration.id } + ) + end + + it "creates an ExternalProcessorPayment" do + payment = ExternalProcessorPayment.find_by(pay_charge_id: pay_charge.id) + expect(payment).to have_attributes( + amount_cents: 10_00, + person: person, + pay_charge: pay_charge + ) + end + + it "creates an Allocation against the CE registration" do + payment = ExternalProcessorPayment.find_by(pay_charge_id: pay_charge.id) + allocation = payment.allocations.first + expect(allocation.allocatable).to eq(ce_registration) + expect(allocation.amount).to eq(10_00) + end + + it "allocates against the CE registration even when event_registration_id is also present" do + payment = ExternalProcessorPayment.find_by(pay_charge_id: pay_charge.id) + expect(payment.allocations.first.allocatable).to eq(ce_registration) + expect(Allocation.where(allocatable: registration, source: payment)).to be_empty + end + + it "is idempotent" do + expect do + pay_charge.update!(object: charge_object) + end.not_to change(ExternalProcessorPayment, :count) + end + end + describe "refund sync" do let(:refunded_object) do charge_object.deep_merge( diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index 581969594e..fab67ec8ed 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -3,7 +3,7 @@ RSpec.describe "ContinuingEducationRegistrations", type: :request do let(:admin) { create(:user, :admin) } let(:event) { create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 12_000) } - let(:registration) { create(:event_registration, event: event, ce_requested: true) } + let(:registration) { create(:event_registration, event: event) } let(:ce_registration) do create(:continuing_education_registration, event_registration: registration, professional_license: create(:professional_license, :placeholder, person: registration.registrant)) @@ -88,7 +88,7 @@ expect(ce.cost_cents).to eq(9_000) expect(ce.professional_license).to have_attributes(kind: "LMFT", number: "555", issuing_state: "CA", expires_on: Date.new(2027, 1, 31)) - expect(registration.reload.ce_requested).to be(true) + expect(registration.reload.ce_registered?).to be(true) end it "creates no license just from opening the new form" do @@ -156,7 +156,7 @@ ce_registration delete continuing_education_registration_path(ce_registration) expect(ContinuingEducationRegistration.exists?(ce_registration.id)).to be(false) - expect(registration.reload.ce_requested).to be(false) + expect(registration.reload.ce_registered?).to be(false) end it "refuses to remove a CE registration that has payments" do diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index 2a6b171a8c..68706fb538 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -316,28 +316,6 @@ def toggle_day(field, value) expect(existing_registration.reload.event_id).to eq(new_event.id) end - it "creates a CE registration stub when the ce_requested flag is set" do - event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 12_000) - patch event_registration_path(existing_registration), - params: { event_registration: { status: existing_registration.status, ce_requested: "1" } } - - ce_registration = existing_registration.reload.continuing_education_registrations.first - expect(ce_registration).to be_present - # Hours/cost default from the event; the license is a placeholder until set. - expect(ce_registration.hours).to eq(6) - expect(ce_registration.professional_license.number).to be_nil - end - - it "creates the CE registration against the registrant's existing license" do - event.update!(ce_hours_offered: 6) - license = create(:professional_license, person: existing_registration.registrant, number: "LIC-987") - - patch event_registration_path(existing_registration), - params: { event_registration: { status: existing_registration.status, ce_requested: "1" } } - - expect(existing_registration.reload.continuing_education_registrations.first.professional_license).to eq(license) - end - it "sets the shout-out flag and stores the shout-out text on the registrant" do patch event_registration_path(existing_registration), params: { event_registration: { diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index ff87c00b3c..28962d9968 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -65,4 +65,107 @@ end end end + + describe "GET /registration/:slug/ce" do + context "when CE is registered" do + let(:event) { create(:event) } + + before do + license = create(:professional_license, person: registration.registrant, number: nil) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + end + + it "renders the CE page" do + get registration_ce_path(registration.slug) + expect(response).to have_http_status(:success) + end + + context "with checkout=success" do + it "shows a success flash" do + get registration_ce_path(registration.slug, checkout: "success") + expect(flash[:notice]).to eq("Your CE payment was successful.") + end + end + + context "with checkout=cancelled" do + it "shows a cancelled flash" do + get registration_ce_path(registration.slug, checkout: "cancelled") + expect(flash[:alert]).to eq("CE payment was cancelled. You can try again whenever you're ready.") + end + end + end + + context "when CE is not registered" do + let(:event) { create(:event) } + + it "renders the opt-in form" do + get registration_ce_path(registration.slug) + expect(response).to have_http_status(:success) + expect(response.body).to include("Request CE credit") + end + end + end + + describe "POST /registration/:slug/ce/pay" do + let(:event) { create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 15_000) } + let(:fake_session) { double(url: "https://checkout.stripe.com/test", id: "cs_test_123") } + + before do + license = create(:professional_license, person: registration.registrant, number: "LIC-1") + create(:continuing_education_registration, event_registration: registration, + professional_license: license, cost_cents: 15_000) + + fake_processor = double(checkout: fake_session) + allow_any_instance_of(Person).to receive(:set_payment_processor) + allow_any_instance_of(Person).to receive(:payment_processor).and_return(fake_processor) + end + + it "redirects to Stripe Checkout when a CE balance is due" do + post registration_ce_pay_path(registration.slug) + expect(response).to redirect_to("https://checkout.stripe.com/test") + expect(response.status).to eq(303) + end + + it "includes ce_registration_id and event_registration_id in the checkout metadata" do + captured = nil + fake_processor = double + allow(fake_processor).to receive(:checkout) { |params| captured = params; fake_session } + allow_any_instance_of(Person).to receive(:set_payment_processor) + allow_any_instance_of(Person).to receive(:payment_processor).and_return(fake_processor) + + post registration_ce_pay_path(registration.slug) + + expect(captured[:metadata]).to include( + ce_registration_id: registration.continuing_education_registrations.first.id, + event_registration_id: registration.id + ) + end + + it "updates the checkout_session_id on the registration" do + post registration_ce_pay_path(registration.slug) + expect(registration.reload.checkout_session_id).to eq("cs_test_123") + end + + context "when no CE registration exists" do + before { registration.continuing_education_registrations.destroy_all } + + it "redirects with an alert" do + post registration_ce_pay_path(registration.slug) + expect(response).to redirect_to(registration_ce_path(registration.slug)) + expect(flash[:alert]).to eq("No CE payment is due.") + end + end + + context "when the CE balance is zero" do + before do + registration.continuing_education_registrations.first.update!(cost_cents: 0) + end + + it "redirects with an alert" do + post registration_ce_pay_path(registration.slug) + expect(response).to redirect_to(registration_ce_path(registration.slug)) + expect(flash[:alert]).to eq("No CE payment is due.") + end + end + end end diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index a2f04159da..22977a2fc5 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -439,14 +439,14 @@ describe "POST /registration/:slug/ce/request" do let(:event) { create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 12_000) } - let!(:registration) { create(:event_registration, event: event, registrant: user.person, ce_requested: false) } + let!(:registration) { create(:event_registration, event: event, registrant: user.person) } it "opts the registrant into CE and creates the registration" do expect { post registration_ce_request_path(registration.slug) }.to change { registration.reload.continuing_education_registrations.count }.from(0).to(1) - expect(registration.ce_requested).to be(true) + expect(registration.reload.ce_registered?).to be(true) expect(response).to redirect_to(registration_ce_path(registration.slug)) end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index c2dce966e6..8ca3e92ddf 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -1215,7 +1215,6 @@ def ce_chip_text expect(response.body).to include("Mailchimp") expect(response.body).to include("CMS") expect(response.body).to include("Portal invite") - expect(response.body).to include("CE requested") expect(response.body).to include("License #") expect(response.body).to include("Event attendance") expect(response.body).to include("Onboard") diff --git a/spec/services/event_registration_readiness_spec.rb b/spec/services/event_registration_readiness_spec.rb index 2e778dab1a..213ea9515d 100644 --- a/spec/services/event_registration_readiness_spec.rb +++ b/spec/services/event_registration_readiness_spec.rb @@ -90,7 +90,6 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) context "continuing education" do it "flags CE as unpaid when CE credit is requested" do pay(registration, 1000) - registration.update!(ce_requested: true) license = create(:professional_license, person: registration.registrant, number: "LIC123") create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 5_000) @@ -99,7 +98,6 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) it "flags a missing CE license number when CE credit is requested" do pay(registration, 1000) - registration.update!(ce_requested: true) license = create(:professional_license, :placeholder, person: registration.registrant) create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 5_000) @@ -156,7 +154,9 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) end it "flags the CE certificate as unsent when CE credit was requested" do - registration.update!(status: "attended", ce_requested: true) + registration.update!(status: "attended") + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, person: registration.registrant, number: "LIC123")) expect(readiness.completion_issues).to include("CE certificate not sent") end @@ -275,7 +275,8 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) end it "names both certificates when CE credit was requested" do - registration.update!(ce_requested: true) + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, person: registration.registrant, number: "LIC123")) expect(readiness.certificate_due_reason).to eq("Reg + CE") end diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index 30b1db22c2..b5cf5164e5 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -513,43 +513,28 @@ def register_with_agency_type(value) end end - describe "CE credit interest (magic question)" do - let!(:ce_field) do - field = form.form_fields.create!( - name: "Might you be seeking continuing education (CE) hours for attending this training?", - answer_type: :single_select_radio, - status: :active, - position: (form.form_fields.maximum(:position) || 0) + 1, - required: false, - field_identifier: described_class::CE_CREDIT_INTEREST_IDENTIFIER, - section: "continuing_education", - visibility: :always_ask - ) - %w[Yes No].each_with_index do |opt, idx| - ao = AnswerOption.find_or_create_by!(name: opt) { |a| a.position = idx } - field.form_field_answer_options.create!(answer_option: ao) - end - field + describe "CE credit interest (continuing_education form)" do + let!(:ce_form) do + f = FormBuilderService.new( + name: "Continuing Education Request", + sections: %i[continuing_education], + role: "continuing_education" + ).call + event.event_forms.create!(form: f, role: "continuing_education") + f end - let!(:ce_license_field) do - form.form_fields.create!( - name: "License number", - answer_type: :free_form_input_one_line, - status: :active, - position: (form.form_fields.maximum(:position) || 0) + 1, - required: false, - field_identifier: described_class::CE_LICENSE_NUMBER_IDENTIFIER, - section: "continuing_education", - visibility: :always_ask - ) + def ce_field_id(key) + ce_form.form_fields.find_by!(field_identifier: key).id.to_s end def register_with_ce(answer, license: nil) params = base_form_params(first_name: "Cy", last_name: "Reed", email: "cy@example.com") - params = params.merge(ce_field.id.to_s => answer) unless answer.nil? - params = params.merge(ce_license_field.id.to_s => license) if license - described_class.call(event: event, registration_form: form, form_params: params) + ce_params = {} + ce_params[ce_field_id(described_class::CE_CREDIT_INTEREST_IDENTIFIER)] = answer unless answer.nil? + ce_params[ce_field_id(described_class::CE_LICENSE_NUMBER_IDENTIFIER)] = license if license + described_class.call(event: event, registration_form: form, form_params: params, + continuing_education_form: ce_form, continuing_education_params: ce_params) end it "creates a CE registration when answered Yes" do @@ -574,13 +559,13 @@ def register_with_ce(answer, license: nil) result = register_with_ce("Yes") expect(result.event_registration).to eq(existing) - expect(existing.reload.ce_requested).to be(true) + expect(existing.reload.ce_registered?).to be(true) expect(existing.continuing_education_registrations.count).to eq(1) end it "does not duplicate a CE registration when the existing one already has one" do person = create(:person, first_name: "Cy", last_name: "Reed", email: "cy@example.com") - existing = create(:event_registration, event: event, registrant: person, ce_requested: true) + existing = create(:event_registration, event: event, registrant: person) license = create(:professional_license, :placeholder, person: person) create(:continuing_education_registration, event_registration: existing, professional_license: license) @@ -606,6 +591,20 @@ def register_with_ce(answer, license: nil) result = register_with_ce("Yes") expect(result.event_registration.continuing_education_registrations.first.hours).to eq(6) end + + it "persists the CE answers as a continuing_education submission when answered Yes" do + register_with_ce("Yes", license: "LMFT 555") + submission = FormSubmission.find_by(role: "continuing_education", event: event) + expect(submission).to be_present + answers = submission.answers_by_identifier + expect(answers[described_class::CE_CREDIT_INTEREST_IDENTIFIER]).to eq("Yes") + expect(answers[described_class::CE_LICENSE_NUMBER_IDENTIFIER]).to eq("LMFT 555") + end + + it "does not persist a continuing_education submission when answered No" do + register_with_ce("No") + expect(FormSubmission.find_by(role: "continuing_education", event: event)).to be_nil + end end describe "Additional forms (multi-select magic question)" do diff --git a/spec/services/event_revenue_report_spec.rb b/spec/services/event_revenue_report_spec.rb index 0de79b0009..24e7f50b1b 100644 --- a/spec/services/event_revenue_report_spec.rb +++ b/spec/services/event_revenue_report_spec.rb @@ -10,7 +10,7 @@ let!(:reg1) { create(:event_registration, event: event, registrant: person1, status: "registered") } let!(:reg2) do - create(:event_registration, event: event, registrant: person2, status: "registered", ce_requested: true) + create(:event_registration, event: event, registrant: person2, status: "registered") end before do @@ -18,7 +18,7 @@ create(:continuing_education_registration, event_registration: reg2, cost_cents: 7_500) # A cancelled registration whose money/CE must be ignored everywhere. - cancelled = create(:event_registration, event: event, registrant: create(:person), status: "cancelled", ce_requested: true) + cancelled = create(:event_registration, event: event, registrant: create(:person), status: "cancelled") create(:continuing_education_registration, event_registration: cancelled, cost_cents: 12_000) create(:allocation, source: create(:payment, amount_cents: 5_000, amount_cents_remaining: 5_000), allocatable: cancelled, amount: 5_000) diff --git a/spec/system/ce_form_toggle_on_event_edit_spec.rb b/spec/system/ce_form_toggle_on_event_edit_spec.rb new file mode 100644 index 0000000000..076c5ff170 --- /dev/null +++ b/spec/system/ce_form_toggle_on_event_edit_spec.rb @@ -0,0 +1,32 @@ +require "rails_helper" + +RSpec.describe "CE form toggle on event edit", type: :system do + let(:admin) { create(:user, :with_person, :admin) } + let(:event) { create(:event) } + let(:ce_form) { create(:form, role: "continuing_education", name: "CE Request") } + + before do + ce_form # ensure the form exists so controller populates @continuing_education_forms + sign_in admin + end + + context "when CE form is not attached" do + it "hides hours/cost fields" do + visit edit_event_path(event) + expect(page).to have_unchecked_field("event[continuing_education_form_id]") + expect(page).to have_field("event[ce_hours_offered]", visible: :hidden) + end + end + + context "when CE form is attached" do + before do + create(:event_form, event: event, form: ce_form, role: "continuing_education") + visit edit_event_path(event) + end + + it "shows hours/cost fields" do + expect(page).to have_checked_field("event[continuing_education_form_id]") + expect(page).to have_field("event[ce_hours_offered]", visible: :visible) + end + end +end diff --git a/spec/system/public_registration_form_submission_spec.rb b/spec/system/public_registration_form_submission_spec.rb index 74102fcb1a..85b66cb545 100644 --- a/spec/system/public_registration_form_submission_spec.rb +++ b/spec/system/public_registration_form_submission_spec.rb @@ -17,6 +17,7 @@ end let(:registration_form) { build_registration_form } + let(:continuing_education_form) { build_continuing_education_form } let(:scholarship_form) do FormBuilderService.new(name: "Scholarship Application", sections: %i[scholarship], role: "scholarship").call end @@ -51,6 +52,7 @@ before do driven_by(:rack_test) EventForm.create!(event: event, form: registration_form, role: "registration") + EventForm.create!(event: event, form: continuing_education_form, role: "continuing_education") EventForm.create!(event: event, form: scholarship_form, role: "scholarship") EventForm.create!(event: event, form: bulk_payment_form, role: "bulk_payment") end @@ -61,7 +63,7 @@ fill_full_registration - choose_pr_radio reg_field("ce_credit_interest"), "Yes" + choose_pr_radio ce_field("ce_credit_interest"), "Yes" choose_pr_radio reg_field("payment_method"), "Check" check_pr_box reg_field("additional_forms"), "W-9" @@ -106,11 +108,15 @@ "racial_ethnic_identity" => "Multi-racial", "referral_source" => "Online Search", "training_motivation" => "Address staff burnout through art", - "ce_credit_interest" => "Yes", "payment_method" => "Check", "additional_forms" => "W-9", "communication_consent" => "Yes" ) + expect(answers).not_to have_key("ce_credit_interest") + + ce_submission = continuing_education_form.form_submissions.find_by!(person: person) + ce_answers = answers_by_identifier(ce_submission) + expect(ce_answers).to include("ce_credit_interest" => "Yes") # The service never stores the confirm_email answer (it only checks it matches). expect(answers).not_to have_key("confirm_email") @@ -277,8 +283,8 @@ # Builds the registration form the way db/seeds/dev/events_management.rb does: # the full set of sections, minus the generic "interested in more?" question, - # plus the magic CE-interest and "Additional forms" questions whose answers - # drive the resulting registration's flags. + # plus the "Additional forms" question whose answers drive the resulting + # registration's flags. def build_registration_form form = FormBuilderService.new( name: "Training Registration Form", @@ -288,16 +294,6 @@ def build_registration_form form.form_fields.where(field_identifier: "interested_in_more").destroy_all position = form.form_fields.maximum(:position).to_i - ce = form.form_fields.create!( - name: "Do you plan to use Continuing Education (CE) hours for this course?", - answer_type: :single_select_radio, status: :active, position: position += 1, required: false, - field_identifier: EventRegistrationServices::PublicRegistration::CE_CREDIT_INTEREST_IDENTIFIER, - section: "continuing_education", visibility: :always_ask - ) - %w[Yes No].each do |name| - ce.form_field_answer_options.create!(answer_option: AnswerOption.find_or_create_by!(name: name)) - end - additional_forms = form.form_fields.create!( name: "Do you need either of the following?", answer_type: :multi_select_checkbox, status: :active, position: position += 1, required: false, @@ -313,12 +309,24 @@ def build_registration_form form end + def build_continuing_education_form + FormBuilderService.new( + name: "Continuing Education Request", + sections: %i[continuing_education], + role: "continuing_education" + ).call + end + # ---- Field lookup ---- def reg_field(identifier) registration_form.form_fields.find_by!(field_identifier: identifier) end + def ce_field(identifier) + continuing_education_form.form_fields.find_by!(field_identifier: identifier) + end + def schol_field(identifier) scholarship_form.form_fields.find_by!(field_identifier: identifier) end From 08ab050b55df5e6bfb6659603b24f9651edbb2fd Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:32:14 -0400 Subject: [PATCH 08/14] make index unique --- db/migrate/20260712183112_unique_magic_key_event_index.rb | 6 ++++++ db/schema.rb | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20260712183112_unique_magic_key_event_index.rb diff --git a/db/migrate/20260712183112_unique_magic_key_event_index.rb b/db/migrate/20260712183112_unique_magic_key_event_index.rb new file mode 100644 index 0000000000..1bae2c0ff2 --- /dev/null +++ b/db/migrate/20260712183112_unique_magic_key_event_index.rb @@ -0,0 +1,6 @@ +class UniqueMagicKeyEventIndex < ActiveRecord::Migration[8.1] + def change + remove_index :registration_ticket_callouts, column: [:event_id, :magic_key] + add_index :registration_ticket_callouts, [:event_id, :magic_key], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 239ca2a9c4..ee633774d4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_11_193528) do +ActiveRecord::Schema[8.1].define(version: 2026_07_12_183112) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -1102,7 +1102,7 @@ t.string "subtitle" t.string "title", null: false t.datetime "updated_at", null: false - t.index ["event_id", "magic_key"], name: "index_registration_ticket_callouts_on_event_id_and_magic_key" + t.index ["event_id", "magic_key"], name: "index_registration_ticket_callouts_on_event_id_and_magic_key", unique: true t.index ["event_id", "position"], name: "index_registration_ticket_callouts_on_event_id_and_position" t.index ["event_id"], name: "index_registration_ticket_callouts_on_event_id" end @@ -1377,7 +1377,7 @@ t.bigint "item_id", null: false t.string "item_type", null: false t.text "object", size: :long - t.text "object_changes" + t.text "object_changes", size: :long t.string "whodunnit" t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" end From 5e3c73541bcf258737c0a49b50014426843055bf Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:59:44 -0400 Subject: [PATCH 09/14] show/hidde callout edit on publish checkbox --- ...egistration_ticket_callout_fields.html.erb | 278 +++++++++--------- 1 file changed, 139 insertions(+), 139 deletions(-) diff --git a/app/views/events/_registration_ticket_callout_fields.html.erb b/app/views/events/_registration_ticket_callout_fields.html.erb index 1b26a064d6..26ef077e39 100644 --- a/app/views/events/_registration_ticket_callout_fields.html.erb +++ b/app/views/events/_registration_ticket_callout_fields.html.erb @@ -18,146 +18,163 @@
<% end %> -
- <% if f.object.magic? %> -
- Built in - Pre-filled defaults you can edit; the app supplies its live status and links. -
- <% end %> +
+ <%# Peer checkbox (first sibling) drives show/hide of the editable content below. %> + <%= f.check_box :published, class: "peer h-5 w-5 rounded border-gray-300 text-purple-600" %> - <%# Live preview of how this callout appears on the ticket — the same card - registrants see (icon, title, subtitle, themed colour). Seeded from the - record and repainted by the callout-preview controller as the admin edits - the title, subtitle, type, colour, or icon. The badge and live status the - app supplies at render time aren't shown here. %> - <% preview_theme = f.object.theme %> -
- -
-

<%= f.object.title %>

-

" - data-callout-preview-target="capperSubtitle"><%= f.object.subtitle %>

-
- + <%# Always-visible summary row: sits inline next to the checkbox so you + know which callout you are toggling even when it is hidden. %> +
+ <% if f.object.magic? %> + Built in + <% end %> + <%= f.label :published, "Published", class: "text-gray-600 cursor-pointer select-none" %> + <%= f.object.title %> + <% if f.object.magic? %> + <% if f.object.persisted? && DefaultTicketCallouts.customized?(f.object) %> + + <% else %> + Matches default + <% end %> + <% end %>
- <%# Two columns that collapse to one as the viewport narrows: the call-out's - identity (title + presentation) on the left, its page content on the right. %> -
-
-
-
-
- <%= f.label :title, "Title", class: "block text-xs font-medium text-gray-600 mb-0.5" %> - <%= f.text_field :title, required: true, - class: "w-full rounded border-gray-300 bg-white shadow-sm px-2 py-1 text-sm" %> -
-
- <%= f.label :subtitle, "Subtitle", class: "block text-xs font-medium text-gray-600 mb-0.5" %> - <%= f.text_field :subtitle, - class: "w-full rounded border-gray-300 bg-white shadow-sm px-2 py-1 text-sm" %> -
-
+ <%# Editable content — hidden when the Published checkbox is unchecked. %> + -
<% unless f.object.magic? %>
-
- <% if f.object.magic? %> - <% if f.object.persisted? && DefaultTicketCallouts.customized?(f.object) %> - <%# Applied on the main Save, not a separate request. %> - - <% else %> - Matches default - <% end %> - <% end %> - -
From b1fe8fe2453b9329c12958b8bba434e80b733b26 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:06:30 -0400 Subject: [PATCH 10/14] ui- add callout name to checkbox row --- ...egistration_ticket_callout_fields.html.erb | 149 ++++++++++-------- 1 file changed, 81 insertions(+), 68 deletions(-) diff --git a/app/views/events/_registration_ticket_callout_fields.html.erb b/app/views/events/_registration_ticket_callout_fields.html.erb index 26ef077e39..a575705946 100644 --- a/app/views/events/_registration_ticket_callout_fields.html.erb +++ b/app/views/events/_registration_ticket_callout_fields.html.erb @@ -1,97 +1,96 @@ -<%# One editable callout row, shared by custom callouts and every materialized - built-in. CE hours additionally shows the event's hours-offered/cost config - inline (via event_f); its text, like every other built-in, lives on the row. %> +<%# + One editable callout row, shared by custom callouts and every materialized + built-in. CE hours additionally shows the event's hours-offered/cost config + inline (via event_f); its text, like every other built-in, lives on the row. +%> <% event_f = local_assigns[:event_f] %> -
data-sortable-id="<%= f.object.id %>"<% end %>> +
-
- -
+ data-sortable-id="<%= f.object.id %>" + <% end %> +> + <% if f.object.persisted? %> +
<% else %> -
- -
+
<% end %>
- <%# Peer checkbox (first sibling) drives show/hide of the editable content below. %> - <%= f.check_box :published, class: "peer h-5 w-5 rounded border-gray-300 text-purple-600" %> + <%= f.check_box :published, class: "peer rounded border-gray-300 text-purple-600" %> + + <%# + Always-visible summary row: sits inline next to the checkbox so you + know which callout you are toggling even when it is hidden. + %> - <%# Always-visible summary row: sits inline next to the checkbox so you - know which callout you are toggling even when it is hidden. %>
- <% if f.object.magic? %> - Built in - <% end %> - <%= f.label :published, "Published", class: "text-gray-600 cursor-pointer select-none" %> <%= f.object.title %> - <% if f.object.magic? %> - <% if f.object.persisted? && DefaultTicketCallouts.customized?(f.object) %> - - <% else %> - Matches default - <% end %> - <% end %>
<%# Editable content — hidden when the Published checkbox is unchecked. %>