diff --git a/AGENTS.md b/AGENTS.md index cf151739df..421634a2d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -234,10 +234,11 @@ action, or `authorize! :workshop, to: :summary?`). - `EventRegistrationServices::ProcessConfirmation` — Registration confirmation flow - `EventRegistrationServices::PublicRegistration` — Public registration handling -- `EventRegistrationReadiness` — Computes a registration's lifecycle `status` (`:not_ready` → `:ready` → `:certificate_due` → `:completed`) from a pre-event "event ready" checklist, a post-event "completion work" checklist (attendance, scholarship tasks), and certificate delivery, returning the specific outstanding reasons. Reads payment/certificate state via `Registerable` (`paid_in_full?`, `certificate_sent?`) on both the registration and its `continuing_education_registrations`. Drives the registrants roster's single far-right Status badge column (with a short reason under "Not ready" and a cert-type note under "Certificate pending") and its matching filter +- `EventRegistrationServices::SurveySubmission` — Records a post-event survey (delivered inline on the ticket via a survey callout) as a role-tagged `FormSubmission`: static answers plus per-resource "clarity" answers (one per `FormFieldResource`, nil `form_field`, full sentence snapshotted in `question_name_when_answered`). Writes the anonymity + name-display questions through to the `Person` profile (`anonymous_contributions`, `display_name_preference`), exposing `#profile_changes` so the controller Ahoy-tracks real edits, and stamps `post_survey_completed_at` for a scholarship recipient's recipients survey +- `EventRegistrationReadiness` — Computes a registration's lifecycle `status` (`:not_ready` → `:ready` → `:survey_pending` → `:certificate_due` → `:completed`); `:survey_pending` is a scholarship recipient whose live (published + past-drip) post-event recipients survey is still unsubmitted (`post_survey_completed_at` blank), which gates the certificate. Status is computed from a pre-event "event ready" checklist, a post-event "completion work" checklist (attendance, scholarship tasks, the recipients survey), and certificate delivery, returning the specific outstanding reasons. Reads payment/certificate state via `Registerable` (`paid_in_full?`, `certificate_sent?`) on both the registration and its `continuing_education_registrations`. Drives the registrants roster's single far-right Status badge column (with a short reason under "Not ready" and a cert-type note under "Certificate pending") and its matching filter - `ReminderRecipientFilter` — Decides which event registrations stay checked on the bulk reminder page given the admin's filters (matches in memory, returns matching ids) - `BuiltinCalloutCards` — Renders the live, per-registration ticket callout cards (payment, certificate, scholarship, CE hours, videoconference), overlaying dynamic status (badge, colour, visibility guard, destination) on each materialized built-in row via `#card_for`. Rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `BuiltinCallouts`) so the two paths never double-render, and `#cards` serves as the fallback for events not yet seeded; `.editor_cards` builds the editor's preview cards. Handouts and FAQ are pure content cards with no builder here — they render from their row. Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized) -- `BuiltinCallouts` — Owns the built-in callout definitions and materializes them into `RegistrationTicketCallout` rows in canonical ticket order: `seed` persists (on create, and lazily on edit so older events heal with no backfill), `build` makes the same rows in memory for the new-event form (with `builtin_key` round-tripped through nested attributes), `reset`/`customized?` back the "Restore default" control. All eight seed **hidden** by default — admins publish the ones they want; there's no config-based auto-publish. Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Handouts, FAQ) render their own copy/resources on the generic callout page; "behavioral" cards render live status through `BuiltinCalloutCards#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Videoconference drips a week before start via `display_from`. CE hours is edited like every other built-in — its title/text live entirely on the row (the legacy `event_details*`/`ce_hours_details*` event columns were dropped); the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE page reads the row's title/description. Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`) +- `BuiltinCallouts` — Owns the built-in callout definitions and materializes them into `RegistrationTicketCallout` rows in canonical ticket order: `seed` persists (on create, and lazily on edit so older events heal with no backfill), `build` makes the same rows in memory for the new-event form (with `builtin_key` round-tripped through nested attributes), `reset`/`customized?` back the "Restore default" control. All seed **hidden** by default — admins publish the ones they want; there's no config-based auto-publish. The set includes three post-event survey built-ins (`day_1_survey`, `day_2_survey`, `scholarship_recipients_survey`), each carrying a `form_id` (its inline survey form, picked in the callout editor) and a relevant default drip; a `seed_if` gate keeps `day_2_survey` off single-day events. Their live ticket cards (and the recipients-only guard) live in `BuiltinCalloutCards`; the inline page is `Events::CalloutsController#survey`. Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Handouts, FAQ) render their own copy/resources on the generic callout page; "behavioral" cards render live status through `BuiltinCalloutCards#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Videoconference drips a week before start via `display_from`. CE hours is edited like every other built-in — its title/text live entirely on the row (the legacy `event_details*`/`ce_hours_details*` event columns were dropped); the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE page reads the row's title/description. Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`) - `CalloutContent` — Parses admin-authored callout HTML into ordered segments so **every** callout content page renders the same way: plain rich text, with each standard `
` disclosure (the markup any HTML generator/LLM produces; `` and a `title` attribute are accepted aliases; `
` starts expanded) rebuilt into a styled collapsible card. `
`/`` are also on the `form_label_html` allowlist (`FORM_LABEL_TAGS`, plus the `open` attribute), so a disclosure is never stripped on save — the parser only upgrades its styling. Rendered through the shared `app/views/events/callouts/_rich_content.html.erb` partial (which wraps each disclosure in `_toggle.html.erb`), used by the CE hours, custom-callout, behavioural-card-intro, and FAQ pages. The FAQ page renders the editable `faq` callout `description` (each question a `
`); the default questions hydrate onto the row when it's materialized (from `BuiltinCallouts.faq_html`), so a blanked description shows blank with no render-time fallback. Content with no disclosure renders unchanged - `SampleTicketRegistration` — Builds the **unsaved, data-free** `EventRegistration` ("Sample Person") that the sample ticket and its admin-only callout-page previews render from; nothing is ever persisted, so the preview can't read from or write to a real registrant or leak into counts/revenue/rosters/reminders. `all_options:` mirrors the ticket's "Show all options" toggle (turns on scholarship/CE/W-9 so those cards and preview pages render). Shared by `EventsController#sample_ticket` and `Events::CalloutsController`'s sample mode (the `sample` param → admin-authed in-memory previews of the behavioral built-in pages, linked from the sample ticket via `EventHelper#sample_callout_path`) diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index d6c70cfd92..58257bbeeb 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -2,7 +2,7 @@ class EventRegistrationsController < ApplicationController require "csv" # show redirects to slug URL; kept for backwards compatibility - before_action :set_event_registration, only: [ :show, :edit, :update, :destroy, :update_onboarding, :toggle_certificate_issued, :update_attendance ] + before_action :set_event_registration, only: [ :show, :edit, :update, :destroy, :update_onboarding, :toggle_certificate_issued, :update_attendance, :toggle_post_survey ] def index authorize! @@ -195,6 +195,19 @@ def update_attendance redirect_to attendance_report_path(date, reopen: true), status: :see_other end + # Admin toggle for whether the post-event (scholarship recipients) survey is in. + # Independent of the certificate: clears/sets only its own timestamp. + def toggle_post_survey + authorize! @event_registration, to: :update? + if @event_registration.post_survey_completed? + @event_registration.clear_post_survey_completed! + else + @event_registration.mark_post_survey_completed! + end + redirect_back fallback_location: edit_event_registration_path(@event_registration), + notice: "Post-event survey updated." + end + def confirm @event_registration = EventRegistration.includes(registrant: :user, event: :location).find(params[:id]) authorize! @event_registration, to: :confirm? diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 715c883da2..407e1b353f 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -259,6 +259,47 @@ def faq @faq_content = callout&.description end + # Maps each survey built-in to the FormSubmission role it records under. The + # scholarship recipients survey is the one that gates readiness, tagged + # "post_event_survey". + SURVEY_ROLES = { + "day_1_survey" => "day_1_survey", + "day_2_survey" => "day_2_survey", + "scholarship_recipients_survey" => "post_event_survey" + }.freeze + + # The inline survey page for a survey callout: renders the drip notice before + # the drip date, the form to fill, or the submitted answers (read-only) with an + # edit affordance. + def survey + @callout = survey_callout + return redirect_to registration_ticket_path(@event_registration.slug) unless @callout + + @form = @callout.form + @role = SURVEY_ROLES.fetch(@callout.builtin_key) + @dripping = @callout.dripping? + @submission = survey_submission + @editing = @submission.nil? || params[:edit].present? + end + + def submit_survey + @callout = survey_callout + return redirect_to registration_ticket_path(@event_registration.slug) if @callout.nil? || @callout.dripping? + + service = EventRegistrationServices::SurveySubmission.call( + event_registration: @event_registration, + form: @callout.form, + role: SURVEY_ROLES.fetch(@callout.builtin_key), + field_params: survey_field_params, + clarity_params: survey_clarity_params + ) + track_survey_profile_changes(service.profile_changes) + NotificationMailer.survey_submitted_fyi(service.submission).deliver_later + + redirect_to registration_survey_path(@event_registration.slug, @callout.builtin_key), + notice: "Thanks! Your responses have been submitted." + end + private # Attendance sign-in/out follows the CE payment — it's the CE sign-in sheet. Any-of @@ -296,6 +337,35 @@ def sign_out_notice(entry) "Signed out for #{entry.attendance_date.strftime("%a, %b %-d")} at #{time}." end + # The published survey callout named by :builtin_key, once it actually carries a + # form. Nil (→ back to the ticket) for anything else. + def survey_callout + callout = @event.registration_ticket_callouts.find_by(builtin_key: params[:builtin_key]) + return unless callout && callout.form && !callout.hidden? && SURVEY_ROLES.key?(callout.builtin_key) + callout + end + + def survey_submission + FormSubmission.find_by(person: @event_registration.registrant, form: @callout.form, + event: @event, role: SURVEY_ROLES.fetch(@callout.builtin_key)) + end + + def survey_field_params + params.dig(:survey, :fields)&.to_unsafe_h || {} + end + + def survey_clarity_params + params.dig(:survey, :clarity)&.to_unsafe_h || {} + end + + # One Ahoy event per profile field the survey actually changed (anonymity / name + # display), so the change is auditable. + def track_survey_profile_changes(changes) + changes.each do |attribute, (from, to)| + ahoy.track("profile.#{attribute}", person_id: @event_registration.registrant_id, from: from, to: to) + end + end + # Whether the event's built-in callout for this key is materialized and # published (visible). These public pages gate on that alone now — the admin's # published/hidden choice on the row decides whether the page is reachable, so diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 79638d7a6d..a1e3e9e463 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -240,7 +240,7 @@ def registrants @readiness = @event_registrations.to_h do |registration| [ registration.id, EventRegistrationReadiness.new(registration) ] end - if params[:readiness].in?(%w[ not_ready ready certificate_due completed ]) + if params[:readiness].in?(%w[ not_ready ready survey_pending certificate_due completed ]) @event_registrations.select! { |r| @readiness[r.id].status.to_s == params[:readiness] } end @@ -1217,6 +1217,7 @@ def set_form_variables @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) + @survey_forms = Form.standalone.where(role: %w[ day_1_survey day_2_survey post_event_survey ]).order(:name) @categories_grouped = Category .includes(:category_type) diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 4a50c43b19..ac787668e8 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -16,6 +16,19 @@ def event_registration_confirmation_fyi(notification) ) end + # Staff heads-up that a registrant submitted a post-event survey. Takes the + # FormSubmission directly (no Notification record) and goes to the default system + # address. + def survey_submitted_fyi(form_submission) + @form_submission = form_submission + @person = form_submission.person + @event = form_submission.event + @form = form_submission.form + @answers = form_submission.form_answers.order(:id) + + mail(subject: "#{FYI_PREFIX} New #{@form.name} submission from #{@person.full_name}") + end + def event_registration_cancelled_fyi(notification) @event_registration = notification.noticeable @event = @event_registration.event.decorate diff --git a/app/models/event.rb b/app/models/event.rb index 32312bb10e..8dd3dcd372 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -188,6 +188,23 @@ def videoconference_details_visible?(now = Time.current) from.blank? || now >= from end + # The scholarship recipients survey callout, if seeded. Memoized so readiness + # (which runs per registration) hits the callouts once per event, not per row. + def scholarship_recipients_survey_callout + return @scholarship_recipients_survey_callout if defined?(@scholarship_recipients_survey_callout) + @scholarship_recipients_survey_callout = + registration_ticket_callouts.detect { |callout| callout.builtin_key == "scholarship_recipients_survey" } + end + + # Whether the scholarship recipients survey is live — published and past its drip. + # Only then does an unsubmitted survey count against a recipient's completion; an + # unpublished (or not-yet-dripped) survey blocks no one. + def post_event_survey_open?(now = Time.current) + callout = scholarship_recipients_survey_callout + return false unless callout && !callout.hidden? + callout.display_from.blank? || callout.display_from <= now + end + def registerable? !ended? && (registration_close_date.nil? || registration_close_date >= Time.current) end diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 7d9f1b6160..6dc7cfbedb 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -634,6 +634,22 @@ def attended? status == "attended" end + # The post-event (scholarship recipients) survey is "in" once this timestamp is + # set — by the registrant submitting it or an admin toggling it. Mirrors the + # Certifiable certificate_sent_at pattern so the roster's readiness reads a plain + # column with no extra query. + def post_survey_completed? + post_survey_completed_at.present? + end + + def mark_post_survey_completed!(at: Time.current) + update!(post_survey_completed_at: at) + end + + def clear_post_survey_completed! + update!(post_survey_completed_at: nil) + end + # The certificate of completion unlocks once the training has happened, the # registrant attended, and any scholarship tasks are complete. Issuing a CE # certificate (an admin marking the credit sent) is itself an affirmation that diff --git a/app/models/form_field.rb b/app/models/form_field.rb index d0634eea1f..536058b51a 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -5,6 +5,11 @@ class FormField < ApplicationRecord has_many :form_answers, dependent: :nullify has_many :childs, foreign_key: "parent_id", class_name: "FormField" + # A field can fan out over resources: with any linked here it becomes a + # "per-resource" question rendering one input per resource (see FormFieldResource). + has_many :form_field_resources, -> { ordered }, dependent: :destroy, inverse_of: :form_field + has_many :resources, through: :form_field_resources + # has_many through has_many :answer_options, through: :form_field_answer_options @@ -163,6 +168,9 @@ class FormField < ApplicationRecord accepts_nested_attributes_for :form_field_answer_options, allow_destroy: true, reject_if: ->(attrs) { attrs[:option_name].blank? } + accepts_nested_attributes_for :form_field_resources, allow_destroy: true, + reject_if: ->(attrs) { attrs[:resource_id].blank? } + scope :published, -> { where(status: "active") } # Methods @@ -172,6 +180,12 @@ def selectable? answer_type.in?(SELECTABLE_ANSWER_TYPES) end + # True when this field fans out over linked resources — rendered once per resource + # on the survey page, with the resource's title appended to the prompt. + def per_resource? + form_field_resources.any? + end + # True for fields whose answer options are tied to backend logic (currently the # payment-method field's Stripe wiring) and so should be shown read-only in the # form builder rather than freely edited. diff --git a/app/models/form_field_resource.rb b/app/models/form_field_resource.rb new file mode 100644 index 0000000000..038f89d24e --- /dev/null +++ b/app/models/form_field_resource.rb @@ -0,0 +1,16 @@ +class FormFieldResource < ApplicationRecord + # Ordered join between a form field and the resources it fans out over. A field + # with any of these is a "per-resource" question: on the survey page it renders + # one input per linked resource (e.g. the post-event survey clarity question, + # asked once per training topic/handout). The field owns the prompt wording and + # answer options; each resource just supplies the item the prompt is asked about. + belongs_to :form_field + belongs_to :resource + + positioned on: :form_field_id + + validates :resource_id, uniqueness: { scope: :form_field_id } + validates :position, numericality: { only_integer: true, greater_than: 0, allow_nil: true } + + scope :ordered, -> { order(:position, :id) } +end diff --git a/app/models/person.rb b/app/models/person.rb index e401863896..9b2a6b69eb 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -206,6 +206,22 @@ def mailing_list_consented=(value) end end + # Stored display_name_preference key => the human label shown as a survey answer / + # profile choice. Keys match the branches in #name below. + DISPLAY_NAME_PREFERENCES = { + "full_name" => "Full name", + "first_name_last_initial" => "First name and last initial", + "first_name_only" => "First name only", + "last_name_only" => "Last name only" + }.freeze + + # anonymous_contributions boolean => the survey answer label. "Keep anonymous" is the + # true branch; naming their profile is false. + ANONYMOUS_CONTRIBUTIONS_OPTIONS = { + false => "Display all my content with my profile name", + true => "Keep all my content anonymous" + }.freeze + def name case display_name_preference when "full_name" diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index c936cfdd31..e2517c5590 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -13,6 +13,7 @@ class RegistrationTicketCallout < ApplicationRecord BUILTIN_KEYS = %w[ payment certificate scholarship ce_hours art_supplies videoconference staff handouts faq + day_1_survey day_2_survey scholarship_recipients_survey ].freeze # "Content" built-in callouts render their own editable copy/resources (like custom @@ -47,6 +48,10 @@ class RegistrationTicketCallout < ApplicationRecord belongs_to :event + # The survey form this callout delivers inline (post-event survey callouts). Most + # callouts have none. + belongs_to :form, optional: true + # A callout can link many resources, shown in order on its detail page (PDF # previews + download buttons) beneath its own title/subtitle/content — e.g. # the Handouts card's worksheets, or a custom callout's supporting documents. diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index 602da91fe8..3103b120f9 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -193,7 +193,7 @@ def google_analytics? sector_ids: [], primary_asset_attributes: [ :id, :file, :_destroy ], gallery_assets_attributes: [ :id, :file, :_destroy ], - registration_ticket_callouts_attributes: [ :id, :builtin_key, :title, :subtitle, :description, :callout_type, :icon_class, :color_class, :display_from, :payment_access_gated, :published, :reset_to_default, :_destroy, + registration_ticket_callouts_attributes: [ :id, :builtin_key, :title, :subtitle, :description, :callout_type, :icon_class, :color_class, :display_from, :payment_access_gated, :published, :reset_to_default, :form_id, :_destroy, { registration_ticket_callout_resources_attributes: [ :id, :resource_id, :subtitle, :page_content, :_destroy ] } ], event_staffs_attributes: [ :id, :person_id, :title, :expected_to_attend, :bio, :_destroy ] ] diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index c427a1c7c5..d2db923551 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -70,7 +70,10 @@ def self.editor_cards(event) "ce_hours" => :ce_hours_card, "videoconference" => :videoconference_card, "staff" => :staff_card, - "certificate" => :certificate_card + "certificate" => :certificate_card, + "day_1_survey" => :day_1_survey_card, + "day_2_survey" => :day_2_survey_card, + "scholarship_recipients_survey" => :scholarship_recipients_survey_card }.freeze # Why a built-in card with this builtin_key can never appear on the given event's @@ -123,8 +126,12 @@ def initialize(event_registration, preview: false) # event has materialized into editable rows are omitted here — the ticket renders # those from the row (calling #card_for for behavioral ones), so this is both the # non-materialized set and the fallback for events not yet seeded. + # Survey cards have no config-driven legacy default — they only ever render from a + # materialized (seeded) row via #card_for, never from this fallback. + FALLBACK_EXCLUDED_KEYS = %w[ day_1_survey day_2_survey scholarship_recipients_survey ].freeze + def cards - CARD_BUILDERS.reject { |builtin_key, _| materialized?(builtin_key) } + CARD_BUILDERS.reject { |builtin_key, _| materialized?(builtin_key) || FALLBACK_EXCLUDED_KEYS.include?(builtin_key) } .filter_map { |_, builder| send(builder) } end @@ -426,4 +433,27 @@ def videoconference_card href: registration_videoconference_path(registration.slug), target: nil, trailing_icon: "fa-solid fa-arrow-right") end + + # Post-event survey cards link to the inline survey page (which itself shows the + # drip notice before the survey opens, then the form). The day cards show for + # everyone; the scholarship recipients card only for recipients. + def day_1_survey_card + survey_card("day_1_survey") + end + + def day_2_survey_card + survey_card("day_2_survey") + end + + def scholarship_recipients_survey_card + return unless registration.scholarship? + survey_card("scholarship_recipients_survey") + end + + def survey_card(builtin_key) + Card.new(icon_class: "fa-solid fa-clipboard-list", color: "indigo", + title: "Survey", subtitle: "Share your feedback", + href: registration_survey_path(registration.slug, builtin_key), + target: nil, trailing_icon: "fa-solid fa-arrow-right") + end end diff --git a/app/services/builtin_callouts.rb b/app/services/builtin_callouts.rb index a48f0d7e3d..46b2ea80b2 100644 --- a/app/services/builtin_callouts.rb +++ b/app/services/builtin_callouts.rb @@ -130,7 +130,7 @@ def initialize(event) # never clobbers admin edits. Returns the created rows. def seed existing_keys = @event.registration_ticket_callouts.builtin.pluck(:builtin_key).to_set - definitions.reject { |definition| existing_keys.include?(definition[:builtin_key]) } + definitions.reject { |definition| existing_keys.include?(definition[:builtin_key]) || !applicable?(definition) } .filter_map { |definition| create(definition) } end @@ -138,7 +138,7 @@ def seed # association (built or persisted) so it's safe to call on every form render. def build existing_keys = @event.registration_ticket_callouts.reject(&:marked_for_destruction?).filter_map(&:builtin_key).to_set - definitions.reject { |definition| existing_keys.include?(definition[:builtin_key]) } + definitions.reject { |definition| existing_keys.include?(definition[:builtin_key]) || !applicable?(definition) } .map { |definition| build_row(definition) } end @@ -154,7 +154,8 @@ def reset(callout) icon_class: definition[:icon_class], color_class: definition[:color_class], hidden: definition[:hidden].call(@event), - display_from: definition[:display_from]&.call(@event) + display_from: definition[:display_from]&.call(@event), + form_id: definition[:form]&.call(@event)&.id ) callout.registration_ticket_callout_resources.destroy_all build_resource_links(callout, definition) @@ -185,6 +186,21 @@ def resolve(value) value.respond_to?(:call) ? value.call(@event) : value end + # Whether a definition applies to this event. `seed_if` gates cards that only make + # sense for some events (e.g. the Day 2 survey on multi-day trainings). + def applicable?(definition) + definition[:seed_if].nil? || definition[:seed_if].call(@event) + end + + # A post-event day-N survey opens 30 minutes before that day's end time. Day N's + # date is the start date plus (N - 1) days; the time-of-day comes from the event's + # end_date (used as the daily end time for every day). Nil when dates are unset. + def survey_drip(event, day) + return unless event.start_date && event.end_date + target = event.start_date.to_date + (day - 1) + event.end_date.change(year: target.year, month: target.month, day: target.day) - 30.minutes + end + # Ordered built-in callout definitions. `hidden` / `display_from` are procs so # each event derives its own defaults; `resources` resolves the linked records; # `seed_if` gates whether the card applies. Content cards (Handouts, FAQ) render @@ -286,6 +302,43 @@ def definitions color_class: "blue", description: self.class.faq_html, hidden: ->(_event) { true } + }, + { + builtin_key: "day_1_survey", + title: "Day 1 survey", + subtitle: "Share your feedback on day 1 of the training", + callout_type: "action", + icon_class: "fa-solid fa-clipboard-list", + color_class: "indigo", + hidden: ->(_event) { true }, + form: ->(_event) { Form.standalone.find_by(name: "Day 1 Survey") }, + # Opens 30 min before day 1's end time; admins can edit per event. + display_from: ->(event) { survey_drip(event, 1) } + }, + { + builtin_key: "day_2_survey", + title: "Day 2 survey", + subtitle: "Share your feedback on day 2 of the training", + callout_type: "action", + icon_class: "fa-solid fa-clipboard-list", + color_class: "indigo", + hidden: ->(_event) { true }, + # Only seeds on multi-day events — a one-day training has no day 2. + seed_if: ->(event) { event.day_count >= 2 }, + form: ->(_event) { Form.standalone.find_by(name: "Day 2 Survey") }, + display_from: ->(event) { survey_drip(event, 2) } + }, + { + builtin_key: "scholarship_recipients_survey", + title: "Scholarship recipients survey", + subtitle: "Post-training questions for scholarship recipients", + callout_type: "action", + icon_class: "fa-solid fa-clipboard-list", + color_class: "fuchsia", + hidden: ->(_event) { true }, + form: ->(_event) { Form.standalone.find_by(name: "Post-Training Recipients Survey") }, + # Opens 30 min before the event ends. + display_from: ->(event) { event.end_date - 30.minutes if event.end_date } } ] end @@ -320,7 +373,8 @@ def attributes_for(definition) icon_class: definition[:icon_class], color_class: definition[:color_class], hidden: definition[:hidden].call(@event), - display_from: definition[:display_from]&.call(@event) + display_from: definition[:display_from]&.call(@event), + form_id: definition[:form]&.call(@event)&.id } end diff --git a/app/services/event_registration_readiness.rb b/app/services/event_registration_readiness.rb index a53717f3ee..50abd96b6f 100644 --- a/app/services/event_registration_readiness.rb +++ b/app/services/event_registration_readiness.rb @@ -15,12 +15,13 @@ def initialize(registration) STATUS_LABELS = { not_ready: "Not ready", ready: "Ready", + survey_pending: "Survey pending", certificate_due: "Certificate pending", completed: "Completed" }.freeze # Lifecycle order for sorting the roster's Status column. - STATUS_ORDER = %i[ not_ready ready certificate_due completed ].freeze + STATUS_ORDER = %i[ not_ready ready survey_pending certificate_due completed ].freeze def event_ready? event_ready_issues.empty? @@ -31,11 +32,11 @@ def completed? completion_issues.empty? end - # All post-event work done (attended, scholarship tasks met) — i.e. the only - # thing left is sending the certificate(s). This is the admin's "send a - # certificate" queue. + # All post-event work done (attended, scholarship tasks met, post-event survey in) + # — i.e. the only thing left is sending the certificate(s). This is the admin's + # "send a certificate" queue. def certifiable? - completion_work_issues.empty? + (completion_work_issues + survey_issues).empty? end # The registration's single lifecycle state for the roster's one Status column @@ -46,10 +47,18 @@ def certifiable? def status return :completed if completed? return :not_ready unless event_ready? + return :survey_pending if survey_pending? return :certificate_due if certifiable? :ready end + # A scholarship recipient who has finished the other post-event work but still owes + # the (now-live) post-event survey. Sits between "ready" and "certificate pending": + # the survey is the one thing keeping them from the certificate queue. + def survey_pending? + survey_outstanding? && completion_work_issues.empty? + end + def status_label STATUS_LABELS.fetch(status) end @@ -64,6 +73,7 @@ def status_sort_key def status_issues case status when :not_ready then event_ready_issues + when :survey_pending then survey_issues when :certificate_due then certificate_issues else [] end @@ -111,7 +121,14 @@ def event_ready_reason end def completion_issues - completion_work_issues + certificate_issues + completion_work_issues + survey_issues + certificate_issues + end + + # The post-event (scholarship recipients) survey, when a recipient still owes a + # live one. Gates certifiable?/completed? so the certificate can't close out until + # the survey is in. + def survey_issues + @survey_issues ||= survey_outstanding? ? [ "Post-event survey outstanding" ] : [] end # Post-event work that must happen before a certificate can be issued. @@ -157,6 +174,15 @@ def scholarship_tasks_incomplete? registration.scholarship? && !registration.scholarship_tasks_met? end + # Only scholarship recipients owe the post-event survey, and only once it's live + # (published + past drip). Reads a plain column plus the event's memoized survey + # callout, so it adds no per-row query on the roster. + def survey_outstanding? + registration.scholarship? && + registration.event.post_event_survey_open? && + !registration.post_survey_completed? + end + def ce_unpaid? registration.ce_registered? && !ce_paid? end diff --git a/app/services/event_registration_services/survey_submission.rb b/app/services/event_registration_services/survey_submission.rb new file mode 100644 index 0000000000..e757bed743 --- /dev/null +++ b/app/services/event_registration_services/survey_submission.rb @@ -0,0 +1,105 @@ +module EventRegistrationServices + # Records a post-event survey delivered inline on a registrant's ticket. Persists a + # role-tagged FormSubmission with: + # - static answers (one per ordinary field), and + # - dynamic "clarity" answers (a per-resource question fans out to one answer per + # linked resource, its full rendered sentence snapshotted in + # question_name_when_answered with a nil form_field). + # Two fields also write through to the Person profile (anonymity + name display); + # #profile_changes reports what actually changed so the caller can Ahoy-track it. + # Stamps post_survey_completed_at for a scholarship recipient's recipients survey. + # + # Idempotent on re-submit (edit): find-or-initialize keeps one answer per field, and + # per (nil field, snapshotted question) for the dynamic ones. + class SurveySubmission + attr_reader :submission, :profile_changes + + def self.call(**kwargs) + instance = new(**kwargs) + instance.call + instance + end + + def initialize(event_registration:, form:, role:, field_params: {}, clarity_params: {}) + @event_registration = event_registration + @form = form + @role = role + @field_params = (field_params || {}).transform_keys(&:to_s) + @clarity_params = clarity_params || {} + @profile_changes = {} + end + + def call + person = @event_registration.registrant + ActiveRecord::Base.transaction do + @submission = FormSubmission.find_or_create_by!( + person: person, form: @form, event: @event_registration.event, role: @role + ) + save_static_answers + save_clarity_answers + sync_profile(person) + stamp_completion + end + @submission + end + + private + + def save_static_answers + @form.form_fields.each do |field| + next if field.answer_type == "group_header" || field.per_resource? + raw = @field_params[field.id.to_s] + next if raw.nil? + text = raw.is_a?(Array) ? raw.reject(&:blank?).join(", ") : raw + record = @submission.form_answers.find_or_initialize_by(form_field: field) + record.update!(submitted_answer: text, question_name_when_answered: field.name) + end + end + + # Each per-resource field fans out: one answer per linked resource, keyed by the + # snapshotted sentence so re-submits update in place (form_field stays nil). + def save_clarity_answers + @clarity_params.each do |field_id, per_resource| + field = @form.form_fields.find_by(id: field_id) + next unless field&.per_resource? + field.form_field_resources.includes(:resource).each do |link| + raw = per_resource[link.resource_id.to_s] || per_resource[link.resource_id] + next if raw.blank? + question = "#{field.name} #{link.resource.title}" + record = @submission.form_answers.find_or_initialize_by(form_field: nil, question_name_when_answered: question) + record.update!(submitted_answer: raw) + end + end + end + + # Route the two identified questions to the Person profile, recording only the + # values that actually change so the caller can Ahoy-track a real edit. + def sync_profile(person) + apply_profile_change(person, :anonymous_contributions, + Person::ANONYMOUS_CONTRIBUTIONS_OPTIONS.invert[value_for("anonymous_contributions")]) + apply_profile_change(person, :display_name_preference, + Person::DISPLAY_NAME_PREFERENCES.invert[value_for("display_name_preference")]) + person.save! if person.changed? + end + + def apply_profile_change(person, attribute, new_value) + return if new_value.nil? + current = person.public_send(attribute) + return if current == new_value + @profile_changes[attribute] = [ current, new_value ] + person.public_send("#{attribute}=", new_value) + end + + # The submitted label for a field identified by its field_identifier. + def value_for(field_identifier) + field = @form.form_fields.find_by(field_identifier: field_identifier) + field && @field_params[field.id.to_s] + end + + def stamp_completion + return unless @role == "post_event_survey" && @event_registration.scholarship? + return if @event_registration.post_survey_completed? + @event_registration.mark_post_survey_completed! + end + end +end diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb index 2d72897d9c..7eed10b157 100644 --- a/app/services/form_builder_service.rb +++ b/app/services/form_builder_service.rb @@ -5,6 +5,18 @@ class FormBuilderService PAYMENT_METHOD_PAY_NOW = "Credit card (now)".freeze PAYMENT_METHOD_OPTIONS = [ PAYMENT_METHOD_PAY_NOW, "Credit card (later)", "Check" ].freeze + # Post-event survey option sets. Likert agreement scale for the workshop-impact + # questions; Yes/No/Other for the "was it clear?" questions (Other reveals a + # specify box); a likelihood scale for the recipient survey. + LIKERT_AGREEMENT_OPTIONS = [ "Strongly agree", "Agree", "Neutral", "Disagree", "Strongly disagree" ].freeze + CLARITY_OPTIONS = [ "Yes", "No", "Other" ].freeze + LIKELIHOOD_OPTIONS = [ "Very likely", "Likely", "Unsure", "Unlikely", "Very unlikely" ].freeze + + # The clarity radios fan out over the day's topics: link resources to them (in the + # field editor) and the survey page renders one input per resource with the + # resource title appended to this prompt. + CLARITY_PROMPT = "Overall, was the information presented in a clear and concise manner for".freeze + SECTIONS = { person_identifier: { label: "Person identifier", method: :build_person_identifier_fields }, person_contact_info: { label: "Person contact info", method: :build_person_contact_info_fields }, @@ -16,6 +28,10 @@ class FormBuilderService payment: { label: "Payment", method: :build_payment_fields }, consent: { label: "Consent", method: :build_consent_fields }, post_event_feedback: { label: "Post-event feedback", method: :build_post_event_feedback_fields }, + day_1_survey: { label: "Day 1 survey", method: :build_day_1_survey_fields }, + day_2_survey: { label: "Day 2 survey", method: :build_day_2_survey_fields }, + recipient_survey: { label: "Scholarship recipient survey", method: :build_recipient_survey_fields }, + content_sharing_preferences: { label: "Content sharing preferences", method: :build_content_sharing_preferences_fields }, bulk_payment: { label: "Bulk payment", method: :build_bulk_payment_fields } }.freeze @@ -57,6 +73,21 @@ def call payment: %w[payment_method someone_else_will_pay], consent: %w[communication_consent], post_event_feedback: %w[event_rating most_valuable improvement_suggestions], + day_1_survey: %w[ + d1_clarity_part_one d1_clarity_part_one_detail d1_clarity_part_two d1_clarity_part_two_detail + d1_touchstone_personal d1_touchstone_professional d1_safer_braver_personal d1_safer_braver_professional + d1_take_a_break_personal d1_take_a_break_professional d1_breakout_rooms d1_grounding + d1_prepared_facilitate d1_prepared_trauma_informed d1_review_reflect + d1_improvements d1_enjoyed d1_recommend d1_comments + ], + day_2_survey: %w[ + d2_clarity_part_one d2_clarity_part_one_detail d2_clarity_part_two d2_clarity_part_two_detail + d2_monster_personal d2_monster_professional d2_claiming_personal d2_claiming_professional + d2_breakout_rooms d2_intersectionality d2_questions_challenges d2_review_reflect d2_warmup_importance + d2_improvements d2_enjoyed d2_stay_in_touch d2_support_needs d2_recommend d2_comments + ], + recipient_survey: %w[impact insights more_valuable facilitate_likelihood anything_else], + content_sharing_preferences: %w[anonymous_contributions display_name_preference], bulk_payment: %w[payer_first_name payer_last_name payer_email payer_phone payer_organization number_of_attendees payment_method bulk_payment_attendees] }.freeze @@ -72,6 +103,10 @@ def call payment: [ "Payment Information" ], consent: [ "Consent" ], post_event_feedback: [ "Post-Event Feedback" ], + day_1_survey: [ "Day 1 evaluation" ], + day_2_survey: [ "Day 2 evaluation" ], + recipient_survey: [ "Post-training recipient questions" ], + content_sharing_preferences: [ "Sharing preferences" ], bulk_payment: [ "Payer Information", "Payment Information", "Attendees" ] }.freeze @@ -112,6 +147,53 @@ def call payment: [ "Payment method", "Will someone else be paying for your registration?" ], consent: [ "I agree to receive email communications from A Window Between Worlds." ], post_event_feedback: [ "How would you rate this event?", "What did you find most valuable?", "Any suggestions for improvement?" ], + day_1_survey: [ + CLARITY_PROMPT, "Please elaborate.", CLARITY_PROMPT, "Please elaborate.", + "The Touchstone Journey workshop supported my personal growth.", + "The Touchstone Journey workshop supported my professional growth.", + "The Creating A Safer/Braver Place workshop supported my personal growth.", + "The Creating A Safer/Braver Place workshop supported my professional growth.", + "The Take A Break, Self-Regulate workshop supported my personal growth.", + "The Take A Break, Self-Regulate workshop supported my professional growth.", + "The breakout rooms supported me in sharing about my experience and connect with other trainees.", + "I was able to practice grounding and self-regulation during the training.", + "What I learned today better prepared me to facilitate art workshops.", + "What I learned today better prepared me to utilize trauma informed practices during art workshops.", + "Taking time to review and reflect after each workshop on how an element of the arc of healing connected to a part of the workshop structure will support me in facilitating art workshops.", + "Please tell us what aspects of day 1 of the training could be improved.", + "Please tell us what aspects of day 1 you enjoyed the most.", + "Imagine how you would share your experience of this training with someone who is considering attending. Please share in a couple of sentences what you would say or tell them.", + "Comments" + ], + day_2_survey: [ + CLARITY_PROMPT, "Please elaborate.", CLARITY_PROMPT, "Please elaborate.", + "The Monster In Me workshop supported my personal growth.", + "The Monster In Me workshop supported my professional growth.", + "The Claiming Who I Am workshop supported my personal growth.", + "The Claiming Who I Am workshop supported my professional growth.", + "The breakout rooms supported me in sharing about my experience and connect with other trainees.", + "What I learned today better prepared me to facilitate art workshops that honor intersectionality.", + "Having time to dive into topics related to questions and challenges helped me feel more prepared to facilitate art workshops.", + "Taking time to review and reflect after each workshop on how an element of the arc of healing connected to a part of the workshop structure will support me in facilitating art workshops.", + "To best prepare art workshop participants to create, I understand the importance of providing a warm-up before the creation portion of the art workshop.", + "Please tell us what aspects of day 2 of the training could be improved.", + "Please tell us what aspects of day 2 of the training you enjoyed the most.", + "Would you like your name and email address included on a list we will share with your fellow trainees (for those who would like to stay in touch)?", + "How can we better support your needs and those of your art workshop participants?", + "Imagine how you would share your experience of this training with someone who is considering attending. Please share in a couple of sentences what you would say or tell them.", + "Comments" + ], + recipient_survey: [ + "How did participating in this training impact you personally and/or professionally?", + "What insights, tools, or facilitation skills from the training stood out most to you?", + "What would have made the training more valuable for you?", + "How likely are you to facilitate an AWBW art workshop in the next 3 months?", + "Anything else you'd like us to know?" + ], + content_sharing_preferences: [ + "How may we display the content you share (reflections, quotes, artwork)?", + "Display my name as…" + ], bulk_payment: [ "Payer first name", "Payer last name", "Payer email", "Phone", "Organization", "Payment method", "Number of attendees", "Attendees" @@ -138,6 +220,10 @@ def self.section_field_names(key) payment: %w[payment], consent: %w[consent], post_event_feedback: %w[post_event_feedback], + day_1_survey: %w[day_1_survey], + day_2_survey: %w[day_2_survey], + recipient_survey: %w[recipient_survey], + content_sharing_preferences: %w[content_sharing], bulk_payment: %w[bulk_payment] }.freeze @@ -613,6 +699,130 @@ def build_post_event_feedback_fields(form, position) position end + def build_day_1_survey_fields(form, position) + position = add_header(form, position, "Day 1 evaluation", group: "day_1_survey") + + position = add_field(form, position, CLARITY_PROMPT, :single_select_radio, + key: "d1_clarity_part_one", group: "day_1_survey", subtitle: "Day 1 — Part One", + options: CLARITY_OPTIONS) + position = add_field(form, position, "Please elaborate.", :free_form_input_paragraph, + key: "d1_clarity_part_one_detail", group: "day_1_survey", required: false) + position = add_field(form, position, CLARITY_PROMPT, :single_select_radio, + key: "d1_clarity_part_two", group: "day_1_survey", subtitle: "Day 1 — Part Two", + options: CLARITY_OPTIONS) + position = add_field(form, position, "Please elaborate.", :free_form_input_paragraph, + key: "d1_clarity_part_two_detail", group: "day_1_survey", required: false) + + position = add_field(form, position, "The Touchstone Journey workshop supported my personal growth.", :single_select_radio, + key: "d1_touchstone_personal", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Touchstone Journey workshop supported my professional growth.", :single_select_radio, + key: "d1_touchstone_professional", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Creating A Safer/Braver Place workshop supported my personal growth.", :single_select_radio, + key: "d1_safer_braver_personal", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Creating A Safer/Braver Place workshop supported my professional growth.", :single_select_radio, + key: "d1_safer_braver_professional", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Take A Break, Self-Regulate workshop supported my personal growth.", :single_select_radio, + key: "d1_take_a_break_personal", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Take A Break, Self-Regulate workshop supported my professional growth.", :single_select_radio, + key: "d1_take_a_break_professional", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The breakout rooms supported me in sharing about my experience and connect with other trainees.", :single_select_radio, + key: "d1_breakout_rooms", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "I was able to practice grounding and self-regulation during the training.", :single_select_radio, + key: "d1_grounding", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "What I learned today better prepared me to facilitate art workshops.", :single_select_radio, + key: "d1_prepared_facilitate", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "What I learned today better prepared me to utilize trauma informed practices during art workshops.", :single_select_radio, + key: "d1_prepared_trauma_informed", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "Taking time to review and reflect after each workshop on how an element of the arc of healing connected to a part of the workshop structure will support me in facilitating art workshops.", :single_select_radio, + key: "d1_review_reflect", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + + position = add_field(form, position, "Please tell us what aspects of day 1 of the training could be improved.", :free_form_input_paragraph, + key: "d1_improvements", group: "day_1_survey", required: false) + position = add_field(form, position, "Please tell us what aspects of day 1 you enjoyed the most.", :free_form_input_paragraph, + key: "d1_enjoyed", group: "day_1_survey", required: false) + position = add_field(form, position, "Imagine how you would share your experience of this training with someone who is considering attending. Please share in a couple of sentences what you would say or tell them.", :free_form_input_paragraph, + key: "d1_recommend", group: "day_1_survey", required: false) + position = add_field(form, position, "Comments", :free_form_input_paragraph, + key: "d1_comments", group: "day_1_survey", required: false) + position + end + + def build_day_2_survey_fields(form, position) + position = add_header(form, position, "Day 2 evaluation", group: "day_2_survey") + + position = add_field(form, position, CLARITY_PROMPT, :single_select_radio, + key: "d2_clarity_part_one", group: "day_2_survey", subtitle: "Day 2 — Part 1", + options: CLARITY_OPTIONS) + position = add_field(form, position, "Please elaborate.", :free_form_input_paragraph, + key: "d2_clarity_part_one_detail", group: "day_2_survey", required: false) + position = add_field(form, position, CLARITY_PROMPT, :single_select_radio, + key: "d2_clarity_part_two", group: "day_2_survey", subtitle: "Day 2 — Part 2", + options: CLARITY_OPTIONS) + position = add_field(form, position, "Please elaborate.", :free_form_input_paragraph, + key: "d2_clarity_part_two_detail", group: "day_2_survey", required: false) + + position = add_field(form, position, "The Monster In Me workshop supported my personal growth.", :single_select_radio, + key: "d2_monster_personal", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Monster In Me workshop supported my professional growth.", :single_select_radio, + key: "d2_monster_professional", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Claiming Who I Am workshop supported my personal growth.", :single_select_radio, + key: "d2_claiming_personal", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Claiming Who I Am workshop supported my professional growth.", :single_select_radio, + key: "d2_claiming_professional", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The breakout rooms supported me in sharing about my experience and connect with other trainees.", :single_select_radio, + key: "d2_breakout_rooms", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "What I learned today better prepared me to facilitate art workshops that honor intersectionality.", :single_select_radio, + key: "d2_intersectionality", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "Having time to dive into topics related to questions and challenges helped me feel more prepared to facilitate art workshops.", :single_select_radio, + key: "d2_questions_challenges", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "Taking time to review and reflect after each workshop on how an element of the arc of healing connected to a part of the workshop structure will support me in facilitating art workshops.", :single_select_radio, + key: "d2_review_reflect", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "To best prepare art workshop participants to create, I understand the importance of providing a warm-up before the creation portion of the art workshop.", :single_select_radio, + key: "d2_warmup_importance", group: "day_2_survey", options: CLARITY_OPTIONS) + + position = add_field(form, position, "Please tell us what aspects of day 2 of the training could be improved.", :free_form_input_paragraph, + key: "d2_improvements", group: "day_2_survey", required: false) + position = add_field(form, position, "Please tell us what aspects of day 2 of the training you enjoyed the most.", :free_form_input_paragraph, + key: "d2_enjoyed", group: "day_2_survey", required: false) + position = add_field(form, position, "Would you like your name and email address included on a list we will share with your fellow trainees (for those who would like to stay in touch)?", :single_select_radio, + key: "d2_stay_in_touch", group: "day_2_survey", required: false, options: %w[Yes No]) + position = add_field(form, position, "How can we better support your needs and those of your art workshop participants?", :free_form_input_paragraph, + key: "d2_support_needs", group: "day_2_survey", required: false) + position = add_field(form, position, "Imagine how you would share your experience of this training with someone who is considering attending. Please share in a couple of sentences what you would say or tell them.", :free_form_input_paragraph, + key: "d2_recommend", group: "day_2_survey", required: false) + position = add_field(form, position, "Comments", :free_form_input_paragraph, + key: "d2_comments", group: "day_2_survey", required: false) + position + end + + def build_recipient_survey_fields(form, position) + position = add_header(form, position, "Post-training recipient questions", group: "recipient_survey") + + position = add_field(form, position, "How did participating in this training impact you personally and/or professionally?", :free_form_input_paragraph, + key: "impact", group: "recipient_survey", required: true) + position = add_field(form, position, "What insights, tools, or facilitation skills from the training stood out most to you?", :free_form_input_paragraph, + key: "insights", group: "recipient_survey", required: true) + position = add_field(form, position, "What would have made the training more valuable for you?", :free_form_input_paragraph, + key: "more_valuable", group: "recipient_survey", required: false) + position = add_field(form, position, "How likely are you to facilitate an AWBW art workshop in the next 3 months?", :single_select_radio, + key: "facilitate_likelihood", group: "recipient_survey", options: LIKELIHOOD_OPTIONS) + position = add_field(form, position, "Anything else you'd like us to know?", :free_form_input_paragraph, + key: "anything_else", group: "recipient_survey", required: false) + position + end + + def build_content_sharing_preferences_fields(form, position) + position = add_header(form, position, "Sharing preferences", group: "content_sharing") + + position = add_field(form, position, "How may we display the content you share (reflections, quotes, artwork)?", :single_select_radio, + key: "anonymous_contributions", group: "content_sharing", + options: Person::ANONYMOUS_CONTRIBUTIONS_OPTIONS.values) + position = add_field(form, position, "Display my name as…", :single_select_radio, + key: "display_name_preference", group: "content_sharing", + options: Person::DISPLAY_NAME_PREFERENCES.values) + position + end + def build_bulk_payment_fields(form, position) position = add_header(form, position, "Payer Information", group: "bulk_payment", visibility: :logged_out_only) diff --git a/app/views/event_registrations/_readiness_badge.html.erb b/app/views/event_registrations/_readiness_badge.html.erb index 22f89d3224..341150b462 100644 --- a/app/views/event_registrations/_readiness_badge.html.erb +++ b/app/views/event_registrations/_readiness_badge.html.erb @@ -12,6 +12,7 @@ style, icon, subtext_color = case status when :completed then [ "bg-green-50 text-green-700 border-green-200", "fa-flag-checkered", "text-green-600" ] when :certificate_due then [ "bg-purple-50 text-purple-700 border-purple-200", "fa-certificate", "text-purple-600" ] + when :survey_pending then [ "bg-indigo-50 text-indigo-700 border-indigo-200", "fa-clipboard-list", "text-indigo-600" ] when :ready then [ "bg-blue-50 text-blue-700 border-blue-200", "fa-circle-check", "text-blue-600" ] else [ "bg-amber-50 text-amber-700 border-amber-200", "fa-circle-exclamation", "text-amber-600" ] end diff --git a/app/views/event_registrations/edit.html.erb b/app/views/event_registrations/edit.html.erb index 1d6e0bddf7..ee7bffde76 100644 --- a/app/views/event_registrations/edit.html.erb +++ b/app/views/event_registrations/edit.html.erb @@ -63,5 +63,21 @@ event_url: dashboard_event_path(@event_registration.event) %> <%= render "form", event_registration: @event_registration %> + + <%# Admin completion controls. The certificate is issued by sending its email; the + post-event survey is marked here (independently) when a recipient's survey is in. %> +
+ Post-event survey received + <%= button_to toggle_post_survey_event_registration_path(@event_registration, return_to: params[:return_to].presence), + method: :patch, + class: "rounded-lg border px-3 py-1.5 text-sm font-medium cursor-pointer #{@event_registration.post_survey_completed? ? "border-green-300 bg-green-50 text-green-700 hover:bg-green-100" : "border-gray-300 text-gray-600 hover:bg-gray-50"}" do %> + <% if @event_registration.post_survey_completed? %> + Received<% if @event_registration.post_survey_completed_at %> · <%= @event_registration.post_survey_completed_at.to_date.to_fs(:long) %><% end %> + <% else %> + Mark received + <% end %> + <% end %> +
+ <%= render "shared/audit_info", resource: @event_registration %> diff --git a/app/views/events/_registrant_filters.html.erb b/app/views/events/_registrant_filters.html.erb index a62bcfeb9e..8211f0b501 100644 --- a/app/views/events/_registrant_filters.html.erb +++ b/app/views/events/_registrant_filters.html.erb @@ -60,7 +60,7 @@ pending → Completed. Param stays :readiness (backed by EventRegistrationReadiness); the user-facing label reads "Progress". %> <%= render "events/filter_select", param: :readiness, label: "Progress", - options: [ [ "Not ready", "not_ready" ], [ "Ready", "ready" ], [ "Certificate pending", "certificate_due" ], [ "Completed", "completed" ] ], + options: [ [ "Not ready", "not_ready" ], [ "Ready", "ready" ], [ "Survey pending", "survey_pending" ], [ "Certificate pending", "certificate_due" ], [ "Completed", "completed" ] ], selected: params[:readiness], blank: "Any stage", field_class: field_class %> <% end %> diff --git a/app/views/events/_registration_ticket_callout_fields.html.erb b/app/views/events/_registration_ticket_callout_fields.html.erb index 95b1d4dfe1..451bc580b8 100644 --- a/app/views/events/_registration_ticket_callout_fields.html.erb +++ b/app/views/events/_registration_ticket_callout_fields.html.erb @@ -234,6 +234,16 @@
+ <%# Survey callouts deliver a form inline on their page. Ordinary callouts leave this blank. %> +
+ <%= f.label :form_id, "Survey form", class: "block text-xs font-medium text-gray-600 mb-0.5" %> + <%= f.select :form_id, + options_from_collection_for_select(@survey_forms, :id, :name, f.object.form_id), + { include_blank: "No form" }, + class: "w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm" %> +

Delivered inline on this callout's page (drips with the display date above).

+
+

Optional. Each resource links to its own callout page. Subtitle shows on the callout card; page content shows under the resource title on its callout page.

diff --git a/app/views/events/callouts/survey.html.erb b/app/views/events/callouts/survey.html.erb new file mode 100644 index 0000000000..d2b705b8b9 --- /dev/null +++ b/app/views/events/callouts/survey.html.erb @@ -0,0 +1,95 @@ +<% content_for(:page_bg_class, "public") %> +<% content_for(:page_title, "#{@callout.title} — #{@event.title}") %> + +<%= render layout: "events/callouts/callout_page", locals: { title: @callout.title } do %> + <% if @callout.subtitle.present? %> +

<%= @callout.subtitle %>

+ <% end %> + + <% if @dripping %> + <%# Questions are withheld until the drip date, mirroring other callout pages. %> +
+ + This survey will open on <%= @callout.display_from.to_date.to_fs(:long) %>. Please check back then. +
+ + <% elsif @submission && !@editing %> + <%# Submitted: show the answers read-only with an edit affordance. %> +
+ Thanks — your responses are recorded. +
+
+ <% @submission.form_answers.order(:id).each do |answer| %> +
+
<%= answer.name %>
+
<%= answer.submitted_answer.presence || "—" %>
+
+ <% end %> +
+
+ <%= link_to "Edit responses", registration_survey_path(@event_registration.slug, @callout.builtin_key, edit: 1), + class: "inline-flex items-center gap-1.5 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50" %> +
+ + <% else %> + <%= form_with url: registration_survey_submit_path(@event_registration.slug, @callout.builtin_key), method: :post do %> +
+ <% @form.form_fields.order(:position).each do |field| %> + <% if field.answer_type == "group_header" %> +

