diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index edcf29d573..c783fe98ee 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 -- **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 +- **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 - **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 084e9f755c..12d579d53e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,19 +48,19 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| -| `app/models/` | ActiveRecord models | ~78 files | +| `app/models/` | ActiveRecord models | ~80 files | | `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~30 files | -| `app/jobs/` | SolidQueue background jobs | 3 files | +| `app/jobs/` | SolidQueue background jobs | 4 files | | `app/models/concerns/` | Shared model modules | 16 concerns | ### Presentation | Directory | Purpose | Count | |---|---|---| -| `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/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/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/` | ~28 initializer files | +| `config/initializers/` | ~30 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** (~52 controllers): Workshops, stories, resources, events, people, organizations, registration ticket callouts, etc. +- **Root level** (~58 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,6 +284,7 @@ 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 @@ -349,17 +350,17 @@ Custom colors defined in `app/frontend/stylesheets/application.tailwind.css`: | Directory | Count | Purpose | |---|---|---| -| `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/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/mailers/` | ~5 | Mailer tests | -| `spec/helpers/` | ~1 | Helper tests | -| `spec/factories/` | ~53 | FactoryBot factory definitions | +| `spec/helpers/` | ~5 | Helper tests | +| `spec/factories/` | ~67 | FactoryBot factory definitions | ### Configuration @@ -432,8 +433,12 @@ RuboCop linting on PRs and pushes to main. ## Rake Tasks -Located in `lib/tasks/` (4 files): +Located in `lib/tasks/` (8 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 d63d4dfe90..b3d99a42d2 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 -- **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 +- **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 - **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 e3135d7434..e59fa26901 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,6 +2,7 @@ class ApplicationController < ActionController::Base before_action :authenticate_user! # ensures only logged-in users can access pages before_action :track_user_with_ahoy, unless: :devise_controller? before_action :set_current_user # for AhoyTrackable in models + before_action :set_honeybadger_context # so faults name the affected user before_action :set_paper_trail_whodunnit before_action :preload_current_user_associations @@ -73,4 +74,10 @@ def preload_current_user_associations def set_current_user Current.user = current_user if user_signed_in? # needed for Ahoy tracking in models end + + def set_honeybadger_context + return unless user_signed_in? + + Honeybadger.context(user_id: current_user.id, user_email: current_user.email) + end end diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb new file mode 100644 index 0000000000..3faa969518 --- /dev/null +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -0,0 +1,95 @@ +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 8ca1063862..3dccb72f18 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -87,22 +87,23 @@ 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: "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 + 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 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: "Registration was successfully updated.", status: :see_other + redirect_to registrants_event_path(@event_registration.event), notice: notice, status: :see_other else - redirect_to registration_ticket_path(@event_registration.slug), notice: "Registration was successfully updated.", status: :see_other + redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other end end } @@ -268,6 +269,10 @@ 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 @@ -326,9 +331,6 @@ 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 a9efc39703..b2a3c700bb 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -61,11 +61,67 @@ def scholarship @form_responses_available = @event.registration_form&.form_submissions&.exists?(person: @event_registration.registrant) end - # 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). + # CE hours status: hours, amount owed, and license number. def ce - @ce_callout = @event.registration_ticket_callouts.find_by(magic_key: "ce_hours") + 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." end # Forms page: callout-card links to the W-9 (when seeded) and, for paid @@ -134,6 +190,17 @@ 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 @@ -179,5 +246,42 @@ def resource_card(icon:, title:, subtitle:, href:, target: "_blank", trailing_ic MagicTicketCallouts::Card.new(icon_class: icon, color: "blue", title: title, subtitle: subtitle, href: href, target: target, trailing_icon: trailing_icon) end + + def redirect_to_ce_stripe_checkout(ce_registration) + person = @event_registration.registrant + amount = ce_registration.remaining_cost + + person.set_payment_processor :stripe + + checkout_session = person.payment_processor.checkout( + mode: "payment", + metadata: { + ce_registration_id: ce_registration.id, + event_registration_id: @event_registration.id, + event_id: @event.id + }, + payment_intent_data: { + metadata: { + ce_registration_id: ce_registration.id, + event_registration_id: @event_registration.id, + event_id: @event.id + }, + description: "CE Hours: #{@event.title}" + }, + line_items: [ { + price_data: { + currency: "usd", + product_data: { name: "CE Hours: #{@event.title}" }, + unit_amount: amount + }, + quantity: 1 + } ], + success_url: registration_ce_url(@event_registration.slug, checkout: "success"), + cancel_url: registration_ce_url(@event_registration.slug, checkout: "cancelled") + ) + + @event_registration.update!(checkout_session_id: checkout_session.id) + redirect_to checkout_session.url, allow_other_host: true, status: :see_other + end end end diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb index 0645472d04..a4ea0e5979 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -21,6 +21,7 @@ def new @form_fields = visible_form_fields @scholarship = scholarship_mode? @scholarship_form = @event.scholarship_form if @scholarship + @continuing_education_form = @event.continuing_education_form @event = @event.decorate end @@ -35,17 +36,23 @@ def create @registration_form = registration_form @scholarship = scholarship_mode? @scholarship_form = @event.scholarship_form if @scholarship + @continuing_education_form = @event.continuing_education_form all_params = params.dig(:public_registration, :form_fields)&.to_unsafe_h || {} - registration_params, scholarship_params = split_form_params(all_params) + registration_params, scholarship_params, continuing_education_params = split_form_params(all_params) @field_errors = validate_required_fields(registration_params) if @scholarship_form scholarship_fields = @scholarship_form.form_fields.where.not(answer_type: :group_header).to_a @field_errors = @field_errors.merge(FormAnswerValidator.call(scholarship_fields, scholarship_params)) end + if @continuing_education_form + ce_fields = @continuing_education_form.form_fields.where.not(answer_type: :group_header).to_a + @field_errors = @field_errors.merge(FormAnswerValidator.call(ce_fields, continuing_education_params)) + end if @field_errors.any? @form_fields = visible_form_fields + @continuing_education_form = @event.continuing_education_form @event = @event.decorate flash.now[:alert] = "Your registration is not complete yet. Scroll down to check for any errors or missing information." render :new, status: :unprocessable_content @@ -61,7 +68,9 @@ def create scholarship_requested: @scholarship, person: current_user&.person, scholarship_form: @scholarship_form, - scholarship_params: scholarship_params + scholarship_params: scholarship_params, + continuing_education_form: @continuing_education_form, + continuing_education_params: continuing_education_params ) if result.success? @@ -76,6 +85,7 @@ def create end else @form_fields = visible_form_fields + @continuing_education_form = @event.continuing_education_form @event = @event.decorate flash.now[:error] = result.errors.join(", ") flash.now[:alert] = "Your registration is not complete yet. Scroll down to check for any errors or missing information." @@ -120,6 +130,7 @@ def show @responses = @form_submission.form_answers.index_by(&:form_field_id) load_scholarship_submission(person) + load_continuing_education_submission(person) @event = @event.decorate end @@ -190,6 +201,19 @@ def load_scholarship_submission(person) @scholarship_responses = application.answers_by_field_id end + def load_continuing_education_submission(person) + ce_form = @event.continuing_education_form + return unless ce_form + + submission = ce_form.form_submissions.find_by(person: person, event: @event, role: "continuing_education") + return unless submission + + @continuing_education_submission = submission + @continuing_education_form = ce_form + @continuing_education_fields = ce_form.form_fields.reorder(position: :asc) + @continuing_education_responses = submission.form_answers.index_by(&:form_field_id) + end + def scholarship_mode? params[:scholarship_requested] == "true" end @@ -204,7 +228,13 @@ def split_form_params(all_params) scholarship = all_params.slice(*scholarship_field_ids) end - [ registration, scholarship ] + continuing_education = {} + if @continuing_education_form + ce_field_ids = @continuing_education_form.form_fields.pluck(:id).map(&:to_s) + continuing_education = all_params.slice(*ce_field_ids) + end + + [ registration, scholarship, continuing_education ] end def visible_form_fields diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 0f7c86b8da..88a23e0fbb 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -78,11 +78,9 @@ 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 @@ -153,7 +151,7 @@ def onboarding authorize! @event, to: :registrants? @event = @event.decorate scope = @event.event_registrations - .includes(:checklist_completions, :organizations, :allocations, :scholarships, :comments, registrant: [ :user, { affiliations: :organization } ]) + .includes(:checklist_completions, :organizations, :allocations, :scholarships, :comments, { continuing_education_registrations: :professional_license }, registrant: [ :user, { affiliations: :organization } ]) .joins(:registrant) scope = scope.keyword(params[:keyword]) if params[:keyword].present? @@ -211,7 +209,7 @@ def details def ce_hours authorize! @event, to: :ce_hours? - if @event.ce_hours_details.blank? + unless @event.ce_eligible? redirect_to event_path(@event, reg: params[:reg].presence) return end @@ -540,6 +538,18 @@ 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. @@ -670,11 +680,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_requested.to_i - row << (registration.ce_credit_requested? ? "Yes" : "No") - row << (ce_hours.positive? ? ce_hours : "") + ce_hours = registration.ce_hours_total + row << (registration.ce_registered? ? "Yes" : "No") + row << (ce_hours.positive? ? helpers.plain_number(ce_hours) : "") row << (registration.ce_amount_owed_cents.positive? ? helpers.dollars_from_cents(registration.ce_amount_owed_cents) : "") - row << registration.ce_license_number.to_s + row << registration.ce_license_numbers.join("; ") end EventRegistration::CHECKLIST_STEPS.each_key do |step| row << (registration.checklist_step_completed?(step) ? "Yes" : "No") @@ -701,6 +711,7 @@ def assign_event_forms(event) assign_event_form(event, :registration, params.dig(:event, :registration_form_id)) assign_event_form(event, :scholarship, params.dig(:event, :scholarship_form_id)) assign_event_form(event, :bulk_payment, params.dig(:event, :bulk_payment_form_id)) + assign_event_form(event, :continuing_education, params.dig(:event, :continuing_education_form_id)) end def assign_event_form(event, role, form_id) @@ -720,6 +731,7 @@ def set_form_variables @registration_forms = Form.standalone.where(role: "registration").order(:name) @scholarship_forms = Form.standalone.where(role: "scholarship").order(:name) @bulk_payment_forms = Form.standalone.where(role: "bulk_payment").order(:name) + @continuing_education_forms = Form.standalone.where(role: "continuing_education").order(:name) @categories_grouped = Category .includes(:category_type) diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 57d6c8810d..f51058adf4 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -25,10 +25,9 @@ def new if params[:allocatable_sgid].present? @allocatable = GlobalID::Locator.locate_signed(params[:allocatable_sgid]) - if @allocatable.is_a?(EventRegistration) - @payment.person = @allocatable.registrant - @payment.organization = @allocatable.organizations.first - end + person, organization = registrant_and_organization_for(@allocatable) + @payment.person = person + @payment.organization = organization end end @@ -170,10 +169,9 @@ def allocation_form if params[:allocatable_sgid].present? @allocatable = GlobalID::Locator.locate_signed(params[:allocatable_sgid]) - if @allocatable.is_a?(EventRegistration) - @payment.person = @allocatable.registrant - @payment.organization = @allocatable.organizations.first - end + person, organization = registrant_and_organization_for(@allocatable) + @payment.person = person + @payment.organization = organization end respond_to do |format| @@ -197,9 +195,21 @@ 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) - attrs[:person_id] ||= allocatable.try(:registrant_id) if allocatable.is_a?(EventRegistration) + person, = registrant_and_organization_for(allocatable) + attrs[:person_id] ||= person&.id attrs end @@ -227,28 +237,21 @@ def process_allocation!(payment, allocatable) end def calculate_allocation_amount(payment, allocatable) - payment_amount = payment.amount_cents - - if allocatable.is_a?(EventRegistration) - event_cost = allocatable.event.cost_cents + cost_cents = allocatable.cost_cents - if event_cost.blank? - payment.errors.add(:base, "Cannot allocate to a free event.") - raise ActiveRecord::RecordInvalid.new(payment) - end - - already_allocated = allocatable.allocations_sum - remaining_needed = event_cost - already_allocated + if cost_cents.blank? || cost_cents <= 0 + payment.errors.add(:base, "Cannot allocate to a free registration.") + raise ActiveRecord::RecordInvalid.new(payment) + end - if remaining_needed <= 0 - payment.errors.add(:base, "Event registration is already fully paid.") - raise ActiveRecord::RecordInvalid.new(payment) - end + remaining_needed = cost_cents - allocatable.allocations_sum - [ payment_amount, remaining_needed ].min - else - payment_amount + if remaining_needed <= 0 + payment.errors.add(:base, "Registration is already fully paid.") + raise ActiveRecord::RecordInvalid.new(payment) end + + [ payment.amount_cents, remaining_needed ].min end def edit_payment_params diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 1c51370125..a314ef04ad 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -221,7 +221,9 @@ def update @person.avatar.purge end - @person.assign_attributes(person_params) + attrs = person_params + reject_locked_license_changes!(attrs) + @person.assign_attributes(attrs) @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 } @@ -450,6 +452,30 @@ 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, @@ -459,7 +485,6 @@ 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, @@ -558,7 +583,8 @@ 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 ] + 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 ] ) end end diff --git a/app/controllers/rich_text_asset_mentions_controller.rb b/app/controllers/rich_text_asset_mentions_controller.rb index d5fbedbd37..d93ed4d889 100644 --- a/app/controllers/rich_text_asset_mentions_controller.rb +++ b/app/controllers/rich_text_asset_mentions_controller.rb @@ -2,9 +2,9 @@ class RichTextAssetMentionsController < ApplicationController skip_verify_authorized def index # authorize! - record = GlobalID::Locator.locate_signed(params[:sgid]) + record = Draper.undecorate(GlobalID::Locator.locate_signed(params[:sgid])) - unless record + unless record.respond_to?(:rich_text_assets) render json: [] and return end diff --git a/app/controllers/workshop_variations_controller.rb b/app/controllers/workshop_variations_controller.rb index 38c9d126db..9f42ac9ffe 100644 --- a/app/controllers/workshop_variations_controller.rb +++ b/app/controllers/workshop_variations_controller.rb @@ -123,7 +123,6 @@ def update # end def set_form_variables - @people = Person.order(Arel.sql("LOWER(first_name), LOWER(last_name)")) @workshop = @workshop_variation.workshop || (Workshop.find_by(id: params[:workshop_id]) if params[:workshop_id].present?) @workshop_variation_idea = WorkshopVariationIdea.find_by(id: params[:workshop_variation_idea_id]) if params[:workshop_variation_idea_id].present? @workshop_variation.build_primary_asset if @workshop_variation.primary_asset.blank? diff --git a/app/decorators/event_registration_decorator.rb b/app/decorators/event_registration_decorator.rb index 2987881005..82374fdae6 100644 --- a/app/decorators/event_registration_decorator.rb +++ b/app/decorators/event_registration_decorator.rb @@ -1,4 +1,28 @@ 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 @@ -27,4 +51,10 @@ 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 62a4a6b5d5..007416009b 100644 --- a/app/frontend/javascript/controllers/attendance_status_controller.js +++ b/app/frontend/javascript/controllers/attendance_status_controller.js @@ -4,9 +4,30 @@ import { Controller } from "@hotwired/stimulus" // Swaps the colored icon inside the status select as the dropdown changes. The // select looks like an ordinary form field (not the roster's autosaving chip), // so an "Unsaved" hint is shown until the form is saved. +// +// The per-day attendance checkboxes drive the status the same way the Onboarding +// matrix does: toggling a day rolls the status forward/back (registered → +// incomplete_attendance → attended), but only while the status is an active one — +// deliberate manual states (cancelled, no_show, transferred_out) are never +// overridden. Mirrors EventRegistration#sync_attendance_status_to_days!. export default class extends Controller { - static targets = ["select", "icon", "dirty"] - static values = { colors: Object, icons: Object, initial: String } + static targets = ["select", "icon", "dirty", "day"] + static values = { colors: Object, icons: Object, initial: String, activeStatuses: Array, dayCount: Number } + + // Recompute the status from how many day checkboxes are checked, then reflect it + // in the dropdown (which re-renders the icon and the unsaved hint via update()). + deriveFromDays() { + if (!this.activeStatusesValue.includes(this.selectTarget.value)) return + + const checked = this.dayTargets.filter((day) => day.checked).length + let derived = "incomplete_attendance" + if (checked === 0) derived = "registered" + else if (checked >= this.dayCountValue) derived = "attended" + + if (this.selectTarget.value === derived) return + this.selectTarget.value = derived + this.update() + } update() { const status = this.selectTarget.value diff --git a/app/frontend/javascript/controllers/ce_credit_requested_controller.js b/app/frontend/javascript/controllers/ce_credit_requested_controller.js deleted file mode 100644 index 546ecea9c7..0000000000 --- a/app/frontend/javascript/controllers/ce_credit_requested_controller.js +++ /dev/null @@ -1,62 +0,0 @@ -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 new file mode 100644 index 0000000000..40f56de15d --- /dev/null +++ b/app/frontend/javascript/controllers/ce_license_picker_controller.js @@ -0,0 +1,20 @@ +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 c1bffb8daa..346eda3b17 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -33,6 +33,9 @@ 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) @@ -138,15 +141,9 @@ 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 deleted file mode 100644 index 37ed8d11e5..0000000000 --- a/app/frontend/javascript/controllers/scholarship_requested_controller.js +++ /dev/null @@ -1,34 +0,0 @@ -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 1055151363..fb3c441e60 100644 --- a/app/frontend/javascript/controllers/section_filter_controller.js +++ b/app/frontend/javascript/controllers/section_filter_controller.js @@ -15,8 +15,10 @@ export default class extends Controller { el.classList.toggle("hidden", sectionRole !== "scholarship") } else if (role === "bulk_payment") { el.classList.toggle("hidden", sectionRole !== "bulk_payment") + } else if (role === "continuing_education") { + el.classList.toggle("hidden", sectionRole !== "continuing_education") } else { - el.classList.toggle("hidden", sectionRole === "scholarship" || sectionRole === "bulk_payment") + el.classList.toggle("hidden", sectionRole === "scholarship" || sectionRole === "bulk_payment" || sectionRole === "continuing_education") } }) } diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0d1ffdec7d..5b38db935e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -19,6 +19,17 @@ 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 @@ -475,6 +486,19 @@ 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 25ac9f890a..6e61efefa4 100644 --- a/app/helpers/event_helper.rb +++ b/app/helpers/event_helper.rb @@ -1,10 +1,9 @@ module EventHelper # The "please specify" placeholder for an option label, or nil when the option - # 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) + # 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) 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 2871b44ba8..1c6d6fe302 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -53,7 +53,6 @@ def onboarding_columns(event) columns << { key: "funder", label: "Scholarship grant", kind: :funder, sortable: true, align: "left", toggle: "funder" } columns << { key: "scholarship_tasks_completed", label: "Scholarship tasks done", kind: :scholarship_tasks, sortable: true, align: "center", toggle: "scholarship_tasks_completed" } if event.ce_eligible? - columns << { key: "ce_requested", label: "CE requested", kind: :ce_requested, sortable: true, align: "center", toggle: "ce_requested" } columns << { key: "ce_hours", label: "CE hours", kind: :ce_hours, sortable: true, align: "center", toggle: "ce_hours" } columns << { key: "ce_amount", label: "CE amount", kind: :ce_amount, sortable: true, align: "center", toggle: "ce_amount" } columns << { key: "ce_license", label: "License #", kind: :ce_license, sortable: true, align: "center", toggle: "ce_license" } diff --git a/app/helpers/rhino_editor_helper.rb b/app/helpers/rhino_editor_helper.rb index ccb59c7d2b..abfad9bcc5 100644 --- a/app/helpers/rhino_editor_helper.rb +++ b/app/helpers/rhino_editor_helper.rb @@ -1,9 +1,10 @@ module RhinoEditorHelper # custom rhino editor with stimulus controller attached to edit raw source html def rhino_editor(form, base_attribute_name, label: nil, hint: nil) + object = Draper.undecorate(form.object) rhino_attr = :"rhino_#{base_attribute_name}" field_id = form.field_id(rhino_attr) - value = form.object.public_send(rhino_attr) + value = object.public_send(rhino_attr) label_tag = if label.present? @@ -70,7 +71,7 @@ def rhino_editor(form, base_attribute_name, label: nil, hint: nil) data: { blob_url_template: rails_service_blob_url(":signed_id", ":filename"), direct_upload_url: rails_direct_uploads_url, - model_sgid: form.object.persisted? ? form.object.to_sgid.to_s : nil + model_sgid: object.persisted? ? object.to_sgid.to_s : nil } ) diff --git a/app/models/concerns/pay_charge_extensions.rb b/app/models/concerns/pay_charge_extensions.rb index 54cadffe59..a5dc7d44dd 100644 --- a/app/models/concerns/pay_charge_extensions.rb +++ b/app/models/concerns/pay_charge_extensions.rb @@ -12,7 +12,9 @@ def create_external_processor_payment return if ExternalProcessorPayment.exists?(pay_charge_id: id) return unless object["paid"] == true - if (event_registration_id = metadata["event_registration_id"]) + if (ce_registration_id = metadata["ce_registration_id"]) + create_ce_payment(ce_registration_id) + elsif (event_registration_id = metadata["event_registration_id"]) create_event_registration_payment(event_registration_id) elsif (form_submission_id = metadata["form_submission_id"]) create_bulk_payment(form_submission_id) @@ -54,6 +56,36 @@ def create_event_registration_payment(event_registration_id) registration.update!(payment_unresolved: false) end + def create_ce_payment(ce_registration_id) + ce_registration = ContinuingEducationRegistration.find_by(id: ce_registration_id) + return unless ce_registration + + person = ce_registration.event_registration&.registrant + return unless person + + payment = ExternalProcessorPayment.create!( + stripe_charge_id: processor_id, + external_origin: false, + person: person, + amount_cents: amount, + amount_cents_remaining: amount, + currency: currency, + pay_charge_id: id, + metadata: metadata.merge(stripe_charge: object) + ) + + remaining_needed = ce_registration.remaining_cost + allocation_amount = [ amount, remaining_needed ].min + + if allocation_amount > 0 + Allocation.create!( + source: payment, + allocatable: ce_registration, + amount: allocation_amount + ) + end + end + def create_bulk_payment(form_submission_id) submission = FormSubmission.find_by(id: form_submission_id) return unless submission&.role == "bulk_payment" diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 16d697dee8..f2ba66ae96 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -16,6 +16,7 @@ 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. @@ -29,6 +30,56 @@ 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 @@ -45,4 +96,11 @@ 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 44480dead4..d65fb096aa 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -154,6 +154,10 @@ def bulk_payment_form forms.find_by(event_forms: { role: "bulk_payment" }) end + def continuing_education_form + forms.find_by(event_forms: { role: "continuing_education" }) + end + def active_registration_for(person) return nil unless person event_registrations.active.find_by(registrant_id: person.id) diff --git a/app/models/event_form.rb b/app/models/event_form.rb index 4f2f6bddae..5ffb4c88b8 100644 --- a/app/models/event_form.rb +++ b/app/models/event_form.rb @@ -2,7 +2,7 @@ class EventForm < ApplicationRecord belongs_to :event belongs_to :form - ROLES = %w[registration scholarship bulk_payment].freeze + ROLES = %w[registration scholarship bulk_payment continuing_education].freeze validates :role, presence: true, inclusion: { in: ROLES } validates :form_id, uniqueness: { scope: [ :event_id, :role ] } @@ -10,4 +10,5 @@ class EventForm < ApplicationRecord scope :registration, -> { where(role: "registration") } scope :scholarship, -> { where(role: "scholarship") } scope :bulk_payment, -> { where(role: "bulk_payment") } + scope :continuing_education, -> { where(role: "continuing_education") } end diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 0d0ffff9a3..b65673f356 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -41,16 +41,11 @@ 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( @@ -148,18 +143,34 @@ class EventRegistration < ApplicationRecord else all end } - # 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. + # 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. scope :ce_status, ->(value) { - 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 + 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 } scope :comment_status, ->(value) { commented = Comment.where(commentable_type: "EventRegistration").select(:commentable_id) @@ -243,6 +254,9 @@ 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 @@ -365,26 +379,84 @@ def cost_cents event.cost_cents end - # True when the registrant has supplied a CE license number. - def ce_license_provided? - ce_license_number.present? + # 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) end # A short label summarizing the registrant's CE credit standing, matching the - # 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. + # 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. def ce_status_label - return unless ce_credit_requested? - return "Incomplete" if !ce_license_provided? || ce_hours_requested.to_i <= 0 - return "Paid" if paid_in_full? + 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? "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 - ce_hours_requested.to_i * CE_HOURLY_RATE_DOLLARS * 100 + 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? end def joinable? diff --git a/app/models/form_field.rb b/app/models/form_field.rb index c8ee2feba6..78bb92bdce 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -66,17 +66,6 @@ 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 @@ -343,20 +332,17 @@ 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, 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) + # against SPECIFY_OPTION_PLACEHOLDERS. + def self.specify_placeholder_for(label) normalized = label.to_s.strip return if normalized.blank? - 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 + SPECIFY_OPTION_PLACEHOLDERS.find { |key, _| key.casecmp?(normalized) }&.last end - # 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? + # True when an option label reveals a free-text "please specify" box. + def self.specify_option?(label) + specify_placeholder_for(label).present? end # True when an option label is the generic free-text "Other" choice. Unlike @@ -375,7 +361,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, field_identifier) } + option_names.select { |name| FormField.specify_option?(name) } 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 ee685ad2f6..66c166e3ae 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -100,6 +100,10 @@ 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 @@ -213,6 +217,13 @@ 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 0f07ff774c..59971b92e8 100644 --- a/app/models/professional_license.rb +++ b/app/models/professional_license.rb @@ -7,7 +7,15 @@ class ProfessionalLicense < ApplicationRecord has_many :continuing_education_registrations, dependent: :destroy - validates :number, uniqueness: { scope: :person_id }, allow_nil: true + # 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 # 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 @@ -30,4 +38,26 @@ 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 new file mode 100644 index 0000000000..07f9eda7db --- /dev/null +++ b/app/policies/continuing_education_registration_policy.rb @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000000..d70e434edc --- /dev/null +++ b/app/policies/professional_license_policy.rb @@ -0,0 +1,18 @@ +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 11c9b52aee..bc9417938f 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. # -# 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. +# 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. # -# 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. +# 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. 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. 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. + # 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. 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,8 +146,7 @@ def definitions callout_type: "action", icon_class: "fa-solid fa-credit-card", color_class: "orange", - hidden: ->(_event) { false }, - seed_if: ->(event) { event.cost_cents.to_i.positive? } + hidden: ->(event) { !event.facilitator_training? } }, { magic_key: "certificate", @@ -156,8 +155,8 @@ def definitions callout_type: "action", icon_class: "fa-solid fa-certificate", color_class: "green", - # Off by default except on facilitator trainings; admins opt other events - # in. When shown, it still only appears once the certificate unlocks. + # Off by default except on facilitator trainings. When shown, it still only + # appears once the certificate unlocks (MagicTicketCallouts guards this). hidden: ->(event) { !event.facilitator_training? } }, { @@ -167,33 +166,32 @@ def definitions callout_type: "action", icon_class: "fa-solid fa-award", color_class: "fuchsia", - hidden: ->(_event) { false }, - seed_if: ->(event) { event.scholarship_form.present? } + hidden: ->(event) { !event.facilitator_training? } }, { 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. Always seeded. + # carries the CE hours-offered/cost config. 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) { false } + hidden: ->(event) { !event.facilitator_training? } }, { magic_key: "event_details", # Title/text seed from the event's details columns (migrating existing - # content); thereafter they live on the row. Always seeded. + # content); thereafter they live on the row. 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) { false } + hidden: ->(event) { !event.facilitator_training? } }, { magic_key: "videoconference", @@ -202,11 +200,10 @@ def definitions callout_type: "action", icon_class: "fa-solid fa-video", color_class: "blue", - hidden: ->(_event) { false }, + hidden: ->(event) { !event.facilitator_training? }, # 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 }, - seed_if: ->(event) { event.videoconference_url.present? } + display_from: ->(event) { event.start_date - 7.days if event.start_date } }, { magic_key: "forms", @@ -215,12 +212,11 @@ def definitions callout_type: "action", icon_class: "fa-solid fa-file-lines", color_class: "blue", - hidden: ->(_event) { false }, + hidden: ->(event) { !event.facilitator_training? }, # 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 : [] }, - seed_if: ->(event) { event.show_forms_callout? } + resources: -> { @event.cost_cents.to_i.positive? ? [ Resource.find_by(title: "W-9") ].compact : [] } }, { magic_key: "handouts", @@ -229,7 +225,7 @@ def definitions callout_type: "reference", icon_class: "fa-solid fa-folder-open", color_class: "blue", - hidden: ->(event) { !event.show_handouts_callout? }, + hidden: ->(event) { !event.facilitator_training? }, resources: -> { handout_resources } }, { @@ -240,7 +236,7 @@ def definitions icon_class: "fa-solid fa-circle-question", color_class: "blue", description: faq_html, - hidden: ->(event) { !event.show_faq_callout? } + hidden: ->(event) { !event.facilitator_training? } } ] end diff --git a/app/services/event_registration_readiness.rb b/app/services/event_registration_readiness.rb index 509dae0aec..4b42953aff 100644 --- a/app/services/event_registration_readiness.rb +++ b/app/services/event_registration_readiness.rb @@ -158,15 +158,15 @@ def scholarship_tasks_incomplete? end def ce_unpaid? - registration.ce_credit_requested? && !ce_paid? + registration.ce_registered? && !ce_paid? end def ce_license_missing? - registration.ce_credit_requested? && !registration.ce_license_provided? + registration.ce_registered? && !registration.ce_license_provided? end def ce_certificate_pending? - registration.ce_credit_requested? && !ce_certificate_sent? + registration.ce_registered? && !ce_certificate_sent? end # Post-event criteria are only met by a full "attended". "incomplete_attendance" diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index ec56ebccc7..1719e172da 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -3,10 +3,14 @@ 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" toggles the registration's - # ce_credit_requested flag. Kept here so the seed, service, and specs agree. + # registration form. Answering it "Yes" creates a ContinuingEducationRegistration + # (hours come from the event). 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. @@ -24,13 +28,16 @@ class PublicRegistration ORGANIZATION_POSITION_IDENTIFIER = "agency_position".freeze def self.call(event:, registration_form:, form_params:, scholarship_requested: false, person: nil, - scholarship_form: nil, scholarship_params: {}) + scholarship_form: nil, scholarship_params: {}, + continuing_education_form: nil, continuing_education_params: {}) new(event:, registration_form:, form_params:, scholarship_requested:, person:, - scholarship_form:, scholarship_params:).call + scholarship_form:, scholarship_params:, + continuing_education_form:, continuing_education_params:).call end def initialize(event:, registration_form:, form_params:, scholarship_requested: false, person: nil, - scholarship_form: nil, scholarship_params: {}) + scholarship_form: nil, scholarship_params: {}, + continuing_education_form: nil, continuing_education_params: {}) @event = event @registration_form = registration_form @form_params = form_params @@ -38,6 +45,8 @@ def initialize(event:, registration_form:, form_params:, scholarship_requested: @person = person @scholarship_form = scholarship_form @scholarship_params = scholarship_params || {} + @continuing_education_form = continuing_education_form + @continuing_education_params = continuing_education_params || {} @errors = [] end @@ -62,7 +71,7 @@ def call existing = @event.event_registrations.find_by(registrant: person) if existing existing.update!(scholarship_requested: true) if @scholarship_requested - existing.update!(ce_credit_requested: true, ce_hours_requested: ce_hours_requested) if ce_credit_requested? + create_ce_registration(existing, person) existing.update!(w9_requested: true) if w9_requested? existing.update!(invoice_requested: true) if invoice_requested? payment_method = field_value("payment_method")&.strip @@ -74,13 +83,16 @@ 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) @@ -376,33 +388,41 @@ 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 - # 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 + # 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) end - # True when the registrant answered "Yes" to the seeded CE-interest question. def ce_credit_requested? - ce_answer_label.casecmp?("yes") + return false unless @continuing_education_form + + ce_field_value(CE_CREDIT_INTEREST_IDENTIFIER).to_s.strip.casecmp?("yes") end - # 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? + def ce_license_number + return nil unless @continuing_education_form - hours = field_value(CE_CREDIT_INTEREST_IDENTIFIER).to_s.split(":", 2).last.to_s.strip.to_i - hours.positive? ? hours : nil + ce_field_value(CE_LICENSE_NUMBER_IDENTIFIER)&.strip.presence + end + + def ce_field_value(key) + field = @continuing_education_form.form_fields.find_by(field_identifier: key) + return nil unless field + + @continuing_education_params[field.id.to_s] end # The "Additional forms" question is a multi-select, so its submitted value is @@ -478,6 +498,32 @@ def save_scholarship_submission(person) end end + def save_continuing_education_submission(person) + return unless @continuing_education_form && @continuing_education_params.present? + return unless ce_credit_requested? + + submission = FormSubmission.find_or_create_by!( + person: person, form: @continuing_education_form, role: "continuing_education", event: @event + ) do |record| + record.event = @event + end + + @continuing_education_params.each do |field_id, raw_value| + field = @continuing_education_form.form_fields.find_by(id: field_id) + next unless field + next if field.group_header? + + text = if raw_value.is_a?(Array) + raw_value.reject(&:blank?).join(", ") + else + raw_value.to_s + end + + record = submission.form_answers.find_or_initialize_by(form_field: field) + record.update!(submitted_answer: text, question_name_when_answered: field.name) + end + end + def send_notifications(event_registration) registrant_email = event_registration.registrant.preferred_email diff --git a/app/services/event_revenue_report.rb b/app/services/event_revenue_report.rb index fce5c6f85a..06c3da3af1 100644 --- a/app/services/event_revenue_report.rb +++ b/app/services/event_revenue_report.rb @@ -7,12 +7,11 @@ # Org subsidy = unfunded scholarships + discounts (cost the org absorbs). # Net = money in - org subsidy. # -# 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. +# 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. 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, @@ -24,8 +23,8 @@ class EventRevenueReport :registration_outstanding_cents, keyword_init: true ) do - # Fees actually collected: registration payments plus CE paid. CE has no - # payment tracking, so the CE portion is $0 today. + # Fees actually collected: registration payments plus CE paid. CE payments + # aren't netted here yet, so the CE portion is $0 today. def fees_cents registration_payments_cents end @@ -153,11 +152,14 @@ def build_row(event) ) end - # Projected CE revenue across this event's active registrants: $25 per - # requested hour. Not collected yet — there's no CE payment tracking. + # 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. def ce_projected_cents_for(event) - hours = event.event_registrations.active.where(ce_credit_requested: true).sum(:ce_hours_requested) - hours.to_i * CE_HOURLY_RATE_CENTS + ContinuingEducationRegistration + .joins(:event_registration) + .where(event_registrations: { event_id: event.id, status: EventRegistration::ACTIVE_STATUSES }) + .sum(:cost_cents) end def to_dollars(cents) diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb index 5144d3dfa0..dd5e727c10 100644 --- a/app/services/form_builder_service.rb +++ b/app/services/form_builder_service.rb @@ -11,6 +11,7 @@ class FormBuilderService person_background: { label: "Person background", method: :build_person_background_fields }, professional_info: { label: "Professional info", method: :build_professional_info_fields }, marketing: { label: "Marketing", method: :build_marketing_fields }, + continuing_education: { label: "Continuing education", method: :build_continuing_education_fields }, scholarship: { label: "Scholarship", method: :build_scholarship_fields }, payment: { label: "Payment", method: :build_payment_fields }, consent: { label: "Consent", method: :build_consent_fields }, @@ -51,6 +52,7 @@ def call person_background: %w[racial_ethnic_identity], professional_info: %w[primary_sector_single additional_sectors primary_age_group additional_age_group], marketing: %w[referral_source training_motivation interested_in_more], + continuing_education: %w[ce_credit_interest ce_license_number], scholarship: %w[scholarship_eligibility scholarship_contribution impact_description implementation_plan additional_comments], payment: %w[payment_method], consent: %w[communication_consent], @@ -65,6 +67,7 @@ def call person_background: [ "Background Information" ], professional_info: [ "Professional Information" ], marketing: [ "Marketing" ], + continuing_education: [ "Continuing education" ], scholarship: [ "Scholarship Application", "Your goals", "Anything else?" ], payment: [ "Payment Information" ], consent: [ "Consent" ], @@ -92,6 +95,10 @@ def call "What motivated you to sign up for AWBW's Facilitator Training?", "Are you interested in learning more about upcoming trainings or resources?" ], + continuing_education: [ + "Do you seek Continuing Education (CE) hours for this training?", + "If seeking CE hours, what is your LMFT, LCSW, LPCC or LEP license number?" + ], scholarship: [ "I and/or my organization cannot afford the full training cost and need a scholarship to attend.", "How much are you (and/or your organization) able to pay for this training?", @@ -123,6 +130,7 @@ def self.section_field_names(key) person_background: %w[background], professional_info: %w[professional], marketing: %w[marketing], + continuing_education: %w[continuing_education], scholarship: %w[scholarship], payment: %w[payment], consent: %w[consent], @@ -138,7 +146,8 @@ def self.applicable_section_keys(role) case role when "scholarship" then key == :scholarship when "bulk_payment" then key == :bulk_payment - else key != :scholarship && key != :bulk_payment + when "continuing_education" then key == :continuing_education + else key != :scholarship && key != :bulk_payment && key != :continuing_education end end end @@ -291,6 +300,7 @@ def self.reorder_to_page_order!(form, section_order) "background" => :logged_out_only, "professional" => :answers_on_file, "marketing" => :answers_on_file, + "continuing_education" => :always_ask, "payment" => :always_ask, "scholarship" => :always_ask, "consent" => :answers_on_file, @@ -496,6 +506,25 @@ def build_marketing_fields(form, position) position end + def build_continuing_education_fields(form, position) + position = add_header(form, position, "Continuing education", group: "continuing_education") + + position = add_field(form, position, + "Do you seek Continuing Education (CE) hours for this training?", + :single_select_radio, + key: "ce_credit_interest", group: "continuing_education", required: false, + options: %w[Yes No]) + position = add_field(form, position, + "If seeking CE hours, what is your LMFT, LCSW, LPCC or LEP license number?", + :free_form_input_one_line, + key: "ce_license_number", group: "continuing_education", required: false, + subtitle: "Acceptance of continuing education hours is determined by each individual state board separately, " \ + "and AWBW cannot guarantee your specific state board will accept them. " \ + "Participants are responsible for confirming whether the hours meet the requirements " \ + "for their specific license and state.") + position + end + def build_scholarship_fields(form, position) position = add_header(form, position, "Scholarship Application", group: "scholarship") diff --git a/app/services/magic_ticket_callouts.rb b/app/services/magic_ticket_callouts.rb index fbb06ec3ea..51a74d0cf2 100644 --- a/app/services/magic_ticket_callouts.rb +++ b/app/services/magic_ticket_callouts.rb @@ -174,12 +174,9 @@ 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_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", + return unless registration.ce_registered? + complete = registration.ce_license_provided? + Card.new(icon_class: "fa-solid fa-graduation-cap", color: "teal", title: event.ce_hours_details_label, subtitle: ce_hours_subtitle, href: registration_ce_path(registration.slug), @@ -191,8 +188,10 @@ def ce_hours_card end def ce_hours_subtitle - return "#{registration.ce_hours_requested} hours" if registration.ce_hours_requested.present? - "Continuing education credit" + total = registration.ce_hours_total + return "Continuing education credit" unless total.positive? + + "#{NumberFormatter.plain(total)} hours" end # Teal "$X due" once hours + license are on file and money is owed; otherwise an @@ -211,11 +210,9 @@ 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 new file mode 100644 index 0000000000..85e4cecaec --- /dev/null +++ b/app/services/number_formatter.rb @@ -0,0 +1,15 @@ +# 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 481b30726b..acae7071d3 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 b2733d8fc1..031032faea 100644 --- a/app/views/allocations/index.html.erb +++ b/app/views/allocations/index.html.erb @@ -22,6 +22,10 @@ <%= 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 @@ -56,6 +60,25 @@ + <% 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", @@ -65,7 +88,7 @@ person: (@allocatable.registrant if @allocatable.respond_to?(:registrant)) %> <% end %> - <% if @allocatable.is_a?(EventRegistration) %> + <% if @allocatable.is_a?(EventRegistration) || @allocatable.is_a?(ContinuingEducationRegistration) %>
<%= 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 new file mode 100644 index 0000000000..573c9c4d21 --- /dev/null +++ b/app/views/continuing_education_registrations/_details_section.html.erb @@ -0,0 +1,84 @@ +<%# 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 new file mode 100644 index 0000000000..b6be489a9e --- /dev/null +++ b/app/views/continuing_education_registrations/_payment_history.html.erb @@ -0,0 +1,52 @@ +<%# ---- 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 new file mode 100644 index 0000000000..f7ea5c96f0 --- /dev/null +++ b/app/views/continuing_education_registrations/edit.html.erb @@ -0,0 +1,80 @@ +<% 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 new file mode 100644 index 0000000000..1ce90560dd --- /dev/null +++ b/app/views/continuing_education_registrations/new.html.erb @@ -0,0 +1,36 @@ +<% 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 new file mode 100644 index 0000000000..2ae94fe973 --- /dev/null +++ b/app/views/event_registrations/_ce_status_badge.html.erb @@ -0,0 +1,23 @@ +<%# 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 new file mode 100644 index 0000000000..04f3d56270 --- /dev/null +++ b/app/views/event_registrations/_continuing_education.html.erb @@ -0,0 +1,57 @@ +<%# ---- 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 f14d44e550..783acbabec 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -31,7 +31,15 @@ } %> <% current_icon_color = status_icon_colors[f.object.status] || "text-gray-500" %> <% current_icon = status_icons[f.object.status] || "fa-question" %> -
+ <%# The attendance-status controller spans the whole card so the per-day + checkmarks in the meta strip below can drive the status select above. %> +
<%= person_profile_button(f.object.registrant, subtitle: f.object.registrant.preferred_email) %> @@ -76,11 +84,7 @@
<% end %> -
+
Registration status <%= link_to registration_ticket_path(f.object.slug), - class: "group inline-flex items-center gap-1.5 font-medium #{DomainTheme.text_class_for(:event_registrations, intensity: 600)} hover:underline", + class: "group inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-2.5 py-1 font-medium shadow-sm #{DomainTheme.text_class_for(:event_registrations, intensity: 600)} hover:bg-gray-50 transition-colors", target: "_blank", data: { turbo_frame: "_top" } do %> @@ -129,10 +133,29 @@ title: "#{form_name} — submitted #{ts.strftime('%b %-d, %Y')}", data: { turbo_frame: "_top" } do %> - View submission<%= " ##{i + 1}" if submissions.size > 1 %> + View form submission<%= " ##{i + 1}" if submissions.size > 1 %> <% end %> <% end %> + + <%# ---- 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 %> +
+
@@ -141,16 +164,21 @@ <% connected_org_ids = f.object.organizations.map(&:id) %> <% addable_orgs = active_orgs.reject { |org| connected_org_ids.include?(org.id) } %>
-
+
- + -

Registration organizations

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

Registration-linked organizations

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

Registration comments

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

Shout out

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

Registration payments and allocations

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

Scholarship

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

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

<% end %> -
+ <%# Pinned to the card's bottom so it lines up with the CE card's chip and + the organizations card's "Connect organization" link. %> +
<% if scholarship.tasks_completed? %> @@ -68,11 +65,11 @@
<% else %> -
+
<%= link_to new_scholarship_path(allocatable_sgid: event_registration.to_sgid.to_s, return_to: "registration"), - class: "inline-flex items-center gap-1.5 text-sm font-medium #{DomainTheme.text_class_for(:scholarships, intensity: 600)} hover:underline", + class: "inline-flex items-center gap-1.5 self-start rounded-md px-2 py-1 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700", target: "_blank", rel: "noopener" do %> - + Add scholarship <% end %> diff --git a/app/views/events/_builtin_callout_card.html.erb b/app/views/events/_builtin_callout_card.html.erb new file mode 100644 index 0000000000..77b4e6f7d6 --- /dev/null +++ b/app/views/events/_builtin_callout_card.html.erb @@ -0,0 +1,28 @@ +<%# 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 f67d22fb92..0180b5f4c0 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -8,6 +8,7 @@
+
@@ -22,8 +23,10 @@ "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> + <%= f.input :facilitator_training, as: :boolean, label: "Facilitator training event", wrapper_html: { class: "mb-0!" } %>
+ <%= f.input :pre_title, label: "Pre-title", hint: "Displays above the title", @@ -38,14 +41,14 @@ <% if allowed_to?(:manage?, @event) %>
<%= render "shared/visibility_info", flags: %i[published featured publicly_visible publicly_featured public_registration_enabled] %> +
<%= visibility_flag_input f, :published %> <%= visibility_flag_input f, :featured %> <%= visibility_flag_input f, :publicly_visible %> <%= visibility_flag_input f, :publicly_featured %> -
- <%= visibility_flag_input f, :public_registration_enabled, label: "Publicly registerable" %> -
+ +
<%= visibility_flag_input f, :public_registration_enabled, label: "Publicly registerable" %>
<% end %> @@ -58,97 +61,83 @@ id="event_details_button" class="flex items-center justify-between cursor-pointer w-full bg-gray-500 text-white hover:bg-gray-300 px-3 py-2 rounded-t-md" data-action="dropdown#toggle" - data-dropdown-payload-param='[{"event_details":"hidden"}, {"event_details_arrow":"rotate-180"}, {"event_details_button":"rounded-t-md rounded-md"}]'> + data-dropdown-payload-param='[{"event_details":"hidden"}, {"event_details_arrow":"rotate-180"}, {"event_details_button":"rounded-t-md rounded-md"}]' + > Event details
- <%# LEFT: Date/time, cost + pre-date text, videoconference + location %> -
-
-
-

Start

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

Start

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

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

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

End

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

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

+ <% end %> +
+ +
+

End

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

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

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

Registration closed

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

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

+ <% end %> +
+ +
+

Registration closed

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

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

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

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

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

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

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

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

+ <% end %> +
-
- <%= f.input :pre_date_text, +
<%= f.input :pre_date_text, label: "Pre-date text", input_html: { class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", placeholder: "e.g. Upcoming Workshop" - } %> -
-
+ } %>
+
-
- <%= f.input :videoconference_url, +
+ <%= f.input :videoconference_url, label: "Videoconference URL", as: :url, input_html: { @@ -184,8 +170,8 @@ class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> -
- <%= f.input :location_id, +
+ <%= f.input :location_id, label: "Location", collection: @locations, label_method: :name, @@ -195,26 +181,27 @@ class: "ts-flat w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", data: { searchable_select_target: "select" }, } %> -
-
+
+
-
- <%= f.input :videoconference_label, +
+ <%= f.input :videoconference_label, label: "Videoconference label", input_html: { placeholder: "Virtual event", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> - <%= f.input :videoconference_passcode, + + <%= f.input :videoconference_passcode, label: "Videoconference passcode", hint: "Shown alongside the join link. The meeting ID/code is read from the URL automatically.", input_html: { placeholder: "e.g. 123456", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> -
+
- <%= f.input :short_description, + <%= f.input :short_description, label: "Short description", as: :text, hint: "Plain-text blurb for the \"Add to calendar\" links. Calendars don't render HTML — leave blank to fall back to the page description.", @@ -223,30 +210,33 @@ placeholder: "Short description for calendar entries…", class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> +
-
- - <%# 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 %> +
+
@@ -254,29 +244,32 @@
<% if allowed_to?(:manage?, @event) %> -
+
- <%# On a failed save the event_forms aren't persisted, so re-read the - admin's registration choices from the submitted params instead of - the (now-empty) associations. %> + <%# + On a failed save the event_forms aren't persisted, so re-read the + admin's registration choices from the submitted params instead of + the (now-empty) associations. + %> <% resubmitted = params[:event].present? %> +
<% current_reg_form = @event.registration_form %> + <% default_form_id = if resubmitted params.dig(:event, :registration_form_id).presence&.to_i elsif current_reg_form @@ -286,45 +279,57 @@ else @registration_forms.find { |rf| rf.name == "Short Event Registration" }&.id end %> + -

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

+ +

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

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

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

+
- +

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

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

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

-

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

-
- <% if @scholarship_forms.any? %> -
- <% if @scholarship_forms.size == 1 %> - <% single_form = @scholarship_forms.first %> -
+ <% end %>
@@ -453,9 +518,11 @@ id="page_content_button" class=" flex items-center justify-between cursor-pointer w-full - bg-gray-500 text-white hover:bg-gray-300 px-3 py-2 rounded-md" + bg-gray-500 text-white hover:bg-gray-300 px-3 py-2 rounded-md + " data-action="dropdown#toggle" - data-dropdown-payload-param='[{"page_content":"hidden"}, {"page_content_arrow":"rotate-180"}, {"page_content_button":"rounded-md rounded-t-md"}]'> + data-dropdown-payload-param='[{"page_content":"hidden"}, {"page_content_arrow":"rotate-180"}, {"page_content_button":"rounded-md rounded-t-md"}]' + > Event show page @@ -467,74 +534,121 @@ <%= 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. + %> +
+ + <%# + Registrants supply (or correct) their license type + number here. With a + number on file we show it read-only with an Edit link; that link reloads the + page with ?editing=license to flip in the form (a plain full-page nav, no + JS). Saving POSTs and redirects back to the read-only view. With nothing on + file the form shows straight away. + %> + <% editing_license = params[:editing] == "license" %> + + <%# + Once the certificate is issued the license is locked — it's the credential + the certificate was issued under. Read-only, no Edit link; admins correct it + on the admin CE edit page. + %> + <% license_locked = ce_registration.certificate_sent_at.present? %> + +
+
+

Your professional license

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

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

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

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

+ <% end %> + +

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

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

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

+

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

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

Details

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

+ <%= @continuing_education_form.display_name %> +

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

Continuing education

+

<%= @continuing_education_form.display_name %>

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

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

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

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

+
+
+
+ <% end %>
diff --git a/app/views/events/revenue.html.erb b/app/views/events/revenue.html.erb index a61752cdc9..9c5d6626f6 100644 --- a/app/views/events/revenue.html.erb +++ b/app/views/events/revenue.html.erb @@ -171,7 +171,7 @@ Net = fees collected + scholarships − org subsidy. Total expected = net once all outstanding is paid.
- * Outstanding includes projected CE, an estimate ($<%= EventRegistration::CE_HOURLY_RATE_DOLLARS %> per requested hour) not yet collected. + * Outstanding includes projected CE — the CE fees owed by active registrants — not yet netted against CE payments collected.

<% else %> diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb index 107ea464be..564dd204d3 100644 --- a/app/views/forms/edit.html.erb +++ b/app/views/forms/edit.html.erb @@ -79,7 +79,7 @@
<%= f.select :role, - [["General", ""], ["Registration", "registration"], ["Scholarship", "scholarship"], ["Bulk Payment", "bulk_payment"]], + [["General", ""], ["Registration", "registration"], ["Scholarship", "scholarship"], ["Bulk Payment", "bulk_payment"], ["Continuing Education", "continuing_education"]], { selected: @form.role || "" }, class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 text-sm" %>
diff --git a/app/views/forms/new.html.erb b/app/views/forms/new.html.erb index 2cee1b8fd9..f24898f771 100644 --- a/app/views/forms/new.html.erb +++ b/app/views/forms/new.html.erb @@ -43,6 +43,7 @@ +
@@ -57,7 +58,12 @@
<% FormBuilderService::SECTIONS.each do |key, section| %> - <% section_role = key == :scholarship ? "scholarship" : key == :bulk_payment ? "bulk_payment" : "other" %> + <% section_role = case key + when :scholarship then "scholarship" + when :bulk_payment then "bulk_payment" + when :continuing_education then "continuing_education" + else "other" + end %> <% field_names = FormBuilderService.section_field_names(key) %>
diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index dc99406a5e..e2c8ff349f 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -351,11 +351,6 @@ focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50" }, hint: "Maximum 250 characters" %> - - <%= f.input :credentials, - label: "Credentials", - input_html: { class: "w-full" }, - wrapper_html: { class: "w-full" } %>
@@ -375,6 +370,23 @@
+
+
+ Professional licenses +
+
+

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

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

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

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

+
+<% end %> diff --git a/app/views/scholarships/edit.html.erb b/app/views/scholarships/edit.html.erb index 27a3311abb..c478953e97 100644 --- a/app/views/scholarships/edit.html.erb +++ b/app/views/scholarships/edit.html.erb @@ -36,6 +36,17 @@ <% end %> <% end %>
+ <% if @allocatable.respond_to?(:event) %> + <%# Admin jump to the registrant-facing scholarship callout (what the registrant + sees). Opens in a new tab so the edit context is kept; matches the sky + admin-link convention and the CE edit page's "Registrant's CE page". %> + <%= link_to registration_scholarship_path(@allocatable.slug, return_to: "scholarship"), target: "_blank", rel: "noopener", + class: "inline-flex items-center gap-1 text-xs rounded-full border px-2 py-0.5 bg-sky-100 text-sky-700 border-sky-200 hover:bg-sky-200 transition" do %> + + Registrant's scholarship page + + <% end %> + <% end %> <%= link_to "View", scholarship_path(@scholarship), class: "text-sm text-gray-500 hover:text-gray-700" %> <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700" %>
diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index ce76bbb7b8..3b2f00607c 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -105,7 +105,7 @@ collection: us_time_zone_fundamentals, selected: f.object.time_zone.presence || "Pacific Time (US & Canada)", include_blank: false, - hint: "Event times and other dates will be shown in this timezone.", + hint: timezone_visibility_hint(f.object), input_html: { class: "w-full" }, wrapper_html: { class: "w-full" } %> <% end %> diff --git a/app/views/workshop_variations/_form.html.erb b/app/views/workshop_variations/_form.html.erb index d5a5f690ff..b7c14b83c4 100644 --- a/app/views/workshop_variations/_form.html.erb +++ b/app/views/workshop_variations/_form.html.erb @@ -105,18 +105,21 @@ <% if params[:admin] == "true" %> <%= f.input :position, as: :integer, label: "Ordering", input_html: { class: "w-24" } %> <% end %> -
- <%= f.input :author_id, - as: :select, - label: (f.object.author ? ( - link_to "Variation author", - person_path(f.object.author), - class: "hover:underline") : "Variation author").html_safe, - collection: @people.map { |p| [ p.full_name_with_email, p.id ] }, - selected: (f.object.author_person&.id || current_user.person_id), - input_html: { class: "w-full rounded-lg px-3 py-2", - data: { searchable_select_target: "select" } } %> -
+ <%= f.input :author_id, + as: :select, + label: (f.object.author ? ( + link_to "Variation author", + person_path(f.object.author), + class: "hover:underline") : "Variation author").html_safe, + collection: f.object.author_person.present? ? [[ f.object.author_person.remote_search_label[:label], f.object.author_person.id ]] : [], + selected: f.object.author_person&.id || current_user.person_id, + include_blank: true, + input_html: { + data: { + controller: "remote-select", + remote_select_model_value: "person" + } + } %>

<% end %>
diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 7d8b4112e0..785b764f14 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -15,7 +15,7 @@ "type": "controller", "class": "EventsController", "method": "create", - "line": 123, + "line": 458, "file": "app/controllers/events_controller.rb", "rendered": { "name": "events/show", @@ -34,6 +34,63 @@ ], "note": "Stakeholder requirement for admin to add google analytics" }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 2, + "fingerprint": "343336d81aa87ab12862c5f9ca2b0f9ac4155488c053c1ce9c7ed27c456e3dba", + "check_name": "CrossSiteScripting", + "message": "Unescaped parameter value", + "file": "app/views/events/confirm_reminder.html.erb", + "line": 58, + "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", + "code": "EventMailer.event_registration_reminder(selected_reminder_registrations.first, :custom_message => params[:custom_message].to_s, :custom_subject => params[:custom_subject].to_s).html_part.body.decoded", + "render_path": [ + { + "type": "controller", + "class": "EventsController", + "method": "confirm_reminder", + "line": 397, + "file": "app/controllers/events_controller.rb", + "rendered": { + "name": "events/confirm_reminder", + "file": "app/views/events/confirm_reminder.html.erb" + } + } + ], + "location": { + "type": "template", + "template": "events/confirm_reminder" + }, + "user_input": "params[:custom_message].to_s", + "confidence": "Weak", + "cwe_id": [ + 79 + ], + "note": "Admin-only reminder confirmation. Same as preview_reminder: the raw value is the server-rendered email HTML; the embedded custom message is sanitized via reminder_message_html (SafeListSanitizer) and the custom subject is shown escaped, separately, so no unsanitized user input reaches the page." + }, + { + "warning_type": "Redirect", + "warning_code": 18, + "fingerprint": "6b0a218f30eb40af7b6cf4ec15de35003eb42e1cea35ecd6acb69139643a338b", + "check_name": "Redirect", + "message": "Possible unprotected redirect", + "file": "app/controllers/events/callouts_controller.rb", + "line": 257, + "link": "https://brakemanscanner.org/docs/warning_types/redirect/", + "code": "redirect_to(EventRegistration.find_by!(:slug => params[:slug]).registrant.payment_processor.checkout(:mode => \"payment\", :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :payment_intent_data => ({ :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :description => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :line_items => ([{ :price_data => ({ :currency => \"usd\", :product_data => ({ :name => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :unit_amount => ce_registration.remaining_cost }), :quantity => 1 }]), :success_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"success\"), :cancel_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"cancelled\")).url, :allow_other_host => true, :status => :see_other)", + "render_path": null, + "location": { + "type": "method", + "class": "Events::CalloutsController", + "method": "redirect_to_ce_stripe_checkout" + }, + "user_input": "EventRegistration.find_by!(:slug => params[:slug]).registrant.payment_processor.checkout(:mode => \"payment\", :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :payment_intent_data => ({ :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :description => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :line_items => ([{ :price_data => ({ :currency => \"usd\", :product_data => ({ :name => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :unit_amount => ce_registration.remaining_cost }), :quantity => 1 }]), :success_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"success\"), :cancel_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"cancelled\")).url", + "confidence": "Weak", + "cwe_id": [ + 601 + ], + "note": "known redirect for stripe" + }, { "warning_type": "Cross-Site Scripting", "warning_code": 2, @@ -49,7 +106,7 @@ "type": "controller", "class": "EventsController", "method": "create", - "line": 123, + "line": 458, "file": "app/controllers/events_controller.rb", "rendered": { "name": "events/show", @@ -75,7 +132,7 @@ "check_name": "Redirect", "message": "Possible unprotected redirect", "file": "app/controllers/people_controller.rb", - "line": 119, + "line": 148, "link": "https://brakemanscanner.org/docs/warning_types/redirect/", "code": "redirect_to(Person.find(params[:id]).payment_processor.checkout(:mode => \"payment\", :metadata => ({ :person_id => Person.find(params[:id]).id }), :payment_intent_data => ({ :metadata => ({ :person_id => Person.find(params[:id]).id }) }), :line_items => ([{ :price_data => ({ :currency => \"usd\", :product_data => ({ :name => \"Donation to A Window Between Worlds\" }), :unit_amount => (((params[:amount].to_i * 100).to_i or 1000)) }), :quantity => 1 }]), :success_url => person_url(Person.find(params[:id]), :checkout => \"success\"), :cancel_url => person_url(Person.find(params[:id]), :checkout => \"cancelled\")).url, :allow_other_host => true, :status => :see_other)", "render_path": null, @@ -93,49 +150,49 @@ }, { "warning_type": "Mass Assignment", - "warning_code": 70, - "fingerprint": "abea946803acd0f0fe12c3bcd54e9b5b04d04f78ff6672e38a6cb8db02e3b5eb", - "check_name": "MassAssignment", - "message": "Specify exact keys allowed for mass assignment instead of using `permit!` which allows any keys", - "file": "app/controllers/reports_controller.rb", - "line": 263, + "warning_code": 105, + "fingerprint": "a750b09d4d42ef567df595f7a9035933dc1178c533bf8ae8f8986ae8e837c6b8", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/forms_controller.rb", + "line": 138, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", - "code": "params[:quotes].permit!", + "code": "params.require(:form).permit(:name, :role, :header, :hide_answered_person_questions, :hide_answered_form_questions, :form_fields_attributes => ([:id, :name, :answer_type, :required, :subtitle, :hint_text, :field_identifier, :section, :position, :visibility, :one_time, :width, :min_words, :max_characters, :_destroy, { :form_field_answer_options_attributes => ([:id, :option_name, :_destroy]) }]))", "render_path": null, "location": { "type": "method", - "class": "ReportsController", - "method": "quotes_params" + "class": "FormsController", + "method": "form_params" }, - "user_input": null, + "user_input": ":role", "confidence": "Medium", "cwe_id": [ 915 ], - "note": "" + "note": "admin only" }, { "warning_type": "Mass Assignment", - "warning_code": 105, - "fingerprint": "a750b09d4d42ef567df595f7a9035933dc1178c533bf8ae8f8986ae8e837c6b8", - "check_name": "PermitAttributes", - "message": "Potentially dangerous key allowed for mass assignment", - "file": "app/controllers/forms_controller.rb", - "line": 138, + "warning_code": 70, + "fingerprint": "abea946803acd0f0fe12c3bcd54e9b5b04d04f78ff6672e38a6cb8db02e3b5eb", + "check_name": "MassAssignment", + "message": "Specify exact keys allowed for mass assignment instead of using `permit!` which allows any keys", + "file": "app/controllers/reports_controller.rb", + "line": 263, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", - "code": "params.require(:form).permit(:name, :role, :header, :hide_answered_person_questions, :hide_answered_form_questions, :form_fields_attributes => ([:id, :name, :answer_type, :required, :subtitle, :hint_text, :field_identifier, :section, :position, :visibility, :one_time, :width, :min_words, :max_characters, :_destroy, { :form_field_answer_options_attributes => ([:id, :option_name, :_destroy]) }]))", + "code": "params[:quotes].permit!", "render_path": null, "location": { "type": "method", - "class": "FormsController", - "method": "form_params" + "class": "ReportsController", + "method": "quotes_params" }, - "user_input": ":role", + "user_input": null, "confidence": "Medium", "cwe_id": [ 915 ], - "note": "admin only" + "note": "" }, { "warning_type": "Cross-Site Scripting", @@ -152,7 +209,7 @@ "type": "controller", "class": "EventsController", "method": "create", - "line": 123, + "line": 458, "file": "app/controllers/events_controller.rb", "rendered": { "name": "events/show", @@ -178,7 +235,7 @@ "check_name": "CrossSiteScripting", "message": "Unescaped model attribute", "file": "app/views/notifications/show.html.erb", - "line": 190, + "line": 196, "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", "code": "Notification.find(params[:id]).email_body_html", "render_path": [ @@ -204,42 +261,7 @@ 79 ], "note": "email_body_html is the server-rendered HTML of a notification email, generated from trusted mailer templates rather than user-supplied free text. It is rendered intentionally as a preview on notifications/show, which is gated to admin-or-owner (NotificationPolicy#show?), so no untrusted input reaches the page." - }, - { - "warning_type": "Cross-Site Scripting", - "warning_code": 2, - "fingerprint": "343336d81aa87ab12862c5f9ca2b0f9ac4155488c053c1ce9c7ed27c456e3dba", - "check_name": "CrossSiteScripting", - "message": "Unescaped parameter value", - "file": "app/views/events/confirm_reminder.html.erb", - "line": 53, - "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", - "code": "EventMailer.event_registration_reminder(selected_reminder_registrations.first, :custom_message => params[:custom_message].to_s, :custom_subject => params[:custom_subject].to_s).html_part.body.decoded", - "render_path": [ - { - "type": "controller", - "class": "EventsController", - "method": "confirm_reminder", - "line": 316, - "file": "app/controllers/events_controller.rb", - "rendered": { - "name": "events/confirm_reminder", - "file": "app/views/events/confirm_reminder.html.erb" - } - } - ], - "location": { - "type": "template", - "template": "events/confirm_reminder" - }, - "user_input": "params[:custom_message].to_s", - "confidence": "Weak", - "cwe_id": [ - 79 - ], - "note": "Admin-only reminder confirmation. Same as preview_reminder: the raw value is the server-rendered email HTML; the embedded custom message is sanitized via reminder_message_html (SafeListSanitizer) and the custom subject is shown escaped, separately, so no unsanitized user input reaches the page." } ], - "brakeman_version": "8.0.4", - "updated": "2026-06-19 10:05:46 -0400" + "brakeman_version": "8.0.5" } diff --git a/config/routes.rb b/config/routes.rb index b107a16572..00eea12ffe 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -84,6 +84,9 @@ 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 @@ -117,6 +120,9 @@ resources :scholarships, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do member { patch :toggle_tasks } end + resources :continuing_education_registrations, only: [ :new, :create, :edit, :update, :destroy ] do + member { patch :toggle_certificate } + end resources :discounts, only: [ :create, :show, :destroy ] do collection do post :allocation_form diff --git a/db/migrate/20260629122601_remove_ce_columns_from_event_registrations.rb b/db/migrate/20260629122601_remove_ce_columns_from_event_registrations.rb new file mode 100644 index 0000000000..029e38859d --- /dev/null +++ b/db/migrate/20260629122601_remove_ce_columns_from_event_registrations.rb @@ -0,0 +1,16 @@ +class RemoveCeColumnsFromEventRegistrations < ActiveRecord::Migration[8.1] + # CE is now tracked as ContinuingEducationRegistration records, so these flat + # columns are obsolete. No data to preserve (the CE form was never used in + # production). + def up + remove_column :event_registrations, :ce_credit_requested, if_exists: true + remove_column :event_registrations, :ce_hours_requested, if_exists: true + remove_column :event_registrations, :ce_license_number, if_exists: true + end + + def down + add_column :event_registrations, :ce_credit_requested, :boolean, null: false, default: false unless column_exists?(:event_registrations, :ce_credit_requested) + add_column :event_registrations, :ce_hours_requested, :integer unless column_exists?(:event_registrations, :ce_hours_requested) + add_column :event_registrations, :ce_license_number, :string unless column_exists?(:event_registrations, :ce_license_number) + end +end diff --git a/db/migrate/20260629173817_add_ce_requested_to_event_registrations.rb b/db/migrate/20260629173817_add_ce_requested_to_event_registrations.rb new file mode 100644 index 0000000000..8d294a7fc9 --- /dev/null +++ b/db/migrate/20260629173817_add_ce_requested_to_event_registrations.rb @@ -0,0 +1,14 @@ +class AddCeRequestedToEventRegistrations < ActiveRecord::Migration[8.1] + # The CE "Requested" intent flag, mirroring scholarship_requested / w9_requested + # / invoice_requested. It's the toggle the registrant/admin sets; the + # ContinuingEducationRegistration record is the fulfillment, created from it. + def up + unless column_exists?(:event_registrations, :ce_requested) + add_column :event_registrations, :ce_requested, :boolean, null: false, default: false + end + end + + def down + remove_column :event_registrations, :ce_requested, if_exists: true + end +end diff --git a/db/migrate/20260630133153_remove_credentials_from_people.rb b/db/migrate/20260630133153_remove_credentials_from_people.rb new file mode 100644 index 0000000000..fb0886729c --- /dev/null +++ b/db/migrate/20260630133153_remove_credentials_from_people.rb @@ -0,0 +1,12 @@ +class RemoveCredentialsFromPeople < ActiveRecord::Migration[8.1] + # The free-text credentials field is no longer edited or displayed — the profile + # credential suffix now derives from the person's professional-license types + # (Person#license_credentials). The profile_show_credentials toggle stays. + def up + remove_column :people, :credentials, if_exists: true + end + + def down + add_column :people, :credentials, :string unless column_exists?(:people, :credentials) + end +end diff --git a/db/migrate/20260630220947_scope_professional_license_uniqueness_to_kind.rb b/db/migrate/20260630220947_scope_professional_license_uniqueness_to_kind.rb new file mode 100644 index 0000000000..856a7c646a --- /dev/null +++ b/db/migrate/20260630220947_scope_professional_license_uniqueness_to_kind.rb @@ -0,0 +1,10 @@ +class ScopeProfessionalLicenseUniquenessToKind < ActiveRecord::Migration[8.1] + # A license is identified by kind + number, not number alone, so "LMFT 12345" + # and "LCSW 12345" are distinct. Widen the per-person unique index. + def change + remove_index :professional_licenses, [ :person_id, :number ], unique: true, + name: "index_professional_licenses_on_person_and_number" + add_index :professional_licenses, [ :person_id, :kind, :number ], unique: true, + name: "index_professional_licenses_on_person_kind_number" + end +end diff --git a/db/migrate/20260711193528_remove_ce_requested_from_event_registrations.rb b/db/migrate/20260711193528_remove_ce_requested_from_event_registrations.rb new file mode 100644 index 0000000000..c4165fb9dd --- /dev/null +++ b/db/migrate/20260711193528_remove_ce_requested_from_event_registrations.rb @@ -0,0 +1,15 @@ +class RemoveCeRequestedFromEventRegistrations < ActiveRecord::Migration[8.1] + # `ce_requested` was the intent flag mirrored by the existence of a + # ContinuingEducationRegistration record. Every flow that set it also created + # (or destroyed) the record in the same transaction, so `ce_registered?` is + # the same signal — the column is redundant and is dropped here. + def up + remove_column :event_registrations, :ce_requested, if_exists: true + end + + def down + unless column_exists?(:event_registrations, :ce_requested) + add_column :event_registrations, :ce_requested, :boolean, null: false, default: false + end + end +end diff --git a/db/migrate/20260712183112_unique_magic_key_event_index.rb b/db/migrate/20260712183112_unique_magic_key_event_index.rb new file mode 100644 index 0000000000..9ac54ae310 --- /dev/null +++ b/db/migrate/20260712183112_unique_magic_key_event_index.rb @@ -0,0 +1,6 @@ +class UniqueMagicKeyEventIndex < ActiveRecord::Migration[8.1] + def change + remove_index :registration_ticket_callouts, column: [ :event_id, :magic_key ] + add_index :registration_ticket_callouts, [ :event_id, :magic_key ], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 05981cec33..ee633774d4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_09_184214) do +ActiveRecord::Schema[8.1].define(version: 2026_07_12_183112) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -479,9 +479,6 @@ 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 @@ -968,7 +965,6 @@ t.boolean "blog_contributor", default: false, null: false t.datetime "created_at", null: false t.integer "created_by_id" - t.string "credentials" t.date "date_of_birth" t.string "display_name_preference" t.string "email" @@ -1034,7 +1030,7 @@ t.datetime "updated_at", null: false t.bigint "updated_by_id" t.index ["created_by_id"], name: "index_professional_licenses_on_created_by_id" - t.index ["person_id", "number"], name: "index_professional_licenses_on_person_and_number", unique: true + t.index ["person_id", "kind", "number"], name: "index_professional_licenses_on_person_kind_number", unique: true t.index ["person_id"], name: "index_professional_licenses_on_person_id" t.index ["updated_by_id"], name: "index_professional_licenses_on_updated_by_id" end @@ -1106,7 +1102,7 @@ t.string "subtitle" t.string "title", null: false t.datetime "updated_at", null: false - t.index ["event_id", "magic_key"], name: "index_registration_ticket_callouts_on_event_id_and_magic_key" + t.index ["event_id", "magic_key"], name: "index_registration_ticket_callouts_on_event_id_and_magic_key", unique: true t.index ["event_id", "position"], name: "index_registration_ticket_callouts_on_event_id_and_position" t.index ["event_id"], name: "index_registration_ticket_callouts_on_event_id" end @@ -1381,7 +1377,7 @@ t.bigint "item_id", null: false t.string "item_type", null: false t.text "object", size: :long - t.text "object_changes" + t.text "object_changes", size: :long t.string "whodunnit" t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" end diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index 3986c176ab..19a9959749 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -37,11 +37,20 @@ ).call end +unless Form.standalone.find_by(role: "continuing_education") + FormBuilderService.new( + name: "Continuing Education Request", + sections: %i[continuing_education], + role: "continuing_education" + ).call +end + puts "Creating Events with shared forms…" admin_user = User.find_by(email: "umberto.user@example.com") registration_form = Form.standalone.find_by!(role: "registration") scholarship_form = Form.standalone.find_by!(role: "scholarship") bulk_payment_form = Form.standalone.find_by!(role: "bulk_payment") +continuing_education_form = Form.standalone.find_by!(role: "continuing_education") # Seed an example header (admin-authored HTML intro shown under the form title on # the public registration page) onto the base registration form, mirroring the @@ -155,71 +164,6 @@ .update_all(subtitle: "Payments are due no more than three weeks after your registration date. " \ "Training details will be sent after payments are received.") -# The CE-interest "magic question": a single Yes/No whose 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 @@ -267,7 +211,7 @@ # the order the real form uses. Idempotent: re-running lands on the same order. registration_section_order = %w[ person_identifier person_contact_info professional background marketing - continuing_education payment additional_forms consent + payment additional_forms consent ] registration_section_rank = registration_section_order.each_with_index.to_h registration_form.form_fields.reorder(:position).to_a.each_with_index @@ -769,10 +713,11 @@ # so she can reach her training materials (the intends_to_pay scenario). Pairs # with Amy on this same event, who DOES have payments, for side-by-side review. if facilitator_training + facilitator_training.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) [ - { person: amy_person, status: "registered", scholarship_requested: true, w9_requested: true, invoice_requested: true, ce_credit_requested: true }, + { person: amy_person, status: "registered", scholarship_requested: true, w9_requested: true, invoice_requested: true, ce_credit_requested: true, ce_license_number: "LMFT 90210" }, { person: maria_j, status: "registered", invoice_requested: true, ce_credit_requested: true, intends_to_pay: true }, - { person: anna_g, status: "attended", ce_credit_requested: true, intends_to_pay: true }, + { person: anna_g, status: "attended", ce_credit_requested: true, intends_to_pay: true, ce_license_number: "LCSW 11223", ce_status: "issued" }, { person: mario_j, status: "registered" }, { person: kim_d, status: "cancelled" }, { person: aisha_person, status: "registered", intends_to_pay: true } @@ -788,8 +733,9 @@ # Angel Garcia: registered, no form (no user) # Linda Williams: no_show (no user) if trauma_training + trauma_training.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) [ - { person: sarah_s, status: "registered", invoice_requested: true, ce_credit_requested: true }, + { person: sarah_s, status: "registered", invoice_requested: true, ce_credit_requested: true, ce_license_number: "LPCC 44556" }, { person: jessica_b, status: "registered", scholarship_requested: true, ce_credit_requested: true }, { person: angel_g, status: "registered" }, { person: linda_w, status: "no_show" } @@ -858,9 +804,17 @@ # 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 837167f75e..49bb4337cf 100644 --- a/spec/decorators/event_registration_decorator_spec.rb +++ b/spec/decorators/event_registration_decorator_spec.rb @@ -74,4 +74,78 @@ 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 3f17a9fb12..277bcec170 100644 --- a/spec/factories/event_forms.rb +++ b/spec/factories/event_forms.rb @@ -11,5 +11,9 @@ trait :scholarship do role { "scholarship" } end + + trait :continuing_education do + role { "continuing_education" } + end end end diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 7b7c3681c1..8533528d3e 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -53,6 +53,21 @@ end end + describe "#timezone_visibility_hint" do + let(:me) { create(:user) } + let(:other) { create(:user) } + + before { allow(helper).to receive(:current_user).and_return(me) } + + it "uses second person when the form is about the current user" do + expect(helper.timezone_visibility_hint(me)).to eq("You will see times and dates in this timezone.") + end + + it "uses 'User' when an admin edits someone else" do + expect(helper.timezone_visibility_hint(other)).to eq("User will see times and dates in this timezone.") + end + end + describe "#dollars_from_cents" do it "drops the cents for whole-dollar amounts and adds thousands separators" do expect(helper.dollars_from_cents(150_000)).to eq("$1,500") diff --git a/spec/helpers/event_helper_spec.rb b/spec/helpers/event_helper_spec.rb index 8426f5f677..d06f3bbbe5 100644 --- a/spec/helpers/event_helper_spec.rb +++ b/spec/helpers/event_helper_spec.rb @@ -19,13 +19,6 @@ expect(helper.specify_placeholder("Other reasons")).to be_nil expect(helper.specify_placeholder(nil)).to be_nil end - - it "returns the CE hours placeholder for 'Yes' only on the CE field" do - ce_field = FormField.new(field_identifier: "ce_credit_interest") - other_field = FormField.new(field_identifier: "interested_in_more") - expect(helper.specify_placeholder("Yes", ce_field)).to eq("How many CE hours?") - expect(helper.specify_placeholder("Yes", other_field)).to be_nil - end end describe "#specify_option_selected?" do diff --git a/spec/models/concerns/pay_charge_extensions_spec.rb b/spec/models/concerns/pay_charge_extensions_spec.rb index bf9a82df2f..831a77151e 100644 --- a/spec/models/concerns/pay_charge_extensions_spec.rb +++ b/spec/models/concerns/pay_charge_extensions_spec.rb @@ -58,6 +58,64 @@ end end + describe "CE payment via ce_registration_id metadata" do + let(:ce_registration) do + create(:continuing_education_registration, event_registration: registration, cost_cents: 15_00) + end + + let(:charge_object) do + { + "id" => "ch_ce_456", + "object" => "charge", + "amount" => 10_00, + "currency" => "usd", + "paid" => true, + "metadata" => { "ce_registration_id" => ce_registration.id, "event_registration_id" => registration.id }, + "refunds" => { "object" => "list", "data" => [], "has_more" => false } + } + end + + let!(:pay_charge) do + Pay::Charge.create!( + customer: pay_customer, + processor_id: "ch_ce_456", + amount: 10_00, + amount_refunded: 0, + currency: "usd", + object: charge_object, + metadata: { "ce_registration_id" => ce_registration.id, "event_registration_id" => registration.id } + ) + end + + it "creates an ExternalProcessorPayment" do + payment = ExternalProcessorPayment.find_by(pay_charge_id: pay_charge.id) + expect(payment).to have_attributes( + amount_cents: 10_00, + person: person, + pay_charge: pay_charge + ) + end + + it "creates an Allocation against the CE registration" do + payment = ExternalProcessorPayment.find_by(pay_charge_id: pay_charge.id) + allocation = payment.allocations.first + expect(allocation.allocatable).to eq(ce_registration) + expect(allocation.amount).to eq(10_00) + end + + it "allocates against the CE registration even when event_registration_id is also present" do + payment = ExternalProcessorPayment.find_by(pay_charge_id: pay_charge.id) + expect(payment.allocations.first.allocatable).to eq(ce_registration) + expect(Allocation.where(allocatable: registration, source: payment)).to be_empty + end + + it "is idempotent" do + expect do + pay_charge.update!(object: charge_object) + end.not_to change(ExternalProcessorPayment, :count) + end + end + describe "refund sync" do let(:refunded_object) do charge_object.deep_merge( diff --git a/spec/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb index e90ab505d1..1af98e0860 100644 --- a/spec/models/continuing_education_registration_spec.rb +++ b/spec/models/continuing_education_registration_spec.rb @@ -12,6 +12,41 @@ expect(ce_reg).not_to be_valid expect(ce_reg.errors[:professional_license]).to include("must belong to the registrant") end + + describe "cost_not_below_allocations" do + it "allows setting cost above allocations" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + payment = create(:payment, amount_cents: 3_000, amount_cents_remaining: 3_000) + create(:allocation, source: payment, allocatable: ce_reg, amount: 3_000) + + ce_reg.cost_cents = 5_000 + expect(ce_reg).to be_valid + end + + it "allows setting cost equal to allocations" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + payment = create(:payment, amount_cents: 5_000, amount_cents_remaining: 5_000) + create(:allocation, source: payment, allocatable: ce_reg, amount: 5_000) + + ce_reg.cost_cents = 5_000 + expect(ce_reg).to be_valid + end + + it "rejects setting cost below allocations" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + payment = create(:payment, amount_cents: 5_000, amount_cents_remaining: 5_000) + create(:allocation, source: payment, allocatable: ce_reg, amount: 5_000) + + ce_reg.cost_cents = 3_000 + expect(ce_reg).not_to be_valid + expect(ce_reg.errors[:cost_cents]).to include("can't be less than the amount already allocated ($50)") + end + + it "allows setting cost on create even without allocations" do + ce_reg = build(:continuing_education_registration, cost_cents: 10_000) + expect(ce_reg).to be_valid + end + end end describe "hours and cost defaults from the event" do @@ -186,5 +221,28 @@ def scholarship_for(reg, amount) expect(queries).to be_empty expect(preloaded.paid_in_full?).to be(true) end + + it "reports discounted? and discount_sum like an event registration" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + expect(ce_reg.discounted?).to be(false) + expect(ce_reg.discount_sum).to eq(0) + + create(:allocation, source: create(:discount, amount_cents: 4_000), allocatable: ce_reg, amount: 4_000) + + expect(ce_reg.discounted?).to be(true) + expect(ce_reg.discount_sum).to eq(4_000) + end + + it "labels payment status Due → Partial → Paid" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + expect(ce_reg.payment_status_label).to eq("Due") + + payment = create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000) + create(:allocation, source: payment, allocatable: ce_reg, amount: 4_000) + expect(ce_reg.payment_status_label).to eq("Partial") + + create(:allocation, source: payment, allocatable: ce_reg, amount: 6_000) + expect(ce_reg.payment_status_label).to eq("Paid") + end end end diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index 6376fcef6b..f947d0194a 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -323,41 +323,68 @@ def registration_with_scholarship end describe ".ce_status" do - let!(:complete_ce) do - create(:event_registration, event: event, ce_credit_requested: true, ce_license_number: "ABC123", ce_hours_requested: 3).tap do |r| - create(:allocation, source: create(:payment, amount_cents: event.cost_cents, amount_cents_remaining: event.cost_cents), - allocatable: r, amount: event.cost_cents) + let(:ce_cost) { 15_000 } + # Known license, fully paid (certificate not yet issued). + let!(:paid_ce) do + create(:event_registration, event: event).tap do |r| + cer = create(:continuing_education_registration, event_registration: r, cost_cents: ce_cost) + create(:allocation, source: create(:payment, amount_cents: ce_cost, amount_cents_remaining: ce_cost), + allocatable: cer, amount: ce_cost) end end - let!(:missing_ce) { create(:event_registration, event: event, ce_credit_requested: true) } - let!(:no_ce) { create(:event_registration, event: event, ce_credit_requested: false) } + # Known license, unpaid. + let!(:requested_ce) do + create(:event_registration, event: event).tap do |r| + create(:continuing_education_registration, event_registration: r, cost_cents: ce_cost) + end + end + # CE registration sitting on a placeholder (numberless) license. + let!(:needs_license_ce) do + create(:event_registration, event: event).tap do |r| + license = create(:professional_license, :placeholder, person: r.registrant) + create(:continuing_education_registration, event_registration: r, professional_license: license, cost_cents: ce_cost) + end + end + # Certificate delivered. + let!(:issued_ce) do + create(:event_registration, event: event).tap do |r| + create(:continuing_education_registration, event_registration: r, cost_cents: ce_cost, certificate_sent_at: Time.current) + end + end + let!(:no_ce) { create(:event_registration, event: event) } - it "maps 'requested' to anyone who asked for CE credit" do - results = EventRegistration.ce_status("requested") - expect(results).to include(complete_ce, missing_ce) - expect(results).not_to include(no_ce) + it "maps 'needs_license' to CE on a placeholder license" do + results = EventRegistration.ce_status("needs_license") + expect(results).to include(needs_license_ce) + expect(results).not_to include(paid_ce, requested_ce, no_ce) end - it "maps 'license_not_provided' to CE requests missing a license number" do - results = EventRegistration.ce_status("license_not_provided") - expect(results).to include(missing_ce) - expect(results).not_to include(complete_ce, no_ce) + it "maps 'paid' to fully paid CE registrations" do + results = EventRegistration.ce_status("paid") + expect(results).to include(paid_ce) + expect(results).not_to include(requested_ce, no_ce) end - it "maps 'hours_not_provided' to CE requests missing hours" do - results = EventRegistration.ce_status("hours_not_provided") - expect(results).to include(missing_ce) - expect(results).not_to include(complete_ce, no_ce) + it "maps 'requested' to CE registrations not yet paid" do + results = EventRegistration.ce_status("requested") + expect(results).to include(requested_ce, needs_license_ce) + expect(results).not_to include(paid_ce, no_ce) end - it "maps 'paid' to CE requests that are paid in full" do - results = EventRegistration.ce_status("paid") - expect(results).to include(complete_ce) - expect(results).not_to include(missing_ce, no_ce) + it "maps 'issued' to CE registrations with a delivered certificate" do + results = EventRegistration.ce_status("issued") + expect(results).to include(issued_ce) + expect(results).not_to include(requested_ce, no_ce) + end + + it "maps 'not_issued' to CE registrations without a delivered certificate" do + results = EventRegistration.ce_status("not_issued") + expect(results).to include(paid_ce, requested_ce) + expect(results).not_to include(issued_ce, no_ce) end it "returns an unfiltered relation for unknown values" do - expect(EventRegistration.ce_status("bogus")).to include(complete_ce, missing_ce, no_ce) + expect(EventRegistration.ce_status("bogus")).to include(paid_ce, requested_ce, no_ce) end end @@ -718,33 +745,66 @@ def registration_with_scholarship describe "continuing education" do let(:reg) { create(:event_registration) } + def add_ce(number: "LIC-123", hours: 4, cost_cents: 15_000) + license = create(:professional_license, person: reg.registrant, number: number) + create(:continuing_education_registration, event_registration: reg, professional_license: license, hours: hours, cost_cents: cost_cents) + end + describe "#ce_amount_owed_cents" do - it "multiplies requested hours by the default hourly rate" do - reg.ce_hours_requested = 4 - expect(reg.ce_amount_owed_cents).to eq(4 * EventRegistration::CE_HOURLY_RATE_DOLLARS * 100) + it "sums the cost across the registration's CE registrations" do + add_ce(cost_cents: 10_000) + expect(reg.ce_amount_owed_cents).to eq(10_000) end - it "is zero when no hours are requested" do - reg.ce_hours_requested = nil + it "is zero when no CE is requested" do expect(reg.ce_amount_owed_cents).to eq(0) end end + describe "#ce_registered?" do + it "is true only once a CE registration exists" do + expect(reg).not_to be_ce_registered + add_ce + expect(reg.reload).to be_ce_registered + end + end + describe "#ce_license_provided?" do - it "is true only when a license number is present" do - reg.ce_license_number = "LIC-123" - expect(reg).to be_ce_license_provided - reg.ce_license_number = "" - expect(reg).not_to be_ce_license_provided + it "is true only when every CE registration has a known license number" do + add_ce(number: "LIC-123") + expect(reg.reload).to be_ce_license_provided + end + + it "is false when a CE registration sits on a placeholder license" do + add_ce(number: nil) + expect(reg.reload).not_to be_ce_license_provided + end + end + + describe "#ce_amount_due_cents" do + it "is the cost not yet covered by payments, floored at zero" do + cer = add_ce(cost_cents: 15_000) + expect(reg.ce_amount_due_cents).to eq(15_000) + + payment = create(:payment, person: reg.registrant, amount_cents: 6_000, amount_cents_remaining: nil) + create(:allocation, source: payment, allocatable: cer, amount: 6_000) + expect(reg.reload.ce_amount_due_cents).to eq(9_000) + end + + it "is zero once fully paid" do + cer = add_ce(cost_cents: 15_000) + payment = create(:payment, person: reg.registrant, amount_cents: 15_000, amount_cents_remaining: nil) + create(:allocation, source: payment, allocatable: cer, amount: 15_000) + expect(reg.reload.ce_amount_due_cents).to eq(0) end end - describe "ce_hours_requested validation" do - it "rejects negative or non-integer hours but allows nil" do - reg.ce_hours_requested = nil - expect(reg).to be_valid - reg.ce_hours_requested = -1 - expect(reg).not_to be_valid + describe "#ce_certificate_issued?" do + it "is true only once every CE registration's certificate is sent" do + cer = add_ce + expect(reg.reload).not_to be_ce_certificate_issued + cer.mark_certificate_sent! + expect(reg.reload).to be_ce_certificate_issued end end end diff --git a/spec/models/form_field_spec.rb b/spec/models/form_field_spec.rb index b937d699b0..4acece44a7 100644 --- a/spec/models/form_field_spec.rb +++ b/spec/models/form_field_spec.rb @@ -392,12 +392,12 @@ def selectable_field(type:, option_names:) expect(field.answer_inclusion_error("Foundation/Funder: ACME")).to eq("has an invalid selection") end - it "accepts the CE question's field-scoped 'Yes: ' specify answer" do + it "treats the CE question's 'Yes' as a plain choice (hours come from the event)" do field = selectable_field(type: :single_select_radio, option_names: %w[Yes No]) field.update!(field_identifier: "ce_credit_interest") expect(field.answer_inclusion_error("Yes")).to be_nil - expect(field.answer_inclusion_error("Yes: 6")).to be_nil expect(field.answer_inclusion_error("No")).to be_nil + expect(field.answer_inclusion_error("Yes: 6")).to eq("has an invalid selection") end it "treats a bare 'Yes' as a plain choice on other fields" do diff --git a/spec/models/professional_license_spec.rb b/spec/models/professional_license_spec.rb index 9ed1565e62..ad43d763b6 100644 --- a/spec/models/professional_license_spec.rb +++ b/spec/models/professional_license_spec.rb @@ -45,11 +45,60 @@ end describe "validations" do - it "rejects a duplicate number for the same person" do - create(:professional_license, person: person, number: "DUP") - dup = build(:professional_license, person: person, number: "DUP") + it "rejects a duplicate kind + number for the same person" do + create(:professional_license, person: person, kind: "LMFT", number: "DUP") + dup = build(:professional_license, person: person, kind: "LMFT", number: "DUP") expect(dup).not_to be_valid end + + it "allows (and persists) the same number under a different kind" do + create(:professional_license, person: person, kind: "LMFT", number: "DUP") + + expect { + create(:professional_license, person: person, kind: "LCSW", number: "DUP") + }.to change(described_class, :count).by(1) + end + end + + describe "removal guard" do + let(:license) { create(:professional_license, person: person, number: "GUARD-1") } + + def attach_ce(paid: false) + registration = create(:event_registration, registrant: person) + ce = create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 10_000) + return ce unless paid + create(:allocation, source: create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000), + allocatable: ce, amount: 10_000) + ce + end + + it "is removable with no CE registrations" do + expect(license).to be_removable + expect { license.destroy }.to change(described_class, :count).by(-1) + end + + it "refuses to destroy when an unpaid CE registration exists" do + attach_ce(paid: false) + expect(license.reload).not_to be_removable + expect { license.destroy }.not_to change(described_class, :count) + end + + it "refuses to destroy when a CE registration carries payments" do + attach_ce(paid: true) + expect(license.reload).not_to be_removable + expect { license.destroy }.not_to change(described_class, :count) + end + end + + describe "#used_for_ce?" do + let(:license) { create(:professional_license, person: person, number: "USED-1") } + + it "is false without CE registrations and true once one exists" do + expect(license).not_to be_used_for_ce + registration = create(:event_registration, registrant: person) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + expect(license.reload).to be_used_for_ce + end end end diff --git a/spec/policies/professional_license_policy_spec.rb b/spec/policies/professional_license_policy_spec.rb new file mode 100644 index 0000000000..13668c33d1 --- /dev/null +++ b/spec/policies/professional_license_policy_spec.rb @@ -0,0 +1,59 @@ +require "rails_helper" + +RSpec.describe ProfessionalLicensePolicy, type: :policy do + let(:owner) { create(:user, :with_person) } + let(:admin) { create(:user, :admin) } + let(:other) { create(:user, :with_person) } + + let(:license) { create(:professional_license, person: owner.person, number: "POL-1") } + + def policy_for(user:, record: license) + described_class.new(record, user: user) + end + + def attach_ce + registration = create(:event_registration, registrant: owner.person) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + end + + describe "#edit? / #update?" do + context "an admin" do + it "may always edit, even with CE registrations" do + expect(policy_for(user: admin)).to be_allowed_to(:edit?) + attach_ce + expect(policy_for(user: admin, record: license.reload)).to be_allowed_to(:edit?) + end + end + + context "the holder (owner)" do + it "may edit a license with no CE registrations" do + expect(policy_for(user: owner)).to be_allowed_to(:edit?) + expect(policy_for(user: owner)).to be_allowed_to(:update?) + end + + it "may not edit once the license has CE registrations" do + attach_ce + expect(policy_for(user: owner, record: license.reload)).not_to be_allowed_to(:edit?) + end + end + + context "an unrelated user" do + it "may not edit" do + expect(policy_for(user: other)).not_to be_allowed_to(:edit?) + end + end + end + + describe "#destroy?" do + it "is allowed for the owner only when the license has no CE registrations" do + expect(policy_for(user: owner)).to be_allowed_to(:destroy?) + attach_ce + expect(policy_for(user: owner, record: license.reload)).not_to be_allowed_to(:destroy?) + end + + it "is blocked even for an admin once a CE registration exists" do + attach_ce + expect(policy_for(user: admin, record: license.reload)).not_to be_allowed_to(:destroy?) + end + end +end diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb new file mode 100644 index 0000000000..fab67ec8ed --- /dev/null +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -0,0 +1,178 @@ +require "rails_helper" + +RSpec.describe "ContinuingEducationRegistrations", type: :request do + let(:admin) { create(:user, :admin) } + let(:event) { create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 12_000) } + let(:registration) { create(:event_registration, event: event) } + 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 a22e5714d6..68706fb538 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -34,6 +34,17 @@ expect(response.body).not_to include(existing_registration.registrant.first_name) end + it "filters registrations by ce_status" do + needs_license = create(:event_registration) + placeholder = create(:professional_license, :placeholder, person: needs_license.registrant) + create(:continuing_education_registration, event_registration: needs_license, professional_license: placeholder) + + get event_registrations_path(ce_status: "needs_license") + expect(response).to have_http_status(:success) + expect(response.body).to include(needs_license.registrant.first_name) + expect(response.body).not_to include(existing_registration.registrant.first_name) + end + it "exports CSV with headers and data only (no captions)" do get event_registrations_path, params: { format: :csv } @@ -305,22 +316,6 @@ 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: { @@ -452,6 +447,18 @@ def unrequest(registration) expect(flash[:alert]).to include("can't be deleted") end + + it "refuses to delete a registration whose CE registration has payments" do + ce = create(:continuing_education_registration, event_registration: existing_registration, cost_cents: 12_000) + create(:allocation, source: create(:payment, amount_cents: 12_000, amount_cents_remaining: 12_000), + allocatable: ce, amount: 12_000) + + expect { + delete event_registration_path(existing_registration) + }.not_to change(EventRegistration, :count) + + expect(flash[:alert]).to match(/has payments/) + end end describe "organization linking" do diff --git a/spec/requests/events/bulk_reminders_spec.rb b/spec/requests/events/bulk_reminders_spec.rb index 091d9a2029..37126a731b 100644 --- a/spec/requests/events/bulk_reminders_spec.rb +++ b/spec/requests/events/bulk_reminders_spec.rb @@ -77,7 +77,7 @@ def checked?(body, registration) it "returns to the picker after save" do patch event_registration_path(jane), - params: { return_to: "preview_reminder", event_registration: { ce_credit_requested: "1" } } + params: { return_to: "preview_reminder", event_registration: { intends_to_pay: "1" } } expect(response).to redirect_to(preview_reminder_event_path(event)) end diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index 9ad496aa83..f0545c9497 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -97,4 +97,107 @@ 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 7795d06709..42ae4e42ca 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -217,7 +217,8 @@ } expect(event.registration_ticket_callouts.magic.pluck(:magic_key)).to contain_exactly( - "payment", "certificate", "ce_hours", "event_details", "forms", "handouts", "faq" + "payment", "certificate", "scholarship", "ce_hours", "event_details", + "videoconference", "forms", "handouts", "faq" ) end end diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index d1d64e25b9..22977a2fc5 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -232,6 +232,29 @@ expect(response.body).not_to include("Review your form responses") end + + it "shows an admin edit-scholarship link and a back-to-scholarship eyebrow when reached from there" do + scholarship = create(:scholarship, amount_cents: 75_000) + create(:allocation, source: scholarship, allocatable: registration, amount: 75_000) + + sign_in create(:user, :with_person, super_user: true) + get registration_scholarship_path(registration.slug, return_to: "scholarship") + + expect(response.body).to include("Edit scholarship") + expect(response.body).to include("Back to scholarship") + expect(response.body).to include(edit_scholarship_path(scholarship)) + end + + it "keeps the default ticket eyebrow for a registrant even with the scholarship origin" do + scholarship = create(:scholarship, amount_cents: 75_000) + create(:allocation, source: scholarship, allocatable: registration, amount: 75_000) + + get registration_scholarship_path(registration.slug, return_to: "scholarship") + + expect(response.body).to include("Back to ticket") + expect(response.body).not_to include("Back to scholarship") + expect(response.body).not_to include("Edit scholarship") + end end describe "GET /registration/:slug/faq" do @@ -292,20 +315,139 @@ describe "GET /registration/:slug/ce" do let!(:registration) { create(:event_registration, event: event, registrant: user.person) } - it "shows status, cost, and the license number on file" do - registration.update!(ce_credit_requested: true, ce_hours_requested: 6, ce_license_number: "LIC123") + it "shows status, cost, and the license number on file, read-only with an edit link" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) get registration_ce_path(registration.slug) expect(response).to have_http_status(:success) - expect(response.body).to include("Requested") - expect(response.body).to include("Hours requested") + # License on file but unpaid → the badge shows the balance due. + expect(response.body).to include("$150 due") + expect(response.body).to include("Hours") expect(response.body).to include("$150") expect(response.body).to include("LIC123") + # Read-only by default: the form is not rendered, just an Edit link that flips to it. + expect(response.body).to include("editing=license") + expect(response.body).not_to include("Save changes") + end + + it "flips to the editable form when reached with ?editing=license" do + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) + get registration_ce_path(registration.slug, editing: "license") + expect(response.body).to include("Save changes") + expect(response.body).to include("Cancel") end - it "notes when the license number is not yet on file" do - registration.update!(ce_credit_requested: true, ce_hours_requested: 6, ce_license_number: nil) + it "shows blank license fields and a needs-license prompt when nothing is on file yet" do + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) get registration_ce_path(registration.slug) - expect(response.body).to include("We don't have your license number on file yet.") + expect(response.body).to include("License type") + expect(response.body).to include("Your license number") + expect(response.body).to include("License # needed") + expect(response.body).to include("We need your license type and number") + expect(response.body).to include("Save changes") + # Nothing on file yet, so there's no read-only value to edit or cancel back to. + expect(response.body).not_to include("editing=license") + expect(response.body).not_to include(">Cancel<") + end + + it "shows an admin jump link to the CE registration only to admins" do + ce = create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant), hours: 6) + + get registration_ce_path(registration.slug) + expect(response.body).not_to include(edit_continuing_education_registration_path(ce)) + + sign_in create(:user, :with_person, super_user: true) + get registration_ce_path(registration.slug) + expect(response.body).to include(edit_continuing_education_registration_path(ce)) + end + + it "lets an admin preview the paid (Pending) state with ?admin=true" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) + + sign_in create(:user, :with_person, super_user: true) + get registration_ce_path(registration.slug, admin: "true") + expect(response.body).to include("Pending") + expect(response.body).not_to include("$150 due") + end + + it "ignores ?admin=true for a registrant (no access)" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) + + get registration_ce_path(registration.slug, admin: "true") + expect(response.body).to include("$150 due") + expect(response.body).not_to include("Pending") + end + + it "points the eyebrow back to the CE registration when reached from there" do + ce = create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant), hours: 6) + + sign_in create(:user, :with_person, super_user: true) + get registration_ce_path(registration.slug, return_to: "ce_registration") + expect(response.body).to include("Back to CE registration") + expect(response.body).to include(edit_continuing_education_registration_path(ce)) + end + + it "keeps the default ticket eyebrow for a registrant even with the ce_registration origin" do + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant), hours: 6) + + get registration_ce_path(registration.slug, return_to: "ce_registration") + expect(response.body).to include("Back to ticket") + expect(response.body).not_to include("Back to CE registration") + end + end + + describe "POST /registration/:slug/ce/license" do + let!(:registration) { create(:event_registration, event: event, registrant: user.person) } + + it "saves the license type, number, issuing state, and expiry entered on the callout" do + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) + + post registration_ce_license_path(registration.slug), + params: { license_kind: "LMFT", license_number: "7788", + license_issuing_state: "CA", license_expires_on: "2027-01-31" } + + expect(response).to redirect_to(registration_ce_path(registration.slug)) + saved = registration.continuing_education_registrations.first.professional_license + expect(saved.kind).to eq("LMFT") + expect(saved.number).to eq("7788") + expect(saved.issuing_state).to eq("CA") + expect(saved.expires_on).to eq(Date.new(2027, 1, 31)) + end + + it "refuses to change the license once the certificate is issued" do + license = create(:professional_license, person: registration.registrant, kind: "LMFT", number: "111") + create(:continuing_education_registration, event_registration: registration, professional_license: license, + hours: 6, certificate_sent_at: Time.current) + + post registration_ce_license_path(registration.slug), params: { license_kind: "LCSW", license_number: "999" } + + expect(license.reload).to have_attributes(kind: "LMFT", number: "111") + expect(flash[:alert]).to match(/certificate has been issued/) + end + end + + describe "POST /registration/:slug/ce/request" do + let(:event) { create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 12_000) } + let!(:registration) { create(:event_registration, event: event, registrant: user.person) } + + 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)) end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 4dcf8c40a3..4ec4bc016c 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -202,15 +202,15 @@ def offer_ce!(target_event) describe "GET /ce_hours" do let(:event) { create(:event, :published, :publicly_visible) } - it "renders the CE hours page when details are present" do - event.update!(ce_hours_details_label: "Continuing education", ce_hours_details: "

Email your license number

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

Email your license number

") get ce_hours_event_path(event) expect(response).to have_http_status(:ok) expect(response.body).to include("Continuing education") expect(response.body).to include("Email your license number") end - it "redirects to the event when details are blank" do + it "redirects to the event when the event is not CE-eligible" do get ce_hours_event_path(event) expect(response).to redirect_to(event_path(event)) end @@ -1019,25 +1019,30 @@ def submit_agency_name(name) end describe "GET /events/:id/registrants with the CE status filter" do - let(:event) { create(:event, cost_cents: 1_000) } - let(:complete_person) { create(:person, first_name: "Complete", last_name: "Person") } - let(:missing_person) { create(:person, first_name: "Missing", last_name: "Person") } + let(:event) { offer_ce!(create(:event, cost_cents: 1_000)) } + let(:paid_person) { create(:person, first_name: "Paid", last_name: "Person") } + let(:needs_person) { create(:person, first_name: "Needs", last_name: "License") } let(:none_person) { create(:person, first_name: "Noce", last_name: "Person") } - let!(:complete_reg) do - reg = create(:event_registration, event: event, registrant: complete_person, - ce_credit_requested: true, ce_license_number: "ABC123", ce_hours_requested: 3) - create(:allocation, source: create(:payment, amount_cents: 1_000, amount_cents_remaining: 1_000), - allocatable: reg, amount: 1_000) + # Known license, fully paid. + let!(:paid_reg) do + reg = create(:event_registration, event: event, registrant: paid_person) + cer = create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000) + create(:allocation, source: create(:payment, amount_cents: 15_000, amount_cents_remaining: 15_000), + allocatable: cer, amount: 15_000) reg end - let!(:missing_reg) { create(:event_registration, event: event, registrant: missing_person, ce_credit_requested: true) } - let!(:none_reg) { create(:event_registration, event: event, registrant: none_person, ce_credit_requested: false) } - - before do - offer_ce!(event) - sign_in admin + # CE registration sitting on a placeholder (numberless) license. + let!(:needs_reg) do + reg = create(:event_registration, event: event, registrant: needs_person) + create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, + professional_license: create(:professional_license, :placeholder, person: needs_person)) + reg end + # No CE registration. + let!(:none_reg) { create(:event_registration, event: event, registrant: none_person) } + + before { sign_in admin } it "shows the CE status column and filter when the event offers CE" do get registrants_event_path(event) @@ -1054,29 +1059,23 @@ def submit_agency_name(name) expect(response.body).to include('data-column-toggle-group-value="ce"') end - it "filters to all CE requests" do + it "filters to CE registrations not yet paid" do get registrants_event_path(event, ce_status: "requested") - expect(response.body).to include("Complete Person") - expect(response.body).to include("Missing Person") + expect(response.body).to include("Needs License") + expect(response.body).not_to include("Paid Person") expect(response.body).not_to include("Noce Person") end - it "filters to CE requests missing a license number" do - get registrants_event_path(event, ce_status: "license_not_provided") - expect(response.body).to include("Missing Person") - expect(response.body).not_to include("Complete Person") - end - - it "filters to CE requests missing hours" do - get registrants_event_path(event, ce_status: "hours_not_provided") - expect(response.body).to include("Missing Person") - expect(response.body).not_to include("Complete Person") + it "filters to CE registrations on a placeholder license" do + get registrants_event_path(event, ce_status: "needs_license") + expect(response.body).to include("Needs License") + expect(response.body).not_to include("Paid Person") end - it "filters to paid CE requests" do + it "filters to paid CE registrations" do get registrants_event_path(event, ce_status: "paid") - expect(response.body).to include("Complete Person") - expect(response.body).not_to include("Missing Person") + expect(response.body).to include("Paid Person") + expect(response.body).not_to include("Needs License") end it "does not crash on an invalid ce_status" do @@ -1084,9 +1083,9 @@ def submit_agency_name(name) expect(response).to have_http_status(:ok) end - it "hides CE entirely when the event's registration form doesn't offer CE" do + it "hides CE entirely when the event does not offer CE" do plain_event = create(:event) - create(:event_registration, event: plain_event, ce_credit_requested: true) + create(:event_registration, event: plain_event) get registrants_event_path(plain_event) expect(response.body).not_to include("CE status") end @@ -1094,7 +1093,7 @@ def submit_agency_name(name) it "includes a CE status column in the CSV export" do get registrants_event_path(event, format: :csv) expect(response.body).to include("CE status") - expect(response.body).to include("Incomplete") + expect(response.body).to include("Needs license") end end @@ -1110,42 +1109,47 @@ def ce_chip_text Nokogiri::HTML(response.body).at_css('td[data-column-toggle-col="ce"]')&.text&.squish end - it "shows Create when CE was not requested" do - create(:event_registration, event: event, registrant: person, ce_credit_requested: false) + it "shows Create when no CE registration exists" do + create(:event_registration, event: event, registrant: person) get registrants_event_path(event) expect(ce_chip_text).to eq("Create") end - it "shows Requested when requested but no CE registration record exists yet" do - create(:event_registration, event: event, registrant: person, ce_credit_requested: true) - get registrants_event_path(event) - expect(ce_chip_text).to eq("Requested") - end - - it "shows No license # once a CE record exists without a license number" do - reg = create(:event_registration, event: event, registrant: person, ce_credit_requested: true) + it "shows License # needed once a CE record exists without a license number" do + reg = create(:event_registration, event: event, registrant: person) create(:continuing_education_registration, event_registration: reg, professional_license: create(:professional_license, :placeholder, person: person)) get registrants_event_path(event) - expect(ce_chip_text).to eq("No license #") + expect(ce_chip_text).to eq("License # needed") end - it "shows Filed once a license is on file but the CE balance is unpaid" do - reg = create(:event_registration, event: event, registrant: person, ce_credit_requested: true) + it "shows the balance due once a license is on file but the CE balance is unpaid" do + reg = create(:event_registration, event: event, registrant: person) create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, professional_license: create(:professional_license, person: person)) get registrants_event_path(event) - expect(ce_chip_text).to eq("Filed") + expect(ce_chip_text).to eq("$150 due") + end + + it "shows Pending when the CE balance is paid but the certificate isn't issued" do + reg = create(:event_registration, event: event, registrant: person) + cer = create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, + professional_license: create(:professional_license, person: person)) + create(:allocation, source: create(:payment, amount_cents: 15_000, amount_cents_remaining: 15_000), + allocatable: cer, amount: 15_000) + get registrants_event_path(event) + expect(ce_chip_text).to eq("Pending") end - it "shows Recipient when the CE balance is paid" do - reg = create(:event_registration, event: event, registrant: person, ce_credit_requested: true) + it "shows Issued once the CE certificate has been delivered" do + reg = create(:event_registration, event: event, registrant: person) cer = create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, professional_license: create(:professional_license, person: person)) create(:allocation, source: create(:payment, amount_cents: 15_000, amount_cents_remaining: 15_000), allocatable: cer, amount: 15_000) + cer.mark_certificate_sent! get registrants_event_path(event) - expect(ce_chip_text).to eq("Recipient") + expect(ce_chip_text).to eq("Issued") end end @@ -1212,7 +1216,6 @@ def ce_chip_text expect(response.body).to include("Mailchimp") expect(response.body).to include("CMS") expect(response.body).to include("Portal invite") - expect(response.body).to include("CE requested") expect(response.body).to include("License #") expect(response.body).to include("Event attendance") expect(response.body).to include("Onboard") diff --git a/spec/requests/honeybadger_context_spec.rb b/spec/requests/honeybadger_context_spec.rb new file mode 100644 index 0000000000..c74c54c61a --- /dev/null +++ b/spec/requests/honeybadger_context_spec.rb @@ -0,0 +1,21 @@ +require "rails_helper" + +RSpec.describe "Honeybadger context", type: :request do + before { allow(Honeybadger).to receive(:context) } + + it "records the signed-in user so faults name who hit them" do + user = create(:user) + + sign_in user + get root_path + + expect(Honeybadger).to have_received(:context) + .with(hash_including(user_id: user.id, user_email: user.email)) + end + + it "does not set user context for anonymous visitors" do + get root_path + + expect(Honeybadger).not_to have_received(:context).with(hash_including(:user_id)) + end +end diff --git a/spec/requests/people_professional_licenses_spec.rb b/spec/requests/people_professional_licenses_spec.rb new file mode 100644 index 0000000000..bf94569c00 --- /dev/null +++ b/spec/requests/people_professional_licenses_spec.rb @@ -0,0 +1,122 @@ +require "rails_helper" + +RSpec.describe "People professional licenses", type: :request do + let(:admin) { create(:user, :admin) } + + before { sign_in admin } + + it "renders the professional licenses section on the edit page" do + person = create(:person) + create(:professional_license, person: person, number: "LMFT 90210") + + get edit_person_path(person) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Professional licenses") + expect(response.body).to include("LMFT 90210") + end + + it "adds a license through the person form" do + person = create(:person) + + expect { + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { number: "LCSW 11223", kind: "LCSW", issuing_state: "CA" } } + } } + }.to change { person.professional_licenses.count }.by(1) + + expect(person.professional_licenses.last.number).to eq("LCSW 11223") + end + + it "ignores a blank license row" do + person = create(:person) + + expect { + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { number: "", kind: "", issuing_state: "", expires_on: "" } } + } } + }.not_to change { person.professional_licenses.count } + end + + it "removes a license with no CE registrations" do + person = create(:person) + license = create(:professional_license, person: person, number: "GONE-1") + + expect { + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { id: license.id, _destroy: "1" } } + } } + }.to change { person.professional_licenses.count }.by(-1) + end + + it "keeps a license whose CE registration has no payments" do + person = create(:person) + license = create(:professional_license, person: person, number: "UNPAID-1") + registration = create(:event_registration, registrant: person) + create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 10_000) + + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { id: license.id, _destroy: "1" } } + } } + + expect(ProfessionalLicense.exists?(license.id)).to be(true) + end + + it "keeps a license whose CE registration has payments" do + person = create(:person) + license = create(:professional_license, person: person, number: "PAID-1") + registration = create(:event_registration, registrant: person) + ce = create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 10_000) + create(:allocation, source: create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000), + allocatable: ce, amount: 10_000) + + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { id: license.id, _destroy: "1" } } + } } + + expect(ProfessionalLicense.exists?(license.id)).to be(true) + end + + # The person form is admin-only today, so the per-license guard is dormant. These + # simulate the future state where PersonPolicy lets an owner reach the form, to + # prove the server-side backstop (ProfessionalLicensePolicy) holds before we flip it. + context "when a non-admin owner can reach the form (simulating the future PersonPolicy)" do + let(:owner) { create(:user, :with_person) } + let(:person) { owner.person } + + before do + sign_in owner + allow_any_instance_of(PersonPolicy).to receive(:edit?).and_return(true) + allow_any_instance_of(PersonPolicy).to receive(:update?).and_return(true) + end + + it "ignores edits to a CE-tied license but applies edits to an unlocked one" do + locked = create(:professional_license, person: person, number: "LOCKED-1", kind: "LMFT") + registration = create(:event_registration, registrant: person) + create(:continuing_education_registration, event_registration: registration, professional_license: locked) + unlocked = create(:professional_license, person: person, number: "OPEN-1", kind: "LCSW") + + patch person_path(person), params: { person: { + professional_licenses_attributes: { + "0" => { id: locked.id, kind: "HACKED" }, + "1" => { id: unlocked.id, kind: "LPCC" } + } + } } + + expect(locked.reload.kind).to eq("LMFT") + expect(unlocked.reload.kind).to eq("LPCC") + end + + it "blocks deleting a CE-tied license" do + locked = create(:professional_license, person: person, number: "LOCKED-2") + registration = create(:event_registration, registrant: person) + create(:continuing_education_registration, event_registration: registration, professional_license: locked) + + patch person_path(person), params: { person: { + professional_licenses_attributes: { "0" => { id: locked.id, _destroy: "1" } } + } } + + expect(ProfessionalLicense.exists?(locked.id)).to be(true) + end + end +end diff --git a/spec/requests/people_profile_flags_spec.rb b/spec/requests/people_profile_flags_spec.rb index 61bc7bf565..f90b057041 100644 --- a/spec/requests/people_profile_flags_spec.rb +++ b/spec/requests/people_profile_flags_spec.rb @@ -65,12 +65,12 @@ end describe "#profile_show_credentials" do - before { person.update!(credentials: "LCSW") } + before { create(:professional_license, person: person, kind: "LCSW", number: "11223") } context "when false" do before { person.update!(profile_show_credentials: false) } - it "hides credentials on own profile" do + it "hides the license credentials on own profile" do sign_in owner_user get person_path(person) expect(response.body).not_to include("LCSW") @@ -80,13 +80,13 @@ context "when true" do before { person.update!(profile_show_credentials: true) } - it "shows credentials as a suffix on own profile" do + it "shows the license type as a suffix on own profile" do sign_in owner_user get person_path(person) expect(response.body).to include("LCSW") end - it "shows credentials when admin views profile" do + it "shows the license type when admin views profile" do sign_in admin get person_path(person) expect(response.body).to include("LCSW") diff --git a/spec/requests/rich_text_asset_mentions_spec.rb b/spec/requests/rich_text_asset_mentions_spec.rb new file mode 100644 index 0000000000..60b7977c90 --- /dev/null +++ b/spec/requests/rich_text_asset_mentions_spec.rb @@ -0,0 +1,26 @@ +require "rails_helper" + +RSpec.describe "/rich_text_asset_mentions", type: :request do + let(:admin) { create(:user, :admin) } + + before { sign_in admin } + + describe "GET /index" do + it "returns an empty list when the sgid resolves to a decorated record that owns no rich text assets" do + event = create(:event) + sgid = event.decorate.to_sgid.to_s + + get rich_text_asset_mentions_path(format: :json, sgid: sgid, query: "1") + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to eq([]) + end + + it "returns an empty list when the sgid cannot be located" do + get rich_text_asset_mentions_path(format: :json, sgid: "bogus", query: "1") + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to eq([]) + end + end +end diff --git a/spec/services/default_ticket_callouts_spec.rb b/spec/services/default_ticket_callouts_spec.rb index e46f60b23d..65a9430629 100644 --- a/spec/services/default_ticket_callouts_spec.rb +++ b/spec/services/default_ticket_callouts_spec.rb @@ -2,27 +2,16 @@ RSpec.describe DefaultTicketCallouts do describe "#seed" do - 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", "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") + it "materializes all nine built-in callouts for every event" do + event = create(:event, cost_cents: 0) # free, no scholarship form, no VC link 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 + expect(keys).to contain_exactly( + "payment", "certificate", "scholarship", "ce_hours", "event_details", + "videoconference", "forms", "handouts", "faq" + ) end it "seeds callouts in canonical ticket order" do @@ -38,17 +27,33 @@ ) end - it "seeds the Videoconference card, dripping a week before start, only once the event has a link" do + it "seeds the Videoconference card with a drip date a week before start" 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(false) + 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 end it "defaults Certificate off for a non-training event and on for a facilitator training" do @@ -79,8 +84,8 @@ described_class.seed(event) forms = event.registration_ticket_callouts.find_by(magic_key: "forms") - expect(forms).to be_present # seeded because it's a training - expect(forms.resources).to be_empty + expect(forms).to be_present + expect(forms.resources).to be_empty # no W-9 on a free event end it "migrates CE hours and event-details content from the event onto the row" do @@ -184,7 +189,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 ce_hours event_details forms handouts faq] + %w[payment certificate scholarship ce_hours event_details videoconference forms handouts faq] ) end end diff --git a/spec/services/event_registration_readiness_spec.rb b/spec/services/event_registration_readiness_spec.rb index 9ce53d2111..213ea9515d 100644 --- a/spec/services/event_registration_readiness_spec.rb +++ b/spec/services/event_registration_readiness_spec.rb @@ -88,16 +88,18 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) end context "continuing education" do - it "flags CE as unpaid when CE credit is requested (no CE payment is tracked yet)" do + it "flags CE as unpaid when CE credit is requested" do pay(registration, 1000) - registration.update!(ce_credit_requested: true, ce_license_number: "LIC123") + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 5_000) expect(readiness.event_ready_issues).to include("CE not paid") end it "flags a missing CE license number when CE credit is requested" do pay(registration, 1000) - registration.update!(ce_credit_requested: true, ce_license_number: nil) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license, cost_cents: 5_000) expect(readiness.event_ready_issues).to include("CE license number missing") end @@ -152,7 +154,9 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) end it "flags the CE certificate as unsent when CE credit was requested" do - registration.update!(status: "attended", ce_credit_requested: true) + registration.update!(status: "attended") + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, person: registration.registrant, number: "LIC123")) expect(readiness.completion_issues).to include("CE certificate not sent") end @@ -271,7 +275,8 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) end it "names both certificates when CE credit was requested" do - registration.update!(ce_credit_requested: true) + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, person: registration.registrant, number: "LIC123")) expect(readiness.certificate_due_reason).to eq("Reg + CE") end diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index f0b503d5ee..b5cf5164e5 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -513,86 +513,97 @@ def register_with_agency_type(value) end end - describe "CE credit interest (magic question)" do - let!(:ce_field) do - field = form.form_fields.create!( - name: "Might you be seeking continuing education (CE) hours for attending this training?", - answer_type: :single_select_radio, - status: :active, - position: (form.form_fields.maximum(:position) || 0) + 1, - required: false, - field_identifier: described_class::CE_CREDIT_INTEREST_IDENTIFIER, - section: "continuing_education", - visibility: :always_ask - ) - %w[Yes No].each_with_index do |opt, idx| - ao = AnswerOption.find_or_create_by!(name: opt) { |a| a.position = idx } - field.form_field_answer_options.create!(answer_option: ao) - end - field + describe "CE credit interest (continuing_education form)" do + let!(:ce_form) do + f = FormBuilderService.new( + name: "Continuing Education Request", + sections: %i[continuing_education], + role: "continuing_education" + ).call + event.event_forms.create!(form: f, role: "continuing_education") + f + end + + def ce_field_id(key) + ce_form.form_fields.find_by!(field_identifier: key).id.to_s end - def register_with_ce(answer) + def register_with_ce(answer, license: nil) params = base_form_params(first_name: "Cy", last_name: "Reed", email: "cy@example.com") - params = params.merge(ce_field.id.to_s => answer) unless answer.nil? - described_class.call(event: event, registration_form: form, form_params: params) + ce_params = {} + ce_params[ce_field_id(described_class::CE_CREDIT_INTEREST_IDENTIFIER)] = answer unless answer.nil? + ce_params[ce_field_id(described_class::CE_LICENSE_NUMBER_IDENTIFIER)] = license if license + described_class.call(event: event, registration_form: form, form_params: params, + continuing_education_form: ce_form, continuing_education_params: ce_params) end - it "toggles ce_credit_requested on when answered Yes" do + it "creates a CE registration when answered Yes" do result = register_with_ce("Yes") - expect(result.event_registration.ce_credit_requested).to be true + expect(result.event_registration.continuing_education_registrations.count).to eq(1) end - it "leaves ce_credit_requested off when answered No" do + it "creates no CE registration when answered No" do result = register_with_ce("No") - expect(result.event_registration.ce_credit_requested).to be false + expect(result.event_registration.continuing_education_registrations).to be_empty end - it "leaves ce_credit_requested off when unanswered" do + it "creates no CE registration when unanswered" do result = register_with_ce(nil) - expect(result.event_registration.ce_credit_requested).to be false + expect(result.event_registration.continuing_education_registrations).to be_empty end - it "toggles ce_credit_requested on for an existing registration that answers Yes" do + it "creates a CE registration for an existing registration that answers Yes" do person = create(:person, first_name: "Cy", last_name: "Reed", email: "cy@example.com") - existing = create(:event_registration, event: event, registrant: person, ce_credit_requested: false) + existing = create(:event_registration, event: event, registrant: person) result = register_with_ce("Yes") expect(result.event_registration).to eq(existing) - expect(existing.reload.ce_credit_requested).to be true + expect(existing.reload.ce_registered?).to be(true) + expect(existing.continuing_education_registrations.count).to eq(1) end - it "saves the hours folded into a 'Yes: ' specify answer" do - result = register_with_ce("Yes: 6") - expect(result.event_registration.ce_credit_requested).to be true - expect(result.event_registration.ce_hours_requested).to eq(6) - end + it "does not duplicate a CE registration when the existing one already has one" do + person = create(:person, first_name: "Cy", last_name: "Reed", email: "cy@example.com") + existing = create(:event_registration, event: event, registrant: person) + license = create(:professional_license, :placeholder, person: person) + create(:continuing_education_registration, event_registration: existing, professional_license: license) - it "leaves ce_hours_requested nil when Yes carries no hours" do - result = register_with_ce("Yes") - expect(result.event_registration.ce_hours_requested).to be_nil + register_with_ce("Yes") + + expect(existing.reload.continuing_education_registrations.count).to eq(1) end - 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 + it "records the typed license number on the CE registration's license" do + result = register_with_ce("Yes", license: "LMFT 555") + license = result.event_registration.continuing_education_registrations.first.professional_license + expect(license.number).to eq("LMFT 555") + expect(license.person).to eq(result.event_registration.registrant) end - it "ignores non-numeric hours in the specify answer" do - result = register_with_ce("Yes: lots") - expect(result.event_registration.ce_credit_requested).to be true - expect(result.event_registration.ce_hours_requested).to be_nil + it "uses a placeholder license when no number is given" do + result = register_with_ce("Yes") + expect(result.event_registration.continuing_education_registrations.first.professional_license.number).to be_nil end - 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) + it "takes the CE hours from the event" do + event.update!(ce_hours_offered: 6) + result = register_with_ce("Yes") + expect(result.event_registration.continuing_education_registrations.first.hours).to eq(6) + end - register_with_ce("Yes: 4") + it "persists the CE answers as a continuing_education submission when answered Yes" do + register_with_ce("Yes", license: "LMFT 555") + submission = FormSubmission.find_by(role: "continuing_education", event: event) + expect(submission).to be_present + answers = submission.answers_by_identifier + expect(answers[described_class::CE_CREDIT_INTEREST_IDENTIFIER]).to eq("Yes") + expect(answers[described_class::CE_LICENSE_NUMBER_IDENTIFIER]).to eq("LMFT 555") + end - expect(existing.reload.ce_credit_requested).to be true - expect(existing.ce_hours_requested).to eq(4) + it "does not persist a continuing_education submission when answered No" do + register_with_ce("No") + expect(FormSubmission.find_by(role: "continuing_education", event: event)).to be_nil end end diff --git a/spec/services/event_revenue_report_spec.rb b/spec/services/event_revenue_report_spec.rb index 2420a17a88..24e7f50b1b 100644 --- a/spec/services/event_revenue_report_spec.rb +++ b/spec/services/event_revenue_report_spec.rb @@ -10,14 +10,16 @@ let!(:reg1) { create(:event_registration, event: event, registrant: person1, status: "registered") } let!(:reg2) do - create(:event_registration, event: event, registrant: person2, status: "registered", - ce_credit_requested: true, ce_hours_requested: 3) + create(:event_registration, event: event, registrant: person2, status: "registered") end before do + # reg2's projected CE fee: a $75 CE registration. + create(:continuing_education_registration, event_registration: reg2, cost_cents: 7_500) + # A cancelled registration whose money/CE must be ignored everywhere. - cancelled = create(:event_registration, event: event, registrant: create(:person), status: "cancelled", - ce_credit_requested: true, ce_hours_requested: 6) + cancelled = create(:event_registration, event: event, registrant: create(:person), status: "cancelled") + create(:continuing_education_registration, event_registration: cancelled, cost_cents: 12_000) create(:allocation, source: create(:payment, amount_cents: 5_000, amount_cents_remaining: 5_000), allocatable: cancelled, amount: 5_000) diff --git a/spec/services/magic_ticket_callouts_spec.rb b/spec/services/magic_ticket_callouts_spec.rb index ea005e6fbe..9cfedbbbb7 100644 --- a/spec/services/magic_ticket_callouts_spec.rb +++ b/spec/services/magic_ticket_callouts_spec.rb @@ -85,32 +85,25 @@ def card(reg, title) end it "shows the CE card only when the registrant requested CE credit" do - event.update!(ce_hours_details: "6 hours") expect(card_titles(registration)).not_to include(event.ce_hours_details_label) - registration.update!(ce_credit_requested: true) - expect(card_titles(registration)).to include(event.ce_hours_details_label) - 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 + 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) expect(complete.subtitle).to eq("6 hours") expect(complete.badge).to eq("$150 due") expect(complete.badge_classes).to include("teal") @@ -153,10 +146,12 @@ def card(reg, title) it "places payment first and FAQ last in the full ordering" do event.update!(facilitator_training: true, event_details: "Bring supplies", - ce_hours_details: "6 hours", + ce_hours_details: "6 hours", ce_hours_offered: 6, videoconference_url: "https://example.zoom.us/j/123", start_date: 3.days.ago, end_date: 2.days.ago) - registration.update!(status: "attended", scholarship_requested: true, ce_credit_requested: true) + registration.update!(status: "attended", scholarship_requested: true) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license) expect(card_titles(registration)).to eq([ "Make your payment", "Certificate of completion", diff --git a/spec/services/number_formatter_spec.rb b/spec/services/number_formatter_spec.rb new file mode 100644 index 0000000000..d1b4b6b650 --- /dev/null +++ b/spec/services/number_formatter_spec.rb @@ -0,0 +1,17 @@ +require "rails_helper" + +RSpec.describe NumberFormatter do + describe ".plain" do + it "drops insignificant trailing zeros" do + expect(described_class.plain(6.0)).to eq("6") + expect(described_class.plain(1.5)).to eq("1.5") + expect(described_class.plain(0.25)).to eq("0.25") + expect(described_class.plain(BigDecimal("6"))).to eq("6") + end + + it "returns nil for a blank input" do + expect(described_class.plain(nil)).to be_nil + expect(described_class.plain("")).to be_nil + end + end +end diff --git a/spec/services/reminder_recipient_filter_spec.rb b/spec/services/reminder_recipient_filter_spec.rb index 140d5cdf31..b19f039392 100644 --- a/spec/services/reminder_recipient_filter_spec.rb +++ b/spec/services/reminder_recipient_filter_spec.rb @@ -167,32 +167,42 @@ def matched(params, registrations) end context "CE status" do - # Requested CE, supplied both license and hours, and paid in full. + # Requested CE, supplied a license, and paid the CE balance in full. let!(:complete) do registration(first_name: "Complete").tap do |r| - r.update!(ce_credit_requested: true, ce_license_number: "ABC123", ce_hours_requested: 3) - create(:allocation, allocatable: r, amount: 10_000) + license = create(:professional_license, person: r.registrant, number: "ABC123") + ce_reg = create(:continuing_education_registration, event_registration: r, professional_license: license, hours: 4) + payment = create(:payment, amount_cents: ce_reg.cost_cents, amount_cents_remaining: ce_reg.cost_cents) + create(:allocation, source: payment, allocatable: ce_reg, amount: ce_reg.cost_cents) end end - # Requested CE but missing license and hours, unpaid. - let!(:missing) { registration(first_name: "Missing").tap { |r| r.update!(ce_credit_requested: true) } } - # Did not request CE at all. - let!(:no_ce) { registration(first_name: "None").tap { |r| r.update!(ce_license_number: nil, ce_hours_requested: nil) } } - let(:regs) { [ complete, missing, no_ce ] } - - it "filters CE requested" do - expect(matched({ ce_status: "requested" }, regs)).to eq([ complete.id, missing.id ].to_set) + # CE on a placeholder license, unpaid. + let!(:missing) do + registration(first_name: "Missing").tap do |r| + license = create(:professional_license, :placeholder, person: r.registrant) + create(:continuing_education_registration, event_registration: r, professional_license: license, hours: 4) + end + end + # CE with a license on file but the balance unpaid. + let!(:unpaid_known) do + registration(first_name: "Unpaid").tap do |r| + license = create(:professional_license, person: r.registrant, number: "XYZ789") + create(:continuing_education_registration, event_registration: r, professional_license: license, hours: 4) + end end + # No CE registration at all. + let!(:no_ce) { registration(first_name: "None") } + let(:regs) { [ complete, missing, unpaid_known, no_ce ] } - it "filters CE license not provided (only among CE requesters)" do - expect(matched({ ce_status: "license_not_provided" }, regs)).to eq([ missing.id ].to_set) + it "filters CE not yet paid" do + expect(matched({ ce_status: "requested" }, regs)).to eq([ missing.id, unpaid_known.id ].to_set) end - it "filters CE hours not provided (only among CE requesters)" do - expect(matched({ ce_status: "hours_not_provided" }, regs)).to eq([ missing.id ].to_set) + it "filters CE on a placeholder license" do + expect(matched({ ce_status: "needs_license" }, regs)).to eq([ missing.id ].to_set) end - it "filters CE paid (requested CE and paid in full)" do + it "filters CE paid (CE balance paid in full)" do expect(matched({ ce_status: "paid" }, regs)).to eq([ complete.id ].to_set) end end diff --git a/spec/system/ce_form_toggle_on_event_edit_spec.rb b/spec/system/ce_form_toggle_on_event_edit_spec.rb new file mode 100644 index 0000000000..076c5ff170 --- /dev/null +++ b/spec/system/ce_form_toggle_on_event_edit_spec.rb @@ -0,0 +1,32 @@ +require "rails_helper" + +RSpec.describe "CE form toggle on event edit", type: :system do + let(:admin) { create(:user, :with_person, :admin) } + let(:event) { create(:event) } + let(:ce_form) { create(:form, role: "continuing_education", name: "CE Request") } + + before do + ce_form # ensure the form exists so controller populates @continuing_education_forms + sign_in admin + end + + context "when CE form is not attached" do + it "hides hours/cost fields" do + visit edit_event_path(event) + expect(page).to have_unchecked_field("event[continuing_education_form_id]") + expect(page).to have_field("event[ce_hours_offered]", visible: :hidden) + end + end + + context "when CE form is attached" do + before do + create(:event_form, event: event, form: ce_form, role: "continuing_education") + visit edit_event_path(event) + end + + it "shows hours/cost fields" do + expect(page).to have_checked_field("event[continuing_education_form_id]") + expect(page).to have_field("event[ce_hours_offered]", visible: :visible) + end + end +end diff --git a/spec/system/event_registration_edit_spec.rb b/spec/system/event_registration_edit_spec.rb index c245dce65a..fa3f0ba634 100644 --- a/spec/system/event_registration_edit_spec.rb +++ b/spec/system/event_registration_edit_spec.rb @@ -117,7 +117,7 @@ visit edit_event_registration_path(registration) within("section", text: "Scholarship") do - expect(page).to have_text("Funded by") + expect(page).to have_text("Grantor:") expect(page).to have_link("Acme Foundation", href: organization_path(organization)) end end @@ -136,7 +136,7 @@ end end - it "omits the funder line when the scholarship has no grant" do + it "shows no grantor text (just a spacer) when the scholarship has no grant" do scholarship = create(:scholarship, recipient: registration.registrant, amount_cents: 1_000) create(:allocation, source: scholarship, allocatable: registration, amount: 1_000) @@ -144,7 +144,7 @@ visit edit_event_registration_path(registration) within("section", text: "Scholarship") do - expect(page).to have_no_text("Funded by") + expect(page).to have_no_text("Grantor:") end end end @@ -182,6 +182,63 @@ end end + describe "per-day attendance checkboxes" do + it "renders a checkbox per event day, reflecting stored state, and persists changes" do + registration.update!(completed_day_1: true) + + sign_in(admin) + visit edit_event_registration_path(registration) + + # The event spans 3 days, so Days 1-3 show (and no Day 4). + expect(page).to have_field("Day 1", checked: true) + expect(page).to have_field("Day 2", checked: false) + expect(page).to have_field("Day 3", checked: false) + expect(page).to have_no_field("Day 4") + + check "Day 2", allow_label_click: true + click_on "Save changes" + + expect(page).to have_current_path(registrants_event_path(event)) + expect(registration.reload.completed_day_1).to be(true) + expect(registration.completed_day_2).to be(true) + end + + it "derives the attendance status from the checked days and saves both together" do + sign_in(admin) + visit edit_event_registration_path(registration) + + badge = "[data-attendance-status-target='dirty']" + expect(page).to have_no_css(badge, visible: true) + + # Checking every day rolls the status forward to Attended (mirrors onboarding). + check "Day 1", allow_label_click: true + check "Day 2", allow_label_click: true + check "Day 3", allow_label_click: true + + expect(page).to have_css(badge, visible: true, text: "Unsaved") + expect(page).to have_select("event_registration[status]", selected: "Attended") + + click_on "Save changes" + + # Wait for the save round-trip to land before reading the database. + expect(page).to have_current_path(registrants_event_path(event)) + expect(registration.reload.status).to eq("attended") + expect(registration.completed_day_count).to eq(3) + end + + it "leaves an inactive status untouched when days are toggled" do + registration.update!(status: "cancelled") + + sign_in(admin) + visit edit_event_registration_path(registration) + + check "Day 1", allow_label_click: true + + # Cancelled is a deliberate manual state, so toggling a day never overrides it. + expect(page).to have_select("event_registration[status]", selected: "Cancelled") + end + end + describe "notifications box" do it "lists notifications sent to the registrant" do create(:notification, diff --git a/spec/system/public_registration_form_submission_spec.rb b/spec/system/public_registration_form_submission_spec.rb index f44722ef95..85b66cb545 100644 --- a/spec/system/public_registration_form_submission_spec.rb +++ b/spec/system/public_registration_form_submission_spec.rb @@ -17,6 +17,7 @@ end let(:registration_form) { build_registration_form } + let(:continuing_education_form) { build_continuing_education_form } let(:scholarship_form) do FormBuilderService.new(name: "Scholarship Application", sections: %i[scholarship], role: "scholarship").call end @@ -51,6 +52,7 @@ before do driven_by(:rack_test) EventForm.create!(event: event, form: registration_form, role: "registration") + EventForm.create!(event: event, form: continuing_education_form, role: "continuing_education") EventForm.create!(event: event, form: scholarship_form, role: "scholarship") EventForm.create!(event: event, form: bulk_payment_form, role: "bulk_payment") end @@ -61,7 +63,7 @@ fill_full_registration - choose_pr_radio reg_field("ce_credit_interest"), "Yes" + choose_pr_radio ce_field("ce_credit_interest"), "Yes" choose_pr_radio reg_field("payment_method"), "Check" check_pr_box reg_field("additional_forms"), "W-9" @@ -73,8 +75,9 @@ email_2: "robin.alt@example.com") registration = event.event_registrations.find_by!(registrant: person) - expect(registration).to have_attributes(scholarship_requested: false, ce_credit_requested: true, + expect(registration).to have_attributes(scholarship_requested: false, w9_requested: true, invoice_requested: false) + expect(registration.continuing_education_registrations.count).to eq(1) answers = answers_by_identifier(registration_form.form_submissions.find_by!(person: person)) expect(answers).to include( @@ -105,11 +108,15 @@ "racial_ethnic_identity" => "Multi-racial", "referral_source" => "Online Search", "training_motivation" => "Address staff burnout through art", - "ce_credit_interest" => "Yes", "payment_method" => "Check", "additional_forms" => "W-9", "communication_consent" => "Yes" ) + expect(answers).not_to have_key("ce_credit_interest") + + ce_submission = continuing_education_form.form_submissions.find_by!(person: person) + ce_answers = answers_by_identifier(ce_submission) + expect(ce_answers).to include("ce_credit_interest" => "Yes") # The service never stores the confirm_email answer (it only checks it matches). expect(answers).not_to have_key("confirm_email") @@ -276,8 +283,8 @@ # Builds the registration form the way db/seeds/dev/events_management.rb does: # the full set of sections, minus the generic "interested in more?" question, - # plus the magic CE-interest and "Additional forms" questions whose answers - # drive the resulting registration's flags. + # plus the "Additional forms" question whose answers drive the resulting + # registration's flags. def build_registration_form form = FormBuilderService.new( name: "Training Registration Form", @@ -287,16 +294,6 @@ def build_registration_form form.form_fields.where(field_identifier: "interested_in_more").destroy_all position = form.form_fields.maximum(:position).to_i - ce = form.form_fields.create!( - name: "Do you plan to use Continuing Education (CE) hours for this course?", - answer_type: :single_select_radio, status: :active, position: position += 1, required: false, - field_identifier: EventRegistrationServices::PublicRegistration::CE_CREDIT_INTEREST_IDENTIFIER, - section: "continuing_education", visibility: :always_ask - ) - %w[Yes No].each do |name| - ce.form_field_answer_options.create!(answer_option: AnswerOption.find_or_create_by!(name: name)) - end - additional_forms = form.form_fields.create!( name: "Do you need either of the following?", answer_type: :multi_select_checkbox, status: :active, position: position += 1, required: false, @@ -312,12 +309,24 @@ def build_registration_form form end + def build_continuing_education_form + FormBuilderService.new( + name: "Continuing Education Request", + sections: %i[continuing_education], + role: "continuing_education" + ).call + end + # ---- Field lookup ---- def reg_field(identifier) registration_form.form_fields.find_by!(field_identifier: identifier) end + def ce_field(identifier) + continuing_education_form.form_fields.find_by!(field_identifier: identifier) + end + def schol_field(identifier) scholarship_form.form_fields.find_by!(field_identifier: identifier) end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index b298be1428..5cd9ed7c65 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -172,6 +172,8 @@ "app/views/category_types/edit.html.erb" => "admin-only bg-blue-100", "app/views/community_news/edit.html.erb" => "admin-only bg-blue-100", "app/views/event_registrations/edit.html.erb" => "admin-only bg-blue-100", + "app/views/continuing_education_registrations/edit.html.erb" => "admin-only bg-blue-100", + "app/views/continuing_education_registrations/new.html.erb" => "admin-only bg-blue-100", "app/views/events/edit.html.erb" => "admin-only bg-blue-100", "app/views/forms/edit.html.erb" => "admin-only bg-blue-100", "app/views/forms/edit_sections.html.erb" => "admin-only bg-blue-100",