diff --git a/AGENTS.md b/AGENTS.md index fa7405a449..ee9a265414 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,8 @@ This codebase (Rails 8.1) | `EventStaff` | Join model connecting `Person` to `Event` as staff (title, `expected_to_attend`); drives the "Meet the staff" roster and "My events" | | `EventRegistrationChecklistCompletion` | Audited completion row for one manual onboarding step on an `EventRegistration` (`step` from `EventRegistration::CHECKLIST_STEPS`, `completed_by` User, `completed_at`); row-exists = done. Powers the event Onboarding tab's checkbox matrix | | `EventAttendanceTimeEntry` | One sign-in/sign-out pair for a registrant on a day of an event (`signed_in_at`, `signed_out_at` — nil while "open"/still signed in; `created_by`/`updated_by` stamped only on staff edits, nil for registrant self-service). Generic day-of-event timekeeping (many per day for breaks/lunch), currently surfaced only on the CE callout when CE is paid; `EventAttendanceReport` totals minutes per day. Sign-in window derives from `Event#attendance_sign_in_open?`, but the times stay editable outside it — by staff on the report and by the registrant on their own CE callout. An open entry occupies the rest of its day for the overlap guard | -| `RegistrationTicketCallout` | Call-outs shown on an event's registration ticket (title, subtitle, HTML description, `callout_type` action/reference, icon/colour, `payment_access_gated` — only shown once the registrant has `payment_access_granted?`, draggable `position`, `hidden` draft/opt-out, `display_from` drip date, and `has_many :resources` through `RegistrationTicketCalloutResource`); each links to its own public detail page. A nil `builtin_key` is an admin-authored callout; a set `builtin_key` is a built-in card materialized by `BuiltinCallouts` (hidden instead of deleted, restorable to default) | +| `RegistrationTicketCallout` | Call-outs shown on an event's registration ticket (title, subtitle, HTML description, `callout_type` action/reference, icon/colour, `payment_access_gated` — only shown once the registrant has `payment_access_granted?`, draggable `position`, `hidden` draft/opt-out, `display_from` drip date, `has_many :forms` through `RegistrationTicketCalloutForm` and `has_many :resources` through `RegistrationTicketCalloutResource`); each links to its own public detail page. A nil `builtin_key` is an admin-authored callout; a set `builtin_key` is a built-in card materialized by `BuiltinCallouts` (hidden instead of deleted, restorable to default) | +| `RegistrationTicketCalloutForm` | Ordered join linking a `RegistrationTicketCallout` to the `Form`s it delivers inline, each row with its own `display_from` drip date. A row opens at `#available_from` — the later of the callout's own `display_from` (which withholds all of its page content) and the row's — so a form can't open ahead of the page carrying it; `#dripping?` gates both the rendered form and the submit endpoint. One callout can open several forms on their own dates; a single row behaves like a plain single-form callout | | `RegistrationTicketCalloutResource` | Ordered join linking a `RegistrationTicketCallout` to the `Resource`s shown on its detail page | | `Story` | Editorial content with facilitators, primary/gallery assets | | `Resource` | Handouts, toolkits, templates with downloadable assets | @@ -252,16 +253,18 @@ action, or `authorize! :workshop, to: :summary?`). ### Event Registrations - `EventRegistrationServices::ProcessConfirmation` — Registration confirmation flow -- `RegistrantCeForm` — The CE-specific view over the inline form the **CE callout** carries (built on the generic callout→form mechanism: `RegistrationTicketCallout#form` + `CalloutFormSubmission`). Adds the two things CE needs: the post-training form only surfaces once `EventRegistration#ce_signouts_complete?` (event ended + no open attendance entry), and completing it — read-time `complete?`, every required field answered — is a prerequisite for the CE certificate (`EventRegistration#ce_form_requirement_met?` gates `ContinuingEducationRegistration#certificate_available?`). Read by the CE callout page (which links to the generic `registration/:slug/forms/:callout_id` form page, gated so the CE callout's form opens only after sign-outs via `CalloutsController#ce_form_locked?`) and by `BuiltinCalloutCards#ce_form_pending?` for the CE card's "Complete your form" nudge -- `EventRegistrationServices::CalloutFormSubmission` — Records a registrant's answers to the form a registration-ticket callout delivers inline (`RegistrationTicketCallout#form`). Find-or-creates one `FormSubmission` per `(registrant, form, event)` — a re-submit edits the answers in place via `FormSubmission#persist_answer`. The submission carries the form's own role (`.role_for` → `form.role`), indistinguishable from the same form submitted elsewhere. That a callout collected it is recorded separately in `FormSubmission#metadata` (`collected_via: "callout"`, `collected_via_callout_id`) via `#record_callout_collection!` — the flag (`#collected_via_callout?`), not the role, is how you tell a callout-collected submission apart (surfaced as a badge by the submitted time). Drives `Events::CalloutsController#callout`/`#submit_callout` (the person-first `registration/:slug/forms/:callout_id` page) +- `RegistrantCeForm` — The CE-specific view over the inline form the **CE callout** carries (built on the generic callout→form mechanism: `RegistrationTicketCallout#forms` + `CalloutFormSubmission`; the CE callout carries a single form, read as `forms.first`). Adds the two things CE needs: the post-training form only surfaces once `EventRegistration#ce_signouts_complete?` (event ended + no open attendance entry), and completing it — read-time `complete?`, every required field answered — is a prerequisite for the CE certificate (`EventRegistration#ce_form_requirement_met?` gates `ContinuingEducationRegistration#certificate_available?`). Read by the CE callout page (which links to the generic `registration/:slug/forms/:callout_id` form page, gated so the CE callout's form opens only after sign-outs via `CalloutsController#ce_form_locked?`) and by `BuiltinCalloutCards#ce_form_pending?` for the CE card's "Complete your form" nudge +- `EventRegistrationServices::CalloutFormSubmission` — Records a registrant's answers to one of the forms a registration-ticket callout delivers inline (`call(registration:, callout:, form:, form_params:, clarity_params:)`, returns the service instance with `#submission`/`#profile_changes`). Find-or-creates one `FormSubmission` per `(registrant, form, event)` carrying the form's own role — a re-submit edits the answers in place via `FormSubmission#persist_answer`. That a callout collected it is recorded separately in `FormSubmission#metadata` (`collected_via: "callout"`, `collected_via_callout_id`) via `#record_callout_collection!` — the flag (`#collected_via_callout?`), not the role, tells a callout-collected submission apart. There is no survey-role flag — the extra side effects self-gate on the form's own fields, so they no-op on a form that doesn't ask those questions: a choice field with linked `FormFieldResource`s (`FormField#per_resource?`) fans out to one answer per resource (nil `form_field`, `FormField#per_resource_question` snapshotted in `question_name_when_answered` — subtitle-led, so two fan-out questions sharing a prompt can't collide on one answer row); fields carrying the `anonymous_contributions` / `display_name_preference` identifiers write through to the `Person` via `PersonServices::SyncSharingPreferences` (the same write-through the registration form runs), exposing `#profile_changes` so the controller Ahoy-tracks real edits; and submitting any form on the **`scholarship`** callout stamps a recipient's `post_survey_completed_at`. Drives `Events::CalloutsController#callout`/`#submit_callout` (the person-first `registration/:slug/forms/:callout_id` page and its `.../:form_id` submit) +- `SurveyFormSeeder` — Creates the four standalone survey template forms from the form-builder presets (the Day 1 / Day 2 evaluations, the overall post-event survey, and the scholarship recipients survey), idempotent on form name and returning the names created. `#link_fanout_resources` then links each fan-out question to its training-topic `Resource`s by title (skipping any that aren't present). Shared by `db/seeds.rb` and the `data:seed_survey_forms` task so prod can create them without the full seed - `EventRegistrationServices::PublicRegistration` — Public registration handling. An affirmative `communication_consent` answer captures a News (mailing-list) `TopicSubscription` via `NewsSubscriptionCapture`, sourced to the event - `NewsSubscriptionCapture` — Successor to the retired person-level mailing-list consent flag: given a `person` + `source`, idempotently creates an active **News** `TopicSubscription` (skips when one is already active, no-op when the News topic isn't seeded). Called from `PublicRegistration` (source = event) and `PublicFormSubmission` (source = form) when the `communication_consent` question is answered +- `PersonServices::SyncSharingPreferences` — Writes the two content-sharing answers (`anonymous_contributions`, `display_name_preference`) onto the `Person`, mapping each submitted option label back to its stored value; an unanswered or unrecognized answer leaves the profile alone, so a form that doesn't ask the questions is a no-op and every flow can call it unconditionally. The person-side counterpart to `OrganizationServices::SyncProfile`. Shared by `PublicRegistration#sync_person_profile` (so a registration form's answers land in the submission's "what this submission changed" audit via the Ahoy trail) and `CalloutFormSubmission` (whose `#profile_changes` re-reports the same edits for its own Ahoy tracking) - `EventRegistrationServices::TransferContinuingEducation` — Splits/relocates a registrant's CE when they transfer events (issue #1944): a simple forward transfer leaves a paid, zero-hours **stub** on the source (its payments count at the original event) and creates a **live** record on the destination carrying the hours and the outstanding balance; when the reg being transferred out is itself a transfer-in (a collapsing double transfer, or a transfer back to the origin) its live record is relocated forward — merging back into the origin's stub — instead of split again, so no third record appears. Runs inside the transfer transaction, after the destination is saved and before a collapsing middle reg is destroyed - `EventRegistrationServices::RevertTransfer` — Undoes a transfer-out (issue #1944): restores the reg to the status it held before the transfer (via `status_before_transfer`, or "registered"), and when a destination was already recorded, unlinks it (it becomes a normal standalone reg, nothing deleted) and re-merges its split CE back onto the source (`TransferContinuingEducation#revert`). Backs the "Manage transfer" hub's undo action -- `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 +- `EventRegistrationReadiness` — Computes a registration's lifecycle `status` (`:not_ready` → `:ready` → `:survey_pending` → `:certificate_due` → `:completed`); `:survey_pending` is a scholarship recipient whose recipients survey is available (`EventRegistration#recipient_survey_available?` — agreement signed, scholarship tasks complete, and any drip date on the scholarship callout's form row passed) but still unsubmitted (`post_survey_completed_at` blank), which gates the certificate. The recipients survey is delivered on the scholarship callout page, not the everyone-facing Feedback surveys callout. 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. A callout links its forms through `RegistrationTicketCalloutForm` (a definition's `forms:` proc returns `[{ form:, display_from: }]`, each dripping on its own date). The everyone-facing **`feedback_surveys`** content built-in links the Day 1 evaluation, the Day 2 evaluation (multi-day events only), and an overall post-event survey, delivered through the generic callout→form page (`Events::CalloutsController#callout`). The scholarship-only recipients survey (whose `recipient_survey`-role submission gates readiness) is instead linked onto the **`scholarship`** built-in and delivered on the scholarship callout page, gated on `EventRegistration#recipient_survey_available?` (agreement signed + tasks complete + drip passed). 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`) @@ -545,3 +548,4 @@ Located in `lib/tasks/` (13 files): - `backfill_user_stamps.rake` — Backfill `created_by_id`/`updated_by_id` on legacy rows from the Ahoy trail (`data:backfill_user_stamps`) - `backfill_notification_stamps.rake` — Backfill notifications' `created_by_id`/`updated_by_id` from `sender_id`, nil-only (`data:backfill_notification_stamps`) - `resolve_resource_organizations.rake` — Link resources to organizations from the legacy `agency` free-text name (`data:resolve_resource_organizations`, match-or-create, idempotent, DRY_RUN) +- `seed_survey_forms.rake` — Create the Day 1 / Day 2 / recipients survey template forms without the full db:seed (`data:seed_survey_forms`, via `SurveyFormSeeder`, idempotent on form name; run before configuring the survey callouts) diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 2a685750dc..319b1a3ea1 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -2,11 +2,11 @@ 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, :transfer, :process_transfer, :revert_transfer ] + before_action :set_event_registration, only: [ :show, :edit, :update, :destroy, :update_onboarding, :toggle_certificate_issued, :update_attendance, :toggle_post_survey, :transfer, :process_transfer, :revert_transfer ] # A transferred-out reg is locked (issue #1944): its inline write endpoints are # blocked with a warning rather than silently ignored. The full-form `update` is # handled separately (it keeps comments/communications editable). - before_action :block_locked_registration, only: [ :update_onboarding, :toggle_certificate_issued, :update_attendance ] + before_action :block_locked_registration, only: [ :update_onboarding, :toggle_certificate_issued, :update_attendance, :toggle_post_survey ] def index authorize! @@ -323,6 +323,18 @@ def revert_transfer status: :see_other end + # Admin toggle for the post-event survey — independent of the certificate toggle; 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 a72c6b3058..664125285f 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -54,6 +54,16 @@ def scholarship @scholarship = @event_registration.effective_scholarship @form_responses_available = @event.registration_form&.form_submissions&.exists?(person: @event_registration.registrant) + + # The recipients survey, delivered here once the recipient has signed their + # agreement and finished their tasks (and any drip date has passed). + @recipient_survey_link = @event.recipient_survey_form_link + @recipient_survey_available = @recipient_survey_link.present? && @event_registration.recipient_survey_available? + if @recipient_survey_available + @recipient_survey_submission = FormSubmission.find_by(person: @event_registration.registrant, + event: @event, form: @recipient_survey_link.form) + @recipient_survey_editing = @recipient_survey_submission.nil? || params[:edit].to_i == @recipient_survey_link.form_id + end end # The Agree button (agreement=yes) records an "accepted" response. @@ -309,36 +319,46 @@ def faq @faq_content = callout&.description end - # A callout that delivers a form inline: the registrant fills it out here and - # sees their responses on return. The reg slug is the authorization. + # A callout that delivers one or more forms inline: each linked form drips on + # its own date, is filled here, and shows its answers on return. The reg slug + # is the authorization. Forms already submitted collapse to a completed toggle. def callout @callout = @event.registration_ticket_callouts.find(params[:callout_id]) return redirect_to registration_ticket_path(@event_registration.slug) if @callout.hidden? || !@callout.delivers_form? # The CE callout's form is a post-training step — it opens only once the # registrant's sign-outs are complete, and it lives on the CE page. return redirect_to registration_ce_path(@event_registration.slug) if ce_form_locked?(@callout) + # The scholarship callout delivers the recipients survey inline on its own + # page (gated on agreement + tasks), not the generic callout-forms page. + return redirect_to registration_scholarship_path(@event_registration.slug) if @callout.builtin_key == "scholarship" - @form = @callout.form + @callout_forms = @callout.registration_ticket_callout_forms.includes(:form) @resource_cards = @callout.decorate.resource_cards(registrant_slug: @event_registration.slug, return_to: "callout_form") - unless @callout.dripping? - @submission = callout_submission - @editing = @submission.nil? || params[:edit].present? - end + @submissions = callout_submissions + @editing_form_id = params[:edit].to_i if params[:edit].present? end def submit_callout @callout = @event.registration_ticket_callouts.find(params[:callout_id]) - if @callout.hidden? || !@callout.delivers_form? || @callout.dripping? - redirect_to registration_callout_form_path(@event_registration.slug, @callout) + callout_form = @callout.registration_ticket_callout_forms.find_by(form_id: params[:form_id]) + form = callout_form&.form + if @callout.hidden? || callout_form.nil? || callout_form.dripping? || recipient_survey_locked? + redirect_to callout_form_landing(@callout, callout_form) return end return redirect_to registration_ce_path(@event_registration.slug) if ce_form_locked?(@callout) - EventRegistrationServices::CalloutFormSubmission.call( - registration: @event_registration, callout: @callout, form_params: callout_form_params + service = EventRegistrationServices::CalloutFormSubmission.call( + registration: @event_registration, callout: @callout, form: form, + form_params: callout_form_params, clarity_params: callout_clarity_params ) + # A profile write-through (anonymity / name questions) is Ahoy-tracked, and + # staff get a heads-up on the submission. The CE callout runs its own flow, so + # it opts out of the FYI. + track_profile_changes(service.profile_changes) + NotificationMailer.callout_form_submitted_fyi(service.submission, updated: service.edited?).deliver_later unless @callout.ce_config? - redirect_to registration_callout_form_path(@event_registration.slug, @callout), + redirect_to callout_form_landing(@callout, callout_form), notice: "Thanks! Your responses have been submitted." end @@ -361,9 +381,27 @@ def parse_contribution_cents(raw) (amount * 100).to_i end - def callout_submission - FormSubmission.find_by(person: @event_registration.registrant, form: @callout.form, event: @event, - role: EventRegistrationServices::CalloutFormSubmission.role_for(@callout)) + # The registrant's submission for each of the callout's forms, keyed by form_id + # (missing entries mean not yet submitted). Each carries the form's own role. + def callout_submissions + forms = @callout.forms.to_a + FormSubmission.where(person: @event_registration.registrant, event: @event, form: forms) + .index_by(&:form_id) + end + + # A form on the scholarship callout can't be filled until the recipient has + # signed their agreement and finished their tasks — guards a direct POST past + # the page gate. + def recipient_survey_locked? + @callout.builtin_key == "scholarship" && !@event_registration.recipient_survey_available? + end + + # Where to send the registrant after landing on / submitting a callout form. The + # scholarship callout's survey lives on the scholarship page; everything else + # returns to the generic callout-forms page, anchored to the form. + def callout_form_landing(callout, callout_form) + return registration_scholarship_path(@event_registration.slug) if callout.builtin_key == "scholarship" + registration_callout_form_path(@event_registration.slug, callout, anchor: callout_form && "form-#{callout_form.form_id}") end # The CE callout's form is a post-training step gated on sign-out completion — @@ -379,6 +417,11 @@ def callout_form_params params.dig(:callout_form, :form_fields)&.to_unsafe_h || {} end + # Per-resource "clarity" answers: { field_id => { resource_id => answer } }. + def callout_clarity_params + params.dig(:callout_form, :clarity)&.to_unsafe_h || {} + end + # Attendance sign-in/out follows the CE payment — it's the CE sign-in sheet. Any-of # rather than all-of, matching the callout view: each paid CE registration renders # its own sheet, and since they all record the same hours, one paid licence is @@ -414,6 +457,13 @@ def sign_out_notice(entry) "Signed out for #{entry.attendance_date.strftime("%a, %b %-d")} at #{time}." end + # One Ahoy event per profile field the survey actually changed. + def track_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/registrations_controller.rb b/app/controllers/events/registrations_controller.rb index 078509b182..40d3183ddf 100644 --- a/app/controllers/events/registrations_controller.rb +++ b/app/controllers/events/registrations_controller.rb @@ -177,7 +177,7 @@ def create_person_for_current_user def set_event_registration @event_registration = EventRegistration - .includes(event: { registration_ticket_callouts: :resources }) + .includes(event: { registration_ticket_callouts: [ :resources, :registration_ticket_callout_forms ] }) .find_by!(slug: params[:slug]) end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index d8c3732928..add12df753 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -272,7 +272,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 diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb index a45cec6aad..ee85049afa 100644 --- a/app/controllers/forms_controller.rb +++ b/app/controllers/forms_controller.rb @@ -197,7 +197,8 @@ def form_params form_fields_attributes: [ :id, :name, :answer_type, :required, :subtitle, :hint_text, :field_identifier, :section, :position, :visibility, :one_time, :width, :min_words, :max_characters, :_destroy, - form_field_answer_options_attributes: [ :id, :option_name, :_destroy ] + form_field_answer_options_attributes: [ :id, :option_name, :_destroy ], + form_field_resources_attributes: [ :id, :resource_id, :_destroy ] ] ) end diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 0e88cd55c5..34153b67a9 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -1,5 +1,5 @@ module FormsHelper - # Sibling-page subnav shared across a form's Results / Submissions / View / + # Sibling-page subnav shared across a form's Results / Submissions / Preview / # Edit / Edit sections pages so each reads like a set of tabs linking to the # others. `current` renders the active page as plain text instead of a link; # pass `:none` to keep every tab clickable. @@ -8,7 +8,7 @@ def form_page_subnav(form, current:, event: nil) tabs = [ form_subnav_tab("Results", results_form_path(form), active: current == :results), form_subnav_tab("Submissions", form_submissions_path(form_id: form.id, return_to: submissions_return), active: current == :submissions), - form_subnav_tab("View", form_path(form), active: current == :view), + form_subnav_tab("Preview", form_path(form), active: current == :view), form_subnav_tab("Edit", edit_form_path(form, event_id: event&.id), active: current == :edit), form_subnav_tab("Edit sections", edit_sections_form_path(form, event_id: event&.id), active: current == :edit_sections) ] diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index c822043ef2..1fd03d3436 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -16,6 +16,19 @@ def event_registration_confirmation_fyi(notification) ) end + # Takes the FormSubmission directly (not a Notification record, unlike the others). + # `updated:` distinguishes an edit to an existing submission from a first one. + def callout_form_submitted_fyi(form_submission, updated: false) + @form_submission = form_submission + @person = form_submission.person + @event = form_submission.event + @form = form_submission.form + @answers = form_submission.form_answers.order(:id) + @verb = updated ? "Updated" : "New" + + mail(subject: "#{FYI_PREFIX} #{@verb} #{@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 4d7720faef..04a8910a1f 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -240,6 +240,35 @@ def videoconference_details_visible?(now = Time.current) from.blank? || now >= from end + # The recipients survey lives on the scholarship callout. Memoized so readiness + # (which runs per registration) hits the callouts once per event, not per row. + def scholarship_callout + return @scholarship_callout if defined?(@scholarship_callout) + @scholarship_callout = + registration_ticket_callouts.detect { |callout| callout.builtin_key == "scholarship" } + end + + # The recipient's form row on the (published) scholarship callout — whatever form + # an admin links there gates readiness, no special role needed. nil when the + # callout carries no form or is hidden. + def recipient_survey_form_link + return @recipient_survey_form_link if defined?(@recipient_survey_form_link) + callout = scholarship_callout + @recipient_survey_form_link = + if callout && !callout.hidden? + callout.registration_ticket_callout_forms.min_by { |link| [ link.position || 0, link.id ] } + end + end + + # Whether the recipient survey's own drip date (if any) has passed — the + # event-level half of its availability. The per-recipient half (agreement signed, + # tasks complete) lives on EventRegistration#recipient_survey_available?. + def recipient_survey_drip_open?(now = Time.current) + link = recipient_survey_form_link + return false unless link + !link.dripping?(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 68f243ade1..27b30a705e 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -727,6 +727,18 @@ def scholarship_tasks_met? live.all?(&:tasks_completed?) end + # The recipients survey becomes available once the recipient has signed their + # agreement and their scholarship tasks are complete, and once any drip date on + # the scholarship callout row has passed. Reads preloaded scholarships + the + # event's memoized survey row, so it adds no per-row roster query. It's what + # gates both the survey's display on the scholarship page and readiness. + def recipient_survey_available?(now = Time.current) + live = scholarships.reject(&:agreement_declined?) + return false if live.empty? + return false unless live.all?(&:agreement_signed?) && live.all?(&:tasks_completed?) + event.recipient_survey_drip_open?(now) + end + def scholarship_declined? scholarships.any?(&:agreement_declined?) end @@ -742,6 +754,20 @@ def attended? status == "attended" end + # Set by the registrant submitting or an admin toggling. Mirrors certificate_sent_at + # 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 c6b1f965fd..8446b41780 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -7,6 +7,10 @@ class FormField < ApplicationRecord has_many :form_answers, dependent: :nullify has_many :childs, foreign_key: "parent_id", class_name: "FormField" + # A field with any linked resources becomes a per-resource question (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 @@ -180,6 +184,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 @@ -189,6 +196,21 @@ def selectable? answer_type.in?(SELECTABLE_ANSWER_TYPES) end + # Each copy of a fanned-out question renders this field's own answer options, so + # only a choice field can be one. A field switched away from a choice type keeps + # its links but falls back to rendering as an ordinary question. + def per_resource? + selectable? && form_field_resources.any? + end + + # The sentence one fanned-out copy is asked, stored, and shown under. The answer + # keeps a nil form_field, so this string is also its key: the subtitle leads so two + # fan-out questions sharing a name (the clarity pair, which differ only by their + # "Part One" / "Part Two" subtitle) can't collide on one answer row. + def per_resource_question(resource) + [ subtitle.presence, "#{name} #{resource.title}" ].compact.join(": ") + 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..d686bd2f14 --- /dev/null +++ b/app/models/form_field_resource.rb @@ -0,0 +1,14 @@ +class FormFieldResource < ApplicationRecord + # Ordered FormField→Resource join. A field with any of these is a "per-resource" + # question: the survey page renders one input per linked resource (the clarity + # question, asked once per training topic). The field owns the prompt + options. + 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 77b7a6ae87..da2d61eba3 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -356,6 +356,13 @@ def sector_list sectors.pluck(:name) end + # anonymous_contributions boolean => survey consent answer label (true = keep anonymous). + # Name-format labels live in DISPLAY_NAME_PREFERENCE_LABELS above. + ANONYMOUS_CONTRIBUTIONS_OPTIONS = { + false => "Display all my content with my profile name", + true => "Keep all my content anonymous" + }.freeze + # Drives the people index and the profile header. Author credits pass an explicit # preference (the record's own, which outranks the profile) through `name_for`. def name diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index c2e40a2dd2..caf62a8c99 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -13,13 +13,14 @@ class RegistrationTicketCallout < ApplicationRecord BUILTIN_KEYS = %w[ payment certificate scholarship ce_hours art_supplies videoconference staff handouts faq + feedback_surveys ].freeze # "Content" built-in callouts render their own editable copy/resources (like custom # callouts), on the generic callout page. "Behavioral" built-in callouts (the rest) # render live per-registration status through BuiltinCalloutCards#card_for — the row # still owns the editable title/subtitle/text, order, visibility, and resources. - CONTENT_BUILTIN_KEYS = %w[ art_supplies handouts faq ].freeze + CONTENT_BUILTIN_KEYS = %w[ art_supplies handouts faq feedback_surveys ].freeze # Behavioral built-ins that also carry event-level config edited inline in their # row (CE hours offered / cost); their text lives on the row like everything else. @@ -56,7 +57,13 @@ class RegistrationTicketCallout < ApplicationRecord belongs_to :created_by, class_name: "User", optional: true belongs_to :updated_by, class_name: "User", optional: true - belongs_to :form, optional: true + # A callout delivers its linked forms inline, in order, each gated by its own + # drip date (see RegistrationTicketCalloutForm). One row behaves like a plain + # single-form callout; several rows open on their own dates (e.g. a Day 1 and + # Day 2 evaluation, or the post-event survey). + has_many :registration_ticket_callout_forms, -> { ordered }, dependent: :destroy, + inverse_of: :registration_ticket_callout + has_many :forms, through: :registration_ticket_callout_forms # 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. @@ -65,8 +72,10 @@ class RegistrationTicketCallout < ApplicationRecord inverse_of: :registration_ticket_callout has_many :resources, through: :registration_ticket_callout_resources - # Linked resources are added one dropdown at a time in the editor (cocoon - # add/remove), like Sectors on a Person. Blank picks are dropped. + # Linked forms and resources are each added one row at a time in the editor + # (cocoon add/remove), like Sectors on a Person. Blank picks are dropped. + accepts_nested_attributes_for :registration_ticket_callout_forms, allow_destroy: true, + reject_if: proc { |attrs| attrs["form_id"].blank? } accepts_nested_attributes_for :registration_ticket_callout_resources, allow_destroy: true, reject_if: proc { |attrs| attrs["resource_id"].blank? } @@ -160,7 +169,7 @@ def page_content? end def delivers_form? - form_id.present? + registration_ticket_callout_forms.any? end # The Payment built-in's visibility is driven entirely by live balance status, diff --git a/app/models/registration_ticket_callout_form.rb b/app/models/registration_ticket_callout_form.rb new file mode 100644 index 0000000000..fe60864cc6 --- /dev/null +++ b/app/models/registration_ticket_callout_form.rb @@ -0,0 +1,30 @@ +class RegistrationTicketCalloutForm < ApplicationRecord + belongs_to :created_by, class_name: "User", optional: true + belongs_to :updated_by, class_name: "User", optional: true + + # Ordered join between a callout and the forms it delivers inline. Each row + # carries its own `display_from` drip gate, so one callout can open several + # forms on their own dates (e.g. a Day 1 and Day 2 evaluation). A callout with + # a single row behaves like a plain single-form callout. + belongs_to :registration_ticket_callout + belongs_to :form + + positioned on: :registration_ticket_callout_id + + validates :form_id, uniqueness: { scope: :registration_ticket_callout_id } + validates :position, numericality: { only_integer: true, greater_than: 0, allow_nil: true } + + scope :ordered, -> { order(:position, :id) } + + # When this form opens: the later of the callout's own drip date and this row's. + # The callout's date withholds all of its page content, so a row can't open ahead + # of the page carrying it. Nil when neither is set. + def available_from + [ registration_ticket_callout&.display_from, display_from ].compact.max + end + + # Drips like a callout: hidden until it opens. No date either side is open. + def dripping?(now = Time.current) + available_from.present? && available_from > now + end +end diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index 2f5ff32f0d..4195d502c5 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -220,7 +220,8 @@ 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, :ce_payment_access_gated, :published, :reset_to_default, :form_id, :_destroy, + registration_ticket_callouts_attributes: [ :id, :builtin_key, :title, :subtitle, :description, :callout_type, :icon_class, :color_class, :display_from, :payment_access_gated, :ce_payment_access_gated, :published, :reset_to_default, :_destroy, + { registration_ticket_callout_forms_attributes: [ :id, :form_id, :display_from, :_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_callouts.rb b/app/services/builtin_callouts.rb index e4ffd6f691..9ce32958b2 100644 --- a/app/services/builtin_callouts.rb +++ b/app/services/builtin_callouts.rb @@ -156,6 +156,8 @@ def reset(callout) hidden: definition[:hidden].call(@event), display_from: definition[:display_from]&.call(@event) ) + callout.registration_ticket_callout_forms.destroy_all + build_form_links(callout, definition) callout.registration_ticket_callout_resources.destroy_all build_resource_links(callout, definition) callout @@ -173,6 +175,7 @@ def customized?(callout) callout.color_class != definition[:color_class] || callout.hidden != definition[:hidden].call(@event) || callout.display_from != definition[:display_from]&.call(@event) || + form_links_customized?(callout, definition) || callout.resource_ids.sort != Array(definition[:resources]&.call).map(&:id).sort || resource_content_customized?(callout, definition) end @@ -185,9 +188,17 @@ def resolve(value) value.respond_to?(:call) ? value.call(@event) : value end + # Opens 30 min before day N's end time — day N's date (start + N-1 days) at the + # event end_date's time-of-day (the daily end time). 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 + # each event derives its own defaults; `resources` resolves the linked records + # and `forms` the delivered ones. Content cards (Handouts, FAQ) render # their own copy; "behavioral" cards (Certificate, Videoconference) render live # per-registration status through BuiltinCalloutCards#card_for — the row only # governs visibility, drip date, and order. @@ -218,7 +229,13 @@ def definitions callout_type: "action", icon_class: "fa-solid fa-award", color_class: "fuchsia", - hidden: ->(_event) { true } + hidden: ->(_event) { true }, + # The scholarship-only recipients survey, shown on the scholarship page once + # the recipient has signed their agreement and their tasks are complete (and + # any drip date has passed). Seeded with no date; admins can add one. + forms: ->(_event) { + [ { form: Form.standalone.find_by(name: "Post-Training Recipients Survey"), display_from: nil } ] + } }, { builtin_key: "ce_hours", @@ -287,12 +304,34 @@ def definitions color_class: "blue", description: self.class.faq_html, hidden: ->(_event) { true } + }, + { + builtin_key: "feedback_surveys", + title: "Feedback surveys", + subtitle: "Share your feedback on the training", + callout_type: "action", + icon_class: "fa-solid fa-clipboard-list", + color_class: "fuchsia", + hidden: ->(_event) { true }, + # The everyone-facing feedback surveys — one callout that drips the Day 1 + # evaluation, the Day 2 evaluation (multi-day events only), and the overall + # post-event survey, each on its own date. Rows whose template isn't seeded + # yet resolve to nil and are skipped. The scholarship-only recipients survey + # lives on the scholarship callout instead. + forms: ->(event) { + [ + { form: Form.standalone.find_by(name: "Day 1 Survey"), display_from: survey_drip(event, 1) }, + ({ form: Form.standalone.find_by(name: "Day 2 Survey"), display_from: survey_drip(event, 2) } if event.day_count >= 2), + { form: Form.standalone.find_by(name: "Post-Event Survey"), display_from: (event.end_date - 30.minutes if event.end_date) } + ].compact + } } ] end def create(definition) callout = @event.registration_ticket_callouts.create!(attributes_for(definition)) + build_form_links(callout, definition) build_resource_links(callout, definition) callout rescue ActiveRecord::RecordNotUnique @@ -307,10 +346,24 @@ def create(definition) # persisting, so they save as nested attributes when the event is saved. def build_row(definition) callout = @event.registration_ticket_callouts.build(attributes_for(definition)) + build_form_links(callout, definition) build_resource_links(callout, definition) callout end + # Link the definition's forms in order, each with its own drip date. A definition + # supplies `forms` as a proc returning [{ form:, display_from: }] (the form procs + # resolve the seeded standalone templates by name). Persists immediately for a + # saved callout, or stays in memory (saved with the event) when still unsaved. + def build_form_links(callout, definition) + Array(definition[:forms]&.call(@event)).each do |entry| + form = entry[:form] + next unless form + link = callout.registration_ticket_callout_forms.build(form: form, display_from: entry[:display_from]) + link.save! if callout.persisted? + end + end + def attributes_for(definition) { builtin_key: definition[:builtin_key], @@ -345,6 +398,16 @@ def build_resource_links(callout, definition) # Whether any link's subtitle/page_content has been edited away from its # default, so "Restore default" is offered when only the copy was changed. + # Whether the callout's linked forms (form + drip date, in order) diverge from + # the definition's default set. + def form_links_customized?(callout, definition) + expected = Array(definition[:forms]&.call(@event)).filter_map do |entry| + [ entry[:form].id, entry[:display_from] ] if entry[:form] + end + actual = callout.registration_ticket_callout_forms.map { |link| [ link.form_id, link.display_from ] } + expected != actual + end + def resource_content_customized?(callout, definition) content = definition[:resource_content] return false if content.blank? diff --git a/app/services/event_registration_readiness.rb b/app/services/event_registration_readiness.rb index 3b68384a81..7444d8f204 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,17 @@ 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 recipient who's done the other post-event work but still owes the live survey — + # sits between "ready" and "certificate pending". + def survey_pending? + survey_outstanding? && completion_work_issues.empty? + end + def status_label STATUS_LABELS.fetch(status) end @@ -64,6 +72,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 @@ -115,7 +124,13 @@ def event_ready_reason end def completion_issues - completion_work_issues + certificate_issues + completion_work_issues + survey_issues + certificate_issues + end + + # Gates certifiable?/completed? so a recipient's certificate can't close out until + # their 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. @@ -176,6 +191,14 @@ def scholarship_tasks_incomplete? registration.scholarship? && !registration.scholarship_tasks_met? end + # Recipients only, once the survey is available (agreement signed, tasks done, + # drip passed). Reads preloaded scholarships + the event's memoized callout, so + # no per-row roster query. + def survey_outstanding? + registration.recipient_survey_available? && + !registration.post_survey_completed? + end + def ce_unpaid? registration.ce_registered? && !ce_paid? end diff --git a/app/services/event_registration_services/callout_form_submission.rb b/app/services/event_registration_services/callout_form_submission.rb index 7adbbc3acc..10eed2ef46 100644 --- a/app/services/event_registration_services/callout_form_submission.rb +++ b/app/services/event_registration_services/callout_form_submission.rb @@ -1,40 +1,95 @@ module EventRegistrationServices - # Records a registrant's answers to the form a ticket callout delivers inline. + # Records a registrant's answers to one form a ticket callout delivers inline. # One submission per (registrant, form, event) so re-submitting edits in place. + # + # A survey-role form additionally: fans a per-resource "clarity" field out to one + # answer per linked resource, writes the anonymity / name-display answers through + # to the Person (PersonServices::SyncSharingPreferences, the same write-through the + # registration form runs; #profile_changes reports real changes for Ahoy), and + # stamps post_survey_completed_at for a recipient's post-event survey. class CalloutFormSubmission - def self.call(registration:, callout:, form_params:) - new(registration:, callout:, form_params:).call - end + attr_reader :submission, :profile_changes + + # Whether this call edited an existing submission rather than recording a first one. + def edited? = @edited - # The submission carries the form's own role — indistinguishable from the same - # form submitted elsewhere. That a callout collected it is recorded separately, - # in metadata (see FormSubmission#collected_via_callout?). - def self.role_for(callout) - callout.form&.role + def self.call(**kwargs) + instance = new(**kwargs) + instance.call + instance end - def initialize(registration:, callout:, form_params:) + def initialize(registration:, callout:, form:, form_params: {}, clarity_params: {}) @registration = registration @callout = callout - @form_params = form_params || {} + @form = form + @form_params = (form_params || {}).transform_keys(&:to_s) + @clarity_params = clarity_params || {} + @profile_changes = {} end def call - form = @callout.form + person = @registration.registrant ActiveRecord::Base.transaction do - submission = FormSubmission.find_or_create_by!( - person: @registration.registrant, form: form, event: @registration.event, - role: self.class.role_for(@callout) + @submission = FormSubmission.find_or_create_by!( + person: person, form: @form, event: @registration.event, role: @form.role ) - submission.record_callout_collection!(@callout) - @form_params.each do |field_id, raw_value| - field = form.form_fields.find_by(id: field_id) - next unless field - next if field.group_header? - submission.persist_answer(field, raw_value) + # Capture first-time vs. edit before the metadata update below flips the flag. + @edited = !@submission.previously_new_record? + @submission.record_callout_collection!(@callout) + save_answers + # These run for any callout form and self-gate on the form's own fields: + # clarity fans out only per-resource fields, and the profile write-through + # only fires when the form asks the identified anonymity / name questions. + save_clarity_answers + sync_profile(person) + stamp_scholarship_form_completion + end + @submission + end + + private + + def save_answers + @form.form_fields.each do |field| + next if field.group_header? || field.per_resource? + raw = @form_params[field.id.to_s] + next if raw.nil? + text = raw.is_a?(Array) ? raw.reject(&:blank?).join(", ") : raw + @submission.persist_answer(field, text) + end + end + + # 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.per_resource_question(link.resource) + record = @submission.form_answers.find_or_initialize_by(form_field: nil, question_name_when_answered: question) + record.update!(submitted_answer: raw) end - submission end end + + # The same content-sharing write-through the registration flow runs, so the two + # questions behave identically wherever they're asked. + def sync_profile(person) + @profile_changes = PersonServices::SyncSharingPreferences.call( + person: person, form: @form, form_params: @form_params + ).changes + end + + # A form delivered on the scholarship callout is a scholarship recipient's final + # step, so submitting it stamps their readiness for the certificate. + def stamp_scholarship_form_completion + return unless @callout.builtin_key == "scholarship" && @registration.scholarship? + return if @registration.post_survey_completed? + @registration.mark_post_survey_completed! + end end end diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index f331a1f243..3e8050bf86 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -224,6 +224,7 @@ def find_matching_person(last_name:, email:) # capturing the change. A blank answer never clobbers existing data. def sync_person_profile(person) apply_value(person, :racial_ethnic_identity, field_value("racial_ethnic_identity")) + PersonServices::SyncSharingPreferences.call(person: person, form: @registration_form, form_params: @form_params) end def sync_organization_profile(organization) diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb index 7a5278006a..64d78a3130 100644 --- a/app/services/form_builder_service.rb +++ b/app/services/form_builder_service.rb @@ -5,6 +5,23 @@ 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 + + # The per-workshop growth questions fan out the same way — the workshop title lands + # after the prompt. Shared with the seeder, which matches these fields by prompt. + GROWTH_PERSONAL_PROMPT = "This workshop supported my personal growth:".freeze + GROWTH_PROFESSIONAL_PROMPT = "This workshop supported my professional growth:".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 +33,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 +78,13 @@ def call payment: %w[payment_method someone_else_will_pay], consent: %w[communication_consent], post_event_feedback: %w[event_rating most_valuable improvement_suggestions], + # Every survey question is answer-only (stored on the form submission, not written + # to any other record), so none carry an identifier. The seeder finds the fan-out + # questions by their prompt text instead. + day_1_survey: %w[], + day_2_survey: %w[], + recipient_survey: %w[], + content_sharing_preferences: %w[anonymous_contributions display_name_preference], bulk_payment: %w[first_name last_name primary_email phone organization_name number_of_attendees payment_method bulk_payment_attendees] }.freeze @@ -72,6 +100,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 +144,47 @@ 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.", + "This workshop supported my personal growth:", + "This 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.", + "This workshop supported my personal growth:", + "This 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: [ + "Display my name as…", + "How may we display the content you share (reflections, quotes, artwork)?" + ], bulk_payment: [ "Payer first name", "Payer last name", "Payer email", "Phone", "Organization", "Payment method", "Number of attendees", "Attendees" @@ -138,6 +211,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 @@ -330,7 +407,7 @@ def add_header(form, position, title, group:, visibility: nil) position end - def add_field(form, position, field_name, answer_type, key:, group:, required: true, subtitle: nil, options: nil, datatype: nil, visibility: nil, width: :full) + def add_field(form, position, field_name, answer_type, group:, key: nil, required: true, subtitle: nil, options: nil, datatype: nil, visibility: nil, width: :full) position += 1 field = form.form_fields.create!( name: field_name, @@ -613,6 +690,122 @@ 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, + group: "day_1_survey", subtitle: "Day 1 — Part One", + options: CLARITY_OPTIONS) + position = add_field(form, position, "Please elaborate.", :free_form_input_paragraph, + group: "day_1_survey", required: false) + position = add_field(form, position, CLARITY_PROMPT, :single_select_radio, + group: "day_1_survey", subtitle: "Day 1 — Part Two", + options: CLARITY_OPTIONS) + position = add_field(form, position, "Please elaborate.", :free_form_input_paragraph, + group: "day_1_survey", required: false) + + # Fanned per workshop (seeded resource links) — the workshop name lands after + # the prompt, so the text stays generic. + position = add_field(form, position, GROWTH_PERSONAL_PROMPT, :single_select_radio, + group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, GROWTH_PROFESSIONAL_PROMPT, :single_select_radio, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + group: "day_1_survey", required: false) + position = add_field(form, position, "Comments", :free_form_input_paragraph, + 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, + group: "day_2_survey", subtitle: "Day 2 — Part 1", + options: CLARITY_OPTIONS) + position = add_field(form, position, "Please elaborate.", :free_form_input_paragraph, + group: "day_2_survey", required: false) + position = add_field(form, position, CLARITY_PROMPT, :single_select_radio, + group: "day_2_survey", subtitle: "Day 2 — Part 2", + options: CLARITY_OPTIONS) + position = add_field(form, position, "Please elaborate.", :free_form_input_paragraph, + group: "day_2_survey", required: false) + + # Fanned per workshop (seeded resource links) — the workshop name lands after + # the prompt, so the text stays generic. + position = add_field(form, position, GROWTH_PERSONAL_PROMPT, :single_select_radio, + group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, GROWTH_PROFESSIONAL_PROMPT, :single_select_radio, + 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, + 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, + 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, + 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, + 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, + group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + + position = add_field(form, position, "Please tell us what aspects of day 2 of the training could be improved.", :free_form_input_paragraph, + 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, + 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, + 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, + 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, + group: "day_2_survey", required: false) + position = add_field(form, position, "Comments", :free_form_input_paragraph, + 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, + 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, + group: "recipient_survey", required: true) + position = add_field(form, position, "What would have made the training more valuable for you?", :free_form_input_paragraph, + 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, + group: "recipient_survey", options: LIKELIHOOD_OPTIONS) + position = add_field(form, position, "Anything else you'd like us to know?", :free_form_input_paragraph, + 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, "Display my name as…", :single_select_radio, + key: "display_name_preference", group: "content_sharing", + options: Person::DISPLAY_NAME_PREFERENCE_LABELS.values) + 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 + 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/services/form_submission_changes.rb b/app/services/form_submission_changes.rb index 2bea34425e..cd1335b307 100644 --- a/app/services/form_submission_changes.rb +++ b/app/services/form_submission_changes.rb @@ -13,7 +13,15 @@ class FormSubmissionChanges ATTRIBUTE_LABELS = { "website_url" => "Website", "organization_type" => "Type", "value" => "Phone", "racial_ethnic_identity" => "Racial / ethnic identity", - "zip_code" => "ZIP", "street_address" => "Street address" + "zip_code" => "ZIP", "street_address" => "Street address", + "anonymous_contributions" => "Content sharing", "display_name_preference" => "Display name" + }.freeze + + # Columns whose stored value is a code (a boolean, an enum key), mapped to the + # wording the person actually chose so the page doesn't read "true". + VALUE_LABELS = { + "anonymous_contributions" => Person::ANONYMOUS_CONTRIBUTIONS_OPTIONS, + "display_name_preference" => Person::DISPLAY_NAME_PREFERENCE_LABELS }.freeze # Sub-record attributes edit a section of the owner's edit page rather than a @@ -106,18 +114,25 @@ def changes_for(event) def attribute_changes(changes, resource_type) changes.except(*IGNORED_ATTRIBUTES).filter_map do |attribute, before_after| before, after = before_after.values_at("before", "after") - next if after.blank? && before.blank? + coded = VALUE_LABELS[attribute] + next if after.blank? && before.blank? && coded.nil? Change.new( - outcome: before.present? ? "Replaced" : "Filled", + outcome: replaced?(before, coded) ? "Replaced" : "Filled", label: ATTRIBUTE_LABELS[attribute] || attribute.humanize, - value: display_value(after), - previous_value: display_value(before), + value: display_value(after, coded), + previous_value: display_value(before, coded), anchor: anchor_for(resource_type, attribute) ) end end + # On a coded column `false` is a choice, not an empty column, so whether + # something was there before is a nil check rather than a blank check. + def replaced?(before, coded) + coded ? !before.nil? : before.present? + end + # Own-record columns anchor to their form input (person_racial_ethnic_identity, # organization_website_url); sub-records anchor to their section on the owner's # edit page (addresses, contact-methods). @@ -153,7 +168,8 @@ def owner_title(type, id) record&.try(:full_name).presence || record&.try(:name).presence || "#{type} ##{id}" end - def display_value(value) + def display_value(value, coded = nil) + return coded.fetch(value, value) if coded value.is_a?(Array) ? value.join(", ") : value end end diff --git a/app/services/person_services/sync_sharing_preferences.rb b/app/services/person_services/sync_sharing_preferences.rb new file mode 100644 index 0000000000..6acba59fdd --- /dev/null +++ b/app/services/person_services/sync_sharing_preferences.rb @@ -0,0 +1,57 @@ +module PersonServices + # Write the two content-sharing answers onto the Person: whether their contributed + # content is credited or anonymous, and which name format credits it. Both arrive as + # the option label the question offered, so each maps back to its stored value; an + # unanswered or unrecognized answer leaves the profile alone. Self-gating — a form + # that doesn't ask the questions is a no-op — so a submission flow can call it + # unconditionally, the way OrganizationServices::SyncProfile is called for an org. + # + # Shared by the public registration flow and the ticket-callout survey flow. The edit + # lands on the Person, so AhoyTrackable logs it and the submission's "what this + # submission changed" page picks it up; `changes` (attribute => [ from, to ]) reports + # the same edits to a caller that tracks them itself. + class SyncSharingPreferences + # field_identifier => { option label => stored value } + IDENTIFIER_VALUES = { + "anonymous_contributions" => Person::ANONYMOUS_CONTRIBUTIONS_OPTIONS, + "display_name_preference" => Person::DISPLAY_NAME_PREFERENCE_LABELS + }.freeze + + def self.call(person:, form:, form_params: {}) + new(person:, form:, form_params:).call + end + + attr_reader :changes + + def initialize(person:, form:, form_params: {}) + @person = person + @form = form + @form_params = (form_params || {}).transform_keys(&:to_s) + @changes = {} + end + + def call + IDENTIFIER_VALUES.each do |identifier, values| + apply(identifier, values.invert[submitted_label(identifier)]) + end + @person.save! if @person.changed? + self + end + + private + + # The label the person picked, or nil when this form doesn't ask the question. + def submitted_label(identifier) + field = @form.form_fields.find_by(field_identifier: identifier) + field && @form_params[field.id.to_s] + end + + def apply(identifier, new_value) + return if new_value.nil? + current = @person.public_send(identifier) + return if current == new_value + @changes[identifier.to_sym] = [ current, new_value ] + @person.public_send("#{identifier}=", new_value) + end + end +end diff --git a/app/services/registrant_ce_form.rb b/app/services/registrant_ce_form.rb index 4a04da8ddc..d6eea4765a 100644 --- a/app/services/registrant_ce_form.rb +++ b/app/services/registrant_ce_form.rb @@ -10,9 +10,10 @@ def initialize(event_registration) @event_registration = event_registration end - # The form attached to the CE callout, or nil when none is set. + # The form attached to the CE callout, or nil when none is set. The CE callout + # carries a single form, so this reads the first linked form. def form - ce_callout&.form + ce_callout&.forms&.first end # Something to fill: a form with at least one non-header field. @@ -33,7 +34,7 @@ def submission form.form_submissions.find_by(person: @event_registration.registrant, event: @event_registration.event, - role: EventRegistrationServices::CalloutFormSubmission.role_for(ce_callout)) + role: form.role) end # Every required field answered — the read-time "form complete" signal, since diff --git a/app/services/smart_form_fields.rb b/app/services/smart_form_fields.rb index fb2615287b..774a7191e6 100644 --- a/app/services/smart_form_fields.rb +++ b/app/services/smart_form_fields.rb @@ -50,7 +50,9 @@ class SmartFormFields "newest registration is treated as the freshest source — and a blank answer never " \ "erases existing data. The previous value stays in the record's audit trail.", fields: [ - [ "racial_ethnic_identity", "How would you best describe yourself?", "Sets the person's racial/ethnic identity." ] + [ "racial_ethnic_identity", "How would you best describe yourself?", "Sets the person's racial/ethnic identity." ], + [ "anonymous_contributions", "How may we display the content you share?", "Sets whether the person's contributed content (reflections, quotes, artwork) is credited to them or shown anonymously. Also written from a survey delivered on a registration ticket callout." ], + [ "display_name_preference", "Display my name as…", "Sets the name format the person's content is credited under (full name, first name and last initial, and so on). Also written from a survey delivered on a registration ticket callout." ] ] }, { diff --git a/app/services/survey_form_seeder.rb b/app/services/survey_form_seeder.rb new file mode 100644 index 0000000000..f2bf3f0671 --- /dev/null +++ b/app/services/survey_form_seeder.rb @@ -0,0 +1,74 @@ +# Creates the standalone survey template forms the built-in callouts deliver: the +# Day 1 / Day 2 evaluations and the overall post-event survey (everyone, on the +# Feedback surveys callout), and the scholarship recipients survey (on the +# scholarship callout). Built once from the form-builder presets, then left for +# staff to edit in the builder. +# +# Idempotent on form name, so it's safe to run repeatedly — in db/seeds and as the +# `data:seed_survey_forms` task for prod. Returns the names it created (skipping +# any that already exist). +class SurveyFormSeeder + TEMPLATES = [ + { 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-Event Survey", role: "post_event_survey", sections: %i[post_event_feedback content_sharing_preferences] }, + { name: "Post-Training Recipients Survey", role: "recipient_survey", sections: %i[recipient_survey content_sharing_preferences] } + ].freeze + + DAY_1_WORKSHOPS = [ "The Touchstone Journey", "Creating A Safer/Braver Place", "The Take A Break, Self-Regulate" ].freeze + DAY_2_WORKSHOPS = [ "The Monster In Me", "Claiming Who I Am" ].freeze + + # Each fan-out question fans over the training topics it asks about — resources + # matched by title. The questions carry no identifier (their answers only live on + # the submission), so they're matched by prompt (+ subtitle for the clarity pair). + # The growth questions cover the whole day's workshops; clarity uses a default + # split. Admins adjust any of it per form in the editor's "Fan out per resource". + FANOUT_RESOURCES = [ + { form: "Day 1 Survey", name: FormBuilderService::CLARITY_PROMPT, subtitle: "Day 1 — Part One", resources: [ "The Touchstone Journey", "Creating A Safer/Braver Place" ] }, + { form: "Day 1 Survey", name: FormBuilderService::CLARITY_PROMPT, subtitle: "Day 1 — Part Two", resources: [ "The Take A Break, Self-Regulate" ] }, + { form: "Day 1 Survey", name: FormBuilderService::GROWTH_PERSONAL_PROMPT, resources: DAY_1_WORKSHOPS }, + { form: "Day 1 Survey", name: FormBuilderService::GROWTH_PROFESSIONAL_PROMPT, resources: DAY_1_WORKSHOPS }, + { form: "Day 2 Survey", name: FormBuilderService::CLARITY_PROMPT, subtitle: "Day 2 — Part 1", resources: [ "The Monster In Me" ] }, + { form: "Day 2 Survey", name: FormBuilderService::CLARITY_PROMPT, subtitle: "Day 2 — Part 2", resources: [ "Claiming Who I Am" ] }, + { form: "Day 2 Survey", name: FormBuilderService::GROWTH_PERSONAL_PROMPT, resources: DAY_2_WORKSHOPS }, + { form: "Day 2 Survey", name: FormBuilderService::GROWTH_PROFESSIONAL_PROMPT, resources: DAY_2_WORKSHOPS } + ].freeze + + def self.call + new.call + end + + attr_reader :created + + def initialize + @created = [] + end + + def call + TEMPLATES.each do |template| + next if Form.exists?(name: template[:name]) + FormBuilderService.new(name: template[:name], sections: template[:sections], role: template[:role]).call + @created << template[:name] + end + link_fanout_resources + @created + end + + # Link each fan-out question to its topics (resources matched by title). Idempotent + # — skips existing links and skips a resource that isn't present (prod already has + # them; the dev sample seed creates them). Safe to call on its own after the forms + # and resources exist. + def link_fanout_resources + FANOUT_RESOURCES.each do |entry| + form = Form.find_by(name: entry[:form]) + next unless form + scope = form.form_fields.where(name: entry[:name]) + scope = scope.where(subtitle: entry[:subtitle]) if entry[:subtitle] + scope.find_each do |field| + Resource.where(title: entry[:resources]).find_each do |resource| + field.form_field_resources.find_or_create_by!(resource: resource) + end + end + end + end +end diff --git a/app/views/event_registrations/_readiness_badge.html.erb b/app/views/event_registrations/_readiness_badge.html.erb index f112001ad2..f95e336578 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 5eaaf02311..b709ec97fc 100644 --- a/app/views/event_registrations/edit.html.erb +++ b/app/views/event_registrations/edit.html.erb @@ -72,6 +72,21 @@ wrapper_class: "mt-4" %> <%= render "form", event_registration: @event_registration %> + + <%# The certificate is issued by emailing it; the survey is marked here, independently. %> +
+ 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 %> <% if allowed_to?(:index?, with: Admin::AhoyActivityPolicy) %>
diff --git a/app/views/events/_registrant_filters.html.erb b/app/views/events/_registrant_filters.html.erb index 53e4c73373..2887ee53bd 100644 --- a/app/views/events/_registrant_filters.html.erb +++ b/app/views/events/_registrant_filters.html.erb @@ -57,7 +57,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/_registrants_results.html.erb b/app/views/events/_registrants_results.html.erb index efd4002200..dce94f8985 100644 --- a/app/views/events/_registrants_results.html.erb +++ b/app/views/events/_registrants_results.html.erb @@ -3,8 +3,8 @@ <% scholarship_on = @event.cost_cents.to_i > 0 %> <%# The event's visible callouts that deliver a form inline — one toggleable completion column each, under the single "Ticket forms" switch. %> - <% form_callouts = @event.registration_ticket_callouts.where.not(form_id: nil).visible.includes(:form).ordered.to_a %> - <% callout_roles = form_callouts.index_with { |callout| EventRegistrationServices::CalloutFormSubmission.role_for(callout) } %> + <% form_columns = @event.registration_ticket_callouts.visible.includes(registration_ticket_callout_forms: :form).ordered + .flat_map { |callout| callout.registration_ticket_callout_forms.map { |link| [ callout, link.form ] } } %>
<% current_filter = params[:attendance_status].present? ? nil : (params[:status_filter].presence || "active") %> @@ -174,7 +174,7 @@ <%# One switch reveals a completion column per form-bearing callout. %> - <% if form_callouts.any? %> + <% if form_columns.any? %>