<%= field.name %>

+ + <% elsif field.per_resource? %> + <% field.form_field_resources.ordered.includes(:resource).each do |link| %> + <% resource = link.resource %> + <% next unless resource %> + <% prefill = @submission&.form_answers&.find_by(form_field: nil, question_name_when_answered: "#{field.name} #{resource.title}")&.submitted_answer %> +
+ <%= field.name %> <%= resource.title %> +
+ <% field.form_field_answer_options.includes(:answer_option).each do |ffo| %> + <% option = ffo.answer_option.name %> + + <% end %> +
+
+ <% end %> + + <% else %> + <% prefill = @submission&.form_answers&.find_by(form_field: field)&.submitted_answer %> +
+ + <% if field.subtitle.present? %>

<%= field.subtitle %>

<% end %> + + <% if field.answer_type == "single_select_radio" %> +
+ <% field.form_field_answer_options.includes(:answer_option).each do |ffo| %> + <% option = ffo.answer_option.name %> + + <% end %> +
+ <% elsif field.answer_type == "free_form_input_paragraph" %> + <%= text_area_tag "survey[fields][#{field.id}]", prefill, rows: 4, + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" %> + <% else %> + <%= text_field_tag "survey[fields][#{field.id}]", prefill, + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" %> + <% end %> +
+ <% end %> + <% end %> +
+ +
+ <%= submit_tag "Submit", class: "rounded-lg bg-purple-700 px-4 py-2 text-sm font-semibold text-white hover:bg-purple-800 cursor-pointer" %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/notification_mailer/survey_submitted_fyi.html.erb b/app/views/notification_mailer/survey_submitted_fyi.html.erb new file mode 100644 index 0000000000..d6529f62c9 --- /dev/null +++ b/app/views/notification_mailer/survey_submitted_fyi.html.erb @@ -0,0 +1,16 @@ +

New <%= @form.name %> submission

+ +

+ From: <%= @person.full_name %>
+ <% if @event %>Event: <%= @event.title %>
<% end %> + Submitted: <%= @form_submission.created_at.in_time_zone("Pacific Time (US & Canada)").strftime("%B %-d, %Y at %-l:%M %p %Z") %> +

+ +
+ +<% @answers.each do |answer| %> +

+ <%= answer.name %> + <%= answer.submitted_answer.presence || "—" %> +

+<% end %> diff --git a/app/views/notification_mailer/survey_submitted_fyi.text.erb b/app/views/notification_mailer/survey_submitted_fyi.text.erb new file mode 100644 index 0000000000..a131f5071c --- /dev/null +++ b/app/views/notification_mailer/survey_submitted_fyi.text.erb @@ -0,0 +1,14 @@ +New <%= @form.name %> submission +========================== + +From: <%= @person.full_name %> +<% if @event %>Event: <%= @event.title %> +<% end %>Submitted: <%= @form_submission.created_at.in_time_zone("Pacific Time (US & Canada)").strftime("%B %-d, %Y at %-l:%M %p %Z") %> + +------------------------------------------------------------ + +<% @answers.each do |answer| %> +<%= answer.name %> +<%= answer.submitted_answer.presence || "—" %> + +<% end %> diff --git a/config/routes.rb b/config/routes.rb index 76743101a6..f36acd2698 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -96,6 +96,8 @@ get "registration/:slug/resource/:resource_id", to: "events/callouts#resource", as: :registration_resource get "registration/:slug/videoconference", to: "events/callouts#videoconference", as: :registration_videoconference get "registration/:slug/staff", to: "events/callouts#staff", as: :registration_staff + get "registration/:slug/survey/:builtin_key", to: "events/callouts#survey", as: :registration_survey + post "registration/:slug/survey/:builtin_key", to: "events/callouts#submit_survey", as: :registration_survey_submit post "registration/:slug/resend_confirmation", to: "events/registrations#resend_confirmation", as: :registration_resend_confirmation post "registration/:slug/cancel", to: "events/registrations#cancel", as: :registration_cancel post "registration/:slug/reactivate", to: "events/registrations#reactivate", as: :registration_reactivate @@ -111,6 +113,7 @@ patch :update_onboarding patch :toggle_certificate_issued patch :update_attendance + patch :toggle_post_survey end resources :comments, only: [ :create, :update ] end diff --git a/db/migrate/20260809195713_add_form_to_registration_ticket_callouts.rb b/db/migrate/20260809195713_add_form_to_registration_ticket_callouts.rb new file mode 100644 index 0000000000..c28a1c65aa --- /dev/null +++ b/db/migrate/20260809195713_add_form_to_registration_ticket_callouts.rb @@ -0,0 +1,13 @@ +class AddFormToRegistrationTicketCallouts < ActiveRecord::Migration[8.1] + # The survey form a callout delivers inline (post-event survey callouts). Nullable — + # ordinary callouts have no form. Integer FK to match the forms table's integer PK. + def up + return if column_exists?(:registration_ticket_callouts, :form_id) + add_reference :registration_ticket_callouts, :form, type: :integer, foreign_key: true, null: true + end + + def down + return unless column_exists?(:registration_ticket_callouts, :form_id) + remove_reference :registration_ticket_callouts, :form, foreign_key: true + end +end diff --git a/db/migrate/20260809195714_add_post_survey_completed_at_to_event_registrations.rb b/db/migrate/20260809195714_add_post_survey_completed_at_to_event_registrations.rb new file mode 100644 index 0000000000..e3d88310b3 --- /dev/null +++ b/db/migrate/20260809195714_add_post_survey_completed_at_to_event_registrations.rb @@ -0,0 +1,13 @@ +class AddPostSurveyCompletedAtToEventRegistrations < ActiveRecord::Migration[8.1] + # Set when a scholarship recipient completes their post-event (recipients) survey. The + # query-free completion cache the registrants readiness Status column reads, mirroring + # certificate_sent_at. + def up + return if column_exists?(:event_registrations, :post_survey_completed_at) + add_column :event_registrations, :post_survey_completed_at, :datetime + end + + def down + remove_column :event_registrations, :post_survey_completed_at, if_exists: true + end +end diff --git a/db/migrate/20260809195716_create_form_field_resources.rb b/db/migrate/20260809195716_create_form_field_resources.rb new file mode 100644 index 0000000000..f192108892 --- /dev/null +++ b/db/migrate/20260809195716_create_form_field_resources.rb @@ -0,0 +1,21 @@ +class CreateFormFieldResources < ActiveRecord::Migration[8.1] + # Direct FormField -> Resource link. A form field with associated resources is a + # "per-resource" question that renders one input per resource (the post-event survey + # clarity question, one input per training topic/handout). Integer FKs match the + # integer PKs on form_fields and resources. + def up + return if table_exists?(:form_field_resources) + create_table :form_field_resources do |t| + t.references :form_field, type: :integer, null: false, foreign_key: true + t.references :resource, type: :integer, null: false, foreign_key: true + t.integer :position + t.timestamps + end + add_index :form_field_resources, [ :form_field_id, :resource_id ], unique: true, + name: "index_form_field_resources_on_field_and_resource" + end + + def down + drop_table :form_field_resources, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 8da6a717f6..e3a538a744 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -513,6 +513,7 @@ t.boolean "intends_to_pay", default: false, null: false t.boolean "invoice_requested", default: false, null: false t.boolean "payment_unresolved" + t.datetime "post_survey_completed_at" t.bigint "registrant_id", null: false t.boolean "scholarship_requested", default: false, null: false t.boolean "shoutout", default: false, null: false @@ -649,6 +650,17 @@ t.index ["form_field_id"], name: "index_form_field_answer_options_on_form_field_id" end + create_table "form_field_resources", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "form_field_id", null: false + t.integer "position" + t.integer "resource_id", null: false + t.datetime "updated_at", null: false + t.index ["form_field_id", "resource_id"], name: "index_form_field_resources_on_field_and_resource", unique: true + t.index ["form_field_id"], name: "index_form_field_resources_on_form_field_id" + t.index ["resource_id"], name: "index_form_field_resources_on_resource_id" + end + create_table "form_fields", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.integer "answer_type" t.datetime "created_at", precision: nil, null: false @@ -1167,6 +1179,7 @@ t.text "description" t.datetime "display_from" t.bigint "event_id", null: false + t.integer "form_id" t.boolean "hidden", default: false, null: false t.string "icon_class" t.boolean "payment_access_gated", default: false, null: false @@ -1177,6 +1190,7 @@ t.index ["event_id", "builtin_key"], name: "index_registration_ticket_callouts_on_event_id_and_builtin_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" + t.index ["form_id"], name: "index_registration_ticket_callouts_on_form_id" end create_table "report_form_field_answers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| @@ -1839,6 +1853,8 @@ add_foreign_key "form_builders", "windows_types" add_foreign_key "form_field_answer_options", "answer_options" add_foreign_key "form_field_answer_options", "form_fields" + add_foreign_key "form_field_resources", "form_fields" + add_foreign_key "form_field_resources", "resources" add_foreign_key "form_fields", "forms" add_foreign_key "form_submissions", "events" add_foreign_key "form_submissions", "forms" @@ -1870,6 +1886,7 @@ add_foreign_key "registration_ticket_callout_resources", "registration_ticket_callouts", on_delete: :cascade add_foreign_key "registration_ticket_callout_resources", "resources", on_delete: :cascade add_foreign_key "registration_ticket_callouts", "events" + add_foreign_key "registration_ticket_callouts", "forms" add_foreign_key "report_form_field_answers", "answer_options" add_foreign_key "report_form_field_answers", "form_fields" add_foreign_key "report_form_field_answers", "reports" diff --git a/db/seeds.rb b/db/seeds.rb index afb79bb5e2..b3e39815f7 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -493,3 +493,17 @@ def find_or_create_by_name!(klass, name, **attrs, &block) workshop_settings_type.categories .reject { |cat| canonical_names.include?(cat.name.downcase) } .each { |cat| cat.update!(published: false) } + +# Post-event survey templates — the standalone forms the Day 1 / Day 2 / Scholarship +# recipients survey callouts deliver inline. Built once from the form-builder presets, +# then left for staff to edit in the builder. Idempotent on form name. +puts "Creating post-event survey forms…" + +[ + { name: "Day 1 Survey", role: "day_1_survey", sections: %i[day_1_survey content_sharing_preferences] }, + { name: "Day 2 Survey", role: "day_2_survey", sections: %i[day_2_survey content_sharing_preferences] }, + { name: "Post-Training Recipients Survey", role: "post_event_survey", sections: %i[recipient_survey content_sharing_preferences] } +].each do |template| + next if Form.exists?(name: template[:name]) + FormBuilderService.new(name: template[:name], sections: template[:sections], role: template[:role]).call +end diff --git a/spec/factories/form_field_resources.rb b/spec/factories/form_field_resources.rb new file mode 100644 index 0000000000..2fe8dc24b6 --- /dev/null +++ b/spec/factories/form_field_resources.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :form_field_resource do + association :form_field + association :resource + sequence(:position) { |n| n } + end +end diff --git a/spec/mailers/survey_submitted_fyi_spec.rb b/spec/mailers/survey_submitted_fyi_spec.rb new file mode 100644 index 0000000000..9a25676830 --- /dev/null +++ b/spec/mailers/survey_submitted_fyi_spec.rb @@ -0,0 +1,19 @@ +require "rails_helper" + +RSpec.describe NotificationMailer, "#survey_submitted_fyi" do + it "notifies staff with the form name, registrant, and answers" do + person = create(:person, first_name: "Ada", last_name: "Lovelace") + event = create(:event, title: "Spring Training") + form = create(:form, name: "Day 1 Survey") + submission = create(:form_submission, person: person, form: form, event: event, role: "day_1_survey") + field = create(:form_field, form: form, name: "What stood out?") + create(:form_answer, form_submission: submission, form_field: field, + submitted_answer: "The breakout rooms", question_name_when_answered: "What stood out?") + + mail = described_class.survey_submitted_fyi(submission) + + expect(mail.to).to eq([ ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org") ]) + expect(mail.subject).to include("Day 1 Survey").and include("Ada Lovelace") + expect(mail.body.encoded).to include("What stood out?").and include("The breakout rooms") + end +end diff --git a/spec/models/form_field_resource_spec.rb b/spec/models/form_field_resource_spec.rb new file mode 100644 index 0000000000..f62bb9a92c --- /dev/null +++ b/spec/models/form_field_resource_spec.rb @@ -0,0 +1,35 @@ +require "rails_helper" + +RSpec.describe FormFieldResource, type: :model do + it "is valid with a form field and resource" do + expect(build(:form_field_resource)).to be_valid + end + + it "requires a unique resource per form field" do + existing = create(:form_field_resource) + dup = build(:form_field_resource, form_field: existing.form_field, resource: existing.resource) + expect(dup).not_to be_valid + end + + it "orders by position" do + field = create(:form_field) + later = create(:form_field_resource, form_field: field, position: 2) + earlier = create(:form_field_resource, form_field: field, position: 1) + expect(field.form_field_resources.reload.to_a).to eq([ earlier, later ]) + end + + describe "FormField#per_resource?" do + it "is true only once resources are linked" do + field = create(:form_field) + expect(field.per_resource?).to be(false) + create(:form_field_resource, form_field: field) + expect(field.reload.per_resource?).to be(true) + end + + it "exposes the linked resources through the join" do + field = create(:form_field) + link = create(:form_field_resource, form_field: field) + expect(field.resources).to include(link.resource) + end + end +end diff --git a/spec/requests/event_registration_post_survey_spec.rb b/spec/requests/event_registration_post_survey_spec.rb new file mode 100644 index 0000000000..74b4fa15c0 --- /dev/null +++ b/spec/requests/event_registration_post_survey_spec.rb @@ -0,0 +1,28 @@ +require "rails_helper" + +RSpec.describe "EventRegistration post-event survey toggle", type: :request do + let(:admin) { create(:user, :with_person, super_user: true) } + let(:event) { create(:event) } + let(:registration) { create(:event_registration, event: event) } + + before { sign_in admin } + + it "marks the survey received when it was not, and clears it when it was" do + expect(registration.post_survey_completed?).to be(false) + + patch toggle_post_survey_event_registration_path(registration) + expect(registration.reload.post_survey_completed?).to be(true) + + patch toggle_post_survey_event_registration_path(registration) + expect(registration.reload.post_survey_completed?).to be(false) + end + + it "does not touch the certificate timestamp (independent toggles)" do + registration.update!(certificate_sent_at: Time.current) + + patch toggle_post_survey_event_registration_path(registration) + + expect(registration.reload.post_survey_completed?).to be(true) + expect(registration.certificate_sent_at).to be_present + end +end diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index 21ce9bcf05..7965aa884d 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -253,7 +253,8 @@ expect(event.registration_ticket_callouts.builtin.pluck(:builtin_key)).to contain_exactly( "payment", "certificate", "scholarship", "ce_hours", - "videoconference", "staff", "handouts", "faq" + "videoconference", "staff", "handouts", "faq", + "day_1_survey", "day_2_survey", "scholarship_recipients_survey" ) end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 1cbad10b90..52d376c869 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -783,7 +783,7 @@ def add_ce_registrant(target_event) it "materializes the built-in callouts so the preview reads from real rows" do expect { get sample_ticket_event_path(event) } - .to change { event.registration_ticket_callouts.builtin.count }.from(0).to(8) + .to change { event.registration_ticket_callouts.builtin.count }.from(0).to(11) end it "logs an Ahoy page-view event" do @@ -1030,9 +1030,11 @@ def add_ce_registrant(target_event) created = Event.order(created_at: :desc).first # The two submitted built-ins persist their edits, and the post-save seed - # fills the remaining six — every seeded built-in key present exactly once. + # fills the rest — every seeded built-in key present exactly once, including + # the three post-event survey callouts (the event spans multiple days). expect(created.registration_ticket_callouts.builtin.pluck(:builtin_key)).to contain_exactly( - "payment", "certificate", "scholarship", "ce_hours", "videoconference", "staff", "handouts", "faq" + "payment", "certificate", "scholarship", "ce_hours", "videoconference", "staff", "handouts", "faq", + "day_1_survey", "day_2_survey", "scholarship_recipients_survey" ) payment = created.registration_ticket_callouts.find_by(builtin_key: "payment") expect(payment.title).to eq("Pay your balance") diff --git a/spec/requests/registration_survey_spec.rb b/spec/requests/registration_survey_spec.rb new file mode 100644 index 0000000000..9713c1d1eb --- /dev/null +++ b/spec/requests/registration_survey_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +RSpec.describe "Registration survey page", type: :request do + let(:event) { create(:event, cost_cents: 1000) } + let(:registration) { create(:event_registration, event: event) } + let(:form) do + FormBuilderService.new(name: "Post-Training Recipients Survey", + sections: [ :recipient_survey, :content_sharing_preferences ], role: "post_event_survey").call + end + + def make_recipient + create(:allocation, + source: create(:scholarship, recipient: registration.registrant, tasks_completed: true, amount_cents: 100), + allocatable: registration, amount: 100) + end + + def survey_callout(hidden: false, display_from: 1.day.ago) + event.registration_ticket_callouts.create!(builtin_key: "scholarship_recipients_survey", + title: "Scholarship recipients survey", callout_type: "action", + hidden: hidden, display_from: display_from, form: form) + end + + it "renders the form when live" do + survey_callout + get registration_survey_path(registration.slug, "scholarship_recipients_survey") + expect(response).to have_http_status(:success) + expect(response.body).to include("How did participating in this training impact you") + end + + it "withholds the form before the drip date" do + survey_callout(display_from: 3.days.from_now) + get registration_survey_path(registration.slug, "scholarship_recipients_survey") + expect(response.body).to include("will open on") + end + + it "records a submission, stamps completion, and redirects" do + make_recipient + survey_callout + impact = form.form_fields.find_by(field_identifier: "impact") + + expect { + post registration_survey_submit_path(registration.slug, "scholarship_recipients_survey"), + params: { survey: { fields: { impact.id.to_s => "It was transformative" } } } + }.to change(FormSubmission, :count).by(1) + + expect(response).to redirect_to(registration_survey_path(registration.slug, "scholarship_recipients_survey")) + expect(registration.reload.post_survey_completed?).to be(true) + expect(FormSubmission.last.form_answers.find_by(form_field: impact).submitted_answer).to eq("It was transformative") + end +end diff --git a/spec/services/builtin_callouts_spec.rb b/spec/services/builtin_callouts_spec.rb index bad4538617..e2899e2a4a 100644 --- a/spec/services/builtin_callouts_spec.rb +++ b/spec/services/builtin_callouts_spec.rb @@ -2,14 +2,16 @@ RSpec.describe BuiltinCallouts do describe "#build" do - it "builds all eight built-ins as unsaved in-memory rows on a new event" do + it "builds the built-ins as unsaved in-memory rows on a new event" do event = Event.new built = described_class.build(event) + # No dates on a bare Event, so day_count is 1 and the Day 2 survey is skipped. expect(built.map(&:builtin_key)).to contain_exactly( "payment", "certificate", "scholarship", "ce_hours", - "videoconference", "staff", "handouts", "faq" + "videoconference", "staff", "handouts", "faq", + "day_1_survey", "scholarship_recipients_survey" ) expect(built).to all(be_new_record) expect(event.registration_ticket_callouts).to match_array(built) @@ -22,7 +24,8 @@ built = described_class.build(event) expect(built).to be_empty - expect(event.registration_ticket_callouts.builtin.count).to eq(8) + # 8 originals + the 3 survey built-ins (the factory event spans multiple days). + expect(event.registration_ticket_callouts.builtin.count).to eq(11) end it "builds a paid event's Payment card with the W-9 link (subtitle) in memory" do @@ -39,7 +42,7 @@ end describe "#seed" do - it "materializes all eight built-in callouts for every event" do + it "materializes the built-in callouts for every event" do event = create(:event, cost_cents: 0) # free, no scholarship form, no VC link described_class.seed(event) @@ -47,7 +50,8 @@ keys = event.registration_ticket_callouts.builtin.pluck(:builtin_key) expect(keys).to contain_exactly( "payment", "certificate", "scholarship", "ce_hours", - "videoconference", "staff", "handouts", "faq" + "videoconference", "staff", "handouts", "faq", + "day_1_survey", "day_2_survey", "scholarship_recipients_survey" ) end @@ -60,7 +64,7 @@ described_class.seed(event) expect(event.registration_ticket_callouts.ordered.map(&:builtin_key)).to eq( - %w[payment scholarship ce_hours videoconference staff handouts certificate faq] + %w[payment scholarship ce_hours videoconference staff handouts certificate faq day_1_survey day_2_survey scholarship_recipients_survey] ) end @@ -246,7 +250,8 @@ keys = event.registration_ticket_callouts.builtin.pluck(:builtin_key) expect(keys).to contain_exactly( "payment", "certificate", "scholarship", "ce_hours", - "videoconference", "staff", "faq" + "videoconference", "staff", "faq", + "day_1_survey", "day_2_survey", "scholarship_recipients_survey" ) end @@ -258,7 +263,7 @@ expect(event.registration_ticket_callouts.ordered.first).to eq(custom) expect(event.registration_ticket_callouts.ordered.map(&:builtin_key).compact).to eq( - %w[payment scholarship ce_hours videoconference staff handouts certificate faq] + %w[payment scholarship ce_hours videoconference staff handouts certificate faq day_1_survey day_2_survey scholarship_recipients_survey] ) end end @@ -297,4 +302,52 @@ expect { described_class.reset(callout) }.not_to change { callout.reload.title } end end + + describe "post-event survey built-ins" do + it "seeds the Day 1 and scholarship surveys but omits Day 2 on a one-day event" do + event = create(:event, start_date: Time.zone.local(2026, 9, 10, 9), end_date: Time.zone.local(2026, 9, 10, 17)) + + described_class.seed(event) + + keys = event.registration_ticket_callouts.builtin.pluck(:builtin_key) + expect(keys).to include("day_1_survey", "scholarship_recipients_survey") + expect(keys).not_to include("day_2_survey") + end + + it "seeds the Day 2 survey on a multi-day event" do + event = create(:event, start_date: Time.zone.local(2026, 9, 10, 9), end_date: Time.zone.local(2026, 9, 11, 17)) + + described_class.seed(event) + + expect(event.registration_ticket_callouts.pluck(:builtin_key)).to include("day_2_survey") + end + + it "points each survey callout at its seeded template form" do + day_1_form = FormBuilderService.new(name: "Day 1 Survey", sections: [ :day_1_survey ], role: "day_1_survey").call + event = create(:event) + + described_class.seed(event) + + callout = event.registration_ticket_callouts.find_by(builtin_key: "day_1_survey") + expect(callout.form).to eq(day_1_form) + end + + it "drips the Day 1 survey 30 minutes before that day's end time" do + event = create(:event, start_date: Time.zone.local(2026, 9, 10, 9), end_date: Time.zone.local(2026, 9, 11, 17)) + + described_class.seed(event) + + day_1 = event.registration_ticket_callouts.find_by(builtin_key: "day_1_survey") + expect(day_1.display_from).to eq(Time.zone.local(2026, 9, 10, 16, 30)) + end + + it "drips the scholarship survey 30 minutes before the event ends" do + event = create(:event, end_date: Time.zone.local(2026, 9, 11, 17)) + + described_class.seed(event) + + scholarship = event.registration_ticket_callouts.find_by(builtin_key: "scholarship_recipients_survey") + expect(scholarship.display_from).to eq(Time.zone.local(2026, 9, 11, 16, 30)) + end + end end diff --git a/spec/services/event_registration_readiness_survey_spec.rb b/spec/services/event_registration_readiness_survey_spec.rb new file mode 100644 index 0000000000..3c8ff1b65e --- /dev/null +++ b/spec/services/event_registration_readiness_survey_spec.rb @@ -0,0 +1,77 @@ +require "rails_helper" + +RSpec.describe EventRegistrationReadiness, "post-event survey gating" do + let(:event) { create(:event, cost_cents: 1000) } + let(:registration) { create(:event_registration, event: event, status: "attended") } + subject(:readiness) { described_class.new(registration) } + + def link_org(reg) + create(:event_registration_organization, event_registration: reg, organization: create(:organization)) + end + + # A scholarship covering the full cost makes the recipient paid-in-full and, with + # tasks complete, clears every pre-event and post-event check except the survey. + def award_scholarship(reg, amount: 1000) + scholarship = create(:scholarship, recipient: reg.registrant, tasks_completed: true, amount_cents: amount) + create(:allocation, source: scholarship, allocatable: reg, amount: amount) + end + + def open_recipient_survey(hidden: false, display_from: 1.day.ago) + event.registration_ticket_callouts.create!( + builtin_key: "scholarship_recipients_survey", title: "Scholarship recipients survey", + callout_type: "action", hidden: hidden, display_from: display_from + ) + end + + before do + link_org(registration) + award_scholarship(registration) + end + + it "is survey_pending once the survey is live and unsubmitted" do + open_recipient_survey + + expect(readiness.status).to eq(:survey_pending) + expect(readiness.certifiable?).to be(false) + expect(readiness.completed?).to be(false) + expect(readiness.status_issues).to include("Post-event survey outstanding") + end + + it "advances to certificate_due once the survey is submitted" do + open_recipient_survey + registration.mark_post_survey_completed! + + expect(readiness.status).to eq(:certificate_due) + end + + it "does not gate when the survey callout is unpublished" do + open_recipient_survey(hidden: true) + + expect(readiness.status).to eq(:certificate_due) + end + + it "does not gate before the drip date" do + open_recipient_survey(display_from: 1.day.from_now) + + expect(readiness.status).to eq(:certificate_due) + end + + it "orders survey_pending between ready and certificate_due" do + expect(EventRegistrationReadiness::STATUS_ORDER.index(:survey_pending)) + .to be_between( + EventRegistrationReadiness::STATUS_ORDER.index(:ready) + 1, + EventRegistrationReadiness::STATUS_ORDER.index(:certificate_due) - 1 + ) + end + + it "never gates a non-recipient" do + non_recipient = create(:event_registration, event: event, status: "attended") + create(:event_registration_organization, event_registration: non_recipient, organization: create(:organization)) + create(:allocation, + source: create(:payment, amount_cents: 1000, amount_cents_remaining: 1000), + allocatable: non_recipient, amount: 1000) + open_recipient_survey + + expect(described_class.new(non_recipient).status).to eq(:certificate_due) + end +end diff --git a/spec/services/event_registration_services/survey_submission_spec.rb b/spec/services/event_registration_services/survey_submission_spec.rb new file mode 100644 index 0000000000..ec28afe677 --- /dev/null +++ b/spec/services/event_registration_services/survey_submission_spec.rb @@ -0,0 +1,96 @@ +require "rails_helper" + +RSpec.describe EventRegistrationServices::SurveySubmission do + let(:event) { create(:event, cost_cents: 1000) } + let(:registration) { create(:event_registration, event: event) } + let(:person) { registration.registrant } + + let(:form) { create(:form, role: "post_event_survey") } + let!(:static_field) do + create(:form_field, form: form, answer_type: :free_form_input_paragraph, name: "Impact?", field_identifier: "impact") + end + let!(:clarity_field) do + create(:form_field, form: form, answer_type: :single_select_radio, + name: "Overall, was the information presented in a clear and concise manner for") + end + let(:triple_focus) { create(:resource, title: "Triple Focus") } + let(:listening) { create(:resource, title: "Listening is Art") } + let!(:anon_field) do + create(:form_field, form: form, answer_type: :single_select_radio, name: "Anonymity?", field_identifier: "anonymous_contributions") + end + let!(:name_field) do + create(:form_field, form: form, answer_type: :single_select_radio, name: "Name?", field_identifier: "display_name_preference") + end + + before do + create(:form_field_resource, form_field: clarity_field, resource: triple_focus) + create(:form_field_resource, form_field: clarity_field, resource: listening) + # Make the registrant a scholarship recipient so completion stamps. + create(:allocation, + source: create(:scholarship, recipient: person, tasks_completed: true, amount_cents: 100), + allocatable: registration, amount: 100) + end + + def submit(field_params:, clarity_params:) + described_class.call( + event_registration: registration, form: form, role: "post_event_survey", + field_params: field_params, clarity_params: clarity_params + ) + end + + let(:field_params) do + { + static_field.id.to_s => "It changed me", + anon_field.id.to_s => Person::ANONYMOUS_CONTRIBUTIONS_OPTIONS[true], + name_field.id.to_s => Person::DISPLAY_NAME_PREFERENCES["first_name_only"] + } + end + let(:clarity_params) do + { clarity_field.id.to_s => { triple_focus.id.to_s => "Yes", listening.id.to_s => "No" } } + end + + it "creates a role-tagged submission with the static answer" do + service = submit(field_params: field_params, clarity_params: clarity_params) + + submission = service.submission + expect(submission).to have_attributes(person: person, form: form, event: event, role: "post_event_survey") + expect(submission.form_answers.find_by(form_field: static_field).submitted_answer).to eq("It changed me") + end + + it "fans the clarity field out to one nil-field answer per resource, snapshotting the sentence" do + service = submit(field_params: field_params, clarity_params: clarity_params) + + dynamic = service.submission.form_answers.where(form_field: nil) + expect(dynamic.pluck(:question_name_when_answered, :submitted_answer)).to contain_exactly( + [ "Overall, was the information presented in a clear and concise manner for Triple Focus", "Yes" ], + [ "Overall, was the information presented in a clear and concise manner for Listening is Art", "No" ] + ) + end + + it "writes the two profile questions through to the Person and reports the changes" do + service = submit(field_params: field_params, clarity_params: clarity_params) + + expect(person.reload.anonymous_contributions).to be(true) + expect(person.display_name_preference).to eq("first_name_only") + expect(service.profile_changes).to include( + anonymous_contributions: [ false, true ], # main's column defaults to false, not nil + display_name_preference: [ nil, "first_name_only" ] + ) + end + + it "stamps completion for a scholarship recipient's recipients survey" do + submit(field_params: field_params, clarity_params: clarity_params) + + expect(registration.reload.post_survey_completed?).to be(true) + end + + it "is idempotent on re-submit — updates in place without duplicating answers" do + submit(field_params: field_params, clarity_params: clarity_params) + service = submit(field_params: field_params.merge(static_field.id.to_s => "Edited"), clarity_params: clarity_params) + + expect(service.submission.form_answers.where(form_field: static_field).count).to eq(1) + expect(service.submission.form_answers.find_by(form_field: static_field).submitted_answer).to eq("Edited") + expect(service.submission.form_answers.where(form_field: nil).count).to eq(2) + expect(service.profile_changes).to be_empty # unchanged the second time + end +end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index bd401c4af1..7339c32b1d 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -243,6 +243,7 @@ "app/views/events/callouts/resource.html.erb" => "public", "app/views/events/callouts/videoconference.html.erb" => "public", "app/views/events/callouts/staff.html.erb" => "public", + "app/views/events/callouts/survey.html.erb" => "public", "app/views/registration_ticket_callouts/show.html.erb" => "public", # ─── bulk payment views ─── diff --git a/test/mailers/previews/notification_mailer_preview.rb b/test/mailers/previews/notification_mailer_preview.rb index e10eecfed6..62cad17025 100644 --- a/test/mailers/previews/notification_mailer_preview.rb +++ b/test/mailers/previews/notification_mailer_preview.rb @@ -19,6 +19,18 @@ def event_registration_confirmation_fyi NotificationMailer.event_registration_confirmation_fyi(notification) end + def survey_submitted_fyi + submission = FormSubmission.order(:id).last || + FormSubmission.create!( + person: Person.first || raise("Need a Person"), + form: Form.first || raise("Need a Form"), + event: Event.first, + role: "post_event_survey" + ) + + NotificationMailer.survey_submitted_fyi(submission) + end + def event_registration_cancelled_fyi event_registration = EventRegistration.first ||