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