Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
77e537b
Add schema for post-event surveys
maebeale Aug 9, 2026
7284664
Add post-event survey model associations
maebeale Aug 9, 2026
5790c71
Add post-event survey form-builder presets + seed templates
maebeale Aug 9, 2026
54d58f0
Add Day 1 / Day 2 / Scholarship recipients survey built-in callouts
maebeale Aug 9, 2026
739781b
Gate readiness on the post-event survey + admin toggle
maebeale Aug 9, 2026
f06d0e9
Add survey submission service + staff FYI mailer
maebeale Aug 9, 2026
9e55097
Add the survey Forms dropdown to the callout editor
maebeale Aug 9, 2026
1508265
Deliver the survey inline on the ticket + survey page
maebeale Aug 9, 2026
50d1a4e
Document post-event surveys in AGENTS.md
maebeale Aug 9, 2026
06a3b4d
Regenerate schema.rb after rebase onto main
maebeale Aug 10, 2026
3773a5a
Adjust survey profile-change spec for main's anonymous_contributions …
maebeale Aug 15, 2026
050fbcf
Trim survey comments to CLAUDE.md style
maebeale Aug 17, 2026
d2b3ec3
Fix CI: document seeded survey identifiers + assign @survey_forms in …
maebeale Aug 17, 2026
3418791
Repoint survey code onto main's author-credit constants after rebase
maebeale Aug 20, 2026
97a6927
Drop duplicate AddFormToRegistrationTicketCallouts migration
maebeale Aug 30, 2026
6fb9d56
Regenerate schema.rb after re-rebase onto origin/main
maebeale Aug 30, 2026
ded7e04
Deliver post-event surveys as date-gated forms embedded in callouts
maebeale Aug 30, 2026
780b5a2
Move the recipients survey onto the scholarship callout
maebeale Aug 31, 2026
f626c50
Drop the form_id backfill from the callout-forms migration
maebeale Aug 31, 2026
9ddd7cf
Split the survey features.yml entry by audience
maebeale Aug 31, 2026
790d9de
Add data:seed_survey_forms task to create survey forms in prod
maebeale Aug 31, 2026
6d6e059
Document survey gating, submission email, and profile write-through i…
maebeale Aug 31, 2026
4c01fbc
Survey form polish: wider form pages, swap sharing questions, italic …
maebeale Aug 31, 2026
39cd9b3
Drive callout-form side effects from fields, not a survey role
maebeale Aug 31, 2026
2aac17d
Add admin UI to link resources to a form field (per-resource fan-out)
maebeale Aug 31, 2026
5bbe2bd
Seed clarity questions' resource links (default split, editable)
maebeale Aug 31, 2026
c7f1a32
Drop field identifiers from answer-only survey questions
maebeale Aug 31, 2026
00c45ee
Make the per-workshop growth questions fan out per resource
maebeale Aug 31, 2026
0687e41
Drop identifiers from the fan-out survey questions too
maebeale Aug 31, 2026
7d2dfa0
Rename the form's View subnav tab to Preview
maebeale Aug 31, 2026
e8f963b
Add a general post-event survey and rename the built-in to Feedback s…
maebeale Aug 31, 2026
de0ecf2
Fix review findings on the survey callout work
maebeale Aug 31, 2026
fa1ece5
Say New vs Updated in the survey FYI email subject
maebeale Aug 31, 2026
1acfe91
Unify survey radios to card style + fix selected-card width jitter
maebeale Aug 31, 2026
97f1a4e
Make the sharing-preference answers behave the same wherever they're …
maebeale Aug 31, 2026
2904117
Preload a callout's linked forms on the ticket
maebeale Aug 31, 2026
fe41bb8
Stop a callout's forms opening before the callout itself
maebeale Aug 31, 2026
73465a3
Add a plain-language staff guide for administering the training surveys
maebeale Aug 31, 2026
a16ef57
Reword the survey guide intro
maebeale Aug 31, 2026
ffa9288
Say scholarship tasks, not steps, in the survey guide
maebeale Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions AGENTS.md

Large diffs are not rendered by default.

16 changes: 14 additions & 2 deletions app/controllers/event_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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?
Expand Down
80 changes: 65 additions & 15 deletions app/controllers/events/callouts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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 —
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/events/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion app/controllers/forms_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/helpers/forms_helper.rb
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
]
Expand Down
13 changes: 13 additions & 0 deletions app/mailers/notification_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions app/models/form_field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions app/models/form_field_resource.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions app/models/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading