From 51c5663fe238c46da6bb1a97cced4d49338b28e3 Mon Sep 17 00:00:00 2001 From: Justin <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:52:45 -0400 Subject: [PATCH] Revert "Callout cleanup (#1967)" This reverts commit de0364b50c5f160f34ada9899c61367928ced706. --- .github/copilot-instructions.md | 2 +- AGENTS.md | 43 +- CLAUDE.md | 2 +- app/controllers/application_controller.rb | 7 - ...uing_education_registrations_controller.rb | 95 --- .../event_registrations_controller.rb | 22 +- app/controllers/events/callouts_controller.rb | 112 +-- .../events/public_registrations_controller.rb | 36 +- app/controllers/events_controller.rb | 30 +- app/controllers/payments_controller.rb | 57 +- app/controllers/people_controller.rb | 32 +- .../rich_text_asset_mentions_controller.rb | 4 +- .../workshop_variations_controller.rb | 1 + .../event_registration_decorator.rb | 30 - .../attendance_status_controller.js | 25 +- .../ce_credit_requested_controller.js | 62 ++ .../ce_license_picker_controller.js | 20 - app/frontend/javascript/controllers/index.js | 9 +- .../scholarship_requested_controller.js | 34 + .../controllers/section_filter_controller.js | 4 +- app/helpers/application_helper.rb | 24 - app/helpers/event_helper.rb | 9 +- app/helpers/events_helper.rb | 1 + app/helpers/rhino_editor_helper.rb | 5 +- app/models/concerns/pay_charge_extensions.rb | 34 +- .../continuing_education_registration.rb | 58 -- app/models/event.rb | 4 - app/models/event_form.rb | 3 +- app/models/event_registration.rb | 128 +--- app/models/form_field.rb | 28 +- app/models/person.rb | 11 - app/models/professional_license.rb | 32 +- ...ontinuing_education_registration_policy.rb | 6 - app/policies/professional_license_policy.rb | 18 - app/services/default_ticket_callouts.rb | 60 +- app/services/event_registration_readiness.rb | 6 +- .../public_registration.rb | 88 +-- app/services/event_revenue_report.rb | 24 +- app/services/form_builder_service.rb | 31 +- app/services/magic_ticket_callouts.rb | 21 +- app/services/number_formatter.rb | 15 - app/services/scholarship_application.rb | 4 +- app/views/allocations/index.html.erb | 25 +- .../_details_section.html.erb | 84 --- .../_payment_history.html.erb | 52 -- .../edit.html.erb | 80 --- .../new.html.erb | 36 - .../_ce_status_badge.html.erb | 23 - .../_continuing_education.html.erb | 57 -- app/views/event_registrations/_form.html.erb | 145 ++-- .../_payment_history.html.erb | 2 +- .../event_registrations/_scholarship.html.erb | 27 +- .../events/_builtin_callout_card.html.erb | 28 - app/views/events/_form.html.erb | 635 +++++++----------- .../events/_registrants_results.html.erb | 42 +- app/views/events/_registrants_search.html.erb | 2 +- ...egistration_ticket_callout_fields.html.erb | 310 ++++----- .../_reminder_recipient_filters.html.erb | 2 +- app/views/events/callouts/ce.html.erb | 273 ++------ .../events/callouts/scholarship.html.erb | 30 +- app/views/events/onboarding/_row.html.erb | 22 +- .../public_registrations/_form_field.html.erb | 8 +- .../events/public_registrations/new.html.erb | 22 - .../events/public_registrations/show.html.erb | 51 -- app/views/events/revenue.html.erb | 2 +- app/views/forms/edit.html.erb | 2 +- app/views/forms/new.html.erb | 8 +- app/views/people/_form.html.erb | 24 +- app/views/people/show.html.erb | 2 +- .../_professional_license_fields.html.erb | 82 --- app/views/scholarships/edit.html.erb | 11 - app/views/users/_form.html.erb | 2 +- app/views/workshop_variations/_form.html.erb | 27 +- config/brakeman.ignore | 148 ++-- config/routes.rb | 6 - ...ove_ce_columns_from_event_registrations.rb | 16 - ...add_ce_requested_to_event_registrations.rb | 14 - ...30133153_remove_credentials_from_people.rb | 12 - ...professional_license_uniqueness_to_kind.rb | 10 - ...e_ce_requested_from_event_registrations.rb | 15 - ...0712183112_unique_magic_key_event_index.rb | 6 - db/schema.rb | 12 +- db/seeds/dev/events_management.rb | 94 ++- .../event_registration_decorator_spec.rb | 74 -- spec/factories/event_forms.rb | 4 - spec/helpers/application_helper_spec.rb | 15 - spec/helpers/event_helper_spec.rb | 7 + .../concerns/pay_charge_extensions_spec.rb | 58 -- .../continuing_education_registration_spec.rb | 58 -- spec/models/event_registration_spec.rb | 138 ++-- spec/models/form_field_spec.rb | 4 +- spec/models/professional_license_spec.rb | 55 +- .../professional_license_policy_spec.rb | 59 -- ...continuing_education_registrations_spec.rb | 178 ----- spec/requests/event_registrations_spec.rb | 39 +- spec/requests/events/bulk_reminders_spec.rb | 2 +- spec/requests/events/callouts_spec.rb | 103 --- .../registration_ticket_callouts_spec.rb | 3 +- spec/requests/events/registrations_spec.rb | 156 +---- spec/requests/events_spec.rb | 111 ++- spec/requests/honeybadger_context_spec.rb | 21 - .../people_professional_licenses_spec.rb | 122 ---- spec/requests/people_profile_flags_spec.rb | 8 +- .../requests/rich_text_asset_mentions_spec.rb | 26 - spec/services/default_ticket_callouts_spec.rb | 57 +- .../event_registration_readiness_spec.rb | 15 +- .../public_registration_spec.rb | 111 ++- spec/services/event_revenue_report_spec.rb | 10 +- spec/services/magic_ticket_callouts_spec.rb | 49 +- spec/services/number_formatter_spec.rb | 17 - .../reminder_recipient_filter_spec.rb | 42 +- .../ce_form_toggle_on_event_edit_spec.rb | 32 - spec/system/event_registration_edit_spec.rb | 63 +- ...ublic_registration_form_submission_spec.rb | 39 +- spec/views/page_bg_class_alignment_spec.rb | 2 - 115 files changed, 1391 insertions(+), 3973 deletions(-) delete mode 100644 app/controllers/continuing_education_registrations_controller.rb create mode 100644 app/frontend/javascript/controllers/ce_credit_requested_controller.js delete mode 100644 app/frontend/javascript/controllers/ce_license_picker_controller.js create mode 100644 app/frontend/javascript/controllers/scholarship_requested_controller.js delete mode 100644 app/policies/continuing_education_registration_policy.rb delete mode 100644 app/policies/professional_license_policy.rb delete mode 100644 app/services/number_formatter.rb delete mode 100644 app/views/continuing_education_registrations/_details_section.html.erb delete mode 100644 app/views/continuing_education_registrations/_payment_history.html.erb delete mode 100644 app/views/continuing_education_registrations/edit.html.erb delete mode 100644 app/views/continuing_education_registrations/new.html.erb delete mode 100644 app/views/event_registrations/_ce_status_badge.html.erb delete mode 100644 app/views/event_registrations/_continuing_education.html.erb delete mode 100644 app/views/events/_builtin_callout_card.html.erb delete mode 100644 app/views/professional_licenses/_professional_license_fields.html.erb delete mode 100644 db/migrate/20260629122601_remove_ce_columns_from_event_registrations.rb delete mode 100644 db/migrate/20260629173817_add_ce_requested_to_event_registrations.rb delete mode 100644 db/migrate/20260630133153_remove_credentials_from_people.rb delete mode 100644 db/migrate/20260630220947_scope_professional_license_uniqueness_to_kind.rb delete mode 100644 db/migrate/20260711193528_remove_ce_requested_from_event_registrations.rb delete mode 100644 db/migrate/20260712183112_unique_magic_key_event_index.rb delete mode 100644 spec/policies/professional_license_policy_spec.rb delete mode 100644 spec/requests/continuing_education_registrations_spec.rb delete mode 100644 spec/requests/honeybadger_context_spec.rb delete mode 100644 spec/requests/people_professional_licenses_spec.rb delete mode 100644 spec/requests/rich_text_asset_mentions_spec.rb delete mode 100644 spec/services/number_formatter_spec.rb delete mode 100644 spec/system/ce_form_toggle_on_event_edit_spec.rb diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c783fe98ee..edcf29d573 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -287,7 +287,7 @@ Follow the [Stimulus Handbook](https://stimulus.hotwired.dev/handbook/introducti - Include screenshots for UI changes - **On every push**, update the PR title and content to reflect the current diff — preserve any existing images/screenshots in the description - **On every push**, update AI instruction files if the diff adds, removes, or renames anything tracked in AGENTS.md — specifically: Stimulus controllers, services, model/controller concerns, mailers, rake tasks, and directory file counts -- **Inline-comment only to flag what matters to the reviewer** — do NOT comment on every push, and don't annotate routine or self-explanatory changes. Add a `gh api` line comment on the diff only when a reviewer genuinely needs something flagged: a non-obvious decision or trade-off, a risky/surprising change, a load-bearing assumption, or something easy to miss. When nothing rises to that bar, post no inline comments +- **On every push**, add PR review comments on notable lines of code — decisions, trade-offs, non-obvious logic, or anything a reviewer should understand. Use `gh api` to post line comments on the diff - **Attribute every AI-authored GitHub comment** — `gh` posts as the authenticated user, so any comment you create (PR review comments, issue/PR comments, replies) MUST be prefixed to identify the AI agent that wrote it. Begin the comment body with `🤖 _From :_` (e.g. `🤖 _From Claude:_` or `🤖 _From Copilot:_`) followed by the content - **Keep GitHub comments short and to the point** — one or two sentences, stating the key insight directly. Skip preamble, restating the code, and hedging; if a comment needs more than a few lines, it usually belongs in the PR description instead diff --git a/AGENTS.md b/AGENTS.md index 12d579d53e..084e9f755c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,19 +48,19 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| -| `app/models/` | ActiveRecord models | ~80 files | +| `app/models/` | ActiveRecord models | ~78 files | | `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~30 files | -| `app/jobs/` | SolidQueue background jobs | 4 files | +| `app/jobs/` | SolidQueue background jobs | 3 files | | `app/models/concerns/` | Shared model modules | 16 concerns | ### Presentation | Directory | Purpose | Count | |---|---|---| -| `app/controllers/` | Rails controllers (admin/, events/) | ~77 files | -| `app/views/` | ERB templates | ~632 files | -| `app/decorators/` | Draper decorators for view logic | ~40 files | -| `app/policies/` | ActionPolicy authorization rules | ~55 files | +| `app/controllers/` | Rails controllers (admin/, events/) | ~70 files | +| `app/views/` | ERB templates | ~504 files | +| `app/decorators/` | Draper decorators for view logic | ~38 files | +| `app/policies/` | ActionPolicy authorization rules | ~49 files | | `app/presenters/` | Presentation objects | 3 files | | `app/helpers/` | View helpers | ~25 files | | `app/mailers/` | ActionMailer classes | 5 files | @@ -81,7 +81,7 @@ This codebase (Rails 8.1) |---|---| | `config/routes.rb` | All routes (single file) | | `config/database.yml` | MySQL via Trilogy adapter | -| `config/initializers/` | ~30 initializer files | +| `config/initializers/` | ~28 initializer files | | `.github/workflows/` | GitHub Actions CI | | `Procfile.dev` | Dev services: `vite` + `web` | | `ai/` | Shell script shortcuts for common dev tasks (see `ai/README.md`) | @@ -148,7 +148,7 @@ This codebase (Rails 8.1) ### Namespaces -- **Root level** (~58 controllers): Workshops, stories, resources, events, people, organizations, registration ticket callouts, etc. +- **Root level** (~52 controllers): Workshops, stories, resources, events, people, organizations, registration ticket callouts, etc. - **`admin/`**: HomeController, AnalyticsController, AhoyActivitiesController - **`events/`**: Registrations sub-resource (create/destroy + slug-based show at `/registration/:slug`) - **Devise overrides**: Registrations, Confirmations, Passwords @@ -284,7 +284,6 @@ end - `asset_picker` — Asset selection UI - `autosave` — Auto-save form state - `carousel` — Swiper-based carousels -- `ce_license_picker` — Fill the CE license type/number/state/expiry fields from the picked license (or clear them for a new one) - `cocoon` — Nested form handling (cocoon gem) - `collection` — Filter form auto-submit with debounce - `column_toggle` — Toggle table column visibility @@ -350,17 +349,17 @@ Custom colors defined in `app/frontend/stylesheets/application.tailwind.css`: | Directory | Count | Purpose | |---|---|---| -| `spec/models/` | ~71 | Model unit tests | -| `spec/views/` | ~77 | View template tests | -| `spec/requests/` | ~91 | HTTP request/integration tests | -| `spec/system/` | ~20 | End-to-end browser tests (Capybara) | -| `spec/routing/` | ~15 | Route definition tests | -| `spec/policies/` | ~15 | Authorization policy tests | -| `spec/decorators/` | ~15 | Decorator tests | -| `spec/services/` | ~25 | Service object tests | +| `spec/models/` | ~58 | Model unit tests | +| `spec/views/` | ~73 | View template tests | +| `spec/requests/` | ~47 | HTTP request/integration tests | +| `spec/system/` | ~25 | End-to-end browser tests (Capybara) | +| `spec/routing/` | ~13 | Route definition tests | +| `spec/policies/` | ~9 | Authorization policy tests | +| `spec/decorators/` | ~10 | Decorator tests | +| `spec/services/` | ~13 | Service object tests | | `spec/mailers/` | ~5 | Mailer tests | -| `spec/helpers/` | ~5 | Helper tests | -| `spec/factories/` | ~67 | FactoryBot factory definitions | +| `spec/helpers/` | ~1 | Helper tests | +| `spec/factories/` | ~53 | FactoryBot factory definitions | ### Configuration @@ -433,12 +432,8 @@ RuboCop linting on PRs and pushes to main. ## Rake Tasks -Located in `lib/tasks/` (8 files): +Located in `lib/tasks/` (4 files): - `dev.rake` — Development database seeding from XML/CSV - `rhino_migrator.rake` — Rich text editor migration - `attachment_report.rake` — Attachment reporting - `migrate_internal_id_to_filemaker_code.rake` — FileMaker code migration -- `convert_age_ranges.rake` — Age range data conversion -- `legacy_user_permissions_to_comments.rake` — Migrate legacy user permissions into comments -- `migrate_sectors.rake` — Sector data migration -- `migrate_workshop_logs.rake` — Workshop log migration diff --git a/CLAUDE.md b/CLAUDE.md index b3d99a42d2..d63d4dfe90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,7 +287,7 @@ Follow the [Stimulus Handbook](https://stimulus.hotwired.dev/handbook/introducti - Include screenshots for UI changes - **On every push**, update the PR title and content to reflect the current diff — preserve any existing images/screenshots in the description - **On every push**, update AI instruction files if the diff adds, removes, or renames anything tracked in AGENTS.md — specifically: Stimulus controllers, services, model/controller concerns, mailers, rake tasks, and directory file counts -- **Inline-comment only to flag what matters to the reviewer** — do NOT comment on every push, and don't annotate routine or self-explanatory changes. Add a `gh api` line comment on the diff only when a reviewer genuinely needs something flagged: a non-obvious decision or trade-off, a risky/surprising change, a load-bearing assumption, or something easy to miss. When nothing rises to that bar, post no inline comments +- **On every push**, add PR review comments on notable lines of code — decisions, trade-offs, non-obvious logic, or anything a reviewer should understand. Use `gh api` to post line comments on the diff - **Attribute every AI-authored GitHub comment** — `gh` posts as the authenticated user, so any comment you create (PR review comments, issue/PR comments, replies) MUST be prefixed to identify the AI agent that wrote it. Begin the comment body with `🤖 _From :_` (e.g. `🤖 _From Claude:_` or `🤖 _From Copilot:_`) followed by the content - **Keep GitHub comments short and to the point** — one or two sentences, stating the key insight directly. Skip preamble, restating the code, and hedging; if a comment needs more than a few lines, it usually belongs in the PR description instead diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e59fa26901..e3135d7434 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,7 +2,6 @@ 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 @@ -74,10 +73,4 @@ 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/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb deleted file mode 100644 index 3faa969518..0000000000 --- a/app/controllers/continuing_education_registrations_controller.rb +++ /dev/null @@ -1,95 +0,0 @@ -class ContinuingEducationRegistrationsController < ApplicationController - before_action :set_ce_registration, except: [ :new, :create ] - before_action :set_event_registration, only: [ :new, :create ] - - def new - authorize! - @ce_registration = @event_registration.continuing_education_registrations.build( - professional_license: @event_registration.registrant.professional_licenses.first, - hours: @event_registration.event.ce_hours_offered, - cost_cents: @event_registration.event.ce_hours_cost_cents - ) - end - - def create - authorize! - - @ce_registration = @event_registration.continuing_education_registrations.build(professional_license: license_for_create) - - # License and CE registration persist together — a failed save leaves neither. - ActiveRecord::Base.transaction do - apply_ce_params(@ce_registration) - @ce_registration.save! - end - redirect_to edit_event_registration_path(@ce_registration.event_registration), notice: "CE registration created.", status: :see_other - rescue ActiveRecord::RecordInvalid - flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence - render :new, status: :unprocessable_content - end - - def edit - authorize! @ce_registration - end - - def update - authorize! @ce_registration - - ActiveRecord::Base.transaction do - apply_ce_params(@ce_registration) - @ce_registration.save! - end - redirect_to edit_event_registration_path(@ce_registration.event_registration), notice: "CE registration updated.", status: :see_other - rescue ActiveRecord::RecordInvalid - flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence - render :edit, status: :unprocessable_content - end - - def destroy - authorize! @ce_registration - if @ce_registration.allocations.exists? - redirect_to edit_continuing_education_registration_path(@ce_registration, return_to: params[:return_to]), - alert: "Can't remove CE — it has payments. Revert the payment first.", status: :see_other - return - end - - registration = @ce_registration.event_registration - @ce_registration.destroy! - redirect_to edit_event_registration_path(registration), notice: "CE registration removed.", status: :see_other - end - - def toggle_certificate - authorize! @ce_registration - issued = @ce_registration.certificate_sent_at.present? - @ce_registration.update!(certificate_sent_at: issued ? nil : Time.current) - redirect_to edit_continuing_education_registration_path(@ce_registration, return_to: params[:return_to]), - notice: issued ? "Certificate marked not issued." : "Certificate marked issued.", status: :see_other - end - - private - - def set_ce_registration - @ce_registration = ContinuingEducationRegistration.find(params[:id]) - end - - def set_event_registration - sgid = params[:allocatable_sgid] - @event_registration = GlobalID::Locator.locate_signed(sgid) if sgid - redirect_to root_path, alert: "Registration not found.", status: :see_other unless @event_registration - end - - def license_for_create - @event_registration.registrant.professional_licenses.first || - @event_registration.registrant.professional_licenses.build - end - - def apply_ce_params(ce_registration) - ce_registration.assign_license(number: params.dig(:continuing_education_registration, :license_number), - kind: params.dig(:continuing_education_registration, :license_kind), - issuing_state: params.dig(:continuing_education_registration, :license_issuing_state), - expires_on: params.dig(:continuing_education_registration, :license_expires_on), - license_id: params.dig(:continuing_education_registration, :professional_license_id)) - ce_registration.hours = params.dig(:continuing_education_registration, :hours) - cost = params.dig(:continuing_education_registration, :cost_dollars) - ce_registration.cost_cents = (cost.to_d * 100).round if cost.present? - end -end diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 3dccb72f18..8ca1063862 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -87,23 +87,22 @@ def update @event_registration.notifications.select(&:new_record?).each { |n| n.recipient_email = recipient_email } if @event_registration.save - notice = "Registration was successfully updated." respond_to do |format| format.turbo_stream format.html { case params[:return_to] - when "registrants" then redirect_to registrants_event_path(@event_registration.event), notice: notice, status: :see_other - when "index" then redirect_to event_registrations_path, notice: notice, status: :see_other - when "ticket" then redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other - when "preview_reminder" then redirect_to preview_reminder_event_path(@event_registration.event), notice: notice, status: :see_other - when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other + when "registrants" then redirect_to registrants_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other + when "index" then redirect_to event_registrations_path, notice: "Registration was successfully updated.", status: :see_other + when "ticket" then redirect_to registration_ticket_path(@event_registration.slug), notice: "Registration was successfully updated.", status: :see_other + when "preview_reminder" then redirect_to preview_reminder_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other + when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: "Registration was successfully updated.", status: :see_other else # No explicit origin: keep admins in the management context (the # roster) rather than dropping them on the public registration show. if allowed_to?(:manage?, with: EventRegistrationPolicy) - redirect_to registrants_event_path(@event_registration.event), notice: notice, status: :see_other + redirect_to registrants_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other else - redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other + redirect_to registration_ticket_path(@event_registration.slug), notice: "Registration was successfully updated.", status: :see_other end end } @@ -269,10 +268,6 @@ def destroy event = @event_registration.event if !@event_registration.deletable? flash[:alert] = "This registration can't be deleted because it has financial records (payments, scholarships, or the like) or attendance on record." - elsif @event_registration.continuing_education_registrations.any? { |ce| ce.allocations.exists? } - # Deleting the registration would cascade away a paid CE registration (and its - # allocations); make the admin revert the payment first. - flash[:alert] = "Can't delete this registration while its CE registration has payments — revert the payment first." elsif @event_registration.destroy flash[:notice] = "Registration deleted." else @@ -331,6 +326,9 @@ def event_registration_params :shoutout, :intends_to_pay, :expected_payment_method, + :ce_credit_requested, + :ce_hours_requested, + :ce_license_number, :fee_note, *EventRegistration::DAY_FIELDS, organization_ids: [], diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index b2a3c700bb..a9efc39703 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -61,67 +61,11 @@ def scholarship @form_responses_available = @event.registration_form&.form_submissions&.exists?(person: @event_registration.registrant) end - # CE hours status: hours, amount owed, and license number. + # CE hours status: requested hours, amount owed, and license number. Title and + # body come from the materialized CE callout row (falling back to the event's + # columns for events not yet seeded). 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 - # expiry). Edits the license on the registrant's (first) CE registration in - # place, mirrors the number onto the registration's form answer, then returns to - # the callout. Plain full-page POST — no Turbo. Shares - # ContinuingEducationRegistration#assign_license with the admin edit page. - def update_ce_license - ce_registration = @event_registration.continuing_education_registrations.first - return redirect_to(registration_ce_path(@event_registration.slug)) unless ce_registration - - # Once the certificate is issued the license is the credential it was issued - # under — frozen here. Admins can still correct it on the admin CE edit page. - if ce_registration.certificate_sent_at.present? - return redirect_to registration_ce_path(@event_registration.slug), - alert: "Your CE certificate has been issued, so the license can no longer be changed here. Contact us if it needs correcting." - end - - ce_registration.assign_license(number: params[:license_number], kind: params[:license_kind], - issuing_state: params[:license_issuing_state], expires_on: params[:license_expires_on]) - ce_registration.save! - # assign_license already normalized the number; mirror the saved value rather - # than re-stripping the raw param. - record_ce_license_answer(ce_registration.professional_license.number) - - redirect_to registration_ce_path(@event_registration.slug), notice: "License saved." - rescue ActiveRecord::RecordInvalid - redirect_to registration_ce_path(@event_registration.slug), alert: "We couldn't save that license." - end - - # Public CE opt-in from the callout: a registrant who didn't ask for credit at - # registration can request it here. Sets the flag and creates the CE - # registration (against a placeholder license; the number is entered next). - def request_ce - return redirect_to(registration_ce_path(@event_registration.slug)) unless @event.ce_eligible? - - 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) - end - - redirect_to registration_ce_path(@event_registration.slug), notice: "Continuing education credit requested." + @ce_callout = @event.registration_ticket_callouts.find_by(magic_key: "ce_hours") end # Forms page: callout-card links to the W-9 (when seeded) and, for paid @@ -190,17 +134,6 @@ def set_builtin_content @builtin_resources = callout && action_name != "forms" ? callout.resources.to_a : [] end - # Update form submission if ce record is updated via callout - def record_ce_license_answer(number) - 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, role: "continuing_education") - return unless field && submission - - answer = submission.form_answers.find_or_initialize_by(form_field: field) - answer.update!(submitted_answer: number.to_s, question_name_when_answered: field.name) - end - # Builds the callout-card links shown on the forms page. The document links # come from the materialized Forms callout's resources (the W-9 by default, # editable/removable per event) — or the hard-coded W-9 for events not yet @@ -246,42 +179,5 @@ 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 a4ea0e5979..0645472d04 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -21,7 +21,6 @@ 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 @@ -36,23 +35,17 @@ 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, continuing_education_params = split_form_params(all_params) + registration_params, scholarship_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 @@ -68,9 +61,7 @@ def create scholarship_requested: @scholarship, person: current_user&.person, scholarship_form: @scholarship_form, - scholarship_params: scholarship_params, - continuing_education_form: @continuing_education_form, - continuing_education_params: continuing_education_params + scholarship_params: scholarship_params ) if result.success? @@ -85,7 +76,6 @@ 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." @@ -130,7 +120,6 @@ 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 @@ -201,19 +190,6 @@ 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 @@ -228,13 +204,7 @@ def split_form_params(all_params) scholarship = all_params.slice(*scholarship_field_ids) end - 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 ] + [ registration, scholarship ] end def visible_form_fields diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 88a23e0fbb..0f7c86b8da 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -78,9 +78,11 @@ def sample_ticket invoice_requested: @show_all_options, scholarship_requested: @show_all_options, shoutout: @show_all_options, + ce_credit_requested: @show_all_options, + ce_hours_requested: @show_all_options ? 6 : nil, + ce_license_number: @show_all_options ? "SAMPLE-12345" : nil, created_at: Time.current ) - build_sample_ce_registration if @show_all_options end def background @@ -151,7 +153,7 @@ def onboarding authorize! @event, to: :registrants? @event = @event.decorate scope = @event.event_registrations - .includes(:checklist_completions, :organizations, :allocations, :scholarships, :comments, { continuing_education_registrations: :professional_license }, registrant: [ :user, { affiliations: :organization } ]) + .includes(:checklist_completions, :organizations, :allocations, :scholarships, :comments, registrant: [ :user, { affiliations: :organization } ]) .joins(:registrant) scope = scope.keyword(params[:keyword]) if params[:keyword].present? @@ -209,7 +211,7 @@ def details def ce_hours authorize! @event, to: :ce_hours? - unless @event.ce_eligible? + if @event.ce_hours_details.blank? redirect_to event_path(@event, reg: params[:reg].presence) return end @@ -538,18 +540,6 @@ def copy_registration_form private - # Build (unsaved) a CE registration on the sample ticket so the "Show all - # options" preview renders a populated CE card. Mirrors a complete, paid-looking - # CE record without touching the database. - def build_sample_ce_registration - license = ProfessionalLicense.new(person: @event_registration.registrant, number: "SAMPLE-12345") - @event_registration.continuing_education_registrations.build( - professional_license: license, - hours: @event.ce_hours_offered || 6, - cost_cents: @event.ce_hours_cost_cents || 15_000 - ) - end - # The registrations the admin checked on the recipient picker, narrowed to those # we can actually email. Shared by the confirm interstitial and the send action # so both operate on exactly the same set. @@ -680,11 +670,11 @@ def onboarding_csv_row(registration, cost_required, day_count, include_ce = fals row << (scholarship ? (scholarship.grant&.name.presence || "Unfunded") : "") row << onboarding_scholarship_tasks_csv(registration) if include_ce - ce_hours = registration.ce_hours_total - row << (registration.ce_registered? ? "Yes" : "No") - row << (ce_hours.positive? ? helpers.plain_number(ce_hours) : "") + ce_hours = registration.ce_hours_requested.to_i + row << (registration.ce_credit_requested? ? "Yes" : "No") + row << (ce_hours.positive? ? ce_hours : "") row << (registration.ce_amount_owed_cents.positive? ? helpers.dollars_from_cents(registration.ce_amount_owed_cents) : "") - row << registration.ce_license_numbers.join("; ") + row << registration.ce_license_number.to_s end EventRegistration::CHECKLIST_STEPS.each_key do |step| row << (registration.checklist_step_completed?(step) ? "Yes" : "No") @@ -711,7 +701,6 @@ 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) @@ -731,7 +720,6 @@ 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/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index f51058adf4..57d6c8810d 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -25,9 +25,10 @@ def new if params[:allocatable_sgid].present? @allocatable = GlobalID::Locator.locate_signed(params[:allocatable_sgid]) - person, organization = registrant_and_organization_for(@allocatable) - @payment.person = person - @payment.organization = organization + if @allocatable.is_a?(EventRegistration) + @payment.person = @allocatable.registrant + @payment.organization = @allocatable.organizations.first + end end end @@ -169,9 +170,10 @@ def allocation_form if params[:allocatable_sgid].present? @allocatable = GlobalID::Locator.locate_signed(params[:allocatable_sgid]) - person, organization = registrant_and_organization_for(@allocatable) - @payment.person = person - @payment.organization = organization + if @allocatable.is_a?(EventRegistration) + @payment.person = @allocatable.registrant + @payment.organization = @allocatable.organizations.first + end end respond_to do |format| @@ -195,21 +197,9 @@ def locate_allocatable GlobalID::Locator.locate_signed(params[:payment][:allocatable_sgid]) end - def registrant_and_organization_for(allocatable) - case allocatable - when EventRegistration - [ allocatable.registrant, allocatable.organizations.first ] - when ContinuingEducationRegistration - [ allocatable.event_registration.registrant, allocatable.event_registration.organizations.first ] - else - [ nil, nil ] - end - end - def build_payment_attributes(payment_params, allocatable) attrs = payment_params.except(:allocatable_sgid, :type) - person, = registrant_and_organization_for(allocatable) - attrs[:person_id] ||= person&.id + attrs[:person_id] ||= allocatable.try(:registrant_id) if allocatable.is_a?(EventRegistration) attrs end @@ -237,21 +227,28 @@ def process_allocation!(payment, allocatable) end def calculate_allocation_amount(payment, allocatable) - cost_cents = allocatable.cost_cents + payment_amount = payment.amount_cents - if cost_cents.blank? || cost_cents <= 0 - payment.errors.add(:base, "Cannot allocate to a free registration.") - raise ActiveRecord::RecordInvalid.new(payment) - end + if allocatable.is_a?(EventRegistration) + event_cost = allocatable.event.cost_cents - remaining_needed = cost_cents - allocatable.allocations_sum + if event_cost.blank? + payment.errors.add(:base, "Cannot allocate to a free event.") + raise ActiveRecord::RecordInvalid.new(payment) + end - if remaining_needed <= 0 - payment.errors.add(:base, "Registration is already fully paid.") - raise ActiveRecord::RecordInvalid.new(payment) - end + already_allocated = allocatable.allocations_sum + remaining_needed = event_cost - already_allocated + + if remaining_needed <= 0 + payment.errors.add(:base, "Event registration is already fully paid.") + raise ActiveRecord::RecordInvalid.new(payment) + end - [ payment.amount_cents, remaining_needed ].min + [ payment_amount, remaining_needed ].min + else + payment_amount + end end def edit_payment_params diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index a314ef04ad..1c51370125 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -221,9 +221,7 @@ def update @person.avatar.purge end - attrs = person_params - reject_locked_license_changes!(attrs) - @person.assign_attributes(attrs) + @person.assign_attributes(person_params) @person.comments.select(&:new_record?).each { |c| c.created_by = current_user; c.updated_by = current_user } @person.comments.select { |c| c.persisted? && c.body_changed? }.each { |c| c.updated_by = current_user } @@ -452,30 +450,6 @@ def person_update_return_path end # Only allow a list of trusted parameters through. - # Server-side backstop for the per-license edit gating (ProfessionalLicensePolicy): - # drop any submitted change to a license the current user isn't allowed to edit or - # destroy — i.e. a CE-tied license edited by a non-admin owner. The view already - # renders those read-only; this stops a crafted request from slipping past. Admins - # are allowed everything, so it's a no-op for them (and thus for the admin-only form - # today). New licenses (no id) have no CE registrations yet, so they pass through. - def reject_locked_license_changes!(attrs) - license_attrs = attrs[:professional_licenses_attributes] - return if license_attrs.blank? - - license_attrs.keys.each do |key| - license = license_attrs[key] - id = license[:id] - next if id.blank? - - record = @person.professional_licenses.find_by(id: id) - next unless record - - destroying = license[:_destroy].to_s.in?(%w[ 1 true ]) - rule = destroying ? :destroy? : :update? - license_attrs.delete(key) unless allowed_to?(rule, record) - end - end - def person_params params.require(:person).permit( :avatar, @@ -485,6 +459,7 @@ def person_params :street_address, :city, :state, :zip, :country, :mailing_address_type, :best_time_to_call, :date_of_birth, + :credentials, :racial_ethnic_identity, :filemaker_code, :mailing_list_consented, @@ -583,8 +558,7 @@ def person_params :_destroy ], comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ], - notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :noticeable_type, :noticeable_id, :_destroy ], - professional_licenses_attributes: [ :id, :number, :kind, :issuing_state, :expires_on, :_destroy ] + notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :noticeable_type, :noticeable_id, :_destroy ] ) end end diff --git a/app/controllers/rich_text_asset_mentions_controller.rb b/app/controllers/rich_text_asset_mentions_controller.rb index d93ed4d889..d5fbedbd37 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 = Draper.undecorate(GlobalID::Locator.locate_signed(params[:sgid])) + record = GlobalID::Locator.locate_signed(params[:sgid]) - unless record.respond_to?(:rich_text_assets) + unless record render json: [] and return end diff --git a/app/controllers/workshop_variations_controller.rb b/app/controllers/workshop_variations_controller.rb index 9f42ac9ffe..38c9d126db 100644 --- a/app/controllers/workshop_variations_controller.rb +++ b/app/controllers/workshop_variations_controller.rb @@ -123,6 +123,7 @@ 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/decorators/event_registration_decorator.rb b/app/decorators/event_registration_decorator.rb index 82374fdae6..2987881005 100644 --- a/app/decorators/event_registration_decorator.rb +++ b/app/decorators/event_registration_decorator.rb @@ -1,28 +1,4 @@ class EventRegistrationDecorator < ApplicationDecorator - # The CE lifecycle badge, shared by every surface that shows CE status (the - # registrants index, the CE callout, the CE card on the registration edit page). - # One progression: Requested → License # needed → $X due → Pending → Issued. - # Pending is blue, Issued is green, every actionable/in-progress state is amber. - CeBadge = Struct.new(:label, :icon, :classes, keyword_init: true) - - CE_BADGE_CLASSES = { - green: "bg-green-50 text-green-700 border-green-200", - blue: "bg-blue-50 text-blue-700 border-blue-200", - amber: "bg-amber-50 text-amber-700 border-amber-200" - }.freeze - - # Nil when CE isn't in play (so the index can show a "Create" affordance instead). - # `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_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 - - ce_badge("#{h.dollars_from_cents(ce_amount_due_cents)} due", "fa-solid fa-dollar-sign", :amber) - end - def title name end @@ -51,10 +27,4 @@ def default_display_image return event.primary_asset.file if event.respond_to?(:primary_asset) && event.primary_asset&.file&.attached? "theme_default.png" end - - private - - def ce_badge(label, icon, color) - CeBadge.new(label: label, icon: icon, classes: CE_BADGE_CLASSES.fetch(color)) - end end diff --git a/app/frontend/javascript/controllers/attendance_status_controller.js b/app/frontend/javascript/controllers/attendance_status_controller.js index 007416009b..62a4a6b5d5 100644 --- a/app/frontend/javascript/controllers/attendance_status_controller.js +++ b/app/frontend/javascript/controllers/attendance_status_controller.js @@ -4,30 +4,9 @@ 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", "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() - } + static targets = ["select", "icon", "dirty"] + static values = { colors: Object, icons: Object, initial: String } update() { const status = this.selectTarget.value diff --git a/app/frontend/javascript/controllers/ce_credit_requested_controller.js b/app/frontend/javascript/controllers/ce_credit_requested_controller.js new file mode 100644 index 0000000000..546ecea9c7 --- /dev/null +++ b/app/frontend/javascript/controllers/ce_credit_requested_controller.js @@ -0,0 +1,62 @@ +import { Controller } from "@hotwired/stimulus" + +// Drives the CE-credit box on the registration form. Mounted on the
+// so it can tint the whole card to signal save state: amber while the choice is +// pending (changed but not yet saved), the continuing-education theme color once +// it matches the stored "on" value, neutral gray when stored as off — the +// "Requested" toggle and the card share that color. While "Requested" is on it +// reveals the CE details (license number + hours) and keeps the "Provided" badge +// and amount-owed total ($rate × hours) in sync as the admin edits them. +export default class extends Controller { + static targets = ["checkbox", "track", "details", "license", "licenseBadge", "hours", "amount"] + static values = { initial: Boolean, rate: Number } + + connect() { + this.refresh() + } + + refresh() { + const checked = this.checkboxTarget.checked + const pending = checked !== this.initialValue + + this.trackTarget.classList.toggle("bg-amber-500", pending) + this.trackTarget.classList.toggle("bg-teal-600", checked && !pending) + this.trackTarget.classList.toggle("bg-gray-200", !checked && !pending) + + this.element.classList.toggle("bg-amber-50", pending) + this.element.classList.toggle("border-amber-200", pending) + this.element.classList.toggle("bg-teal-50", checked && !pending) + this.element.classList.toggle("border-teal-200", checked && !pending) + this.element.classList.toggle("bg-white", !checked && !pending) + this.element.classList.toggle("border-gray-200", !checked && !pending) + + if (this.hasDetailsTarget) this.detailsTarget.classList.toggle("hidden", !checked) + + this.updateLicenseBadge() + this.updateAmount() + } + + updateLicenseBadge() { + if (!this.hasLicenseTarget || !this.hasLicenseBadgeTarget) return + + const provided = this.licenseTarget.value.trim().length > 0 + this.licenseBadgeTarget.classList.toggle("bg-teal-50", provided) + this.licenseBadgeTarget.classList.toggle("text-teal-700", provided) + this.licenseBadgeTarget.classList.toggle("bg-gray-100", !provided) + this.licenseBadgeTarget.classList.toggle("text-gray-500", !provided) + this.licenseBadgeTarget.innerHTML = provided + ? ' Provided' + : ' Not provided' + } + + updateAmount() { + if (!this.hasHoursTarget || !this.hasAmountTarget) return + + const hours = Math.max(0, parseInt(this.hoursTarget.value, 10) || 0) + const owed = hours * this.rateValue + // Mirror the dollars_from_cents helper: drop the cents when the amount is a + // whole number of dollars, keep two decimals otherwise. + const fractionDigits = Number.isInteger(owed) ? 0 : 2 + this.amountTarget.textContent = `$${owed.toLocaleString("en-US", { minimumFractionDigits: fractionDigits, maximumFractionDigits: 2 })}` + } +} diff --git a/app/frontend/javascript/controllers/ce_license_picker_controller.js b/app/frontend/javascript/controllers/ce_license_picker_controller.js deleted file mode 100644 index 40f56de15d..0000000000 --- a/app/frontend/javascript/controllers/ce_license_picker_controller.js +++ /dev/null @@ -1,20 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// CE license picker. When the admin chooses a license from the dropdown, fill the -// type / number / state / expiry fields from that license (carried on each option's -// data attributes) so the fields describe the selected license. Choosing "Create -// new license" clears them for a fresh entry. -export default class extends Controller { - static targets = ["select", "kind", "number", "state", "expires"] - - sync() { - const option = this.selectTarget.selectedOptions[0] - if (!option) return - - const isNew = this.selectTarget.value === "new" - this.kindTarget.value = isNew ? "" : (option.dataset.kind || "") - this.numberTarget.value = isNew ? "" : (option.dataset.number || "") - this.stateTarget.value = isNew ? "" : (option.dataset.state || "") - this.expiresTarget.value = isNew ? "" : (option.dataset.expires || "") - } -} diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 346eda3b17..c1bffb8daa 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -33,9 +33,6 @@ application.register("callout-preview", CalloutPreviewController) import CarouselController from "./carousel_controller" application.register("carousel", CarouselController) -import CeLicensePickerController from "./ce_license_picker_controller" -application.register("ce-license-picker", CeLicensePickerController) - import ChipSelectController from "./chip_select_controller" application.register("chip-select", ChipSelectController) @@ -141,9 +138,15 @@ application.register("rhino-source", RhinoSourceController) import ScholarshipPreviewController from "./scholarship_preview_controller" application.register("scholarship-preview", ScholarshipPreviewController) +import ScholarshipRequestedController from "./scholarship_requested_controller" +application.register("scholarship-requested", ScholarshipRequestedController) + import ScholarshipStatusToggleController from "./scholarship_status_toggle_controller" application.register("scholarship-status-toggle", ScholarshipStatusToggleController) +import CeCreditRequestedController from "./ce_credit_requested_controller" +application.register("ce-credit-requested", CeCreditRequestedController) + import SearchBoxController from "./search_box_controller" application.register("search-box", SearchBoxController) diff --git a/app/frontend/javascript/controllers/scholarship_requested_controller.js b/app/frontend/javascript/controllers/scholarship_requested_controller.js new file mode 100644 index 0000000000..37ed8d11e5 --- /dev/null +++ b/app/frontend/javascript/controllers/scholarship_requested_controller.js @@ -0,0 +1,34 @@ +import { Controller } from "@hotwired/stimulus" + +// Colors the "Requested" toggle and its whole card to signal save state: amber +// while the choice is pending (changed but not yet saved with the registration +// form), the scholarship theme color once it matches the stored "on" value, and +// neutral gray when stored as off. Mounted on the
so this.element is +// the card to tint. When a scholarship has already been awarded the toggle is +// gone — there's no checkbox to read, so we leave the server-rendered tint. +export default class extends Controller { + static targets = ["checkbox", "track"] + static values = { initial: Boolean } + + connect() { + this.refresh() + } + + refresh() { + if (!this.hasCheckboxTarget) return + + const checked = this.checkboxTarget.checked + const pending = checked !== this.initialValue + + this.trackTarget.classList.toggle("bg-amber-500", pending) + this.trackTarget.classList.toggle("bg-fuchsia-600", checked && !pending) + this.trackTarget.classList.toggle("bg-gray-200", !checked && !pending) + + this.element.classList.toggle("bg-amber-50", pending) + this.element.classList.toggle("border-amber-200", pending) + this.element.classList.toggle("bg-fuchsia-50", checked && !pending) + this.element.classList.toggle("border-fuchsia-200", checked && !pending) + this.element.classList.toggle("bg-white", !checked && !pending) + this.element.classList.toggle("border-gray-200", !checked && !pending) + } +} diff --git a/app/frontend/javascript/controllers/section_filter_controller.js b/app/frontend/javascript/controllers/section_filter_controller.js index fb3c441e60..1055151363 100644 --- a/app/frontend/javascript/controllers/section_filter_controller.js +++ b/app/frontend/javascript/controllers/section_filter_controller.js @@ -15,10 +15,8 @@ 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" || sectionRole === "continuing_education") + el.classList.toggle("hidden", sectionRole === "scholarship" || sectionRole === "bulk_payment") } }) } diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5b38db935e..0d1ffdec7d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -19,17 +19,6 @@ def credited_author_link(record, **link_options) FORM_LABEL_TAGS = %w[br a p span strong b em i u h1 h2 h3 h4 h5 h6 ul ol li font].freeze FORM_LABEL_ATTRIBUTES = %w[href target rel style size color face].freeze - # Tint a section-header icon (the rounded square in a card header) with its - # domain theme colour only when that section actually holds data, falling back - # to a muted grey otherwise. Lets the registration edit cards signal at a glance - # which sections are populated — purely server-rendered, no JS. Pass the domain - # key (see DomainTheme::COLORS) and a boolean for whether the section has data. - def section_icon_class(domain, active) - return "bg-gray-100 text-gray-400" unless active - - "#{DomainTheme.bg_class_for(domain, intensity: 100)} #{DomainTheme.text_class_for(domain, intensity: 600)}" - end - # Render a form field name / header with a safe subset of HTML allowed. # Uses Rails' SafeListSanitizer, which strips dangerous URL schemes # (e.g. javascript:) from href and CSS-scrubs the style attribute (dropping @@ -486,19 +475,6 @@ def signed_dollars_from_cents(cents) MoneyFormatter.signed_dollars_from_cents(cents) end - # A plain number without insignificant trailing zeros (e.g. CE hours): 6.0 → "6", - # 1.5 → "1.5". Nil for a blank input so callers can render their own placeholder. - def plain_number(number) - NumberFormatter.plain(number) - end - - # Timezone hint phrased for whoever the form is about: second person when you're - # editing your own settings, third person ("User") when an admin edits someone else. - def timezone_visibility_hint(user) - subject = user == current_user ? "You" : "User" - "#{subject} will see times and dates in this timezone." - end - def navbar_bg_class if staging_environment? && !params[:nav_bg_primary].present? "bg-red-600" diff --git a/app/helpers/event_helper.rb b/app/helpers/event_helper.rb index 6e61efefa4..25ac9f890a 100644 --- a/app/helpers/event_helper.rb +++ b/app/helpers/event_helper.rb @@ -1,9 +1,10 @@ module EventHelper # The "please specify" placeholder for an option label, or nil when the option - # does not reveal a free-text box. Canonical config lives on FormField (shared - # with answer validation). - def specify_placeholder(label) - FormField.specify_placeholder_for(label) + # does not reveal a free-text box. Pass the field to honor placeholders scoped + # to one field (e.g. the CE question's "Yes"). Canonical config lives on + # FormField (shared with answer validation). + def specify_placeholder(label, field = nil) + FormField.specify_placeholder_for(label, field&.field_identifier) end # True when a stored answer selects the given specify option: the bare label, diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 1c6d6fe302..2871b44ba8 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -53,6 +53,7 @@ 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/helpers/rhino_editor_helper.rb b/app/helpers/rhino_editor_helper.rb index abfad9bcc5..ccb59c7d2b 100644 --- a/app/helpers/rhino_editor_helper.rb +++ b/app/helpers/rhino_editor_helper.rb @@ -1,10 +1,9 @@ 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 = object.public_send(rhino_attr) + value = form.object.public_send(rhino_attr) label_tag = if label.present? @@ -71,7 +70,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: object.persisted? ? object.to_sgid.to_s : nil + model_sgid: form.object.persisted? ? form.object.to_sgid.to_s : nil } ) diff --git a/app/models/concerns/pay_charge_extensions.rb b/app/models/concerns/pay_charge_extensions.rb index a5dc7d44dd..54cadffe59 100644 --- a/app/models/concerns/pay_charge_extensions.rb +++ b/app/models/concerns/pay_charge_extensions.rb @@ -12,9 +12,7 @@ def create_external_processor_payment return if ExternalProcessorPayment.exists?(pay_charge_id: id) return unless object["paid"] == true - if (ce_registration_id = metadata["ce_registration_id"]) - create_ce_payment(ce_registration_id) - elsif (event_registration_id = metadata["event_registration_id"]) + if (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) @@ -56,36 +54,6 @@ 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/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index f2ba66ae96..16d697dee8 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -16,7 +16,6 @@ class ContinuingEducationRegistration < ApplicationRecord validates :hours, numericality: { greater_than_or_equal_to: 0 } validates :cost_cents, numericality: { greater_than_or_equal_to: 0 } validate :license_belongs_to_registrant - validate :cost_not_below_allocations, on: :update # Payment interface (allocations_sum / paid_in_full? / remaining_cost / …) comes from # Registerable, driven by this record's own cost_cents column. @@ -30,56 +29,6 @@ def certificate_available? event.end_date&.past? && event_registration.attended? && paid_in_full? end - # Point this registration at a license for the typed type + number. `license_id` - # comes from the form's license picker (shown when the registrant holds licenses): - # * an existing license → correct it in place from the typed fields (the picker - # populates those fields from whichever license is selected, so editing one - # and saving updates that license); - # * "new" → create a brand-new license for the person from the typed fields; - # * blank (no picker) → correct the current license in place (filling a blank - # placeholder or fixing a typo). - # In the "new"/"blank" cases an existing license already holding the typed number - # wins, to avoid duplicating or colliding on the unique (person, number) index. - # Does not save the registration itself — callers persist it alongside their other - # changes. - def assign_license(number:, kind:, issuing_state: nil, expires_on: nil, license_id: nil) - number = number.to_s.strip.presence - kind = kind.to_s.strip.presence - issuing_state = issuing_state.to_s.strip.presence - expires_on = expires_on.presence - current = professional_license - person = event_registration.registrant - - if license_id.present? && license_id != "new" - picked = person.professional_licenses.find_by(id: license_id) - if picked - picked.update!(number: number, kind: kind, issuing_state: issuing_state, expires_on: expires_on) - self.professional_license = picked - return - end - end - - # Licenses are identified by (kind, number), so only an exact kind + number - # match is a duplicate to link to rather than create. - match = person.professional_licenses.where.not(id: current&.id).find_by(number: number, kind: kind) if number - if match - self.professional_license = match - else - target = license_id == "new" ? person.professional_licenses.new : current - target.update!(number: number, kind: kind, issuing_state: issuing_state, expires_on: expires_on) - self.professional_license = target - end - end - - # Human-readable payment status, mirroring EventRegistration#payment_status_label. - # CE has no "intends to pay" concept (that's an event-access affordance), so the - # middle state is a genuine partial payment instead. - def payment_status_label - return "Paid" if paid_in_full? - return "Partial" if partially_paid? - "Due" - end - private # Snapshot the hours offered and total cost from the event when they aren't set @@ -96,11 +45,4 @@ def license_belongs_to_registrant errors.add(:professional_license, "must belong to the registrant") end - - def cost_not_below_allocations - return if cost_cents.blank? - return if cost_cents.to_i >= allocations_sum - - errors.add(:cost_cents, "can't be less than the amount already allocated (#{MoneyFormatter.dollars_from_cents(allocations_sum)})") - end end diff --git a/app/models/event.rb b/app/models/event.rb index d65fb096aa..44480dead4 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -154,10 +154,6 @@ 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 5ffb4c88b8..4f2f6bddae 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 continuing_education].freeze + ROLES = %w[registration scholarship bulk_payment].freeze validates :role, presence: true, inclusion: { in: ROLES } validates :form_id, uniqueness: { scope: [ :event_id, :role ] } @@ -10,5 +10,4 @@ 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 b65673f356..0d0ffff9a3 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -41,11 +41,16 @@ class EventRegistration < ApplicationRecord # only the first event.day_count of them are shown on the Onboarding tab. DAY_FIELDS = (1..5).map { |day| "completed_day_#{day}" }.freeze + # Default price the registrant owes per requested continuing-education hour. + # The CE summary on the registration form multiplies it by ce_hours_requested. + CE_HOURLY_RATE_DOLLARS = 25 + # Validations validates :registrant_id, uniqueness: { scope: :event_id } validates :event_id, presence: true validates :status, inclusion: { in: ATTENDANCE_STATUSES }, allow_nil: false validates :slug, uniqueness: true, allow_nil: true + validates :ce_hours_requested, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true # Scopes scope :registrant_name, ->(registrant_name) { joins(:registrant).where( @@ -143,34 +148,18 @@ class EventRegistration < ApplicationRecord else all end } - # Filter by CE state. All derived (no stored CE status): payment (requested/paid) - # is computed from allocations vs cost like the registration's own payment state; - # issued/not_issued read the certificate delivery; needs_license is a CE - # registration sitting on a placeholder license. + # Mirrors ReminderRecipientFilter#matches_ce_status?. The "license"/"hours" + # sub-statuses only make sense for someone who requested CE credit, so they're + # gated on it. "paid" has no CE-specific payment record yet, so it falls back + # to the registrant being paid in full. scope :ce_status, ->(value) { - paid_sql = <<~SQL.squish - COALESCE((SELECT SUM(a.amount) FROM allocations a - WHERE a.allocatable_type = 'ContinuingEducationRegistration' - AND a.allocatable_id = cer.id), 0) >= cer.cost_cents - SQL - condition = - case value - when "needs_license" then "pl.number IS NULL" - when "paid" then paid_sql - when "requested" then "NOT (#{paid_sql})" - when "issued" then "cer.certificate_sent_at IS NOT NULL" - when "not_issued" then "cer.certificate_sent_at IS NULL" - end - next all if condition.blank? - - where(<<~SQL.squish) - EXISTS ( - SELECT 1 FROM continuing_education_registrations cer - JOIN professional_licenses pl ON pl.id = cer.professional_license_id - WHERE cer.event_registration_id = event_registrations.id - AND (#{condition}) - ) - SQL + case value + when "requested" then where(ce_credit_requested: true) + when "license_not_provided" then where(ce_credit_requested: true).where(ce_license_number: [ nil, "" ]) + when "hours_not_provided" then where(ce_credit_requested: true).where("COALESCE(ce_hours_requested, 0) <= 0") + when "paid" then where(ce_credit_requested: true).merge(paid_in_full) + else all + end } scope :comment_status, ->(value) { commented = Comment.where(commentable_type: "EventRegistration").select(:commentable_id) @@ -254,9 +243,6 @@ def self.search_by_params(params) .where(event_registration_organizations: { organization_id: params[:organization_id] }) .distinct end - if params[:ce_status].present? - registrations = registrations.ce_status(params[:ce_status]) - end registrations end @@ -379,84 +365,26 @@ def cost_cents event.cost_cents end - # 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_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? - end - continuing_education_registrations.exists? - end - - def ce_hours_total - if ce_registrations_in_memory? - return continuing_education_registrations.sum { |c| c.hours.to_d } - end - continuing_education_registrations.sum(:hours) + # True when the registrant has supplied a CE license number. + def ce_license_provided? + ce_license_number.present? end # A short label summarizing the registrant's CE credit standing, matching the - # ce_status filter buckets. Nil when CE wasn't requested, so callers can render - # a placeholder. "Needs license" takes precedence (it's the actionable state), - # then certificate issuance, then payment. + # ce_status filter buckets. Nil when CE credit was not requested, so callers + # can render a placeholder. "Incomplete" takes precedence over "Paid" because + # a missing license/hours is the actionable state regardless of payment. def ce_status_label - return unless ce_registered? - return "Needs license" unless ce_license_provided? - return "Issued" if continuing_education_registrations.all? { |c| c.certificate_sent_at.present? } - return "Paid" if ce_paid_in_full? + return unless ce_credit_requested? + return "Incomplete" if !ce_license_provided? || ce_hours_requested.to_i <= 0 + return "Paid" if paid_in_full? "Requested" end + # What the registrant owes for their requested CE hours, in cents, at the + # default hourly rate. Zero when no hours were requested. def ce_amount_owed_cents - if ce_registrations_in_memory? - return continuing_education_registrations.sum { |c| c.cost_cents.to_i } - end - continuing_education_registrations.sum(:cost_cents) - end - - # Outstanding CE balance across this registration's CE registrations — cost net - # of payments/discounts, floored at zero. The "$X due" the registrant still owes; - # drops to zero once paid. remaining_cost is computed (not a column), so this sums - # in Ruby. - def ce_amount_due_cents - continuing_education_registrations.sum { |c| c.remaining_cost } - end - - # True only when every CE registration has a known license number on file. - def ce_license_provided? - return false unless ce_registered? - - continuing_education_registrations.all? { |c| c.professional_license&.number_known? } - end - - # True when CE is registered and every CE registration's certificate has been - # issued (sent) — the terminal state of the CE lifecycle. - def ce_certificate_issued? - return false unless ce_registered? - - continuing_education_registrations.all? { |c| c.certificate_sent_at.present? } - end - - # True when a CE registration exists and every one is fully paid. - def ce_paid_in_full? - return false unless ce_registered? - - continuing_education_registrations.all?(&:paid_in_full?) - end - - # License numbers on file across this registration's CE registrations. - def ce_license_numbers - continuing_education_registrations.filter_map { |c| c.professional_license&.number } - end - - # Read CE registrations from the in-memory collection rather than the DB when - # it's already loaded or this registration isn't persisted (e.g. the unsaved - # sample-ticket preview builds CE registrations without saving). - def ce_registrations_in_memory? - continuing_education_registrations.loaded? || new_record? + ce_hours_requested.to_i * CE_HOURLY_RATE_DOLLARS * 100 end def joinable? diff --git a/app/models/form_field.rb b/app/models/form_field.rb index 78bb92bdce..c8ee2feba6 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -66,6 +66,17 @@ class FormField < ApplicationRecord "Foundation/Funder" => "Please list the name of the foundation/funder" }.freeze + # Specify options scoped to a single field by its field_identifier, rather than + # to an option label everywhere it appears (SPECIFY_OPTION_PLACEHOLDERS). The + # CE-interest question's "Yes" reveals a "How many CE hours?" box that only + # makes sense there — a bare "Yes" anywhere else must stay a plain choice. The + # typed value folds into the answer as "Yes: ", which the registration + # service parses onto EventRegistration#ce_hours_requested. The identifier + # matches EventRegistrationServices::PublicRegistration::CE_CREDIT_INTEREST_IDENTIFIER. + FIELD_SPECIFY_OPTION_PLACEHOLDERS = { + "ce_credit_interest" => { "Yes" => "How many CE hours?" } + }.freeze + # Fallback character ceilings applied when a free-form field has no explicit # max_characters set. This is a safety net against pathological submissions # (megabyte answers that bloat the DB and break admin/email rendering), not a @@ -332,17 +343,20 @@ def dynamic_categories # The "please specify" placeholder for an option label, or nil when the option # does not reveal a free-text box. Matched case- and whitespace-insensitively - # against SPECIFY_OPTION_PLACEHOLDERS. - def self.specify_placeholder_for(label) + # against SPECIFY_OPTION_PLACEHOLDERS, then (when a field_identifier is given) + # against that field's FIELD_SPECIFY_OPTION_PLACEHOLDERS, which wins. + def self.specify_placeholder_for(label, field_identifier = nil) normalized = label.to_s.strip return if normalized.blank? - SPECIFY_OPTION_PLACEHOLDERS.find { |key, _| key.casecmp?(normalized) }&.last + field_scoped = FIELD_SPECIFY_OPTION_PLACEHOLDERS[field_identifier]&.find { |key, _| key.casecmp?(normalized) }&.last + field_scoped || SPECIFY_OPTION_PLACEHOLDERS.find { |key, _| key.casecmp?(normalized) }&.last end - # True when an option label reveals a free-text "please specify" box. - def self.specify_option?(label) - specify_placeholder_for(label).present? + # True when an option label reveals a free-text "please specify" box, optionally + # scoped to a field via its field_identifier. + def self.specify_option?(label, field_identifier = nil) + specify_placeholder_for(label, field_identifier).present? end # True when an option label is the generic free-text "Other" choice. Unlike @@ -361,7 +375,7 @@ def self.other_option?(label) # their options, so they expose no specify option. def specify_option_labels option_names = dynamic_options? ? dynamic_option_names : answer_options.map(&:name) - option_names.select { |name| FormField.specify_option?(name) } + option_names.select { |name| FormField.specify_option?(name, field_identifier) } end # The names of this field's dynamically-sourced options (Sector or Category), diff --git a/app/models/person.rb b/app/models/person.rb index 66c166e3ae..ee685ad2f6 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -100,10 +100,6 @@ class Person < ApplicationRecord reject_if: proc { |attrs| attrs["organization_id"].blank? } accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? } accepts_nested_attributes_for :notifications, allow_destroy: true, reject_if: proc { |attrs| attrs["email_subject"].blank? } - # A blank row (number + kind + state + expiry all empty) is ignored rather than - # creating an empty license. - accepts_nested_attributes_for :professional_licenses, allow_destroy: true, - reject_if: proc { |attrs| attrs.slice("number", "kind", "issuing_state", "expires_on").values.all?(&:blank?) } # Search Cop include SearchCop @@ -217,13 +213,6 @@ def full_name "#{first_name} #{last_name}" end - # Distinct professional-license types (e.g. "LMFT, LCSW"), shown as a credential - # suffix after the person's name on their profile (replaces the old free-text - # credentials field). Nil when no licensed types are on file. - def license_credentials - professional_licenses.filter_map { |license| license.kind.presence&.strip }.uniq.join(", ").presence - end - def full_name_with_email email = preferred_email name = full_name diff --git a/app/models/professional_license.rb b/app/models/professional_license.rb index 59971b92e8..0f07ff774c 100644 --- a/app/models/professional_license.rb +++ b/app/models/professional_license.rb @@ -7,15 +7,7 @@ class ProfessionalLicense < ApplicationRecord has_many :continuing_education_registrations, dependent: :destroy - # Deleting a license cascades to its CE registrations (and their allocations), - # so refuse to remove one that has any CE registration at all — regardless of - # whether those registrations carry payments. prepend so this runs before the - # dependent: :destroy cascade below clears those registrations. - before_destroy :prevent_destroy_with_ce, prepend: true - - # A license is identified by its kind + number, so the same number under two - # different kinds is allowed; only a duplicate (kind, number) pair is rejected. - validates :number, uniqueness: { scope: [ :person_id, :kind ] }, allow_nil: true + validates :number, uniqueness: { scope: :person_id }, allow_nil: true # Find the person's license for this number, or create it. A blank number # resolves to the person's single placeholder license (number nil) so a CE @@ -38,26 +30,4 @@ def expired? def name [ kind, number ].compact_blank.join(" ").presence || "License (number pending)" end - - # True when this license has no CE registrations and so can be deleted without - # cascading away CE history — used to gate the remove control on the person edit - # form. Once any CE registration exists (paid or not), the license is permanent. - def removable? - continuing_education_registrations.none? - end - - # Has this license been used for CE credit? Drives edit gating: a license tied to - # any CE registration is locked to admins (see ProfessionalLicensePolicy). - def used_for_ce? - continuing_education_registrations.exists? - end - - private - - def prevent_destroy_with_ce - return if removable? - - errors.add(:base, "Can't remove a license with CE registrations.") - throw :abort - end end diff --git a/app/policies/continuing_education_registration_policy.rb b/app/policies/continuing_education_registration_policy.rb deleted file mode 100644 index 07f9eda7db..0000000000 --- a/app/policies/continuing_education_registration_policy.rb +++ /dev/null @@ -1,6 +0,0 @@ -class ContinuingEducationRegistrationPolicy < ApplicationPolicy - # Registrants manage their license via the public CE callout - not this policy. - alias_rule :new?, :create?, :edit?, :update?, :destroy?, :toggle_certificate?, to: :manage? - - def manage? = admin? -end diff --git a/app/policies/professional_license_policy.rb b/app/policies/professional_license_policy.rb deleted file mode 100644 index d70e434edc..0000000000 --- a/app/policies/professional_license_policy.rb +++ /dev/null @@ -1,18 +0,0 @@ -class ProfessionalLicensePolicy < ApplicationPolicy - alias_rule :new?, :create?, :update?, to: :edit? - - # Locked to admins once any CE registration is attached. - def edit? - admin? || (owner? && !record.used_for_ce?) - end - - def destroy? - edit? && record.removable? - end - - private - - def owner? - authenticated? && record.person&.user == user - end -end diff --git a/app/services/default_ticket_callouts.rb b/app/services/default_ticket_callouts.rb index bc9417938f..11c9b52aee 100644 --- a/app/services/default_ticket_callouts.rb +++ b/app/services/default_ticket_callouts.rb @@ -2,17 +2,17 @@ # event. Run on event create and, for events that predate this, lazily on edit — # so existing events heal the first time they're saved, with no data backfill. # -# All nine built-ins always seed. Their initial visibility (hidden/published) is -# derived from the event's config via each definition's `hidden` proc: published -# by default on facilitator trainings, hidden (unchecked) on everything else. -# Admins toggle visibility per row from the editor. Seeding is idempotent so a -# re-run never clobbers admin edits. +# Each definition carries the callout's default content plus how its initial +# visibility (`hidden`) is derived from the event's config, using the same rules +# the code-defined cards used (see Event#show_*_callout?). Seeding is idempotent: +# a magic_key already present on the event is left untouched, so a re-run never +# clobbers admin edits. # -# Behavioral built-ins (payment, CE, scholarship, …) render live per-registration -# status through MagicTicketCallouts#card_for, which has its own guards (e.g. the -# payment card returns nil on a free event) so publishing a row on an -# non-applicable event is harmless. MagicTicketCallouts skips any card an event -# has already materialized, so the two paths never double-render. +# Only the content-and-resource cards (Handouts, FAQ) are materialized today; the +# stateful cards (payment, CE, scholarship, …) still render through +# MagicTicketCallouts until their per-registration behavior is keyed off the row. +# MagicTicketCallouts skips any card an event has already materialized, so the two +# paths never double-render. class DefaultTicketCallouts # Default FAQ content for the 2-day training, mirrored from the code-defined FAQ # page so a newly materialized card starts with the current copy. Admins edit it @@ -80,13 +80,13 @@ def initialize(event) end # Create any not-yet-present magic callouts for the event, appended in - # definition order after whatever already exists. All built-ins always seed; - # their initial visibility (hidden/published) is derived from the event's - # config via each definition's `hidden` proc. Seeding is idempotent so a re-run - # never clobbers admin edits. Returns the created rows. + # definition order after whatever already exists. A definition's optional + # `seed_if` gates whether it applies to this event (e.g. skip the + # videoconference card until the event has a link). Returns the created rows. def seed existing_keys = @event.registration_ticket_callouts.magic.pluck(:magic_key).to_set definitions.reject { |definition| existing_keys.include?(definition[:magic_key]) } + .select { |definition| definition[:seed_if].nil? || definition[:seed_if].call(@event) } .map { |definition| create(definition) } end @@ -146,7 +146,8 @@ def definitions callout_type: "action", icon_class: "fa-solid fa-credit-card", color_class: "orange", - hidden: ->(event) { !event.facilitator_training? } + hidden: ->(_event) { false }, + seed_if: ->(event) { event.cost_cents.to_i.positive? } }, { magic_key: "certificate", @@ -155,8 +156,8 @@ def definitions callout_type: "action", icon_class: "fa-solid fa-certificate", color_class: "green", - # Off by default except on facilitator trainings. When shown, it still only - # appears once the certificate unlocks (MagicTicketCallouts guards this). + # Off by default except on facilitator trainings; admins opt other events + # in. When shown, it still only appears once the certificate unlocks. hidden: ->(event) { !event.facilitator_training? } }, { @@ -166,32 +167,33 @@ def definitions callout_type: "action", icon_class: "fa-solid fa-award", color_class: "fuchsia", - hidden: ->(event) { !event.facilitator_training? } + hidden: ->(_event) { false }, + seed_if: ->(event) { event.scholarship_form.present? } }, { magic_key: "ce_hours", # Title/text seed from the event's CE columns (migrating existing content); # thereafter they live on the row like every other built-in. The row also - # carries the CE hours-offered/cost config. + # carries the CE hours-offered/cost config. Always seeded. title: ->(event) { event.ce_hours_details_label }, description: ->(event) { event.ce_hours_details }, subtitle: "Continuing education — requirements & how to request", callout_type: "action", icon_class: "fa-solid fa-graduation-cap", color_class: "teal", - hidden: ->(event) { !event.facilitator_training? } + hidden: ->(_event) { false } }, { magic_key: "event_details", # Title/text seed from the event's details columns (migrating existing - # content); thereafter they live on the row. + # content); thereafter they live on the row. Always seeded. title: ->(event) { event.event_details_label }, description: ->(event) { event.event_details }, subtitle: "Important info for this event — please read", callout_type: "reference", icon_class: "fa-solid fa-palette", color_class: "blue", - hidden: ->(event) { !event.facilitator_training? } + hidden: ->(_event) { false } }, { magic_key: "videoconference", @@ -200,10 +202,11 @@ def definitions callout_type: "action", icon_class: "fa-solid fa-video", color_class: "blue", - hidden: ->(event) { !event.facilitator_training? }, + hidden: ->(_event) { false }, # Drips onto the ticket a week before the event starts, replacing the old # hard-coded "one week prior" rule with a stored, editable date. - display_from: ->(event) { event.start_date - 7.days if event.start_date } + display_from: ->(event) { event.start_date - 7.days if event.start_date }, + seed_if: ->(event) { event.videoconference_url.present? } }, { magic_key: "forms", @@ -212,11 +215,12 @@ def definitions callout_type: "action", icon_class: "fa-solid fa-file-lines", color_class: "blue", - hidden: ->(event) { !event.facilitator_training? }, + hidden: ->(_event) { false }, # The W-9 is a removable linked resource, included by default only on paid # events (where a tax form applies); invoice/receipt stay dynamic on the # forms page. Admins add/remove it per event. - resources: -> { @event.cost_cents.to_i.positive? ? [ Resource.find_by(title: "W-9") ].compact : [] } + resources: -> { @event.cost_cents.to_i.positive? ? [ Resource.find_by(title: "W-9") ].compact : [] }, + seed_if: ->(event) { event.show_forms_callout? } }, { magic_key: "handouts", @@ -225,7 +229,7 @@ def definitions callout_type: "reference", icon_class: "fa-solid fa-folder-open", color_class: "blue", - hidden: ->(event) { !event.facilitator_training? }, + hidden: ->(event) { !event.show_handouts_callout? }, resources: -> { handout_resources } }, { @@ -236,7 +240,7 @@ def definitions icon_class: "fa-solid fa-circle-question", color_class: "blue", description: faq_html, - hidden: ->(event) { !event.facilitator_training? } + hidden: ->(event) { !event.show_faq_callout? } } ] end diff --git a/app/services/event_registration_readiness.rb b/app/services/event_registration_readiness.rb index 4b42953aff..509dae0aec 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_registered? && !ce_paid? + registration.ce_credit_requested? && !ce_paid? end def ce_license_missing? - registration.ce_registered? && !registration.ce_license_provided? + registration.ce_credit_requested? && !registration.ce_license_provided? end def ce_certificate_pending? - registration.ce_registered? && !ce_certificate_sent? + registration.ce_credit_requested? && !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 1719e172da..ec56ebccc7 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -3,14 +3,10 @@ class PublicRegistration Result = Struct.new(:success?, :event_registration, :form_submission, :errors, keyword_init: true) # Well-known field_identifier of the "magic" CE question seeded onto the - # registration form. Answering it "Yes" creates a ContinuingEducationRegistration - # (hours come from the event). Kept here so the seed, service, and specs agree. + # registration form. Answering it "Yes" toggles the registration's + # ce_credit_requested flag. Kept here so the seed, service, and specs agree. CE_CREDIT_INTEREST_IDENTIFIER = "ce_credit_interest".freeze - # Well-known field_identifier of the CE license-number question. Its answer - # seeds the registrant's ProfessionalLicense. - CE_LICENSE_NUMBER_IDENTIFIER = "ce_license_number".freeze - # Well-known field_identifier of the "Additional forms" multi-select question. # Checking "Invoice" / "W-9" toggles the registration's invoice_requested / # w9_requested flags, which the digital ticket reads to surface those downloads. @@ -28,16 +24,13 @@ 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: {}, - continuing_education_form: nil, continuing_education_params: {}) + scholarship_form: nil, scholarship_params: {}) new(event:, registration_form:, form_params:, scholarship_requested:, person:, - scholarship_form:, scholarship_params:, - continuing_education_form:, continuing_education_params:).call + scholarship_form:, scholarship_params:).call end def initialize(event:, registration_form:, form_params:, scholarship_requested: false, person: nil, - scholarship_form: nil, scholarship_params: {}, - continuing_education_form: nil, continuing_education_params: {}) + scholarship_form: nil, scholarship_params: {}) @event = event @registration_form = registration_form @form_params = form_params @@ -45,8 +38,6 @@ 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 @@ -71,7 +62,7 @@ def call existing = @event.event_registrations.find_by(registrant: person) if existing existing.update!(scholarship_requested: true) if @scholarship_requested - create_ce_registration(existing, person) + existing.update!(ce_credit_requested: true, ce_hours_requested: ce_hours_requested) if ce_credit_requested? existing.update!(w9_requested: true) if w9_requested? existing.update!(invoice_requested: true) if invoice_requested? payment_method = field_value("payment_method")&.strip @@ -83,16 +74,13 @@ 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 event_registration = create_event_registration(person) - create_ce_registration(event_registration, person) connect_organization(event_registration, organization) submission = create_form_submission(person) save_scholarship_submission(person) - save_continuing_education_submission(person) send_notifications(event_registration) @@ -388,41 +376,33 @@ def create_event_registration(person) @event.event_registrations.create!( registrant: person, scholarship_requested: @scholarship_requested, + ce_credit_requested: ce_credit_requested?, + ce_hours_requested: ce_hours_requested, w9_requested: w9_requested?, invoice_requested: invoice_requested?, expected_payment_method: field_value("payment_method")&.strip.presence ) end - # Create the registrant's CE registration when they opt in, against a license - # found-or-created from the license-number answer (a placeholder license when - # none was given). Hours come from the event via the model. No-op when they - # didn't opt in or a CE registration already exists for this registration. - def create_ce_registration(event_registration, person) - return unless ce_credit_requested? - return if event_registration.continuing_education_registrations.exists? - - license = ProfessionalLicense.find_or_create_for(person: person, number: ce_license_number) - event_registration.continuing_education_registrations.create!(professional_license: license) + # The CE-interest answer, which "Yes" folds an hours specify box into as + # "Yes: ". Split off the leading label so a Yes/No check ignores the + # folded hours (see FormField::FIELD_SPECIFY_OPTION_PLACEHOLDERS). + def ce_answer_label + field_value(CE_CREDIT_INTEREST_IDENTIFIER).to_s.split(":", 2).first.to_s.strip end + # True when the registrant answered "Yes" to the seeded CE-interest question. def ce_credit_requested? - return false unless @continuing_education_form - - ce_field_value(CE_CREDIT_INTEREST_IDENTIFIER).to_s.strip.casecmp?("yes") + ce_answer_label.casecmp?("yes") end - def ce_license_number - 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 + # The CE hours typed into the "Yes" specify box, as a positive integer, or nil + # when CE was not requested or no valid hours were entered. + def ce_hours_requested + return unless ce_credit_requested? - @continuing_education_params[field.id.to_s] + hours = field_value(CE_CREDIT_INTEREST_IDENTIFIER).to_s.split(":", 2).last.to_s.strip.to_i + hours.positive? ? hours : nil end # The "Additional forms" question is a multi-select, so its submitted value is @@ -498,32 +478,6 @@ 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/event_revenue_report.rb b/app/services/event_revenue_report.rb index 06c3da3af1..fce5c6f85a 100644 --- a/app/services/event_revenue_report.rb +++ b/app/services/event_revenue_report.rb @@ -7,11 +7,12 @@ # Org subsidy = unfunded scholarships + discounts (cost the org absorbs). # Net = money in - org subsidy. # -# Projected CE is the CE fees owed across active registrants (each CE -# registration's own cost), flagged in the UI. It's counted in money in (and -# therefore net) per the report's definition, and treated as outstanding — CE -# payments collected aren't netted out here yet. +# Continuing education has no payment tracking yet, so projected CE is an +# estimate ($25 x hours requested), flagged in the UI. It's counted in money in +# (and therefore net) per the report's definition. class EventRevenueReport + CE_HOURLY_RATE_CENTS = EventRegistration::CE_HOURLY_RATE_DOLLARS * 100 + # Raw per-event component figures, with the bucket totals derived from them. Row = Struct.new( :event, @@ -23,8 +24,8 @@ class EventRevenueReport :registration_outstanding_cents, keyword_init: true ) do - # Fees actually collected: registration payments plus CE paid. CE payments - # aren't netted here yet, so the CE portion is $0 today. + # Fees actually collected: registration payments plus CE paid. CE has no + # payment tracking, so the CE portion is $0 today. def fees_cents registration_payments_cents end @@ -152,14 +153,11 @@ def build_row(event) ) end - # Projected CE revenue: the CE fees owed across this event's active registrants - # — each ContinuingEducationRegistration's own cost. Treated as outstanding, not - # netted against any CE payments collected. + # Projected CE revenue across this event's active registrants: $25 per + # requested hour. Not collected yet — there's no CE payment tracking. def ce_projected_cents_for(event) - ContinuingEducationRegistration - .joins(:event_registration) - .where(event_registrations: { event_id: event.id, status: EventRegistration::ACTIVE_STATUSES }) - .sum(:cost_cents) + hours = event.event_registrations.active.where(ce_credit_requested: true).sum(:ce_hours_requested) + hours.to_i * CE_HOURLY_RATE_CENTS end def to_dollars(cents) diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb index dd5e727c10..5144d3dfa0 100644 --- a/app/services/form_builder_service.rb +++ b/app/services/form_builder_service.rb @@ -11,7 +11,6 @@ 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 }, @@ -52,7 +51,6 @@ 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], @@ -67,7 +65,6 @@ 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" ], @@ -95,10 +92,6 @@ 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?", @@ -130,7 +123,6 @@ 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], @@ -146,8 +138,7 @@ def self.applicable_section_keys(role) case role when "scholarship" then key == :scholarship when "bulk_payment" then key == :bulk_payment - when "continuing_education" then key == :continuing_education - else key != :scholarship && key != :bulk_payment && key != :continuing_education + else key != :scholarship && key != :bulk_payment end end end @@ -300,7 +291,6 @@ 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, @@ -506,25 +496,6 @@ 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/services/magic_ticket_callouts.rb b/app/services/magic_ticket_callouts.rb index 51a74d0cf2..fbb06ec3ea 100644 --- a/app/services/magic_ticket_callouts.rb +++ b/app/services/magic_ticket_callouts.rb @@ -174,9 +174,12 @@ def scholarship_badge(awarded, tasks_outstanding) # they have, becoming a reference card once requested with hours and a license # number on file. Shown when the event offers CE or the registrant asked for it. def ce_hours_card - return unless registration.ce_registered? - complete = registration.ce_license_provided? - Card.new(icon_class: "fa-solid fa-graduation-cap", color: "teal", + return unless registration.ce_credit_requested? + complete = registration.ce_hours_requested.present? && registration.ce_license_provided? + Card.new(icon_class: "fa-solid fa-graduation-cap", + # Amber while hours/license are still needed (action needed), teal + # once complete. + color: complete ? "teal" : "amber", title: event.ce_hours_details_label, subtitle: ce_hours_subtitle, href: registration_ce_path(registration.slug), @@ -188,10 +191,8 @@ def ce_hours_card end def ce_hours_subtitle - total = registration.ce_hours_total - return "Continuing education credit" unless total.positive? - - "#{NumberFormatter.plain(total)} hours" + return "#{registration.ce_hours_requested} hours" if registration.ce_hours_requested.present? + "Continuing education credit" end # Teal "$X due" once hours + license are on file and money is owed; otherwise an @@ -210,9 +211,11 @@ def ce_hours_badge(complete) amount_cents.positive? ? "#{amount} · #{needed}" : needed end - # Hours are set by the event now, so the only thing a requesting registrant can - # still be missing is their license number. def ce_missing_text + missing_hours = registration.ce_hours_requested.blank? + missing_license = !registration.ce_license_provided? + return "Hours & license number needed" if missing_hours && missing_license + return "Hours needed" if missing_hours "License number needed" end diff --git a/app/services/number_formatter.rb b/app/services/number_formatter.rb deleted file mode 100644 index 85e4cecaec..0000000000 --- a/app/services/number_formatter.rb +++ /dev/null @@ -1,15 +0,0 @@ -# Centralizes how a plain number renders without insignificant trailing zeros, so -# models, services, helpers, and decorators all format the same way (e.g. CE hours: -# 6.0 => "6", 1.5 => "1.5", 0.25 => "0.25"). View code should call the -# `plain_number` helper, which delegates here; models and other POROs (no -# view-helper access) call NumberFormatter directly. Mirrors MoneyFormatter. -class NumberFormatter - # The number as a string with trailing zeros dropped. Nil for a blank input so - # callers can render their own placeholder. - def self.plain(number) - return if number.blank? - - value = number.to_f - value == value.to_i ? value.to_i.to_s : value.to_s - end -end diff --git a/app/services/scholarship_application.rb b/app/services/scholarship_application.rb index acae7071d3..481b30726b 100644 --- a/app/services/scholarship_application.rb +++ b/app/services/scholarship_application.rb @@ -97,8 +97,8 @@ def answers end def scholarship_answer?(field) - question_field_ids.include?(field&.id) || - (field&.field_identifier.present? && question_field_identifiers.include?(field.field_identifier)) + question_field_ids.include?(field.id) || + (field.field_identifier.present? && question_field_identifiers.include?(field.field_identifier)) end def submissions diff --git a/app/views/allocations/index.html.erb b/app/views/allocations/index.html.erb index 031032faea..b2733d8fc1 100644 --- a/app/views/allocations/index.html.erb +++ b/app/views/allocations/index.html.erb @@ -22,10 +22,6 @@ <%= link_to edit_event_registration_path(@allocatable), class: "text-sm text-gray-500 hover:text-gray-700" do %> Registration <% end %> - <% elsif @allocatable.is_a?(ContinuingEducationRegistration) %> - <%= link_to edit_continuing_education_registration_path(@allocatable), class: "text-sm text-gray-500 hover:text-gray-700" do %> - CE registration - <% end %> <% elsif @allocatable.respond_to?(:event) %> <%= link_to registrants_event_row_path(@allocatable.event, @allocatable.id), class: "text-sm text-gray-500 hover:text-gray-700" do %> Registrants @@ -60,25 +56,6 @@ - <% elsif @allocatable.is_a?(ContinuingEducationRegistration) %> -
-

- - Allocations -

- -
- - Only this CE registration’s allocations - - <%= link_to edit_continuing_education_registration_path(@allocatable), class: "text-sm font-semibold text-gray-800 hover:underline" do %> - <%= @allocatable.event_registration.registrant.full_name %> - <% end %> - - <%= @allocatable.event_registration.event.title %> · <%= @allocatable.event_registration.event.decorate.times(display_day: true, display_date: true) %> - -
-
<% else %> <%= render "shared/event_page_header", title: "Allocations", @@ -88,7 +65,7 @@ person: (@allocatable.registrant if @allocatable.respond_to?(:registrant)) %> <% end %> - <% if @allocatable.is_a?(EventRegistration) || @allocatable.is_a?(ContinuingEducationRegistration) %> + <% if @allocatable.is_a?(EventRegistration) %>
<%= button_to allocation_form_payments_path(type: "CashPayment", allocatable_sgid: params[:allocatable_sgid]), class: "btn btn-primary-outline inline-flex items-center gap-1.5" do %> diff --git a/app/views/continuing_education_registrations/_details_section.html.erb b/app/views/continuing_education_registrations/_details_section.html.erb deleted file mode 100644 index 573c9c4d21..0000000000 --- a/app/views/continuing_education_registrations/_details_section.html.erb +++ /dev/null @@ -1,84 +0,0 @@ -<%# CE details form fields (license type/number/state/expiry + hours + cost), - shared by the new and edit pages. Renders inside a form_with block — `f` is the - builder. Locals: f, license (may be nil on new), ce_registration, event. %> -<% licenses = ce_registration.event_registration.registrant.professional_licenses.order(:kind, :number) %> -
"> -
- - - -

CE details

-
- -
- <%# License picker — shown only when the registrant holds more than one license, - to choose which to load into the fields (or "Create new license"). With zero - or one license there's no picker and the fields edit/create it directly. %> - <% if licenses.many? %> -
- - <%= f.select :professional_license_id, - options_for_select( - licenses.map { |l| [ l.name, l.id, { "data-kind" => l.kind, "data-number" => l.number, "data-state" => l.issuing_state, "data-expires" => l.expires_on&.iso8601 } ] } + - [ [ "Create new license", "new" ] ], - license&.id - ), - {}, - id: "continuing_education_registration_professional_license_id", - data: { "ce-license-picker-target": "select", action: "ce-license-picker#sync" }, - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> -

Pick a license to load it into the fields below, or "Create new license" to add one.

-
- <% end %> - -
-
-
- - <%= f.text_field :license_kind, value: license&.kind, id: "continuing_education_registration_license_kind", - placeholder: "e.g. LCSW", data: { "ce-license-picker-target": "kind" }, - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> -
-
- - <%= f.text_field :license_number, value: license&.number, id: "continuing_education_registration_license_number", - placeholder: "e.g. 12345", data: { "ce-license-picker-target": "number" }, - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> -
-
- - <%= f.select :license_issuing_state, - us_states, - { selected: license&.issuing_state, include_blank: "Select a state" }, - id: "continuing_education_registration_license_issuing_state", - data: { "ce-license-picker-target": "state" }, - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> -
-
- - <%= f.date_field :license_expires_on, value: license&.expires_on, id: "continuing_education_registration_license_expires_on", - data: { "ce-license-picker-target": "expires" }, - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> -
-
- <% if license&.number_known? %> -

Editing the type or number corrects the license already on file.

- <% else %> -

No license on file yet — add the type and number here.

- <% end %> -
- -
- - <%= f.number_field :hours, value: plain_number(ce_registration.hours), min: 0, step: 0.25, - id: "continuing_education_registration_hours", - class: "w-32 rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> - - <% if event.ce_hours_offered.present? %> -

- Event default: <%= plain_number(event.ce_hours_offered) || "—" %> hrs -

- <% end %> -
-
-
diff --git a/app/views/continuing_education_registrations/_payment_history.html.erb b/app/views/continuing_education_registrations/_payment_history.html.erb deleted file mode 100644 index b6be489a9e..0000000000 --- a/app/views/continuing_education_registrations/_payment_history.html.erb +++ /dev/null @@ -1,52 +0,0 @@ -<%# ---- CE Allocations — a cost/allocated/due summary. The per-allocation line - items live on the linked allocations page; this card shows totals only. %> -<% cost_cents = ce_registration.cost_cents.to_i %> -<% paid_cents = ce_registration.allocations_sum %> -<% due_cents = cost_cents - paid_cents %> -
-
- - - -

CE payments and allocations

-
- -
-
-
-
CE cost
- <% if params[:admin] == "true" %> -
- <%= number_field_tag "continuing_education_registration[cost_dollars]", - (ce_registration.cost_cents.to_d / 100), - min: 0, step: 0.01, - class: "w-28 rounded-lg border border-gray-300 px-2 py-1 text-sm tabular-nums text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> -
- <% else %> -
<%= dollars_from_cents(cost_cents) %>
- <% end %> -
- <%= link_to allocations_path(allocatable_sgid: ce_registration.to_sgid.to_s, return_to: "ce_registration"), - target: "_blank", rel: "noopener", - class: "rounded-lg border border-gray-300 bg-gray-50 px-4 py-3 block transition hover:border-gray-400 hover:shadow-sm" do %> -
Amount allocated
-
<%= dollars_from_cents(paid_cents) %>
- <% end %> - <% amount_due = due_cents > 0 %> - <%= link_to allocations_path(allocatable_sgid: ce_registration.to_sgid.to_s, return_to: "ce_registration"), - target: "_blank", rel: "noopener", - class: "rounded-lg border px-4 py-3 block transition hover:shadow-sm #{amount_due ? "border-amber-300 bg-amber-50 hover:border-amber-400" : "border-gray-300 bg-gray-50 hover:border-gray-400"}" do %> -
Amount due
-
"> - <% if amount_due %> - - <%= dollars_from_cents(due_cents) %> - <% else %> - - Nothing - <% end %> -
- <% end %> -
-
-
diff --git a/app/views/continuing_education_registrations/edit.html.erb b/app/views/continuing_education_registrations/edit.html.erb deleted file mode 100644 index f7ea5c96f0..0000000000 --- a/app/views/continuing_education_registrations/edit.html.erb +++ /dev/null @@ -1,80 +0,0 @@ -<% content_for(:page_bg_class, "admin-only bg-blue-100") %> -<% registration = @ce_registration.event_registration %> -<% license = @ce_registration.professional_license %> - -
- <%# Top bar: back link + secondary links, matching the scholarship edit page %> -
- <%= link_to edit_event_registration_path(registration), class: "text-sm text-gray-500 hover:text-gray-700" do %> - Registration - <% end %> -
- <%# Admin jump to the registrant-facing CE callout (what the registrant sees / - where they enter their own license). Opens in a new tab so the edit context - is kept; matches the sky admin-link convention. %> - <%= link_to registration_ce_path(registration.slug, return_to: "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 %> - - Registrant's CE page - - <% end %> - <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700" %> -
-
- - <%= render "shared/event_page_header", - title: "Edit CE registration", - icon: "fa-solid fa-certificate", - color: :continuing_education, - event: registration.event, - person: registration.registrant %> - -
- <%= form_with model: @ce_registration, url: continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), - method: :patch, id: "ce_registration_form", data: { turbo: false } do |f| %> - <%= render "details_section", f: f, license: license, ce_registration: @ce_registration, event: registration.event %> - <%= render "payment_history", ce_registration: @ce_registration %> - <% end %> - -
-
- - - -

Certificate

-
- -
-

- <% if @ce_registration.certificate_sent_at.present? %> - Issued <%= @ce_registration.certificate_sent_at.to_date.to_fs(:long) %> - <% else %> - Not issued yet - <% end %> -

- <%= button_to toggle_certificate_continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), - method: :patch, class: "rounded-lg border px-3 py-1.5 text-sm font-medium #{@ce_registration.certificate_sent_at.present? ? "border-gray-300 text-gray-600 hover:bg-gray-50" : "border-teal-300 bg-teal-50 text-teal-700 hover:bg-teal-100"} cursor-pointer" do %> - <%= @ce_registration.certificate_sent_at.present? ? "Mark not issued" : "Mark certificate issued" %> - <% end %> -
-
- - <%# ---- Footer actions ---- %> -
- <%= button_to continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), - method: :delete, form: { data: { turbo_confirm: "Remove this CE registration?" } }, - class: "btn btn-danger-outline" do %> - - Delete - <% end %> - -
- <%= link_to "Cancel", edit_event_registration_path(registration), class: "btn btn-secondary-outline" %> - <%# Submits the CE details form above (which the certificate section sits outside of). %> - -
-
-
- - <%= render "shared/audit_info", resource: @ce_registration %> -
diff --git a/app/views/continuing_education_registrations/new.html.erb b/app/views/continuing_education_registrations/new.html.erb deleted file mode 100644 index 1ce90560dd..0000000000 --- a/app/views/continuing_education_registrations/new.html.erb +++ /dev/null @@ -1,36 +0,0 @@ -<% content_for(:page_bg_class, "admin-only bg-blue-100") %> -<% registration = @event_registration %> -<% event = registration.event %> - -
- <%# Top bar: back link + Home, matching the CE edit page. %> -
- <%= link_to edit_event_registration_path(registration), class: "text-sm text-gray-500 hover:text-gray-700" do %> - Registration - <% end %> - <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700" %> -
- - <%= render "shared/event_page_header", - title: "Add CE registration", - icon: "fa-solid fa-certificate", - color: :continuing_education, - event: event, - person: registration.registrant %> - -
- <%= form_with model: @ce_registration, url: continuing_education_registrations_path, - method: :post, id: "ce_registration_form", data: { turbo: false } do |f| %> - <%= hidden_field_tag :allocatable_sgid, registration.to_sgid.to_s %> - <%= render "details_section", f: f, license: @ce_registration.professional_license, ce_registration: @ce_registration, event: event %> - <% end %> - - <%# ---- Footer actions ---- %> -
-
- <%= link_to "Cancel", edit_event_registration_path(registration), class: "btn btn-secondary-outline" %> - -
-
-
-
diff --git a/app/views/event_registrations/_ce_status_badge.html.erb b/app/views/event_registrations/_ce_status_badge.html.erb deleted file mode 100644 index 2ae94fe973..0000000000 --- a/app/views/event_registrations/_ce_status_badge.html.erb +++ /dev/null @@ -1,23 +0,0 @@ -<%# The canonical CE status badge. Locals: - registration – the EventRegistration - href – wrap the pill in a link to this path (optional) - simulate_paid – preview the post-payment state (CE callout's ?admin=true) - Renders nothing when CE isn't in play; callers handle that case (e.g. the - registrants index shows a "Create" affordance instead). %> -<% href = local_assigns[:href] %> -<% badge = registration.decorate.ce_status_badge(simulate_paid: local_assigns.fetch(:simulate_paid, false)) %> -<% if badge %> - <% base = "inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border text-xs font-medium #{badge.classes}" %> - <% if href %> - <%= link_to href, class: "#{base} px-5 py-0.5 transition hover:opacity-80 hover:shadow-sm", data: { turbo_frame: "_top" } do %> - - <%= badge.label %> - - <% end %> - <% else %> - - - <%= badge.label %> - - <% end %> -<% end %> diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb deleted file mode 100644 index 04f3d56270..0000000000 --- a/app/views/event_registrations/_continuing_education.html.erb +++ /dev/null @@ -1,57 +0,0 @@ -<%# ---- 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

-
- -
- <% unless ce_registration %> - <%# 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", - 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 783acbabec..f14d44e550 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -31,15 +31,7 @@ } %> <% 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) %> @@ -84,7 +76,11 @@
<% end %> -
+
Registration status <%= link_to registration_ticket_path(f.object.slug), - 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", + class: "group inline-flex items-center gap-1.5 font-medium #{DomainTheme.text_class_for(:event_registrations, intensity: 600)} hover:underline", target: "_blank", data: { turbo_frame: "_top" } do %> @@ -133,29 +129,10 @@ title: "#{form_name} — submitted #{ts.strftime('%b %-d, %Y')}", data: { turbo_frame: "_top" } do %> - View form submission<%= " ##{i + 1}" if submissions.size > 1 %> + View submission<%= " ##{i + 1}" if submissions.size > 1 %> <% 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 %> -
-
@@ -164,21 +141,16 @@ <% connected_org_ids = f.object.organizations.map(&:id) %> <% addable_orgs = active_orgs.reject { |org| connected_org_ids.include?(org.id) } %>
-
+
- + - <%# 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

+

Registration 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? %> @@ -273,7 +304,7 @@ <%# ---- Comments ---- %>
- +

Registration comments

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

Shout out

@@ -336,7 +367,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 73708d1ef9..0986c050eb 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 665f0cf253..dd80e30f14 100644 --- a/app/views/event_registrations/_scholarship.html.erb +++ b/app/views/event_registrations/_scholarship.html.erb @@ -2,15 +2,18 @@ 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"), @@ -38,7 +43,7 @@ <% if scholarship.grant&.funder_name.present? %>

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

<% 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? %> @@ -65,11 +68,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 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", + class: "inline-flex items-center gap-1.5 text-sm font-medium #{DomainTheme.text_class_for(:scholarships, intensity: 600)} hover:underline", target: "_blank", rel: "noopener" do %> - + Add scholarship <% end %> diff --git a/app/views/events/_builtin_callout_card.html.erb b/app/views/events/_builtin_callout_card.html.erb deleted file mode 100644 index 77b4e6f7d6..0000000000 --- a/app/views/events/_builtin_callout_card.html.erb +++ /dev/null @@ -1,28 +0,0 @@ -<%# An editable built-in registration-ticket callout (CE hours, art supplies), - rendered inside the event form's callouts section and tinted in the colour the - card uses on the ticket. locals: f (event form builder), card - (MagicTicketCallouts::EditorCard), label_field/content_field (event attribute - symbols), title_placeholder, content_help, content_placeholder. %> -<% theme = card.theme %> -
- -
-
- Built in - <%= card.visibility %> -
-
-
- <%= f.label label_field, "Title", class: "block text-xs font-medium text-gray-600 mb-0.5" %> - <%= f.text_area label_field, rows: 2, placeholder: title_placeholder, - class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> -
-
- <%= f.label content_field, "Callout page text", class: "block text-xs font-medium text-gray-600 mb-0.5" %> -

<%= content_help %>

- <%= f.text_area content_field, rows: 3, placeholder: content_placeholder, - class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm font-mono" %> -
-
-
-
diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 0180b5f4c0..f67d22fb92 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -8,7 +8,6 @@
-
@@ -23,10 +22,8 @@ "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", @@ -41,14 +38,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 %> @@ -61,83 +58,97 @@ 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 %> -
- -
-

End

+ required: true %> +
+ <% if f.object.errors[:start_date].any? %> +

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

+ <% end %> +
-
<%= f.text_field :end_date_date, +
+

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 %> -
- -
-

Registration closed

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

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

+ <% end %> +
- <%= f.text_field :registration_close_date_date, +
+

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") %>
+ <% 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.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", @@ -146,23 +157,26 @@ "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: { @@ -170,8 +184,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, @@ -181,27 +195,26 @@ 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.", @@ -210,33 +223,30 @@ 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", } %> -
- <%# 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 %> +
+
@@ -244,32 +254,29 @@
<% 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 @@ -279,57 +286,45 @@ 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 %>
@@ -337,174 +332,114 @@
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 %> -
+ <% if @scholarship_forms.any? %> +
+ <% if @scholarship_forms.size == 1 %> + <% single_form = @scholarship_forms.first %> + -

Add a scholarship form so registrants can request financial assistance.

- <% if @event.scholarship_form.present? %> - <%= link_to new_event_public_registration_path(@event, scholarship_requested: true), + Enable scholarship application + +

Add a scholarship form so registrants can request financial assistance.

+ <% if @event.scholarship_form.present? %> + <%= link_to new_event_public_registration_path(@event, scholarship_requested: true), class: "inline-flex items-center gap-1 text-xs text-blue-700 hover:text-blue-900 underline mt-1", target: "_blank" do %> - - Preview scholarship form - <% end %> + Preview scholarship form <% end %> - <% else %> - - <% default_scholarship_id = if resubmitted + <% end %> + <% else %> + + <% default_scholarship_id = if resubmitted params.dig(:event, :scholarship_form_id).presence&.to_i elsif @event.scholarship_form @event.scholarship_form.id end %> - -

Select a scholarship form for registrants to request financial assistance.

- <% end %> -
- <% end %> - - <% if @bulk_payment_forms.any? %> -
- <% if @bulk_payment_forms.size == 1 %> - <% single_form = @bulk_payment_forms.first %> -
+ <% end %> + <% if @bulk_payment_forms.any? %> +
+ <% if @bulk_payment_forms.size == 1 %> + <% single_form = @bulk_payment_forms.first %> + -

Let one person register and pay for a group of attendees at once.

- <% if @event.bulk_payment_form.present? %> - <%= link_to new_event_bulk_payment_path(@event), + Enable bulk payment + +

Let one person register and pay for a group of attendees at once.

+ <% if @event.bulk_payment_form.present? %> + <%= link_to new_event_bulk_payment_path(@event), class: "inline-flex items-center gap-1 text-xs text-blue-700 hover:text-blue-900 underline mt-1", target: "_blank" do %> - - Preview bulk payment page - <% end %> + Preview bulk payment page <% end %> - <% else %> - - <% default_bulk_id = if resubmitted + <% end %> + <% else %> + + <% default_bulk_id = if resubmitted params.dig(:event, :bulk_payment_form_id).presence&.to_i elsif @event.bulk_payment_form @event.bulk_payment_form.id end %> - -

Select a bulk payment form for group registrations.

- <% end %> -
- <% end %> - - <% if @continuing_education_forms&.any? %> -
- <% if @continuing_education_forms.size == 1 %> - <% single_form = @continuing_education_forms.first %> - <%= check_box_tag "event[continuing_education_form_id]", single_form.id, - resubmitted ? params.dig(:event, :continuing_education_form_id).present? : @event.continuing_education_form.present?, - class: "peer rounded border-gray-300" %> - Enable continuing education -

Allow registrants to request CE hours.

- -

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

- <% if @event.continuing_education_form.present? %> - <%= link_to new_event_public_registration_path(@event), - class: "inline-flex items-center gap-1 text-xs text-blue-700 hover:text-blue-900 underline mt-1", - target: "_blank" do %> - - Preview CE form - <% end %> + - - - <% @continuing_education_forms.each do |cf| %> - - <% end %> - -

Select a continuing education form for registrants to request CE hours.

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

Select a bulk payment form for group registrations.

+ <% end %> +
+ <% end %>
@@ -518,11 +453,9 @@ 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 @@ -534,121 +467,74 @@ <%= 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. %>
<% else %> diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb index 564dd204d3..107ea464be 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"], ["Continuing Education", "continuing_education"]], + [["General", ""], ["Registration", "registration"], ["Scholarship", "scholarship"], ["Bulk Payment", "bulk_payment"]], { 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 f24898f771..2cee1b8fd9 100644 --- a/app/views/forms/new.html.erb +++ b/app/views/forms/new.html.erb @@ -43,7 +43,6 @@ -
@@ -58,12 +57,7 @@
<% FormBuilderService::SECTIONS.each do |key, section| %> - <% section_role = case key - when :scholarship then "scholarship" - when :bulk_payment then "bulk_payment" - when :continuing_education then "continuing_education" - else "other" - end %> + <% section_role = key == :scholarship ? "scholarship" : key == :bulk_payment ? "bulk_payment" : "other" %> <% field_names = FormBuilderService.section_field_names(key) %>
diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index e2c8ff349f..dc99406a5e 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -351,6 +351,11 @@ 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" } %>
@@ -370,23 +375,6 @@
-
-
- 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" %> -
-
-
@@ -414,7 +402,7 @@ collection: us_time_zone_fundamentals, selected: user_form.object.time_zone.presence || "Pacific Time (US & Canada)", include_blank: false, - hint: timezone_visibility_hint(user_form.object), + hint: "Event times and other dates will be shown in this timezone.", 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 a6ae611b70..4857fe4d07 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -64,7 +64,7 @@

- <%= @person.name %><% if @person.profile_show_credentials? && @person.license_credentials.present? %>, <%= @person.license_credentials %><% end %> + <%= @person.name %><% if @person.credentials.present? && @person.profile_show_credentials? %>, <%= @person.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 deleted file mode 100644 index 5d6ee69ee0..0000000000 --- a/app/views/professional_licenses/_professional_license_fields.html.erb +++ /dev/null @@ -1,82 +0,0 @@ -<%# 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 c478953e97..27a3311abb 100644 --- a/app/views/scholarships/edit.html.erb +++ b/app/views/scholarships/edit.html.erb @@ -36,17 +36,6 @@ <% 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 3b2f00607c..ce76bbb7b8 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: timezone_visibility_hint(f.object), + hint: "Event times and other dates will be shown in this timezone.", input_html: { class: "w-full" }, wrapper_html: { class: "w-full" } %> <% end %> diff --git a/app/views/workshop_variations/_form.html.erb b/app/views/workshop_variations/_form.html.erb index b7c14b83c4..d5a5f690ff 100644 --- a/app/views/workshop_variations/_form.html.erb +++ b/app/views/workshop_variations/_form.html.erb @@ -105,21 +105,18 @@ <% 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: 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: { - controller: "remote-select", - remote_select_model_value: "person" - } - } %> +
+ <%= 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" } } %> +

<% end %>
diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 785b764f14..7d8b4112e0 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -15,7 +15,7 @@ "type": "controller", "class": "EventsController", "method": "create", - "line": 458, + "line": 123, "file": "app/controllers/events_controller.rb", "rendered": { "name": "events/show", @@ -34,63 +34,6 @@ ], "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, @@ -106,7 +49,7 @@ "type": "controller", "class": "EventsController", "method": "create", - "line": 458, + "line": 123, "file": "app/controllers/events_controller.rb", "rendered": { "name": "events/show", @@ -132,7 +75,7 @@ "check_name": "Redirect", "message": "Possible unprotected redirect", "file": "app/controllers/people_controller.rb", - "line": 148, + "line": 119, "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, @@ -150,49 +93,49 @@ }, { "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": "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": "Cross-Site Scripting", @@ -209,7 +152,7 @@ "type": "controller", "class": "EventsController", "method": "create", - "line": 458, + "line": 123, "file": "app/controllers/events_controller.rb", "rendered": { "name": "events/show", @@ -235,7 +178,7 @@ "check_name": "CrossSiteScripting", "message": "Unescaped model attribute", "file": "app/views/notifications/show.html.erb", - "line": 196, + "line": 190, "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", "code": "Notification.find(params[:id]).email_body_html", "render_path": [ @@ -261,7 +204,42 @@ 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.5" + "brakeman_version": "8.0.4", + "updated": "2026-06-19 10:05:46 -0400" } diff --git a/config/routes.rb b/config/routes.rb index 00eea12ffe..b107a16572 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -84,9 +84,6 @@ 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 - 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 @@ -120,9 +117,6 @@ 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 deleted file mode 100644 index 029e38859d..0000000000 --- a/db/migrate/20260629122601_remove_ce_columns_from_event_registrations.rb +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 8d294a7fc9..0000000000 --- a/db/migrate/20260629173817_add_ce_requested_to_event_registrations.rb +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index fb0886729c..0000000000 --- a/db/migrate/20260630133153_remove_credentials_from_people.rb +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 856a7c646a..0000000000 --- a/db/migrate/20260630220947_scope_professional_license_uniqueness_to_kind.rb +++ /dev/null @@ -1,10 +0,0 @@ -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/migrate/20260711193528_remove_ce_requested_from_event_registrations.rb b/db/migrate/20260711193528_remove_ce_requested_from_event_registrations.rb deleted file mode 100644 index c4165fb9dd..0000000000 --- a/db/migrate/20260711193528_remove_ce_requested_from_event_registrations.rb +++ /dev/null @@ -1,15 +0,0 @@ -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/migrate/20260712183112_unique_magic_key_event_index.rb b/db/migrate/20260712183112_unique_magic_key_event_index.rb deleted file mode 100644 index 9ac54ae310..0000000000 --- a/db/migrate/20260712183112_unique_magic_key_event_index.rb +++ /dev/null @@ -1,6 +0,0 @@ -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 ee633774d4..05981cec33 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_12_183112) do +ActiveRecord::Schema[8.1].define(version: 2026_07_09_184214) 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,6 +479,9 @@ 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.datetime "certificate_sent_at" t.string "checkout_session_id" t.boolean "completed_day_1", default: false, null: false @@ -965,6 +968,7 @@ 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" @@ -1030,7 +1034,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", "kind", "number"], name: "index_professional_licenses_on_person_kind_number", unique: true + t.index ["person_id", "number"], name: "index_professional_licenses_on_person_and_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 @@ -1102,7 +1106,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", unique: true + t.index ["event_id", "magic_key"], name: "index_registration_ticket_callouts_on_event_id_and_magic_key" 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 +1381,7 @@ t.bigint "item_id", null: false t.string "item_type", null: false t.text "object", size: :long - t.text "object_changes", size: :long + t.text "object_changes" t.string "whodunnit" t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" end diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index 19a9959749..3986c176ab 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -37,20 +37,11 @@ ).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 @@ -164,6 +155,71 @@ .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 +# 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 @@ -211,7 +267,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 - payment additional_forms consent + continuing_education 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 @@ -713,11 +769,10 @@ # 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, ce_license_number: "LMFT 90210" }, + { person: amy_person, status: "registered", scholarship_requested: true, w9_requested: true, invoice_requested: true, ce_credit_requested: true }, { 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, ce_license_number: "LCSW 11223", ce_status: "issued" }, + { person: anna_g, status: "attended", ce_credit_requested: true, intends_to_pay: true }, { person: mario_j, status: "registered" }, { person: kim_d, status: "cancelled" }, { person: aisha_person, status: "registered", intends_to_pay: true } @@ -733,9 +788,8 @@ # 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, ce_license_number: "LPCC 44556" }, + { person: sarah_s, status: "registered", invoice_requested: true, ce_credit_requested: true }, { person: jessica_b, status: "registered", scholarship_requested: true, ce_credit_requested: true }, { person: angel_g, status: "registered" }, { person: linda_w, status: "no_show" } @@ -804,17 +858,9 @@ # 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) - # "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 49bb4337cf..837167f75e 100644 --- a/spec/decorators/event_registration_decorator_spec.rb +++ b/spec/decorators/event_registration_decorator_spec.rb @@ -74,78 +74,4 @@ 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 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/factories/event_forms.rb b/spec/factories/event_forms.rb index 277bcec170..3f17a9fb12 100644 --- a/spec/factories/event_forms.rb +++ b/spec/factories/event_forms.rb @@ -11,9 +11,5 @@ trait :scholarship do role { "scholarship" } end - - trait :continuing_education do - role { "continuing_education" } - end end end diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 8533528d3e..7b7c3681c1 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -53,21 +53,6 @@ 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 d06f3bbbe5..8426f5f677 100644 --- a/spec/helpers/event_helper_spec.rb +++ b/spec/helpers/event_helper_spec.rb @@ -19,6 +19,13 @@ 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/concerns/pay_charge_extensions_spec.rb b/spec/models/concerns/pay_charge_extensions_spec.rb index 831a77151e..bf9a82df2f 100644 --- a/spec/models/concerns/pay_charge_extensions_spec.rb +++ b/spec/models/concerns/pay_charge_extensions_spec.rb @@ -58,64 +58,6 @@ 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/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb index 1af98e0860..e90ab505d1 100644 --- a/spec/models/continuing_education_registration_spec.rb +++ b/spec/models/continuing_education_registration_spec.rb @@ -12,41 +12,6 @@ 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 @@ -221,28 +186,5 @@ 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 f947d0194a..6376fcef6b 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -323,68 +323,41 @@ def registration_with_scholarship end describe ".ce_status" do - 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) + 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) end end - # 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) } + let!(:missing_ce) { create(:event_registration, event: event, ce_credit_requested: true) } + let!(:no_ce) { create(:event_registration, event: event, ce_credit_requested: false) } - 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 '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) + 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) end - 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) + 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) end - 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) + 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) 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) + 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) end it "returns an unfiltered relation for unknown values" do - expect(EventRegistration.ce_status("bogus")).to include(paid_ce, requested_ce, no_ce) + expect(EventRegistration.ce_status("bogus")).to include(complete_ce, missing_ce, no_ce) end end @@ -745,66 +718,33 @@ 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 "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) + 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) end - it "is zero when no CE is requested" do + it "is zero when no hours are requested" do + reg.ce_hours_requested = nil 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 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) + 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 end end - 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 + 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 end end end diff --git a/spec/models/form_field_spec.rb b/spec/models/form_field_spec.rb index 4acece44a7..b937d699b0 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 "treats the CE question's 'Yes' as a plain choice (hours come from the event)" do + it "accepts the CE question's field-scoped 'Yes: ' specify answer" 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 ad43d763b6..9ed1565e62 100644 --- a/spec/models/professional_license_spec.rb +++ b/spec/models/professional_license_spec.rb @@ -45,60 +45,11 @@ end describe "validations" do - 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") + 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") 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 deleted file mode 100644 index 13668c33d1..0000000000 --- a/spec/policies/professional_license_policy_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index fab67ec8ed..0000000000 --- a/spec/requests/continuing_education_registrations_spec.rb +++ /dev/null @@ -1,178 +0,0 @@ -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) } - 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_registered?).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_registered?).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 68706fb538..a22e5714d6 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -34,17 +34,6 @@ 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 } @@ -316,6 +305,22 @@ 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 + patch event_registration_path(existing_registration), + params: { event_registration: { ce_credit_requested: "1" } } + + expect(existing_registration.reload.ce_credit_requested).to be(true) + end + + it "updates the CE hours and license number" do + patch event_registration_path(existing_registration), + params: { event_registration: { ce_credit_requested: "1", ce_hours_requested: "5", ce_license_number: "LIC-987" } } + + existing_registration.reload + expect(existing_registration.ce_hours_requested).to eq(5) + expect(existing_registration.ce_license_number).to eq("LIC-987") + 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: { @@ -447,18 +452,6 @@ 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 37126a731b..091d9a2029 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: { intends_to_pay: "1" } } + params: { return_to: "preview_reminder", event_registration: { ce_credit_requested: "1" } } expect(response).to redirect_to(preview_reminder_event_path(event)) end diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index f0545c9497..9ad496aa83 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -97,107 +97,4 @@ expect(response.body).to include(rails_blob_path(resource.downloadable_asset.file, only_path: true)) 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/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index 42ae4e42ca..7795d06709 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -217,8 +217,7 @@ } expect(event.registration_ticket_callouts.magic.pluck(:magic_key)).to contain_exactly( - "payment", "certificate", "scholarship", "ce_hours", "event_details", - "videoconference", "forms", "handouts", "faq" + "payment", "certificate", "ce_hours", "event_details", "forms", "handouts", "faq" ) end end diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index 22977a2fc5..d1d64e25b9 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -232,29 +232,6 @@ 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 @@ -315,139 +292,20 @@ 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, 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) + 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") get registration_ce_path(registration.slug) expect(response).to have_http_status(:success) - # 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("Requested") + expect(response.body).to include("Hours requested") 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 "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) + 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) get registration_ce_path(registration.slug) - 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) } - - 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.reload.ce_registered?).to be(true) - expect(response).to redirect_to(registration_ce_path(registration.slug)) + expect(response.body).to include("We don't have your license number on file yet.") end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 4ec4bc016c..4dcf8c40a3 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 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

") + 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

") 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 the event is not CE-eligible" do + it "redirects to the event when details are blank" do get ce_hours_event_path(event) expect(response).to redirect_to(event_path(event)) end @@ -1019,30 +1019,25 @@ def submit_agency_name(name) end describe "GET /events/:id/registrants with the CE status filter" do - 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(: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(:none_person) { create(:person, first_name: "Noce", last_name: "Person") } - # 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 - # 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)) + 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) reg end - # No CE registration. - let!(:none_reg) { create(:event_registration, event: event, registrant: none_person) } + 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 { sign_in admin } + before do + offer_ce!(event) + sign_in admin + end it "shows the CE status column and filter when the event offers CE" do get registrants_event_path(event) @@ -1059,23 +1054,29 @@ def submit_agency_name(name) expect(response.body).to include('data-column-toggle-group-value="ce"') end - it "filters to CE registrations not yet paid" do + it "filters to all CE requests" do get registrants_event_path(event, ce_status: "requested") - expect(response.body).to include("Needs License") - expect(response.body).not_to include("Paid Person") + expect(response.body).to include("Complete Person") + expect(response.body).to include("Missing Person") expect(response.body).not_to include("Noce Person") end - 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") + 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") end - it "filters to paid CE registrations" do + it "filters to paid CE requests" do get registrants_event_path(event, ce_status: "paid") - expect(response.body).to include("Paid Person") - expect(response.body).not_to include("Needs License") + expect(response.body).to include("Complete Person") + expect(response.body).not_to include("Missing Person") end it "does not crash on an invalid ce_status" do @@ -1083,9 +1084,9 @@ def submit_agency_name(name) expect(response).to have_http_status(:ok) end - it "hides CE entirely when the event does not offer CE" do + it "hides CE entirely when the event's registration form doesn't offer CE" do plain_event = create(:event) - create(:event_registration, event: plain_event) + create(:event_registration, event: plain_event, ce_credit_requested: true) get registrants_event_path(plain_event) expect(response.body).not_to include("CE status") end @@ -1093,7 +1094,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("Needs license") + expect(response.body).to include("Incomplete") end end @@ -1109,47 +1110,42 @@ def ce_chip_text Nokogiri::HTML(response.body).at_css('td[data-column-toggle-col="ce"]')&.text&.squish end - it "shows Create when no CE registration exists" do - create(:event_registration, event: event, registrant: person) + it "shows Create when CE was not requested" do + create(:event_registration, event: event, registrant: person, ce_credit_requested: false) get registrants_event_path(event) expect(ce_chip_text).to eq("Create") end - 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)) + 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("License # needed") + expect(ce_chip_text).to eq("Requested") end - 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)) + 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) + 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("$150 due") + expect(ce_chip_text).to eq("No license #") 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, + 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) + 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") + expect(ce_chip_text).to eq("Filed") end - it "shows Issued once the CE certificate has been delivered" do - reg = create(:event_registration, event: event, registrant: person) + it "shows Recipient when the CE balance is paid" do + reg = create(:event_registration, event: event, registrant: person, ce_credit_requested: true) 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("Issued") + expect(ce_chip_text).to eq("Recipient") end end @@ -1216,6 +1212,7 @@ 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/requests/honeybadger_context_spec.rb b/spec/requests/honeybadger_context_spec.rb deleted file mode 100644 index c74c54c61a..0000000000 --- a/spec/requests/honeybadger_context_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -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 diff --git a/spec/requests/people_professional_licenses_spec.rb b/spec/requests/people_professional_licenses_spec.rb deleted file mode 100644 index bf94569c00..0000000000 --- a/spec/requests/people_professional_licenses_spec.rb +++ /dev/null @@ -1,122 +0,0 @@ -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 f90b057041..61bc7bf565 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 { create(:professional_license, person: person, kind: "LCSW", number: "11223") } + before { person.update!(credentials: "LCSW") } context "when false" do before { person.update!(profile_show_credentials: false) } - it "hides the license credentials on own profile" do + it "hides 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 the license type as a suffix on own profile" do + it "shows credentials as a suffix on own profile" do sign_in owner_user get person_path(person) expect(response.body).to include("LCSW") end - it "shows the license type when admin views profile" do + it "shows credentials when admin views profile" do sign_in admin get person_path(person) expect(response.body).to include("LCSW") diff --git a/spec/requests/rich_text_asset_mentions_spec.rb b/spec/requests/rich_text_asset_mentions_spec.rb deleted file mode 100644 index 60b7977c90..0000000000 --- a/spec/requests/rich_text_asset_mentions_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -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 diff --git a/spec/services/default_ticket_callouts_spec.rb b/spec/services/default_ticket_callouts_spec.rb index 65a9430629..e46f60b23d 100644 --- a/spec/services/default_ticket_callouts_spec.rb +++ b/spec/services/default_ticket_callouts_spec.rb @@ -2,16 +2,27 @@ RSpec.describe DefaultTicketCallouts do describe "#seed" do - it "materializes all nine built-in callouts for every event" do - event = create(:event, cost_cents: 0) # free, no scholarship form, no VC link + it "materializes the built-in callouts the event's config warrants" do + # Factory event: has a cost (payment + forms), no scholarship form / VC link. + # CE hours, Event details, Certificate, Handouts, FAQ always seed. + event = create(:event) described_class.seed(event) keys = event.registration_ticket_callouts.magic.pluck(:magic_key) - expect(keys).to contain_exactly( - "payment", "certificate", "scholarship", "ce_hours", "event_details", - "videoconference", "forms", "handouts", "faq" - ) + expect(keys).to contain_exactly("payment", "certificate", "ce_hours", "event_details", "forms", "handouts", "faq") + end + + it "gates Payment, Scholarship, and Videoconference on config, but always seeds CE and Event details" do + form = create(:form) + event = create(:event, cost_cents: 0) + event.event_forms.create!(form:, role: "scholarship") + + described_class.seed(event) + + keys = event.registration_ticket_callouts.magic.pluck(:magic_key) + expect(keys).to include("scholarship", "ce_hours", "event_details") # CE/details always + expect(keys).not_to include("payment", "videoconference") # free, no VC link end it "seeds callouts in canonical ticket order" do @@ -27,33 +38,17 @@ ) end - it "seeds the Videoconference card with a drip date a week before start" do + it "seeds the Videoconference card, dripping a week before start, only once the event has a link" do event = create(:event, start_date: Date.new(2026, 9, 10)) described_class.seed(event) + expect(event.registration_ticket_callouts.find_by(magic_key: "videoconference")).to be_nil + + event.update!(videoconference_url: "https://example.com/zoom") + described_class.seed(event) vc = event.registration_ticket_callouts.find_by(magic_key: "videoconference") - expect(vc).to be_present expect(vc.display_from.to_date).to eq(Date.new(2026, 9, 3)) - expect(vc.hidden).to be(true) # non-training: hidden by default - end - - it "hides all callouts by default on a non-training event, shows all on a facilitator training" do - form = create(:form) - non_training = create(:event, cost_cents: 0) - non_training.event_forms.create!(form:, role: "scholarship") - training = create(:event, :publicly_visible, facilitator_training: true, cost_cents: 100, - videoconference_url: "https://example.com/vc") - training.event_forms.create!(form:, role: "scholarship") - - described_class.seed(non_training) - described_class.seed(training) - - non_training.registration_ticket_callouts.magic.each do |callout| - expect(callout.hidden).to be(true), "expected #{callout.magic_key} hidden on non-training" - end - training.registration_ticket_callouts.magic.each do |callout| - expect(callout.hidden).to be(false), "expected #{callout.magic_key} visible on training" - end + expect(vc.hidden).to be(false) end it "defaults Certificate off for a non-training event and on for a facilitator training" do @@ -84,8 +79,8 @@ described_class.seed(event) forms = event.registration_ticket_callouts.find_by(magic_key: "forms") - expect(forms).to be_present - expect(forms.resources).to be_empty # no W-9 on a free event + expect(forms).to be_present # seeded because it's a training + expect(forms.resources).to be_empty end it "migrates CE hours and event-details content from the event onto the row" do @@ -189,7 +184,7 @@ expect(event.registration_ticket_callouts.ordered.first).to eq(custom) expect(event.registration_ticket_callouts.ordered.map(&:magic_key).compact).to eq( - %w[payment certificate scholarship ce_hours event_details videoconference forms handouts faq] + %w[payment certificate ce_hours event_details forms handouts faq] ) end end diff --git a/spec/services/event_registration_readiness_spec.rb b/spec/services/event_registration_readiness_spec.rb index 213ea9515d..9ce53d2111 100644 --- a/spec/services/event_registration_readiness_spec.rb +++ b/spec/services/event_registration_readiness_spec.rb @@ -88,18 +88,16 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) end context "continuing education" do - it "flags CE as unpaid when CE credit is requested" do + it "flags CE as unpaid when CE credit is requested (no CE payment is tracked yet)" do pay(registration, 1000) - license = create(:professional_license, person: registration.registrant, number: "LIC123") - create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 5_000) + registration.update!(ce_credit_requested: true, ce_license_number: "LIC123") 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) - license = create(:professional_license, :placeholder, person: registration.registrant) - create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 5_000) + registration.update!(ce_credit_requested: true, ce_license_number: nil) expect(readiness.event_ready_issues).to include("CE license number missing") end @@ -154,9 +152,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") - create(:continuing_education_registration, event_registration: registration, - professional_license: create(:professional_license, person: registration.registrant, number: "LIC123")) + registration.update!(status: "attended", ce_credit_requested: true) expect(readiness.completion_issues).to include("CE certificate not sent") end @@ -275,8 +271,7 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) end it "names both certificates when CE credit was requested" do - create(:continuing_education_registration, event_registration: registration, - professional_license: create(:professional_license, person: registration.registrant, number: "LIC123")) + registration.update!(ce_credit_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 b5cf5164e5..f0b503d5ee 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -513,97 +513,86 @@ def register_with_agency_type(value) end end - 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 - - def ce_field_id(key) - ce_form.form_fields.find_by!(field_identifier: key).id.to_s + 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 end - def register_with_ce(answer, license: nil) + def register_with_ce(answer) params = base_form_params(first_name: "Cy", last_name: "Reed", email: "cy@example.com") - 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) + params = params.merge(ce_field.id.to_s => answer) unless answer.nil? + described_class.call(event: event, registration_form: form, form_params: params) end - it "creates a CE registration when answered Yes" do + it "toggles ce_credit_requested on when answered Yes" do result = register_with_ce("Yes") - expect(result.event_registration.continuing_education_registrations.count).to eq(1) + expect(result.event_registration.ce_credit_requested).to be true end - it "creates no CE registration when answered No" do + it "leaves ce_credit_requested off when answered No" do result = register_with_ce("No") - expect(result.event_registration.continuing_education_registrations).to be_empty + expect(result.event_registration.ce_credit_requested).to be false end - it "creates no CE registration when unanswered" do + it "leaves ce_credit_requested off when unanswered" do result = register_with_ce(nil) - expect(result.event_registration.continuing_education_registrations).to be_empty + expect(result.event_registration.ce_credit_requested).to be false end - it "creates a CE registration for an existing registration that answers Yes" do + it "toggles ce_credit_requested on 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) + existing = create(:event_registration, event: event, registrant: person, ce_credit_requested: false) result = register_with_ce("Yes") expect(result.event_registration).to eq(existing) - 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) - license = create(:professional_license, :placeholder, person: person) - create(:continuing_education_registration, event_registration: existing, professional_license: license) - - register_with_ce("Yes") - - expect(existing.reload.continuing_education_registrations.count).to eq(1) + expect(existing.reload.ce_credit_requested).to be true end - 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) + 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 "uses a placeholder license when no number is given" do + it "leaves ce_hours_requested nil when Yes carries no hours" do result = register_with_ce("Yes") - expect(result.event_registration.continuing_education_registrations.first.professional_license.number).to be_nil + expect(result.event_registration.ce_hours_requested).to be_nil end - 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) + 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 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") + 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 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 + 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") + + expect(existing.reload.ce_credit_requested).to be true + expect(existing.ce_hours_requested).to eq(4) end end diff --git a/spec/services/event_revenue_report_spec.rb b/spec/services/event_revenue_report_spec.rb index 24e7f50b1b..2420a17a88 100644 --- a/spec/services/event_revenue_report_spec.rb +++ b/spec/services/event_revenue_report_spec.rb @@ -10,16 +10,14 @@ let!(:reg1) { create(:event_registration, event: event, registrant: person1, status: "registered") } let!(:reg2) do - create(:event_registration, event: event, registrant: person2, status: "registered") + create(:event_registration, event: event, registrant: person2, status: "registered", + ce_credit_requested: true, ce_hours_requested: 3) 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") - create(:continuing_education_registration, event_registration: cancelled, cost_cents: 12_000) + cancelled = create(:event_registration, event: event, registrant: create(:person), status: "cancelled", + ce_credit_requested: true, ce_hours_requested: 6) 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 9cfedbbbb7..ea005e6fbe 100644 --- a/spec/services/magic_ticket_callouts_spec.rb +++ b/spec/services/magic_ticket_callouts_spec.rb @@ -85,25 +85,32 @@ 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) - 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 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) + registration.update!(ce_credit_requested: true) + expect(card_titles(registration)).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("amber")) # action needed + 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) + expect(complete.theme).to eq(DomainTheme.swatch("teal")) # no longer action needed expect(complete.subtitle).to eq("6 hours") expect(complete.badge).to eq("$150 due") expect(complete.badge_classes).to include("teal") @@ -146,12 +153,10 @@ 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_offered: 6, + ce_hours_details: "6 hours", 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) - license = create(:professional_license, :placeholder, person: registration.registrant) - create(:continuing_education_registration, event_registration: registration, professional_license: license) + registration.update!(status: "attended", scholarship_requested: true, ce_credit_requested: true) 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 deleted file mode 100644 index d1b4b6b650..0000000000 --- a/spec/services/number_formatter_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -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 b19f039392..140d5cdf31 100644 --- a/spec/services/reminder_recipient_filter_spec.rb +++ b/spec/services/reminder_recipient_filter_spec.rb @@ -167,42 +167,32 @@ def matched(params, registrations) end context "CE status" do - # Requested CE, supplied a license, and paid the CE balance in full. + # Requested CE, supplied both license and hours, and paid in full. let!(:complete) do registration(first_name: "Complete").tap do |r| - 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) + r.update!(ce_credit_requested: true, ce_license_number: "ABC123", ce_hours_requested: 3) + create(:allocation, allocatable: r, amount: 10_000) end end - # 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 + # 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) end - # No CE registration at all. - let!(:no_ce) { registration(first_name: "None") } - let(:regs) { [ complete, missing, unpaid_known, no_ce ] } - it "filters CE not yet paid" do - expect(matched({ ce_status: "requested" }, regs)).to eq([ missing.id, unpaid_known.id ].to_set) + 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) end - it "filters CE on a placeholder license" do - expect(matched({ ce_status: "needs_license" }, regs)).to eq([ missing.id ].to_set) + 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) end - it "filters CE paid (CE balance paid in full)" do + it "filters CE paid (requested CE and paid in full)" do expect(matched({ ce_status: "paid" }, regs)).to eq([ complete.id ].to_set) end end diff --git a/spec/system/ce_form_toggle_on_event_edit_spec.rb b/spec/system/ce_form_toggle_on_event_edit_spec.rb deleted file mode 100644 index 076c5ff170..0000000000 --- a/spec/system/ce_form_toggle_on_event_edit_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -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/event_registration_edit_spec.rb b/spec/system/event_registration_edit_spec.rb index fa3f0ba634..c245dce65a 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("Grantor:") + expect(page).to have_text("Funded by") expect(page).to have_link("Acme Foundation", href: organization_path(organization)) end end @@ -136,7 +136,7 @@ end end - it "shows no grantor text (just a spacer) when the scholarship has no grant" do + it "omits the funder line 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("Grantor:") + expect(page).to have_no_text("Funded by") end end end @@ -182,63 +182,6 @@ 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, diff --git a/spec/system/public_registration_form_submission_spec.rb b/spec/system/public_registration_form_submission_spec.rb index 85b66cb545..f44722ef95 100644 --- a/spec/system/public_registration_form_submission_spec.rb +++ b/spec/system/public_registration_form_submission_spec.rb @@ -17,7 +17,6 @@ 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 @@ -52,7 +51,6 @@ 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 @@ -63,7 +61,7 @@ fill_full_registration - choose_pr_radio ce_field("ce_credit_interest"), "Yes" + choose_pr_radio reg_field("ce_credit_interest"), "Yes" choose_pr_radio reg_field("payment_method"), "Check" check_pr_box reg_field("additional_forms"), "W-9" @@ -75,9 +73,8 @@ email_2: "robin.alt@example.com") registration = event.event_registrations.find_by!(registrant: person) - expect(registration).to have_attributes(scholarship_requested: false, + expect(registration).to have_attributes(scholarship_requested: false, ce_credit_requested: true, 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( @@ -108,15 +105,11 @@ "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") @@ -283,8 +276,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 "Additional forms" question whose answers drive the resulting - # registration's flags. + # plus the magic CE-interest and "Additional forms" questions whose answers + # drive the resulting registration's flags. def build_registration_form form = FormBuilderService.new( name: "Training Registration Form", @@ -294,6 +287,16 @@ 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, @@ -309,24 +312,12 @@ 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 diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 5cd9ed7c65..b298be1428 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -172,8 +172,6 @@ "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",