From 77e537b5108d749d3c0d8a5684abdd99d29ed724 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 9 Aug 2026 15:59:25 -0400 Subject: [PATCH 01/40] Add schema for post-event surveys Foundations for the in-house post-event survey feature: a callout's inline survey form (registration_ticket_callouts.form_id), the readiness completion cache (event_registrations.post_survey_completed_at, mirroring certificate_sent_at), the profile anonymity preference set from a survey question (people.anonymous_contributions), and the direct FormField->Resource link that drives per-resource clarity questions (form_field_resources). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...dd_form_to_registration_ticket_callouts.rb | 13 ++++++++++++ ...vey_completed_at_to_event_registrations.rb | 13 ++++++++++++ ...5_add_anonymous_contributions_to_people.rb | 13 ++++++++++++ ...60809195716_create_form_field_resources.rb | 21 +++++++++++++++++++ 4 files changed, 60 insertions(+) create mode 100644 db/migrate/20260809195713_add_form_to_registration_ticket_callouts.rb create mode 100644 db/migrate/20260809195714_add_post_survey_completed_at_to_event_registrations.rb create mode 100644 db/migrate/20260809195715_add_anonymous_contributions_to_people.rb create mode 100644 db/migrate/20260809195716_create_form_field_resources.rb diff --git a/db/migrate/20260809195713_add_form_to_registration_ticket_callouts.rb b/db/migrate/20260809195713_add_form_to_registration_ticket_callouts.rb new file mode 100644 index 0000000000..c28a1c65aa --- /dev/null +++ b/db/migrate/20260809195713_add_form_to_registration_ticket_callouts.rb @@ -0,0 +1,13 @@ +class AddFormToRegistrationTicketCallouts < ActiveRecord::Migration[8.1] + # The survey form a callout delivers inline (post-event survey callouts). Nullable — + # ordinary callouts have no form. Integer FK to match the forms table's integer PK. + def up + return if column_exists?(:registration_ticket_callouts, :form_id) + add_reference :registration_ticket_callouts, :form, type: :integer, foreign_key: true, null: true + end + + def down + return unless column_exists?(:registration_ticket_callouts, :form_id) + remove_reference :registration_ticket_callouts, :form, foreign_key: true + end +end diff --git a/db/migrate/20260809195714_add_post_survey_completed_at_to_event_registrations.rb b/db/migrate/20260809195714_add_post_survey_completed_at_to_event_registrations.rb new file mode 100644 index 0000000000..e3d88310b3 --- /dev/null +++ b/db/migrate/20260809195714_add_post_survey_completed_at_to_event_registrations.rb @@ -0,0 +1,13 @@ +class AddPostSurveyCompletedAtToEventRegistrations < ActiveRecord::Migration[8.1] + # Set when a scholarship recipient completes their post-event (recipients) survey. The + # query-free completion cache the registrants readiness Status column reads, mirroring + # certificate_sent_at. + def up + return if column_exists?(:event_registrations, :post_survey_completed_at) + add_column :event_registrations, :post_survey_completed_at, :datetime + end + + def down + remove_column :event_registrations, :post_survey_completed_at, if_exists: true + end +end diff --git a/db/migrate/20260809195715_add_anonymous_contributions_to_people.rb b/db/migrate/20260809195715_add_anonymous_contributions_to_people.rb new file mode 100644 index 0000000000..64e3e17d94 --- /dev/null +++ b/db/migrate/20260809195715_add_anonymous_contributions_to_people.rb @@ -0,0 +1,13 @@ +class AddAnonymousContributionsToPeople < ActiveRecord::Migration[8.1] + # Profile preference set from a post-event survey question: keep all shared content + # anonymous. Stored now; enforcement across content display is a later change. Nullable + # so "not answered" (nil) stays distinct from an explicit choice. + def up + return if column_exists?(:people, :anonymous_contributions) + add_column :people, :anonymous_contributions, :boolean + end + + def down + remove_column :people, :anonymous_contributions, if_exists: true + end +end diff --git a/db/migrate/20260809195716_create_form_field_resources.rb b/db/migrate/20260809195716_create_form_field_resources.rb new file mode 100644 index 0000000000..f192108892 --- /dev/null +++ b/db/migrate/20260809195716_create_form_field_resources.rb @@ -0,0 +1,21 @@ +class CreateFormFieldResources < ActiveRecord::Migration[8.1] + # Direct FormField -> Resource link. A form field with associated resources is a + # "per-resource" question that renders one input per resource (the post-event survey + # clarity question, one input per training topic/handout). Integer FKs match the + # integer PKs on form_fields and resources. + def up + return if table_exists?(:form_field_resources) + create_table :form_field_resources do |t| + t.references :form_field, type: :integer, null: false, foreign_key: true + t.references :resource, type: :integer, null: false, foreign_key: true + t.integer :position + t.timestamps + end + add_index :form_field_resources, [ :form_field_id, :resource_id ], unique: true, + name: "index_form_field_resources_on_field_and_resource" + end + + def down + drop_table :form_field_resources, if_exists: true + end +end From 7284664014708dbb5641ba97bdff9029e88eae2e Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 9 Aug 2026 16:03:09 -0400 Subject: [PATCH 02/40] Add post-event survey model associations FormFieldResource join (+ FormField has_many resources / per_resource?), RegistrationTicketCallout belongs_to :form, EventRegistration post-survey completion helpers, and Person display-name / anonymity option constants for the two profile-backed survey questions. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/event_registration.rb | 16 +++++++++++ app/models/form_field.rb | 14 ++++++++++ app/models/form_field_resource.rb | 16 +++++++++++ app/models/person.rb | 8 ++++++ spec/factories/form_field_resources.rb | 7 +++++ spec/models/form_field_resource_spec.rb | 35 +++++++++++++++++++++++++ 6 files changed, 96 insertions(+) create mode 100644 app/models/form_field_resource.rb create mode 100644 spec/factories/form_field_resources.rb create mode 100644 spec/models/form_field_resource_spec.rb diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 68f243ade1..2beab0170c 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -742,6 +742,22 @@ def attended? status == "attended" end + # The post-event (scholarship recipients) survey is "in" once this timestamp is + # set — by the registrant submitting it or an admin toggling it. Mirrors the + # Certifiable certificate_sent_at pattern so the roster's readiness reads a plain + # column with no extra query. + def post_survey_completed? + post_survey_completed_at.present? + end + + def mark_post_survey_completed!(at: Time.current) + update!(post_survey_completed_at: at) + end + + def clear_post_survey_completed! + update!(post_survey_completed_at: nil) + end + # The certificate of completion unlocks once the training has happened, the # registrant attended, and any scholarship tasks are complete. Issuing a CE # certificate (an admin marking the credit sent) is itself an affirmation that diff --git a/app/models/form_field.rb b/app/models/form_field.rb index c6b1f965fd..9f2d6ed9f6 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -7,6 +7,11 @@ class FormField < ApplicationRecord has_many :form_answers, dependent: :nullify has_many :childs, foreign_key: "parent_id", class_name: "FormField" + # A field can fan out over resources: with any linked here it becomes a + # "per-resource" question rendering one input per resource (see FormFieldResource). + has_many :form_field_resources, -> { ordered }, dependent: :destroy, inverse_of: :form_field + has_many :resources, through: :form_field_resources + # has_many through has_many :answer_options, through: :form_field_answer_options @@ -180,6 +185,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 +197,12 @@ def selectable? answer_type.in?(SELECTABLE_ANSWER_TYPES) end + # True when this field fans out over linked resources — rendered once per resource + # on the survey page, with the resource's title appended to the prompt. + def per_resource? + form_field_resources.any? + end + # True for fields whose answer options are tied to backend logic (currently the # payment-method field's Stripe wiring) and so should be shown read-only in the # form builder rather than freely edited. diff --git a/app/models/form_field_resource.rb b/app/models/form_field_resource.rb new file mode 100644 index 0000000000..038f89d24e --- /dev/null +++ b/app/models/form_field_resource.rb @@ -0,0 +1,16 @@ +class FormFieldResource < ApplicationRecord + # Ordered join between a form field and the resources it fans out over. A field + # with any of these is a "per-resource" question: on the survey page it renders + # one input per linked resource (e.g. the post-event survey clarity question, + # asked once per training topic/handout). The field owns the prompt wording and + # answer options; each resource just supplies the item the prompt is asked about. + belongs_to :form_field + belongs_to :resource + + positioned on: :form_field_id + + validates :resource_id, uniqueness: { scope: :form_field_id } + validates :position, numericality: { only_integer: true, greater_than: 0, allow_nil: true } + + scope :ordered, -> { order(:position, :id) } +end diff --git a/app/models/person.rb b/app/models/person.rb index 77b7a6ae87..208506efea 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -356,6 +356,14 @@ def sector_list sectors.pluck(:name) end + # anonymous_contributions boolean => the survey consent answer label. "Keep + # anonymous" is the true branch; naming their profile is false. (The 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/spec/factories/form_field_resources.rb b/spec/factories/form_field_resources.rb new file mode 100644 index 0000000000..2fe8dc24b6 --- /dev/null +++ b/spec/factories/form_field_resources.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :form_field_resource do + association :form_field + association :resource + sequence(:position) { |n| n } + end +end diff --git a/spec/models/form_field_resource_spec.rb b/spec/models/form_field_resource_spec.rb new file mode 100644 index 0000000000..f62bb9a92c --- /dev/null +++ b/spec/models/form_field_resource_spec.rb @@ -0,0 +1,35 @@ +require "rails_helper" + +RSpec.describe FormFieldResource, type: :model do + it "is valid with a form field and resource" do + expect(build(:form_field_resource)).to be_valid + end + + it "requires a unique resource per form field" do + existing = create(:form_field_resource) + dup = build(:form_field_resource, form_field: existing.form_field, resource: existing.resource) + expect(dup).not_to be_valid + end + + it "orders by position" do + field = create(:form_field) + later = create(:form_field_resource, form_field: field, position: 2) + earlier = create(:form_field_resource, form_field: field, position: 1) + expect(field.form_field_resources.reload.to_a).to eq([ earlier, later ]) + end + + describe "FormField#per_resource?" do + it "is true only once resources are linked" do + field = create(:form_field) + expect(field.per_resource?).to be(false) + create(:form_field_resource, form_field: field) + expect(field.reload.per_resource?).to be(true) + end + + it "exposes the linked resources through the join" do + field = create(:form_field) + link = create(:form_field_resource, form_field: field) + expect(field.resources).to include(link.resource) + end + end +end From 5790c7132dd198b7cb4953aa689dba1181925173 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 9 Aug 2026 16:09:38 -0400 Subject: [PATCH 03/40] Add post-event survey form-builder presets + seed templates Three new builder sections (day_1_survey, day_2_survey, recipient_survey) plus a shared content_sharing_preferences section carrying the two profile-backed questions (anonymous_contributions, display_name_preference). The clarity radios fan out over linked resources. Seeds the 3 standalone template forms idempotently so the survey callouts have a default form; staff can rebuild/edit them in the form builder in prod. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/form_builder_service.rb | 212 ++++++++++++++++++++++++++- db/seeds.rb | 14 ++ 2 files changed, 225 insertions(+), 1 deletion(-) diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb index 7a5278006a..a886e2f73f 100644 --- a/app/services/form_builder_service.rb +++ b/app/services/form_builder_service.rb @@ -5,6 +5,18 @@ class FormBuilderService PAYMENT_METHOD_PAY_NOW = "Credit card (now)".freeze PAYMENT_METHOD_OPTIONS = [ PAYMENT_METHOD_PAY_NOW, "Credit card (later)", "Check" ].freeze + # Post-event survey option sets. Likert agreement scale for the workshop-impact + # questions; Yes/No/Other for the "was it clear?" questions (Other reveals a + # specify box); a likelihood scale for the recipient survey. + LIKERT_AGREEMENT_OPTIONS = [ "Strongly agree", "Agree", "Neutral", "Disagree", "Strongly disagree" ].freeze + CLARITY_OPTIONS = [ "Yes", "No", "Other" ].freeze + LIKELIHOOD_OPTIONS = [ "Very likely", "Likely", "Unsure", "Unlikely", "Very unlikely" ].freeze + + # The clarity radios fan out over the day's topics: link resources to them (in the + # field editor) and the survey page renders one input per resource with the + # resource title appended to this prompt. + CLARITY_PROMPT = "Overall, was the information presented in a clear and concise manner for".freeze + SECTIONS = { person_identifier: { label: "Person identifier", method: :build_person_identifier_fields }, person_contact_info: { label: "Person contact info", method: :build_person_contact_info_fields }, @@ -16,6 +28,10 @@ class FormBuilderService payment: { label: "Payment", method: :build_payment_fields }, consent: { label: "Consent", method: :build_consent_fields }, post_event_feedback: { label: "Post-event feedback", method: :build_post_event_feedback_fields }, + day_1_survey: { label: "Day 1 survey", method: :build_day_1_survey_fields }, + day_2_survey: { label: "Day 2 survey", method: :build_day_2_survey_fields }, + recipient_survey: { label: "Scholarship recipient survey", method: :build_recipient_survey_fields }, + content_sharing_preferences: { label: "Content sharing preferences", method: :build_content_sharing_preferences_fields }, bulk_payment: { label: "Bulk payment", method: :build_bulk_payment_fields } }.freeze @@ -57,7 +73,22 @@ def call payment: %w[payment_method someone_else_will_pay], consent: %w[communication_consent], post_event_feedback: %w[event_rating most_valuable improvement_suggestions], - bulk_payment: %w[first_name last_name primary_email phone organization_name number_of_attendees payment_method bulk_payment_attendees] + day_1_survey: %w[ + d1_clarity_part_one d1_clarity_part_one_detail d1_clarity_part_two d1_clarity_part_two_detail + d1_touchstone_personal d1_touchstone_professional d1_safer_braver_personal d1_safer_braver_professional + d1_take_a_break_personal d1_take_a_break_professional d1_breakout_rooms d1_grounding + d1_prepared_facilitate d1_prepared_trauma_informed d1_review_reflect + d1_improvements d1_enjoyed d1_recommend d1_comments + ], + day_2_survey: %w[ + d2_clarity_part_one d2_clarity_part_one_detail d2_clarity_part_two d2_clarity_part_two_detail + d2_monster_personal d2_monster_professional d2_claiming_personal d2_claiming_professional + d2_breakout_rooms d2_intersectionality d2_questions_challenges d2_review_reflect d2_warmup_importance + d2_improvements d2_enjoyed d2_stay_in_touch d2_support_needs d2_recommend d2_comments + ], + recipient_survey: %w[impact insights more_valuable facilitate_likelihood anything_else], + content_sharing_preferences: %w[anonymous_contributions display_name_preference], + bulk_payment: %w[payer_first_name payer_last_name payer_email payer_phone payer_organization number_of_attendees payment_method bulk_payment_attendees] }.freeze # Header questions created by each section's builder method @@ -72,6 +103,10 @@ def call payment: [ "Payment Information" ], consent: [ "Consent" ], post_event_feedback: [ "Post-Event Feedback" ], + day_1_survey: [ "Day 1 evaluation" ], + day_2_survey: [ "Day 2 evaluation" ], + recipient_survey: [ "Post-training recipient questions" ], + content_sharing_preferences: [ "Sharing preferences" ], bulk_payment: [ "Payer Information", "Payment Information", "Attendees" ] }.freeze @@ -112,6 +147,53 @@ def call payment: [ "Payment method", "Will someone else be paying for your registration?" ], consent: [ "I agree to receive email communications from A Window Between Worlds." ], post_event_feedback: [ "How would you rate this event?", "What did you find most valuable?", "Any suggestions for improvement?" ], + day_1_survey: [ + CLARITY_PROMPT, "Please elaborate.", CLARITY_PROMPT, "Please elaborate.", + "The Touchstone Journey workshop supported my personal growth.", + "The Touchstone Journey workshop supported my professional growth.", + "The Creating A Safer/Braver Place workshop supported my personal growth.", + "The Creating A Safer/Braver Place workshop supported my professional growth.", + "The Take A Break, Self-Regulate workshop supported my personal growth.", + "The Take A Break, Self-Regulate workshop supported my professional growth.", + "The breakout rooms supported me in sharing about my experience and connect with other trainees.", + "I was able to practice grounding and self-regulation during the training.", + "What I learned today better prepared me to facilitate art workshops.", + "What I learned today better prepared me to utilize trauma informed practices during art workshops.", + "Taking time to review and reflect after each workshop on how an element of the arc of healing connected to a part of the workshop structure will support me in facilitating art workshops.", + "Please tell us what aspects of day 1 of the training could be improved.", + "Please tell us what aspects of day 1 you enjoyed the most.", + "Imagine how you would share your experience of this training with someone who is considering attending. Please share in a couple of sentences what you would say or tell them.", + "Comments" + ], + day_2_survey: [ + CLARITY_PROMPT, "Please elaborate.", CLARITY_PROMPT, "Please elaborate.", + "The Monster In Me workshop supported my personal growth.", + "The Monster In Me workshop supported my professional growth.", + "The Claiming Who I Am workshop supported my personal growth.", + "The Claiming Who I Am workshop supported my professional growth.", + "The breakout rooms supported me in sharing about my experience and connect with other trainees.", + "What I learned today better prepared me to facilitate art workshops that honor intersectionality.", + "Having time to dive into topics related to questions and challenges helped me feel more prepared to facilitate art workshops.", + "Taking time to review and reflect after each workshop on how an element of the arc of healing connected to a part of the workshop structure will support me in facilitating art workshops.", + "To best prepare art workshop participants to create, I understand the importance of providing a warm-up before the creation portion of the art workshop.", + "Please tell us what aspects of day 2 of the training could be improved.", + "Please tell us what aspects of day 2 of the training you enjoyed the most.", + "Would you like your name and email address included on a list we will share with your fellow trainees (for those who would like to stay in touch)?", + "How can we better support your needs and those of your art workshop participants?", + "Imagine how you would share your experience of this training with someone who is considering attending. Please share in a couple of sentences what you would say or tell them.", + "Comments" + ], + recipient_survey: [ + "How did participating in this training impact you personally and/or professionally?", + "What insights, tools, or facilitation skills from the training stood out most to you?", + "What would have made the training more valuable for you?", + "How likely are you to facilitate an AWBW art workshop in the next 3 months?", + "Anything else you'd like us to know?" + ], + content_sharing_preferences: [ + "How may we display the content you share (reflections, quotes, artwork)?", + "Display my name as…" + ], bulk_payment: [ "Payer first name", "Payer last name", "Payer email", "Phone", "Organization", "Payment method", "Number of attendees", "Attendees" @@ -138,6 +220,10 @@ def self.section_field_names(key) payment: %w[payment], consent: %w[consent], post_event_feedback: %w[post_event_feedback], + day_1_survey: %w[day_1_survey], + day_2_survey: %w[day_2_survey], + recipient_survey: %w[recipient_survey], + content_sharing_preferences: %w[content_sharing], bulk_payment: %w[bulk_payment] }.freeze @@ -613,6 +699,130 @@ def build_post_event_feedback_fields(form, position) position end + def build_day_1_survey_fields(form, position) + position = add_header(form, position, "Day 1 evaluation", group: "day_1_survey") + + position = add_field(form, position, CLARITY_PROMPT, :single_select_radio, + key: "d1_clarity_part_one", group: "day_1_survey", subtitle: "Day 1 — Part One", + options: CLARITY_OPTIONS) + position = add_field(form, position, "Please elaborate.", :free_form_input_paragraph, + key: "d1_clarity_part_one_detail", group: "day_1_survey", required: false) + position = add_field(form, position, CLARITY_PROMPT, :single_select_radio, + key: "d1_clarity_part_two", group: "day_1_survey", subtitle: "Day 1 — Part Two", + options: CLARITY_OPTIONS) + position = add_field(form, position, "Please elaborate.", :free_form_input_paragraph, + key: "d1_clarity_part_two_detail", group: "day_1_survey", required: false) + + position = add_field(form, position, "The Touchstone Journey workshop supported my personal growth.", :single_select_radio, + key: "d1_touchstone_personal", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Touchstone Journey workshop supported my professional growth.", :single_select_radio, + key: "d1_touchstone_professional", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Creating A Safer/Braver Place workshop supported my personal growth.", :single_select_radio, + key: "d1_safer_braver_personal", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Creating A Safer/Braver Place workshop supported my professional growth.", :single_select_radio, + key: "d1_safer_braver_professional", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Take A Break, Self-Regulate workshop supported my personal growth.", :single_select_radio, + key: "d1_take_a_break_personal", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Take A Break, Self-Regulate workshop supported my professional growth.", :single_select_radio, + key: "d1_take_a_break_professional", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The breakout rooms supported me in sharing about my experience and connect with other trainees.", :single_select_radio, + key: "d1_breakout_rooms", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "I was able to practice grounding and self-regulation during the training.", :single_select_radio, + key: "d1_grounding", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "What I learned today better prepared me to facilitate art workshops.", :single_select_radio, + key: "d1_prepared_facilitate", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "What I learned today better prepared me to utilize trauma informed practices during art workshops.", :single_select_radio, + key: "d1_prepared_trauma_informed", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "Taking time to review and reflect after each workshop on how an element of the arc of healing connected to a part of the workshop structure will support me in facilitating art workshops.", :single_select_radio, + key: "d1_review_reflect", group: "day_1_survey", options: LIKERT_AGREEMENT_OPTIONS) + + position = add_field(form, position, "Please tell us what aspects of day 1 of the training could be improved.", :free_form_input_paragraph, + key: "d1_improvements", group: "day_1_survey", required: false) + position = add_field(form, position, "Please tell us what aspects of day 1 you enjoyed the most.", :free_form_input_paragraph, + key: "d1_enjoyed", group: "day_1_survey", required: false) + position = add_field(form, position, "Imagine how you would share your experience of this training with someone who is considering attending. Please share in a couple of sentences what you would say or tell them.", :free_form_input_paragraph, + key: "d1_recommend", group: "day_1_survey", required: false) + position = add_field(form, position, "Comments", :free_form_input_paragraph, + key: "d1_comments", group: "day_1_survey", required: false) + position + end + + def build_day_2_survey_fields(form, position) + position = add_header(form, position, "Day 2 evaluation", group: "day_2_survey") + + position = add_field(form, position, CLARITY_PROMPT, :single_select_radio, + key: "d2_clarity_part_one", group: "day_2_survey", subtitle: "Day 2 — Part 1", + options: CLARITY_OPTIONS) + position = add_field(form, position, "Please elaborate.", :free_form_input_paragraph, + key: "d2_clarity_part_one_detail", group: "day_2_survey", required: false) + position = add_field(form, position, CLARITY_PROMPT, :single_select_radio, + key: "d2_clarity_part_two", group: "day_2_survey", subtitle: "Day 2 — Part 2", + options: CLARITY_OPTIONS) + position = add_field(form, position, "Please elaborate.", :free_form_input_paragraph, + key: "d2_clarity_part_two_detail", group: "day_2_survey", required: false) + + position = add_field(form, position, "The Monster In Me workshop supported my personal growth.", :single_select_radio, + key: "d2_monster_personal", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Monster In Me workshop supported my professional growth.", :single_select_radio, + key: "d2_monster_professional", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Claiming Who I Am workshop supported my personal growth.", :single_select_radio, + key: "d2_claiming_personal", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The Claiming Who I Am workshop supported my professional growth.", :single_select_radio, + key: "d2_claiming_professional", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "The breakout rooms supported me in sharing about my experience and connect with other trainees.", :single_select_radio, + key: "d2_breakout_rooms", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "What I learned today better prepared me to facilitate art workshops that honor intersectionality.", :single_select_radio, + key: "d2_intersectionality", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "Having time to dive into topics related to questions and challenges helped me feel more prepared to facilitate art workshops.", :single_select_radio, + key: "d2_questions_challenges", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "Taking time to review and reflect after each workshop on how an element of the arc of healing connected to a part of the workshop structure will support me in facilitating art workshops.", :single_select_radio, + key: "d2_review_reflect", group: "day_2_survey", options: LIKERT_AGREEMENT_OPTIONS) + position = add_field(form, position, "To best prepare art workshop participants to create, I understand the importance of providing a warm-up before the creation portion of the art workshop.", :single_select_radio, + key: "d2_warmup_importance", group: "day_2_survey", options: CLARITY_OPTIONS) + + position = add_field(form, position, "Please tell us what aspects of day 2 of the training could be improved.", :free_form_input_paragraph, + key: "d2_improvements", group: "day_2_survey", required: false) + position = add_field(form, position, "Please tell us what aspects of day 2 of the training you enjoyed the most.", :free_form_input_paragraph, + key: "d2_enjoyed", group: "day_2_survey", required: false) + position = add_field(form, position, "Would you like your name and email address included on a list we will share with your fellow trainees (for those who would like to stay in touch)?", :single_select_radio, + key: "d2_stay_in_touch", group: "day_2_survey", required: false, options: %w[Yes No]) + position = add_field(form, position, "How can we better support your needs and those of your art workshop participants?", :free_form_input_paragraph, + key: "d2_support_needs", group: "day_2_survey", required: false) + position = add_field(form, position, "Imagine how you would share your experience of this training with someone who is considering attending. Please share in a couple of sentences what you would say or tell them.", :free_form_input_paragraph, + key: "d2_recommend", group: "day_2_survey", required: false) + position = add_field(form, position, "Comments", :free_form_input_paragraph, + key: "d2_comments", group: "day_2_survey", required: false) + position + end + + def build_recipient_survey_fields(form, position) + position = add_header(form, position, "Post-training recipient questions", group: "recipient_survey") + + position = add_field(form, position, "How did participating in this training impact you personally and/or professionally?", :free_form_input_paragraph, + key: "impact", group: "recipient_survey", required: true) + position = add_field(form, position, "What insights, tools, or facilitation skills from the training stood out most to you?", :free_form_input_paragraph, + key: "insights", group: "recipient_survey", required: true) + position = add_field(form, position, "What would have made the training more valuable for you?", :free_form_input_paragraph, + key: "more_valuable", group: "recipient_survey", required: false) + position = add_field(form, position, "How likely are you to facilitate an AWBW art workshop in the next 3 months?", :single_select_radio, + key: "facilitate_likelihood", group: "recipient_survey", options: LIKELIHOOD_OPTIONS) + position = add_field(form, position, "Anything else you'd like us to know?", :free_form_input_paragraph, + key: "anything_else", group: "recipient_survey", required: false) + position + end + + def build_content_sharing_preferences_fields(form, position) + position = add_header(form, position, "Sharing preferences", group: "content_sharing") + + position = add_field(form, position, "How may we display the content you share (reflections, quotes, artwork)?", :single_select_radio, + key: "anonymous_contributions", group: "content_sharing", + options: Person::ANONYMOUS_CONTRIBUTIONS_OPTIONS.values) + position = add_field(form, position, "Display my name as…", :single_select_radio, + key: "display_name_preference", group: "content_sharing", + options: Person::DISPLAY_NAME_PREFERENCES.values) + position + end + def build_bulk_payment_fields(form, position) position = add_header(form, position, "Payer Information", group: "bulk_payment", visibility: :logged_out_only) diff --git a/db/seeds.rb b/db/seeds.rb index 4021425d09..14965f1b89 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -506,3 +506,17 @@ def find_or_create_by_name!(klass, name, **attrs, &block) workshop_settings_type.categories .reject { |cat| canonical_names.include?(cat.name.downcase) } .each { |cat| cat.update!(published: false) } + +# Post-event survey templates — the standalone forms the Day 1 / Day 2 / Scholarship +# recipients survey callouts deliver inline. Built once from the form-builder presets, +# then left for staff to edit in the builder. Idempotent on form name. +puts "Creating post-event survey forms…" + +[ + { name: "Day 1 Survey", role: "day_1_survey", sections: %i[day_1_survey content_sharing_preferences] }, + { name: "Day 2 Survey", role: "day_2_survey", sections: %i[day_2_survey content_sharing_preferences] }, + { name: "Post-Training Recipients Survey", role: "post_event_survey", sections: %i[recipient_survey content_sharing_preferences] } +].each do |template| + next if Form.exists?(name: template[:name]) + FormBuilderService.new(name: template[:name], sections: template[:sections], role: template[:role]).call +end From 54d58f0929c821fb05f82c45121b3bc9e73da954 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 9 Aug 2026 16:15:09 -0400 Subject: [PATCH 04/40] Add Day 1 / Day 2 / Scholarship recipients survey built-in callouts Three new survey built-ins wired to their template forms via the callout form_id, with relevant default drips (day N: 30 min before that day's end time; scholarship: 30 min before event end). Adds a seed_if gate so Day 2 only seeds on multi-day events. Updates the existing built-in-set expectations. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/registration_ticket_callout.rb | 1 + app/services/builtin_callouts.rb | 62 ++++++++++++++++++-- spec/services/builtin_callouts_spec.rb | 69 ++++++++++++++++++++--- 3 files changed, 120 insertions(+), 12 deletions(-) diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index c2e40a2dd2..4fcaead3e0 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -13,6 +13,7 @@ class RegistrationTicketCallout < ApplicationRecord BUILTIN_KEYS = %w[ payment certificate scholarship ce_hours art_supplies videoconference staff handouts faq + day_1_survey day_2_survey scholarship_recipients_survey ].freeze # "Content" built-in callouts render their own editable copy/resources (like custom diff --git a/app/services/builtin_callouts.rb b/app/services/builtin_callouts.rb index e4ffd6f691..6c88067662 100644 --- a/app/services/builtin_callouts.rb +++ b/app/services/builtin_callouts.rb @@ -130,7 +130,7 @@ def initialize(event) # never clobbers admin edits. Returns the created rows. def seed existing_keys = @event.registration_ticket_callouts.builtin.pluck(:builtin_key).to_set - definitions.reject { |definition| existing_keys.include?(definition[:builtin_key]) } + definitions.reject { |definition| existing_keys.include?(definition[:builtin_key]) || !applicable?(definition) } .filter_map { |definition| create(definition) } end @@ -138,7 +138,7 @@ def seed # association (built or persisted) so it's safe to call on every form render. def build existing_keys = @event.registration_ticket_callouts.reject(&:marked_for_destruction?).filter_map(&:builtin_key).to_set - definitions.reject { |definition| existing_keys.include?(definition[:builtin_key]) } + definitions.reject { |definition| existing_keys.include?(definition[:builtin_key]) || !applicable?(definition) } .map { |definition| build_row(definition) } end @@ -154,7 +154,8 @@ def reset(callout) icon_class: definition[:icon_class], color_class: definition[:color_class], hidden: definition[:hidden].call(@event), - display_from: definition[:display_from]&.call(@event) + display_from: definition[:display_from]&.call(@event), + form_id: definition[:form]&.call(@event)&.id ) callout.registration_ticket_callout_resources.destroy_all build_resource_links(callout, definition) @@ -185,6 +186,21 @@ def resolve(value) value.respond_to?(:call) ? value.call(@event) : value end + # Whether a definition applies to this event. `seed_if` gates cards that only make + # sense for some events (e.g. the Day 2 survey on multi-day trainings). + def applicable?(definition) + definition[:seed_if].nil? || definition[:seed_if].call(@event) + end + + # A post-event day-N survey opens 30 minutes before that day's end time. Day N's + # date is the start date plus (N - 1) days; the time-of-day comes from the event's + # end_date (used as the daily end time for every day). Nil when dates are unset. + def survey_drip(event, day) + return unless event.start_date && event.end_date + target = event.start_date.to_date + (day - 1) + event.end_date.change(year: target.year, month: target.month, day: target.day) - 30.minutes + end + # Ordered built-in callout definitions. `hidden` / `display_from` are procs so # each event derives its own defaults; `resources` resolves the linked records; # `seed_if` gates whether the card applies. Content cards (Handouts, FAQ) render @@ -287,6 +303,43 @@ def definitions color_class: "blue", description: self.class.faq_html, hidden: ->(_event) { true } + }, + { + builtin_key: "day_1_survey", + title: "Day 1 survey", + subtitle: "Share your feedback on day 1 of the training", + callout_type: "action", + icon_class: "fa-solid fa-clipboard-list", + color_class: "indigo", + hidden: ->(_event) { true }, + form: ->(_event) { Form.standalone.find_by(name: "Day 1 Survey") }, + # Opens 30 min before day 1's end time; admins can edit per event. + display_from: ->(event) { survey_drip(event, 1) } + }, + { + builtin_key: "day_2_survey", + title: "Day 2 survey", + subtitle: "Share your feedback on day 2 of the training", + callout_type: "action", + icon_class: "fa-solid fa-clipboard-list", + color_class: "indigo", + hidden: ->(_event) { true }, + # Only seeds on multi-day events — a one-day training has no day 2. + seed_if: ->(event) { event.day_count >= 2 }, + form: ->(_event) { Form.standalone.find_by(name: "Day 2 Survey") }, + display_from: ->(event) { survey_drip(event, 2) } + }, + { + builtin_key: "scholarship_recipients_survey", + title: "Scholarship recipients survey", + subtitle: "Post-training questions for scholarship recipients", + callout_type: "action", + icon_class: "fa-solid fa-clipboard-list", + color_class: "fuchsia", + hidden: ->(_event) { true }, + form: ->(_event) { Form.standalone.find_by(name: "Post-Training Recipients Survey") }, + # Opens 30 min before the event ends. + display_from: ->(event) { event.end_date - 30.minutes if event.end_date } } ] end @@ -321,7 +374,8 @@ def attributes_for(definition) icon_class: definition[:icon_class], color_class: definition[:color_class], hidden: definition[:hidden].call(@event), - display_from: definition[:display_from]&.call(@event) + display_from: definition[:display_from]&.call(@event), + form_id: definition[:form]&.call(@event)&.id } end diff --git a/spec/services/builtin_callouts_spec.rb b/spec/services/builtin_callouts_spec.rb index ecde6cefe5..08fe8a6685 100644 --- a/spec/services/builtin_callouts_spec.rb +++ b/spec/services/builtin_callouts_spec.rb @@ -2,14 +2,16 @@ RSpec.describe BuiltinCallouts do describe "#build" do - it "builds all eight built-ins as unsaved in-memory rows on a new event" do + it "builds the built-ins as unsaved in-memory rows on a new event" do event = Event.new built = described_class.build(event) + # No dates on a bare Event, so day_count is 1 and the Day 2 survey is skipped. expect(built.map(&:builtin_key)).to contain_exactly( "payment", "certificate", "scholarship", "ce_hours", - "videoconference", "staff", "handouts", "faq" + "videoconference", "staff", "handouts", "faq", + "day_1_survey", "scholarship_recipients_survey" ) expect(built).to all(be_new_record) expect(event.registration_ticket_callouts).to match_array(built) @@ -22,7 +24,8 @@ built = described_class.build(event) expect(built).to be_empty - expect(event.registration_ticket_callouts.builtin.count).to eq(8) + # 8 originals + the 3 survey built-ins (the factory event spans multiple days). + expect(event.registration_ticket_callouts.builtin.count).to eq(11) end it "builds a paid event's Payment card with the W-9 link (subtitle) in memory" do @@ -39,7 +42,7 @@ end describe "#seed" do - it "materializes all eight built-in callouts for every event" do + it "materializes the built-in callouts for every event" do event = create(:event, cost_cents: 0) # free, no scholarship form, no VC link described_class.seed(event) @@ -47,7 +50,8 @@ keys = event.registration_ticket_callouts.builtin.pluck(:builtin_key) expect(keys).to contain_exactly( "payment", "certificate", "scholarship", "ce_hours", - "videoconference", "staff", "handouts", "faq" + "videoconference", "staff", "handouts", "faq", + "day_1_survey", "day_2_survey", "scholarship_recipients_survey" ) end @@ -60,7 +64,7 @@ described_class.seed(event) expect(event.registration_ticket_callouts.ordered.map(&:builtin_key)).to eq( - %w[payment scholarship ce_hours videoconference staff handouts certificate faq] + %w[payment scholarship ce_hours videoconference staff handouts certificate faq day_1_survey day_2_survey scholarship_recipients_survey] ) end @@ -248,7 +252,8 @@ keys = event.registration_ticket_callouts.builtin.pluck(:builtin_key) expect(keys).to contain_exactly( "payment", "certificate", "scholarship", "ce_hours", - "videoconference", "staff", "faq" + "videoconference", "staff", "faq", + "day_1_survey", "day_2_survey", "scholarship_recipients_survey" ) end @@ -260,7 +265,7 @@ expect(event.registration_ticket_callouts.ordered.first).to eq(custom) expect(event.registration_ticket_callouts.ordered.map(&:builtin_key).compact).to eq( - %w[payment scholarship ce_hours videoconference staff handouts certificate faq] + %w[payment scholarship ce_hours videoconference staff handouts certificate faq day_1_survey day_2_survey scholarship_recipients_survey] ) end end @@ -299,4 +304,52 @@ expect { described_class.reset(callout) }.not_to change { callout.reload.title } end end + + describe "post-event survey built-ins" do + it "seeds the Day 1 and scholarship surveys but omits Day 2 on a one-day event" do + event = create(:event, start_date: Time.zone.local(2026, 9, 10, 9), end_date: Time.zone.local(2026, 9, 10, 17)) + + described_class.seed(event) + + keys = event.registration_ticket_callouts.builtin.pluck(:builtin_key) + expect(keys).to include("day_1_survey", "scholarship_recipients_survey") + expect(keys).not_to include("day_2_survey") + end + + it "seeds the Day 2 survey on a multi-day event" do + event = create(:event, start_date: Time.zone.local(2026, 9, 10, 9), end_date: Time.zone.local(2026, 9, 11, 17)) + + described_class.seed(event) + + expect(event.registration_ticket_callouts.pluck(:builtin_key)).to include("day_2_survey") + end + + it "points each survey callout at its seeded template form" do + day_1_form = FormBuilderService.new(name: "Day 1 Survey", sections: [ :day_1_survey ], role: "day_1_survey").call + event = create(:event) + + described_class.seed(event) + + callout = event.registration_ticket_callouts.find_by(builtin_key: "day_1_survey") + expect(callout.form).to eq(day_1_form) + end + + it "drips the Day 1 survey 30 minutes before that day's end time" do + event = create(:event, start_date: Time.zone.local(2026, 9, 10, 9), end_date: Time.zone.local(2026, 9, 11, 17)) + + described_class.seed(event) + + day_1 = event.registration_ticket_callouts.find_by(builtin_key: "day_1_survey") + expect(day_1.display_from).to eq(Time.zone.local(2026, 9, 10, 16, 30)) + end + + it "drips the scholarship survey 30 minutes before the event ends" do + event = create(:event, end_date: Time.zone.local(2026, 9, 11, 17)) + + described_class.seed(event) + + scholarship = event.registration_ticket_callouts.find_by(builtin_key: "scholarship_recipients_survey") + expect(scholarship.display_from).to eq(Time.zone.local(2026, 9, 11, 16, 30)) + end + end end From 739781b9aafdf5c002e50f0d84566e6833d3fadd Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 9 Aug 2026 16:22:42 -0400 Subject: [PATCH 05/40] Gate readiness on the post-event survey + admin toggle Adds a 'Survey pending' readiness state between Ready and Certificate pending: a scholarship recipient with a live (published, past-drip) but unsubmitted recipients survey can't reach the certificate queue until it's in. Reads a plain column plus the event's memoized survey callout, so no per-row roster query. Adds the roster Status filter option, the badge color, and an independent admin 'Survey received' toggle on the registration edit page. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../event_registrations_controller.rb | 17 +++- app/controllers/events_controller.rb | 2 +- app/models/event.rb | 17 ++++ app/services/event_registration_readiness.rb | 38 +++++++-- .../_readiness_badge.html.erb | 1 + app/views/event_registrations/edit.html.erb | 16 ++++ app/views/events/_registrant_filters.html.erb | 2 +- config/routes.rb | 1 + .../event_registration_post_survey_spec.rb | 28 +++++++ ...vent_registration_readiness_survey_spec.rb | 77 +++++++++++++++++++ 10 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 spec/requests/event_registration_post_survey_spec.rb create mode 100644 spec/services/event_registration_readiness_survey_spec.rb diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 2a685750dc..a3aef175f7 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,19 @@ def revert_transfer status: :see_other end + # Admin toggle for whether the post-event (scholarship recipients) survey is in. + # Independent of the certificate: clears/sets only its own timestamp. + def toggle_post_survey + authorize! @event_registration, to: :update? + if @event_registration.post_survey_completed? + @event_registration.clear_post_survey_completed! + else + @event_registration.mark_post_survey_completed! + end + redirect_back fallback_location: edit_event_registration_path(@event_registration), + notice: "Post-event survey updated." + end + def confirm @event_registration = EventRegistration.includes(registrant: :user, event: :location).find(params[:id]) authorize! @event_registration, to: :confirm? diff --git a/app/controllers/events_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/models/event.rb b/app/models/event.rb index 4d7720faef..c0b5e42d11 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -240,6 +240,23 @@ def videoconference_details_visible?(now = Time.current) from.blank? || now >= from end + # The scholarship recipients survey callout, if seeded. Memoized so readiness + # (which runs per registration) hits the callouts once per event, not per row. + def scholarship_recipients_survey_callout + return @scholarship_recipients_survey_callout if defined?(@scholarship_recipients_survey_callout) + @scholarship_recipients_survey_callout = + registration_ticket_callouts.detect { |callout| callout.builtin_key == "scholarship_recipients_survey" } + end + + # Whether the scholarship recipients survey is live — published and past its drip. + # Only then does an unsubmitted survey count against a recipient's completion; an + # unpublished (or not-yet-dripped) survey blocks no one. + def post_event_survey_open?(now = Time.current) + callout = scholarship_recipients_survey_callout + return false unless callout && !callout.hidden? + callout.display_from.blank? || callout.display_from <= now + end + def registerable? !ended? && (registration_close_date.nil? || registration_close_date >= Time.current) end diff --git a/app/services/event_registration_readiness.rb b/app/services/event_registration_readiness.rb index 3b68384a81..35ab937f01 100644 --- a/app/services/event_registration_readiness.rb +++ b/app/services/event_registration_readiness.rb @@ -15,12 +15,13 @@ def initialize(registration) STATUS_LABELS = { not_ready: "Not ready", ready: "Ready", + survey_pending: "Survey pending", certificate_due: "Certificate pending", completed: "Completed" }.freeze # Lifecycle order for sorting the roster's Status column. - STATUS_ORDER = %i[ not_ready ready certificate_due completed ].freeze + STATUS_ORDER = %i[ not_ready ready survey_pending certificate_due completed ].freeze def event_ready? event_ready_issues.empty? @@ -31,11 +32,11 @@ def completed? completion_issues.empty? end - # All post-event work done (attended, scholarship tasks met) — i.e. the only - # thing left is sending the certificate(s). This is the admin's "send a - # certificate" queue. + # All post-event work done (attended, scholarship tasks met, post-event survey in) + # — i.e. the only thing left is sending the certificate(s). This is the admin's + # "send a certificate" queue. def certifiable? - completion_work_issues.empty? + (completion_work_issues + survey_issues).empty? end # The registration's single lifecycle state for the roster's one Status column @@ -46,10 +47,18 @@ def certifiable? def status return :completed if completed? return :not_ready unless event_ready? + return :survey_pending if survey_pending? return :certificate_due if certifiable? :ready end + # A scholarship recipient who has finished the other post-event work but still owes + # the (now-live) post-event survey. Sits between "ready" and "certificate pending": + # the survey is the one thing keeping them from the certificate queue. + def survey_pending? + survey_outstanding? && completion_work_issues.empty? + end + def status_label STATUS_LABELS.fetch(status) end @@ -64,6 +73,7 @@ def status_sort_key def status_issues case status when :not_ready then event_ready_issues + when :survey_pending then survey_issues when :certificate_due then certificate_issues else [] end @@ -115,7 +125,14 @@ def event_ready_reason end def completion_issues - completion_work_issues + certificate_issues + completion_work_issues + survey_issues + certificate_issues + end + + # The post-event (scholarship recipients) survey, when a recipient still owes a + # live one. Gates certifiable?/completed? so the certificate can't close out until + # the survey is in. + def survey_issues + @survey_issues ||= survey_outstanding? ? [ "Post-event survey outstanding" ] : [] end # Post-event work that must happen before a certificate can be issued. @@ -176,6 +193,15 @@ def scholarship_tasks_incomplete? registration.scholarship? && !registration.scholarship_tasks_met? end + # Only scholarship recipients owe the post-event survey, and only once it's live + # (published + past drip). Reads a plain column plus the event's memoized survey + # callout, so it adds no per-row query on the roster. + def survey_outstanding? + registration.scholarship? && + registration.event.post_event_survey_open? && + !registration.post_survey_completed? + end + def ce_unpaid? registration.ce_registered? && !ce_paid? end diff --git a/app/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..1483f2b192 100644 --- a/app/views/event_registrations/edit.html.erb +++ b/app/views/event_registrations/edit.html.erb @@ -72,6 +72,22 @@ wrapper_class: "mt-4" %> <%= render "form", event_registration: @event_registration %> + + <%# Admin completion controls. The certificate is issued by sending its email; the + post-event survey is marked here (independently) when a recipient's survey is in. %> +
+ Post-event survey received + <%= button_to toggle_post_survey_event_registration_path(@event_registration, return_to: params[:return_to].presence), + method: :patch, + class: "rounded-lg border px-3 py-1.5 text-sm font-medium cursor-pointer #{@event_registration.post_survey_completed? ? "border-green-300 bg-green-50 text-green-700 hover:bg-green-100" : "border-gray-300 text-gray-600 hover:bg-gray-50"}" do %> + <% if @event_registration.post_survey_completed? %> + Received<% if @event_registration.post_survey_completed_at %> · <%= @event_registration.post_survey_completed_at.to_date.to_fs(:long) %><% end %> + <% else %> + Mark received + <% end %> + <% end %> +
+ <%= render "shared/audit_info", resource: @event_registration %> <% if allowed_to?(:index?, with: Admin::AhoyActivityPolicy) %>
diff --git a/app/views/events/_registrant_filters.html.erb b/app/views/events/_registrant_filters.html.erb index 53e4c73373..2887ee53bd 100644 --- a/app/views/events/_registrant_filters.html.erb +++ b/app/views/events/_registrant_filters.html.erb @@ -57,7 +57,7 @@ pending → Completed. Param stays :readiness (backed by EventRegistrationReadiness); the user-facing label reads "Progress". %> <%= render "events/filter_select", param: :readiness, label: "Progress", - options: [ [ "Not ready", "not_ready" ], [ "Ready", "ready" ], [ "Certificate pending", "certificate_due" ], [ "Completed", "completed" ] ], + options: [ [ "Not ready", "not_ready" ], [ "Ready", "ready" ], [ "Survey pending", "survey_pending" ], [ "Certificate pending", "certificate_due" ], [ "Completed", "completed" ] ], selected: params[:readiness], blank: "Any stage", field_class: field_class %> <% end %> diff --git a/config/routes.rb b/config/routes.rb index caabdd4f51..22d02f1d6e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -132,6 +132,7 @@ patch :update_onboarding patch :toggle_certificate_issued patch :update_attendance + patch :toggle_post_survey end resources :comments, only: [ :create, :update ] end diff --git a/spec/requests/event_registration_post_survey_spec.rb b/spec/requests/event_registration_post_survey_spec.rb new file mode 100644 index 0000000000..74b4fa15c0 --- /dev/null +++ b/spec/requests/event_registration_post_survey_spec.rb @@ -0,0 +1,28 @@ +require "rails_helper" + +RSpec.describe "EventRegistration post-event survey toggle", type: :request do + let(:admin) { create(:user, :with_person, super_user: true) } + let(:event) { create(:event) } + let(:registration) { create(:event_registration, event: event) } + + before { sign_in admin } + + it "marks the survey received when it was not, and clears it when it was" do + expect(registration.post_survey_completed?).to be(false) + + patch toggle_post_survey_event_registration_path(registration) + expect(registration.reload.post_survey_completed?).to be(true) + + patch toggle_post_survey_event_registration_path(registration) + expect(registration.reload.post_survey_completed?).to be(false) + end + + it "does not touch the certificate timestamp (independent toggles)" do + registration.update!(certificate_sent_at: Time.current) + + patch toggle_post_survey_event_registration_path(registration) + + expect(registration.reload.post_survey_completed?).to be(true) + expect(registration.certificate_sent_at).to be_present + end +end diff --git a/spec/services/event_registration_readiness_survey_spec.rb b/spec/services/event_registration_readiness_survey_spec.rb new file mode 100644 index 0000000000..3c8ff1b65e --- /dev/null +++ b/spec/services/event_registration_readiness_survey_spec.rb @@ -0,0 +1,77 @@ +require "rails_helper" + +RSpec.describe EventRegistrationReadiness, "post-event survey gating" do + let(:event) { create(:event, cost_cents: 1000) } + let(:registration) { create(:event_registration, event: event, status: "attended") } + subject(:readiness) { described_class.new(registration) } + + def link_org(reg) + create(:event_registration_organization, event_registration: reg, organization: create(:organization)) + end + + # A scholarship covering the full cost makes the recipient paid-in-full and, with + # tasks complete, clears every pre-event and post-event check except the survey. + def award_scholarship(reg, amount: 1000) + scholarship = create(:scholarship, recipient: reg.registrant, tasks_completed: true, amount_cents: amount) + create(:allocation, source: scholarship, allocatable: reg, amount: amount) + end + + def open_recipient_survey(hidden: false, display_from: 1.day.ago) + event.registration_ticket_callouts.create!( + builtin_key: "scholarship_recipients_survey", title: "Scholarship recipients survey", + callout_type: "action", hidden: hidden, display_from: display_from + ) + end + + before do + link_org(registration) + award_scholarship(registration) + end + + it "is survey_pending once the survey is live and unsubmitted" do + open_recipient_survey + + expect(readiness.status).to eq(:survey_pending) + expect(readiness.certifiable?).to be(false) + expect(readiness.completed?).to be(false) + expect(readiness.status_issues).to include("Post-event survey outstanding") + end + + it "advances to certificate_due once the survey is submitted" do + open_recipient_survey + registration.mark_post_survey_completed! + + expect(readiness.status).to eq(:certificate_due) + end + + it "does not gate when the survey callout is unpublished" do + open_recipient_survey(hidden: true) + + expect(readiness.status).to eq(:certificate_due) + end + + it "does not gate before the drip date" do + open_recipient_survey(display_from: 1.day.from_now) + + expect(readiness.status).to eq(:certificate_due) + end + + it "orders survey_pending between ready and certificate_due" do + expect(EventRegistrationReadiness::STATUS_ORDER.index(:survey_pending)) + .to be_between( + EventRegistrationReadiness::STATUS_ORDER.index(:ready) + 1, + EventRegistrationReadiness::STATUS_ORDER.index(:certificate_due) - 1 + ) + end + + it "never gates a non-recipient" do + non_recipient = create(:event_registration, event: event, status: "attended") + create(:event_registration_organization, event_registration: non_recipient, organization: create(:organization)) + create(:allocation, + source: create(:payment, amount_cents: 1000, amount_cents_remaining: 1000), + allocatable: non_recipient, amount: 1000) + open_recipient_survey + + expect(described_class.new(non_recipient).status).to eq(:certificate_due) + end +end From f06d0e9a3d8b622dd698bda1435c4641061fc5ef Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 9 Aug 2026 16:27:45 -0400 Subject: [PATCH 06/40] Add survey submission service + staff FYI mailer SurveySubmission records a role-tagged FormSubmission with static answers and per-resource clarity answers (nil form_field, full sentence snapshotted), writes the anonymity + name-display questions through to the Person profile (reporting the changes for Ahoy), and stamps post_survey_completed_at for a recipient's recipients survey. NotificationMailer#survey_submitted_fyi heads up staff on submission via the default system address. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/mailers/notification_mailer.rb | 13 +++ .../survey_submission.rb | 105 ++++++++++++++++++ .../survey_submitted_fyi.html.erb | 16 +++ .../survey_submitted_fyi.text.erb | 14 +++ spec/mailers/survey_submitted_fyi_spec.rb | 19 ++++ .../survey_submission_spec.rb | 96 ++++++++++++++++ .../previews/notification_mailer_preview.rb | 12 ++ 7 files changed, 275 insertions(+) create mode 100644 app/services/event_registration_services/survey_submission.rb create mode 100644 app/views/notification_mailer/survey_submitted_fyi.html.erb create mode 100644 app/views/notification_mailer/survey_submitted_fyi.text.erb create mode 100644 spec/mailers/survey_submitted_fyi_spec.rb create mode 100644 spec/services/event_registration_services/survey_submission_spec.rb diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index c822043ef2..b6db8cce4b 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -16,6 +16,19 @@ def event_registration_confirmation_fyi(notification) ) end + # Staff heads-up that a registrant submitted a post-event survey. Takes the + # FormSubmission directly (no Notification record) and goes to the default system + # address. + def survey_submitted_fyi(form_submission) + @form_submission = form_submission + @person = form_submission.person + @event = form_submission.event + @form = form_submission.form + @answers = form_submission.form_answers.order(:id) + + mail(subject: "#{FYI_PREFIX} New #{@form.name} submission from #{@person.full_name}") + end + def event_registration_cancelled_fyi(notification) @event_registration = notification.noticeable @event = @event_registration.event.decorate diff --git a/app/services/event_registration_services/survey_submission.rb b/app/services/event_registration_services/survey_submission.rb new file mode 100644 index 0000000000..e757bed743 --- /dev/null +++ b/app/services/event_registration_services/survey_submission.rb @@ -0,0 +1,105 @@ +module EventRegistrationServices + # Records a post-event survey delivered inline on a registrant's ticket. Persists a + # role-tagged FormSubmission with: + # - static answers (one per ordinary field), and + # - dynamic "clarity" answers (a per-resource question fans out to one answer per + # linked resource, its full rendered sentence snapshotted in + # question_name_when_answered with a nil form_field). + # Two fields also write through to the Person profile (anonymity + name display); + # #profile_changes reports what actually changed so the caller can Ahoy-track it. + # Stamps post_survey_completed_at for a scholarship recipient's recipients survey. + # + # Idempotent on re-submit (edit): find-or-initialize keeps one answer per field, and + # per (nil field, snapshotted question) for the dynamic ones. + class SurveySubmission + attr_reader :submission, :profile_changes + + def self.call(**kwargs) + instance = new(**kwargs) + instance.call + instance + end + + def initialize(event_registration:, form:, role:, field_params: {}, clarity_params: {}) + @event_registration = event_registration + @form = form + @role = role + @field_params = (field_params || {}).transform_keys(&:to_s) + @clarity_params = clarity_params || {} + @profile_changes = {} + end + + def call + person = @event_registration.registrant + ActiveRecord::Base.transaction do + @submission = FormSubmission.find_or_create_by!( + person: person, form: @form, event: @event_registration.event, role: @role + ) + save_static_answers + save_clarity_answers + sync_profile(person) + stamp_completion + end + @submission + end + + private + + def save_static_answers + @form.form_fields.each do |field| + next if field.answer_type == "group_header" || field.per_resource? + raw = @field_params[field.id.to_s] + next if raw.nil? + text = raw.is_a?(Array) ? raw.reject(&:blank?).join(", ") : raw + record = @submission.form_answers.find_or_initialize_by(form_field: field) + record.update!(submitted_answer: text, question_name_when_answered: field.name) + end + end + + # Each per-resource field fans out: one answer per linked resource, keyed by the + # snapshotted sentence so re-submits update in place (form_field stays nil). + def save_clarity_answers + @clarity_params.each do |field_id, per_resource| + field = @form.form_fields.find_by(id: field_id) + next unless field&.per_resource? + field.form_field_resources.includes(:resource).each do |link| + raw = per_resource[link.resource_id.to_s] || per_resource[link.resource_id] + next if raw.blank? + question = "#{field.name} #{link.resource.title}" + record = @submission.form_answers.find_or_initialize_by(form_field: nil, question_name_when_answered: question) + record.update!(submitted_answer: raw) + end + end + end + + # Route the two identified questions to the Person profile, recording only the + # values that actually change so the caller can Ahoy-track a real edit. + def sync_profile(person) + apply_profile_change(person, :anonymous_contributions, + Person::ANONYMOUS_CONTRIBUTIONS_OPTIONS.invert[value_for("anonymous_contributions")]) + apply_profile_change(person, :display_name_preference, + Person::DISPLAY_NAME_PREFERENCES.invert[value_for("display_name_preference")]) + person.save! if person.changed? + end + + def apply_profile_change(person, attribute, new_value) + return if new_value.nil? + current = person.public_send(attribute) + return if current == new_value + @profile_changes[attribute] = [ current, new_value ] + person.public_send("#{attribute}=", new_value) + end + + # The submitted label for a field identified by its field_identifier. + def value_for(field_identifier) + field = @form.form_fields.find_by(field_identifier: field_identifier) + field && @field_params[field.id.to_s] + end + + def stamp_completion + return unless @role == "post_event_survey" && @event_registration.scholarship? + return if @event_registration.post_survey_completed? + @event_registration.mark_post_survey_completed! + end + end +end diff --git a/app/views/notification_mailer/survey_submitted_fyi.html.erb b/app/views/notification_mailer/survey_submitted_fyi.html.erb new file mode 100644 index 0000000000..d6529f62c9 --- /dev/null +++ b/app/views/notification_mailer/survey_submitted_fyi.html.erb @@ -0,0 +1,16 @@ +

New <%= @form.name %> submission

+ +

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

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

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

+<% end %> diff --git a/app/views/notification_mailer/survey_submitted_fyi.text.erb b/app/views/notification_mailer/survey_submitted_fyi.text.erb new file mode 100644 index 0000000000..a131f5071c --- /dev/null +++ b/app/views/notification_mailer/survey_submitted_fyi.text.erb @@ -0,0 +1,14 @@ +New <%= @form.name %> submission +========================== + +From: <%= @person.full_name %> +<% if @event %>Event: <%= @event.title %> +<% end %>Submitted: <%= @form_submission.created_at.in_time_zone("Pacific Time (US & Canada)").strftime("%B %-d, %Y at %-l:%M %p %Z") %> + +------------------------------------------------------------ + +<% @answers.each do |answer| %> +<%= answer.name %> +<%= answer.submitted_answer.presence || "—" %> + +<% end %> diff --git a/spec/mailers/survey_submitted_fyi_spec.rb b/spec/mailers/survey_submitted_fyi_spec.rb new file mode 100644 index 0000000000..9a25676830 --- /dev/null +++ b/spec/mailers/survey_submitted_fyi_spec.rb @@ -0,0 +1,19 @@ +require "rails_helper" + +RSpec.describe NotificationMailer, "#survey_submitted_fyi" do + it "notifies staff with the form name, registrant, and answers" do + person = create(:person, first_name: "Ada", last_name: "Lovelace") + event = create(:event, title: "Spring Training") + form = create(:form, name: "Day 1 Survey") + submission = create(:form_submission, person: person, form: form, event: event, role: "day_1_survey") + field = create(:form_field, form: form, name: "What stood out?") + create(:form_answer, form_submission: submission, form_field: field, + submitted_answer: "The breakout rooms", question_name_when_answered: "What stood out?") + + mail = described_class.survey_submitted_fyi(submission) + + expect(mail.to).to eq([ ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org") ]) + expect(mail.subject).to include("Day 1 Survey").and include("Ada Lovelace") + expect(mail.body.encoded).to include("What stood out?").and include("The breakout rooms") + end +end diff --git a/spec/services/event_registration_services/survey_submission_spec.rb b/spec/services/event_registration_services/survey_submission_spec.rb new file mode 100644 index 0000000000..d14be0c83b --- /dev/null +++ b/spec/services/event_registration_services/survey_submission_spec.rb @@ -0,0 +1,96 @@ +require "rails_helper" + +RSpec.describe EventRegistrationServices::SurveySubmission do + let(:event) { create(:event, cost_cents: 1000) } + let(:registration) { create(:event_registration, event: event) } + let(:person) { registration.registrant } + + let(:form) { create(:form, role: "post_event_survey") } + let!(:static_field) do + create(:form_field, form: form, answer_type: :free_form_input_paragraph, name: "Impact?", field_identifier: "impact") + end + let!(:clarity_field) do + create(:form_field, form: form, answer_type: :single_select_radio, + name: "Overall, was the information presented in a clear and concise manner for") + end + let(:triple_focus) { create(:resource, title: "Triple Focus") } + let(:listening) { create(:resource, title: "Listening is Art") } + let!(:anon_field) do + create(:form_field, form: form, answer_type: :single_select_radio, name: "Anonymity?", field_identifier: "anonymous_contributions") + end + let!(:name_field) do + create(:form_field, form: form, answer_type: :single_select_radio, name: "Name?", field_identifier: "display_name_preference") + end + + before do + create(:form_field_resource, form_field: clarity_field, resource: triple_focus) + create(:form_field_resource, form_field: clarity_field, resource: listening) + # Make the registrant a scholarship recipient so completion stamps. + create(:allocation, + source: create(:scholarship, recipient: person, tasks_completed: true, amount_cents: 100), + allocatable: registration, amount: 100) + end + + def submit(field_params:, clarity_params:) + described_class.call( + event_registration: registration, form: form, role: "post_event_survey", + field_params: field_params, clarity_params: clarity_params + ) + end + + let(:field_params) do + { + static_field.id.to_s => "It changed me", + anon_field.id.to_s => Person::ANONYMOUS_CONTRIBUTIONS_OPTIONS[true], + name_field.id.to_s => Person::DISPLAY_NAME_PREFERENCES["first_name_only"] + } + end + let(:clarity_params) do + { clarity_field.id.to_s => { triple_focus.id.to_s => "Yes", listening.id.to_s => "No" } } + end + + it "creates a role-tagged submission with the static answer" do + service = submit(field_params: field_params, clarity_params: clarity_params) + + submission = service.submission + expect(submission).to have_attributes(person: person, form: form, event: event, role: "post_event_survey") + expect(submission.form_answers.find_by(form_field: static_field).submitted_answer).to eq("It changed me") + end + + it "fans the clarity field out to one nil-field answer per resource, snapshotting the sentence" do + service = submit(field_params: field_params, clarity_params: clarity_params) + + dynamic = service.submission.form_answers.where(form_field: nil) + expect(dynamic.pluck(:question_name_when_answered, :submitted_answer)).to contain_exactly( + [ "Overall, was the information presented in a clear and concise manner for Triple Focus", "Yes" ], + [ "Overall, was the information presented in a clear and concise manner for Listening is Art", "No" ] + ) + end + + it "writes the two profile questions through to the Person and reports the changes" do + service = submit(field_params: field_params, clarity_params: clarity_params) + + expect(person.reload.anonymous_contributions).to be(true) + expect(person.display_name_preference).to eq("first_name_only") + expect(service.profile_changes).to include( + anonymous_contributions: [ nil, true ], + display_name_preference: [ nil, "first_name_only" ] + ) + end + + it "stamps completion for a scholarship recipient's recipients survey" do + submit(field_params: field_params, clarity_params: clarity_params) + + expect(registration.reload.post_survey_completed?).to be(true) + end + + it "is idempotent on re-submit — updates in place without duplicating answers" do + submit(field_params: field_params, clarity_params: clarity_params) + service = submit(field_params: field_params.merge(static_field.id.to_s => "Edited"), clarity_params: clarity_params) + + expect(service.submission.form_answers.where(form_field: static_field).count).to eq(1) + expect(service.submission.form_answers.find_by(form_field: static_field).submitted_answer).to eq("Edited") + expect(service.submission.form_answers.where(form_field: nil).count).to eq(2) + expect(service.profile_changes).to be_empty # unchanged the second time + end +end diff --git a/test/mailers/previews/notification_mailer_preview.rb b/test/mailers/previews/notification_mailer_preview.rb index efb9a511d4..c36e111793 100644 --- a/test/mailers/previews/notification_mailer_preview.rb +++ b/test/mailers/previews/notification_mailer_preview.rb @@ -19,6 +19,18 @@ def event_registration_confirmation_fyi NotificationMailer.event_registration_confirmation_fyi(notification) end + def survey_submitted_fyi + submission = FormSubmission.order(:id).last || + FormSubmission.create!( + person: Person.first || raise("Need a Person"), + form: Form.first || raise("Need a Form"), + event: Event.first, + role: "post_event_survey" + ) + + NotificationMailer.survey_submitted_fyi(submission) + end + def event_registration_cancelled_fyi event_registration = EventRegistration.first || From 9e550975e123e3fda6f2310f92aee97802d273b0 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 9 Aug 2026 16:30:25 -0400 Subject: [PATCH 07/40] Add the survey Forms dropdown to the callout editor A callout's inline survey form is picked from the standalone survey templates via a Forms dropdown under Linked resources (form_id permitted on the nested callout attributes). Updates the built-in-set expectation in the events request spec. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/requests/events_spec.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index a51f845bfb..948b66f65c 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -1162,9 +1162,11 @@ def add_ce_registrant(target_event) created = Event.order(created_at: :desc).first # The two submitted built-ins persist their edits, and the post-save seed - # fills the remaining six — every seeded built-in key present exactly once. + # fills the rest — every seeded built-in key present exactly once, including + # the three post-event survey callouts (the event spans multiple days). expect(created.registration_ticket_callouts.builtin.pluck(:builtin_key)).to contain_exactly( - "payment", "certificate", "scholarship", "ce_hours", "videoconference", "staff", "handouts", "faq" + "payment", "certificate", "scholarship", "ce_hours", "videoconference", "staff", "handouts", "faq", + "day_1_survey", "day_2_survey", "scholarship_recipients_survey" ) payment = created.registration_ticket_callouts.find_by(builtin_key: "payment") expect(payment.title).to eq("Pay your balance") From 150826546a7deae6e8a70b2346b1e25148eafde7 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 9 Aug 2026 16:36:53 -0400 Subject: [PATCH 08/40] Deliver the survey inline on the ticket + survey page Adds the public survey page (Events::CalloutsController#survey / #submit_survey): drip notice before the open date, the form to fill (static fields + per-resource clarity questions), and read-only answers with an edit affordance after submitting. Submit records via SurveySubmission, Ahoy-tracks profile changes, and emails the staff FYI. Adds the three ticket cards (recipient card gated to scholarship recipients) linking to the page. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/events/callouts_controller.rb | 70 ++++++++++++++ app/services/builtin_callout_cards.rb | 34 ++++++- app/views/events/callouts/survey.html.erb | 95 +++++++++++++++++++ config/routes.rb | 2 + spec/requests/registration_survey_spec.rb | 50 ++++++++++ spec/views/page_bg_class_alignment_spec.rb | 1 + 6 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 app/views/events/callouts/survey.html.erb create mode 100644 spec/requests/registration_survey_spec.rb diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index a72c6b3058..761b5399ae 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -342,6 +342,47 @@ def submit_callout notice: "Thanks! Your responses have been submitted." end + # Maps each survey built-in to the FormSubmission role it records under. The + # scholarship recipients survey is the one that gates readiness, tagged + # "post_event_survey". + SURVEY_ROLES = { + "day_1_survey" => "day_1_survey", + "day_2_survey" => "day_2_survey", + "scholarship_recipients_survey" => "post_event_survey" + }.freeze + + # The inline survey page for a survey callout: renders the drip notice before + # the drip date, the form to fill, or the submitted answers (read-only) with an + # edit affordance. + def survey + @callout = survey_callout + return redirect_to registration_ticket_path(@event_registration.slug) unless @callout + + @form = @callout.form + @role = SURVEY_ROLES.fetch(@callout.builtin_key) + @dripping = @callout.dripping? + @submission = survey_submission + @editing = @submission.nil? || params[:edit].present? + end + + def submit_survey + @callout = survey_callout + return redirect_to registration_ticket_path(@event_registration.slug) if @callout.nil? || @callout.dripping? + + service = EventRegistrationServices::SurveySubmission.call( + event_registration: @event_registration, + form: @callout.form, + role: SURVEY_ROLES.fetch(@callout.builtin_key), + field_params: survey_field_params, + clarity_params: survey_clarity_params + ) + track_survey_profile_changes(service.profile_changes) + NotificationMailer.survey_submitted_fyi(service.submission).deliver_later + + redirect_to registration_survey_path(@event_registration.slug, @callout.builtin_key), + notice: "Thanks! Your responses have been submitted." + end + private # A dollar amount typed into the support-request box ("$1,200", "1200.50") as @@ -414,6 +455,35 @@ def sign_out_notice(entry) "Signed out for #{entry.attendance_date.strftime("%a, %b %-d")} at #{time}." end + # The published survey callout named by :builtin_key, once it actually carries a + # form. Nil (→ back to the ticket) for anything else. + def survey_callout + callout = @event.registration_ticket_callouts.find_by(builtin_key: params[:builtin_key]) + return unless callout && callout.form && !callout.hidden? && SURVEY_ROLES.key?(callout.builtin_key) + callout + end + + def survey_submission + FormSubmission.find_by(person: @event_registration.registrant, form: @callout.form, + event: @event, role: SURVEY_ROLES.fetch(@callout.builtin_key)) + end + + def survey_field_params + params.dig(:survey, :fields)&.to_unsafe_h || {} + end + + def survey_clarity_params + params.dig(:survey, :clarity)&.to_unsafe_h || {} + end + + # One Ahoy event per profile field the survey actually changed (anonymity / name + # display), so the change is auditable. + def track_survey_profile_changes(changes) + changes.each do |attribute, (from, to)| + ahoy.track("profile.#{attribute}", person_id: @event_registration.registrant_id, from: from, to: to) + end + end + # Whether the event's built-in callout for this key is materialized and # published (visible). These public pages gate on that alone now — the admin's # published/hidden choice on the row decides whether the page is reachable, so diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index 309f055e48..5ac1203ed2 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -70,7 +70,10 @@ def self.editor_cards(event) "ce_hours" => :ce_hours_card, "videoconference" => :videoconference_card, "staff" => :staff_card, - "certificate" => :certificate_card + "certificate" => :certificate_card, + "day_1_survey" => :day_1_survey_card, + "day_2_survey" => :day_2_survey_card, + "scholarship_recipients_survey" => :scholarship_recipients_survey_card }.freeze # Why a built-in card with this builtin_key can never appear on the given event's @@ -123,8 +126,12 @@ def initialize(event_registration, preview: false) # event has materialized into editable rows are omitted here — the ticket renders # those from the row (calling #card_for for behavioral ones), so this is both the # non-materialized set and the fallback for events not yet seeded. + # Survey cards have no config-driven legacy default — they only ever render from a + # materialized (seeded) row via #card_for, never from this fallback. + FALLBACK_EXCLUDED_KEYS = %w[ day_1_survey day_2_survey scholarship_recipients_survey ].freeze + def cards - CARD_BUILDERS.reject { |builtin_key, _| materialized?(builtin_key) } + CARD_BUILDERS.reject { |builtin_key, _| materialized?(builtin_key) || FALLBACK_EXCLUDED_KEYS.include?(builtin_key) } .filter_map { |_, builder| send(builder) } end @@ -455,4 +462,27 @@ def videoconference_card href: registration_videoconference_path(registration.slug), target: nil, trailing_icon: "fa-solid fa-arrow-right") end + + # Post-event survey cards link to the inline survey page (which itself shows the + # drip notice before the survey opens, then the form). The day cards show for + # everyone; the scholarship recipients card only for recipients. + def day_1_survey_card + survey_card("day_1_survey") + end + + def day_2_survey_card + survey_card("day_2_survey") + end + + def scholarship_recipients_survey_card + return unless registration.scholarship? + survey_card("scholarship_recipients_survey") + end + + def survey_card(builtin_key) + Card.new(icon_class: "fa-solid fa-clipboard-list", color: "indigo", + title: "Survey", subtitle: "Share your feedback", + href: registration_survey_path(registration.slug, builtin_key), + target: nil, trailing_icon: "fa-solid fa-arrow-right") + end end diff --git a/app/views/events/callouts/survey.html.erb b/app/views/events/callouts/survey.html.erb new file mode 100644 index 0000000000..d2b705b8b9 --- /dev/null +++ b/app/views/events/callouts/survey.html.erb @@ -0,0 +1,95 @@ +<% content_for(:page_bg_class, "public") %> +<% content_for(:page_title, "#{@callout.title} — #{@event.title}") %> + +<%= render layout: "events/callouts/callout_page", locals: { title: @callout.title } do %> + <% if @callout.subtitle.present? %> +

<%= @callout.subtitle %>

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

<%= field.name %>

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

<%= field.subtitle %>

<% end %> + + <% if field.answer_type == "single_select_radio" %> +
+ <% field.form_field_answer_options.includes(:answer_option).each do |ffo| %> + <% option = ffo.answer_option.name %> + + <% end %> +
+ <% elsif field.answer_type == "free_form_input_paragraph" %> + <%= text_area_tag "survey[fields][#{field.id}]", prefill, rows: 4, + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" %> + <% else %> + <%= text_field_tag "survey[fields][#{field.id}]", prefill, + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" %> + <% end %> +
+ <% end %> + <% end %> +
+ +
+ <%= submit_tag "Submit", class: "rounded-lg bg-purple-700 px-4 py-2 text-sm font-semibold text-white hover:bg-purple-800 cursor-pointer" %> +
+ <% end %> + <% end %> +<% end %> diff --git a/config/routes.rb b/config/routes.rb index 22d02f1d6e..85f2fccd8b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -110,6 +110,8 @@ get "registration/:slug/staff", to: "events/callouts#staff", as: :registration_staff get "registration/:slug/forms/:callout_id", to: "events/callouts#callout", as: :registration_callout_form post "registration/:slug/forms/:callout_id", to: "events/callouts#submit_callout", as: :registration_callout_form_submit + get "registration/:slug/survey/:builtin_key", to: "events/callouts#survey", as: :registration_survey + post "registration/:slug/survey/:builtin_key", to: "events/callouts#submit_survey", as: :registration_survey_submit post "registration/:slug/resend_confirmation", to: "events/registrations#resend_confirmation", as: :registration_resend_confirmation post "registration/:slug/cancel", to: "events/registrations#cancel", as: :registration_cancel post "registration/:slug/reactivate", to: "events/registrations#reactivate", as: :registration_reactivate diff --git a/spec/requests/registration_survey_spec.rb b/spec/requests/registration_survey_spec.rb new file mode 100644 index 0000000000..9713c1d1eb --- /dev/null +++ b/spec/requests/registration_survey_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +RSpec.describe "Registration survey page", type: :request do + let(:event) { create(:event, cost_cents: 1000) } + let(:registration) { create(:event_registration, event: event) } + let(:form) do + FormBuilderService.new(name: "Post-Training Recipients Survey", + sections: [ :recipient_survey, :content_sharing_preferences ], role: "post_event_survey").call + end + + def make_recipient + create(:allocation, + source: create(:scholarship, recipient: registration.registrant, tasks_completed: true, amount_cents: 100), + allocatable: registration, amount: 100) + end + + def survey_callout(hidden: false, display_from: 1.day.ago) + event.registration_ticket_callouts.create!(builtin_key: "scholarship_recipients_survey", + title: "Scholarship recipients survey", callout_type: "action", + hidden: hidden, display_from: display_from, form: form) + end + + it "renders the form when live" do + survey_callout + get registration_survey_path(registration.slug, "scholarship_recipients_survey") + expect(response).to have_http_status(:success) + expect(response.body).to include("How did participating in this training impact you") + end + + it "withholds the form before the drip date" do + survey_callout(display_from: 3.days.from_now) + get registration_survey_path(registration.slug, "scholarship_recipients_survey") + expect(response.body).to include("will open on") + end + + it "records a submission, stamps completion, and redirects" do + make_recipient + survey_callout + impact = form.form_fields.find_by(field_identifier: "impact") + + expect { + post registration_survey_submit_path(registration.slug, "scholarship_recipients_survey"), + params: { survey: { fields: { impact.id.to_s => "It was transformative" } } } + }.to change(FormSubmission, :count).by(1) + + expect(response).to redirect_to(registration_survey_path(registration.slug, "scholarship_recipients_survey")) + expect(registration.reload.post_survey_completed?).to be(true) + expect(FormSubmission.last.form_answers.find_by(form_field: impact).submitted_answer).to eq("It was transformative") + end +end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index f16cee10b0..8dfe79fe16 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -281,6 +281,7 @@ "app/views/events/callouts/videoconference.html.erb" => "public", "app/views/events/callouts/staff.html.erb" => "public", "app/views/events/callouts/callout.html.erb" => "public", + "app/views/events/callouts/survey.html.erb" => "public", "app/views/registration_ticket_callouts/show.html.erb" => "public", # ─── public standalone form (pretty URL, no account) ─── From 50d1a4ead47c48aa00fb17203ee16327475a0e0f Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 9 Aug 2026 16:40:06 -0400 Subject: [PATCH 09/40] Document post-event surveys in AGENTS.md New SurveySubmission service, the survey_pending readiness state, and the three survey built-ins (form_id + drips + seed_if). Fixes the built-in-set expectation in the ticket callouts request spec. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 5 +++-- spec/requests/events/registration_ticket_callouts_spec.rb | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa7405a449..c66e5aa7f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -258,10 +258,11 @@ action, or `authorize! :workshop, to: :summary?`). - `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 - `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 +- `EventRegistrationServices::SurveySubmission` — Records a post-event survey (delivered inline on the ticket via a survey callout) as a role-tagged `FormSubmission`: static answers plus per-resource "clarity" answers (one per `FormFieldResource`, nil `form_field`, full sentence snapshotted in `question_name_when_answered`). Writes the anonymity + name-display questions through to the `Person` profile (`anonymous_contributions`, `display_name_preference`), exposing `#profile_changes` so the controller Ahoy-tracks real edits, and stamps `post_survey_completed_at` for a scholarship recipient's recipients survey +- `EventRegistrationReadiness` — Computes a registration's lifecycle `status` (`:not_ready` → `:ready` → `:survey_pending` → `:certificate_due` → `:completed`); `:survey_pending` is a scholarship recipient whose live (published + past-drip) post-event recipients survey is still unsubmitted (`post_survey_completed_at` blank), which gates the certificate. Status is computed from a pre-event "event ready" checklist, a post-event "completion work" checklist (attendance, scholarship tasks, the recipients survey), and certificate delivery, returning the specific outstanding reasons. Reads payment/certificate state via `Registerable` (`paid_in_full?`, `certificate_sent?`) on both the registration and its `continuing_education_registrations`. Drives the registrants roster's single far-right Status badge column (with a short reason under "Not ready" and a cert-type note under "Certificate pending") and its matching filter - `ReminderRecipientFilter` — Decides which event registrations stay checked on the bulk reminder page given the admin's filters (matches in memory, returns matching ids) - `BuiltinCalloutCards` — Renders the live, per-registration ticket callout cards (payment, certificate, scholarship, CE hours, videoconference), overlaying dynamic status (badge, colour, visibility guard, destination) on each materialized built-in row via `#card_for`. Rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `BuiltinCallouts`) so the two paths never double-render, and `#cards` serves as the fallback for events not yet seeded; `.editor_cards` builds the editor's preview cards. Handouts and FAQ are pure content cards with no builder here — they render from their row. Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized) -- `BuiltinCallouts` — Owns the built-in callout definitions and materializes them into `RegistrationTicketCallout` rows in canonical ticket order: `seed` persists (on create, and lazily on edit so older events heal with no backfill), `build` makes the same rows in memory for the new-event form (with `builtin_key` round-tripped through nested attributes), `reset`/`customized?` back the "Restore default" control. All eight seed **hidden** by default — admins publish the ones they want; there's no config-based auto-publish. Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Handouts, FAQ) render their own copy/resources on the generic callout page; "behavioral" cards render live status through `BuiltinCalloutCards#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Videoconference drips a week before start via `display_from`. CE hours is edited like every other built-in — its title/text live entirely on the row (the legacy `event_details*`/`ce_hours_details*` event columns were dropped); the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE page reads the row's title/description. Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`) +- `BuiltinCallouts` — Owns the built-in callout definitions and materializes them into `RegistrationTicketCallout` rows in canonical ticket order: `seed` persists (on create, and lazily on edit so older events heal with no backfill), `build` makes the same rows in memory for the new-event form (with `builtin_key` round-tripped through nested attributes), `reset`/`customized?` back the "Restore default" control. All seed **hidden** by default — admins publish the ones they want; there's no config-based auto-publish. The set includes three post-event survey built-ins (`day_1_survey`, `day_2_survey`, `scholarship_recipients_survey`), each carrying a `form_id` (its inline survey form, picked in the callout editor) and a relevant default drip; a `seed_if` gate keeps `day_2_survey` off single-day events. Their live ticket cards (and the recipients-only guard) live in `BuiltinCalloutCards`; the inline page is `Events::CalloutsController#survey`. Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Handouts, FAQ) render their own copy/resources on the generic callout page; "behavioral" cards render live status through `BuiltinCalloutCards#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Videoconference drips a week before start via `display_from`. CE hours is edited like every other built-in — its title/text live entirely on the row (the legacy `event_details*`/`ce_hours_details*` event columns were dropped); the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE page reads the row's title/description. Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`) - `CalloutContent` — Parses admin-authored callout HTML into ordered segments so **every** callout content page renders the same way: plain rich text, with each standard `
` disclosure (the markup any HTML generator/LLM produces; `` and a `title` attribute are accepted aliases; `
` starts expanded) rebuilt into a styled collapsible card. `
`/`` are also on the `form_label_html` allowlist (`FORM_LABEL_TAGS`, plus the `open` attribute), so a disclosure is never stripped on save — the parser only upgrades its styling. Rendered through the shared `app/views/events/callouts/_rich_content.html.erb` partial (which wraps each disclosure in `_toggle.html.erb`), used by the CE hours, custom-callout, behavioural-card-intro, and FAQ pages. The FAQ page renders the editable `faq` callout `description` (each question a `
`); the default questions hydrate onto the row when it's materialized (from `BuiltinCallouts.faq_html`), so a blanked description shows blank with no render-time fallback. Content with no disclosure renders unchanged - `SampleTicketRegistration` — Builds the **unsaved, data-free** `EventRegistration` ("Sample Person") that the sample ticket and its admin-only callout-page previews render from; nothing is ever persisted, so the preview can't read from or write to a real registrant or leak into counts/revenue/rosters/reminders. `all_options:` mirrors the ticket's "Show all options" toggle (turns on scholarship/CE/W-9 so those cards and preview pages render). Shared by `EventsController#sample_ticket` and `Events::CalloutsController`'s sample mode (the `sample` param → admin-authed in-memory previews of the behavioral built-in pages, linked from the sample ticket via `EventHelper#sample_callout_path`) diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index 21ce9bcf05..7965aa884d 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -253,7 +253,8 @@ expect(event.registration_ticket_callouts.builtin.pluck(:builtin_key)).to contain_exactly( "payment", "certificate", "scholarship", "ce_hours", - "videoconference", "staff", "handouts", "faq" + "videoconference", "staff", "handouts", "faq", + "day_1_survey", "day_2_survey", "scholarship_recipients_survey" ) end end From 06a3b4d932fe8d284fb894d0343d74c304ce9a1e Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 9 Aug 2026 22:44:14 -0400 Subject: [PATCH 10/40] Regenerate schema.rb after rebase onto main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-dumped so the 3 post-event-survey migrations (callout form_id, post_survey_completed_at, form_field_resources) land on main's current schema. Drops my redundant add_anonymous_contributions migration — main added that column independently (default false, not null); the survey code writes to it. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...9195715_add_anonymous_contributions_to_people.rb | 13 ------------- spec/requests/events_spec.rb | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) delete mode 100644 db/migrate/20260809195715_add_anonymous_contributions_to_people.rb diff --git a/db/migrate/20260809195715_add_anonymous_contributions_to_people.rb b/db/migrate/20260809195715_add_anonymous_contributions_to_people.rb deleted file mode 100644 index 64e3e17d94..0000000000 --- a/db/migrate/20260809195715_add_anonymous_contributions_to_people.rb +++ /dev/null @@ -1,13 +0,0 @@ -class AddAnonymousContributionsToPeople < ActiveRecord::Migration[8.1] - # Profile preference set from a post-event survey question: keep all shared content - # anonymous. Stored now; enforcement across content display is a later change. Nullable - # so "not answered" (nil) stays distinct from an explicit choice. - def up - return if column_exists?(:people, :anonymous_contributions) - add_column :people, :anonymous_contributions, :boolean - end - - def down - remove_column :people, :anonymous_contributions, if_exists: true - end -end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 948b66f65c..5d811c335c 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -900,7 +900,7 @@ def add_ce_registrant(target_event) it "materializes the built-in callouts so the preview reads from real rows" do expect { get sample_ticket_event_path(event) } - .to change { event.registration_ticket_callouts.builtin.count }.from(0).to(8) + .to change { event.registration_ticket_callouts.builtin.count }.from(0).to(11) end it "logs an Ahoy page-view event" do From 3773a5a641c859bbcc30575b01db823d4449c00d Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 15 Aug 2026 11:40:06 -0400 Subject: [PATCH 11/40] Adjust survey profile-change spec for main's anonymous_contributions default Main added people.anonymous_contributions as non-null default false, so a survey that sets it to true records [false, true], not [nil, true]. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../event_registration_services/survey_submission_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/services/event_registration_services/survey_submission_spec.rb b/spec/services/event_registration_services/survey_submission_spec.rb index d14be0c83b..ec28afe677 100644 --- a/spec/services/event_registration_services/survey_submission_spec.rb +++ b/spec/services/event_registration_services/survey_submission_spec.rb @@ -73,7 +73,7 @@ def submit(field_params:, clarity_params:) expect(person.reload.anonymous_contributions).to be(true) expect(person.display_name_preference).to eq("first_name_only") expect(service.profile_changes).to include( - anonymous_contributions: [ nil, true ], + anonymous_contributions: [ false, true ], # main's column defaults to false, not nil display_name_preference: [ nil, "first_name_only" ] ) end From 050fbcf8fcf72e73f9ea7336e7f95ea1b11c68f0 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 16 Aug 2026 21:27:07 -0400 Subject: [PATCH 12/40] Trim survey comments to CLAUDE.md style Cut comments that restated the code and collapsed the rest to one or two lines, keeping only the non-obvious why (N+1 memoization, drip derivation, readiness gating, submission idempotency, MySQL integer-FK match). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../event_registrations_controller.rb | 3 +-- app/controllers/events/callouts_controller.rb | 14 +++-------- app/mailers/notification_mailer.rb | 4 +-- app/models/event.rb | 9 +++---- app/models/event_registration.rb | 6 ++--- app/models/form_field.rb | 5 +--- app/models/form_field_resource.rb | 8 +++--- app/models/person.rb | 5 ++-- app/services/builtin_callout_cards.rb | 7 ++---- app/services/builtin_callouts.rb | 9 +++---- app/services/event_registration_readiness.rb | 15 +++++------ .../survey_submission.rb | 25 +++++++------------ app/views/event_registrations/edit.html.erb | 3 +-- app/views/events/callouts/survey.html.erb | 1 - ...dd_form_to_registration_ticket_callouts.rb | 3 +-- ...vey_completed_at_to_event_registrations.rb | 3 +-- ...60809195716_create_form_field_resources.rb | 6 ++--- 17 files changed, 43 insertions(+), 83 deletions(-) diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index a3aef175f7..319b1a3ea1 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -323,8 +323,7 @@ def revert_transfer status: :see_other end - # Admin toggle for whether the post-event (scholarship recipients) survey is in. - # Independent of the certificate: clears/sets only its own timestamp. + # 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? diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 761b5399ae..de0f3e6e7a 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -342,18 +342,14 @@ def submit_callout notice: "Thanks! Your responses have been submitted." end - # Maps each survey built-in to the FormSubmission role it records under. The - # scholarship recipients survey is the one that gates readiness, tagged - # "post_event_survey". + # builtin_key => the FormSubmission role. The recipients survey is tagged + # "post_event_survey" — the one that gates readiness. SURVEY_ROLES = { "day_1_survey" => "day_1_survey", "day_2_survey" => "day_2_survey", "scholarship_recipients_survey" => "post_event_survey" }.freeze - # The inline survey page for a survey callout: renders the drip notice before - # the drip date, the form to fill, or the submitted answers (read-only) with an - # edit affordance. def survey @callout = survey_callout return redirect_to registration_ticket_path(@event_registration.slug) unless @callout @@ -455,8 +451,7 @@ def sign_out_notice(entry) "Signed out for #{entry.attendance_date.strftime("%a, %b %-d")} at #{time}." end - # The published survey callout named by :builtin_key, once it actually carries a - # form. Nil (→ back to the ticket) for anything else. + # The published survey callout for :builtin_key once it carries a form; nil otherwise. def survey_callout callout = @event.registration_ticket_callouts.find_by(builtin_key: params[:builtin_key]) return unless callout && callout.form && !callout.hidden? && SURVEY_ROLES.key?(callout.builtin_key) @@ -476,8 +471,7 @@ def survey_clarity_params params.dig(:survey, :clarity)&.to_unsafe_h || {} end - # One Ahoy event per profile field the survey actually changed (anonymity / name - # display), so the change is auditable. + # One Ahoy event per profile field the survey actually changed. def track_survey_profile_changes(changes) changes.each do |attribute, (from, to)| ahoy.track("profile.#{attribute}", person_id: @event_registration.registrant_id, from: from, to: to) diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index b6db8cce4b..89c9bd6fcf 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -16,9 +16,7 @@ def event_registration_confirmation_fyi(notification) ) end - # Staff heads-up that a registrant submitted a post-event survey. Takes the - # FormSubmission directly (no Notification record) and goes to the default system - # address. + # Takes the FormSubmission directly (not a Notification record, unlike the others). def survey_submitted_fyi(form_submission) @form_submission = form_submission @person = form_submission.person diff --git a/app/models/event.rb b/app/models/event.rb index c0b5e42d11..a9b077aeb3 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -240,17 +240,16 @@ def videoconference_details_visible?(now = Time.current) from.blank? || now >= from end - # The scholarship recipients survey callout, if seeded. Memoized so readiness - # (which runs per registration) hits the callouts once per event, not per row. + # Memoized so readiness (which runs per registration) hits the callouts once per + # event, not per row. def scholarship_recipients_survey_callout return @scholarship_recipients_survey_callout if defined?(@scholarship_recipients_survey_callout) @scholarship_recipients_survey_callout = registration_ticket_callouts.detect { |callout| callout.builtin_key == "scholarship_recipients_survey" } end - # Whether the scholarship recipients survey is live — published and past its drip. - # Only then does an unsubmitted survey count against a recipient's completion; an - # unpublished (or not-yet-dripped) survey blocks no one. + # Published + past drip. Only then does an unsubmitted survey gate a recipient's + # completion. def post_event_survey_open?(now = Time.current) callout = scholarship_recipients_survey_callout return false unless callout && !callout.hidden? diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 2beab0170c..8dc6000098 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -742,10 +742,8 @@ def attended? status == "attended" end - # The post-event (scholarship recipients) survey is "in" once this timestamp is - # set — by the registrant submitting it or an admin toggling it. Mirrors the - # Certifiable certificate_sent_at pattern so the roster's readiness reads a plain - # column with no extra query. + # 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 diff --git a/app/models/form_field.rb b/app/models/form_field.rb index 9f2d6ed9f6..6055c94845 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -7,8 +7,7 @@ class FormField < ApplicationRecord has_many :form_answers, dependent: :nullify has_many :childs, foreign_key: "parent_id", class_name: "FormField" - # A field can fan out over resources: with any linked here it becomes a - # "per-resource" question rendering one input per resource (see FormFieldResource). + # 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 @@ -197,8 +196,6 @@ def selectable? answer_type.in?(SELECTABLE_ANSWER_TYPES) end - # True when this field fans out over linked resources — rendered once per resource - # on the survey page, with the resource's title appended to the prompt. def per_resource? form_field_resources.any? end diff --git a/app/models/form_field_resource.rb b/app/models/form_field_resource.rb index 038f89d24e..d686bd2f14 100644 --- a/app/models/form_field_resource.rb +++ b/app/models/form_field_resource.rb @@ -1,9 +1,7 @@ class FormFieldResource < ApplicationRecord - # Ordered join between a form field and the resources it fans out over. A field - # with any of these is a "per-resource" question: on the survey page it renders - # one input per linked resource (e.g. the post-event survey clarity question, - # asked once per training topic/handout). The field owns the prompt wording and - # answer options; each resource just supplies the item the prompt is asked about. + # 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 diff --git a/app/models/person.rb b/app/models/person.rb index 208506efea..da2d61eba3 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -356,9 +356,8 @@ def sector_list sectors.pluck(:name) end - # anonymous_contributions boolean => the survey consent answer label. "Keep - # anonymous" is the true branch; naming their profile is false. (The name-format - # labels live in DISPLAY_NAME_PREFERENCE_LABELS above.) + # 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" diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index 5ac1203ed2..cddf4f5528 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -126,8 +126,7 @@ def initialize(event_registration, preview: false) # event has materialized into editable rows are omitted here — the ticket renders # those from the row (calling #card_for for behavioral ones), so this is both the # non-materialized set and the fallback for events not yet seeded. - # Survey cards have no config-driven legacy default — they only ever render from a - # materialized (seeded) row via #card_for, never from this fallback. + # Survey cards only render from a seeded row via #card_for, never from this fallback. FALLBACK_EXCLUDED_KEYS = %w[ day_1_survey day_2_survey scholarship_recipients_survey ].freeze def cards @@ -463,9 +462,7 @@ def videoconference_card target: nil, trailing_icon: "fa-solid fa-arrow-right") end - # Post-event survey cards link to the inline survey page (which itself shows the - # drip notice before the survey opens, then the form). The day cards show for - # everyone; the scholarship recipients card only for recipients. + # The day cards show for everyone; the recipients card only for scholarship recipients. def day_1_survey_card survey_card("day_1_survey") end diff --git a/app/services/builtin_callouts.rb b/app/services/builtin_callouts.rb index 6c88067662..863dea4e43 100644 --- a/app/services/builtin_callouts.rb +++ b/app/services/builtin_callouts.rb @@ -186,15 +186,13 @@ def resolve(value) value.respond_to?(:call) ? value.call(@event) : value end - # Whether a definition applies to this event. `seed_if` gates cards that only make - # sense for some events (e.g. the Day 2 survey on multi-day trainings). + # `seed_if` gates cards that only apply to some events (e.g. Day 2 survey on multi-day trainings). def applicable?(definition) definition[:seed_if].nil? || definition[:seed_if].call(@event) end - # A post-event day-N survey opens 30 minutes before that day's end time. Day N's - # date is the start date plus (N - 1) days; the time-of-day comes from the event's - # end_date (used as the daily end time for every day). Nil when dates are unset. + # 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) @@ -313,7 +311,6 @@ def definitions color_class: "indigo", hidden: ->(_event) { true }, form: ->(_event) { Form.standalone.find_by(name: "Day 1 Survey") }, - # Opens 30 min before day 1's end time; admins can edit per event. display_from: ->(event) { survey_drip(event, 1) } }, { diff --git a/app/services/event_registration_readiness.rb b/app/services/event_registration_readiness.rb index 35ab937f01..1115ea2cf3 100644 --- a/app/services/event_registration_readiness.rb +++ b/app/services/event_registration_readiness.rb @@ -52,9 +52,8 @@ def status :ready end - # A scholarship recipient who has finished the other post-event work but still owes - # the (now-live) post-event survey. Sits between "ready" and "certificate pending": - # the survey is the one thing keeping them from the certificate queue. + # 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 @@ -128,9 +127,8 @@ def completion_issues completion_work_issues + survey_issues + certificate_issues end - # The post-event (scholarship recipients) survey, when a recipient still owes a - # live one. Gates certifiable?/completed? so the certificate can't close out until - # the survey is in. + # 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 @@ -193,9 +191,8 @@ def scholarship_tasks_incomplete? registration.scholarship? && !registration.scholarship_tasks_met? end - # Only scholarship recipients owe the post-event survey, and only once it's live - # (published + past drip). Reads a plain column plus the event's memoized survey - # callout, so it adds no per-row query on the roster. + # Recipients only, once the survey is live. Reads a plain column + the event's + # memoized callout, so no per-row roster query. def survey_outstanding? registration.scholarship? && registration.event.post_event_survey_open? && diff --git a/app/services/event_registration_services/survey_submission.rb b/app/services/event_registration_services/survey_submission.rb index e757bed743..c245d9b240 100644 --- a/app/services/event_registration_services/survey_submission.rb +++ b/app/services/event_registration_services/survey_submission.rb @@ -1,16 +1,10 @@ module EventRegistrationServices - # Records a post-event survey delivered inline on a registrant's ticket. Persists a - # role-tagged FormSubmission with: - # - static answers (one per ordinary field), and - # - dynamic "clarity" answers (a per-resource question fans out to one answer per - # linked resource, its full rendered sentence snapshotted in - # question_name_when_answered with a nil form_field). - # Two fields also write through to the Person profile (anonymity + name display); - # #profile_changes reports what actually changed so the caller can Ahoy-track it. - # Stamps post_survey_completed_at for a scholarship recipient's recipients survey. - # - # Idempotent on re-submit (edit): find-or-initialize keeps one answer per field, and - # per (nil field, snapshotted question) for the dynamic ones. + # Records a survey as a role-tagged FormSubmission: static answers plus dynamic + # "clarity" answers (a per-resource field fans out to one nil-form_field answer per + # resource, its sentence snapshotted in question_name_when_answered). The anonymity + # and name-display questions also write through to the Person (#profile_changes + # reports real changes for Ahoy). Stamps post_survey_completed_at for a recipient's + # recipients survey. Idempotent on re-submit. class SurveySubmission attr_reader :submission, :profile_changes @@ -56,8 +50,8 @@ def save_static_answers end end - # Each per-resource field fans out: one answer per linked resource, keyed by the - # snapshotted sentence so re-submits update in place (form_field stays nil). + # 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) @@ -72,8 +66,7 @@ def save_clarity_answers end end - # Route the two identified questions to the Person profile, recording only the - # values that actually change so the caller can Ahoy-track a real edit. + # Write the two identified questions to the Person, recording only real changes. def sync_profile(person) apply_profile_change(person, :anonymous_contributions, Person::ANONYMOUS_CONTRIBUTIONS_OPTIONS.invert[value_for("anonymous_contributions")]) diff --git a/app/views/event_registrations/edit.html.erb b/app/views/event_registrations/edit.html.erb index 1483f2b192..b709ec97fc 100644 --- a/app/views/event_registrations/edit.html.erb +++ b/app/views/event_registrations/edit.html.erb @@ -73,8 +73,7 @@ <%= render "form", event_registration: @event_registration %> - <%# Admin completion controls. The certificate is issued by sending its email; the - post-event survey is marked here (independently) when a recipient's survey is in. %> + <%# 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), diff --git a/app/views/events/callouts/survey.html.erb b/app/views/events/callouts/survey.html.erb index d2b705b8b9..0f13d8b316 100644 --- a/app/views/events/callouts/survey.html.erb +++ b/app/views/events/callouts/survey.html.erb @@ -14,7 +14,6 @@
<% elsif @submission && !@editing %> - <%# Submitted: show the answers read-only with an edit affordance. %>
Thanks — your responses are recorded.
diff --git a/db/migrate/20260809195713_add_form_to_registration_ticket_callouts.rb b/db/migrate/20260809195713_add_form_to_registration_ticket_callouts.rb index c28a1c65aa..9ce377ce6d 100644 --- a/db/migrate/20260809195713_add_form_to_registration_ticket_callouts.rb +++ b/db/migrate/20260809195713_add_form_to_registration_ticket_callouts.rb @@ -1,6 +1,5 @@ class AddFormToRegistrationTicketCallouts < ActiveRecord::Migration[8.1] - # The survey form a callout delivers inline (post-event survey callouts). Nullable — - # ordinary callouts have no form. Integer FK to match the forms table's integer PK. + # Integer FK to match the forms table's integer PK (MySQL requires the exact type). def up return if column_exists?(:registration_ticket_callouts, :form_id) add_reference :registration_ticket_callouts, :form, type: :integer, foreign_key: true, null: true diff --git a/db/migrate/20260809195714_add_post_survey_completed_at_to_event_registrations.rb b/db/migrate/20260809195714_add_post_survey_completed_at_to_event_registrations.rb index e3d88310b3..aed77283e7 100644 --- a/db/migrate/20260809195714_add_post_survey_completed_at_to_event_registrations.rb +++ b/db/migrate/20260809195714_add_post_survey_completed_at_to_event_registrations.rb @@ -1,6 +1,5 @@ class AddPostSurveyCompletedAtToEventRegistrations < ActiveRecord::Migration[8.1] - # Set when a scholarship recipient completes their post-event (recipients) survey. The - # query-free completion cache the registrants readiness Status column reads, mirroring + # The query-free completion cache the registrants readiness Status reads, mirroring # certificate_sent_at. def up return if column_exists?(:event_registrations, :post_survey_completed_at) diff --git a/db/migrate/20260809195716_create_form_field_resources.rb b/db/migrate/20260809195716_create_form_field_resources.rb index f192108892..09f36330d9 100644 --- a/db/migrate/20260809195716_create_form_field_resources.rb +++ b/db/migrate/20260809195716_create_form_field_resources.rb @@ -1,8 +1,6 @@ class CreateFormFieldResources < ActiveRecord::Migration[8.1] - # Direct FormField -> Resource link. A form field with associated resources is a - # "per-resource" question that renders one input per resource (the post-event survey - # clarity question, one input per training topic/handout). Integer FKs match the - # integer PKs on form_fields and resources. + # Drives per-resource questions (see FormFieldResource). Integer FKs match the + # integer PKs on form_fields and resources (MySQL requires the exact type). def up return if table_exists?(:form_field_resources) create_table :form_field_resources do |t| From d2b3ec39df581015fa1891064083b68309a9ba89 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 17 Aug 2026 09:36:36 -0400 Subject: [PATCH 13/40] Fix CI: document seeded survey identifiers + assign @survey_forms in event view specs The drift guard requires every form-builder-seeded identifier to be documented; the survey questions are answer-only (stored on the submission, no Person/Org/Stripe wiring). The event form view specs render the callout editor, which reads @survey_forms. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/form_builder_service.rb | 2 +- app/services/smart_form_fields.rb | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb index a886e2f73f..dce4e84a05 100644 --- a/app/services/form_builder_service.rb +++ b/app/services/form_builder_service.rb @@ -88,7 +88,7 @@ def call ], recipient_survey: %w[impact insights more_valuable facilitate_likelihood anything_else], content_sharing_preferences: %w[anonymous_contributions display_name_preference], - bulk_payment: %w[payer_first_name payer_last_name payer_email payer_phone payer_organization number_of_attendees payment_method bulk_payment_attendees] + bulk_payment: %w[first_name last_name primary_email phone organization_name number_of_attendees payment_method bulk_payment_attendees] }.freeze # Header questions created by each section's builder method diff --git a/app/services/smart_form_fields.rb b/app/services/smart_form_fields.rb index fb2615287b..79bb42cffb 100644 --- a/app/services/smart_form_fields.rb +++ b/app/services/smart_form_fields.rb @@ -184,6 +184,17 @@ class SmartFormFields scholarship_eligibility scholarship_contribution impact_description implementation_plan additional_comments event_rating most_valuable improvement_suggestions + d1_clarity_part_one d1_clarity_part_one_detail d1_clarity_part_two d1_clarity_part_two_detail + d1_touchstone_personal d1_touchstone_professional d1_safer_braver_personal d1_safer_braver_professional + d1_take_a_break_personal d1_take_a_break_professional d1_breakout_rooms d1_grounding + d1_prepared_facilitate d1_prepared_trauma_informed d1_review_reflect + d1_improvements d1_enjoyed d1_recommend d1_comments + d2_clarity_part_one d2_clarity_part_one_detail d2_clarity_part_two d2_clarity_part_two_detail + d2_monster_personal d2_monster_professional d2_claiming_personal d2_claiming_professional + d2_breakout_rooms d2_intersectionality d2_questions_challenges d2_review_reflect d2_warmup_importance + d2_improvements d2_enjoyed d2_stay_in_touch d2_support_needs d2_recommend d2_comments + impact insights more_valuable facilitate_likelihood anything_else + anonymous_contributions display_name_preference ].freeze def self.groups From 341879166f3aed555ca231dc783edd0c3c6d0ab8 Mon Sep 17 00:00:00 2001 From: maebeale Date: Thu, 20 Aug 2026 11:47:53 -0400 Subject: [PATCH 14/40] Repoint survey code onto main's author-credit constants after rebase Main's #2093 landed display_name_preference + DISPLAY_NAME_PREFERENCE_LABELS on Person; the survey branch had built a colliding DISPLAY_NAME_PREFERENCES hash. Drop the duplicate and read main's label hash instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/event_registration_services/survey_submission.rb | 2 +- app/services/form_builder_service.rb | 2 +- .../event_registration_services/survey_submission_spec.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/services/event_registration_services/survey_submission.rb b/app/services/event_registration_services/survey_submission.rb index c245d9b240..4f6ccd4f7a 100644 --- a/app/services/event_registration_services/survey_submission.rb +++ b/app/services/event_registration_services/survey_submission.rb @@ -71,7 +71,7 @@ def sync_profile(person) apply_profile_change(person, :anonymous_contributions, Person::ANONYMOUS_CONTRIBUTIONS_OPTIONS.invert[value_for("anonymous_contributions")]) apply_profile_change(person, :display_name_preference, - Person::DISPLAY_NAME_PREFERENCES.invert[value_for("display_name_preference")]) + Person::DISPLAY_NAME_PREFERENCE_LABELS.invert[value_for("display_name_preference")]) person.save! if person.changed? end diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb index dce4e84a05..e8b3bd39ce 100644 --- a/app/services/form_builder_service.rb +++ b/app/services/form_builder_service.rb @@ -819,7 +819,7 @@ def build_content_sharing_preferences_fields(form, position) options: Person::ANONYMOUS_CONTRIBUTIONS_OPTIONS.values) position = add_field(form, position, "Display my name as…", :single_select_radio, key: "display_name_preference", group: "content_sharing", - options: Person::DISPLAY_NAME_PREFERENCES.values) + options: Person::DISPLAY_NAME_PREFERENCE_LABELS.values) position end diff --git a/spec/services/event_registration_services/survey_submission_spec.rb b/spec/services/event_registration_services/survey_submission_spec.rb index ec28afe677..9ca9c37733 100644 --- a/spec/services/event_registration_services/survey_submission_spec.rb +++ b/spec/services/event_registration_services/survey_submission_spec.rb @@ -42,7 +42,7 @@ def submit(field_params:, clarity_params:) { static_field.id.to_s => "It changed me", anon_field.id.to_s => Person::ANONYMOUS_CONTRIBUTIONS_OPTIONS[true], - name_field.id.to_s => Person::DISPLAY_NAME_PREFERENCES["first_name_only"] + name_field.id.to_s => Person::DISPLAY_NAME_PREFERENCE_LABELS["first_name_only"] } end let(:clarity_params) do From 97a6927aea8d3196214f45fe0b6be5e61f5ce619 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 30 Aug 2026 16:19:23 -0400 Subject: [PATCH 15/40] Drop duplicate AddFormToRegistrationTicketCallouts migration origin/main independently shipped an identical migration (20260827000423), so the branch's copy collides on class name. The callout form_id column is main's now. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...95713_add_form_to_registration_ticket_callouts.rb | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 db/migrate/20260809195713_add_form_to_registration_ticket_callouts.rb diff --git a/db/migrate/20260809195713_add_form_to_registration_ticket_callouts.rb b/db/migrate/20260809195713_add_form_to_registration_ticket_callouts.rb deleted file mode 100644 index 9ce377ce6d..0000000000 --- a/db/migrate/20260809195713_add_form_to_registration_ticket_callouts.rb +++ /dev/null @@ -1,12 +0,0 @@ -class AddFormToRegistrationTicketCallouts < ActiveRecord::Migration[8.1] - # Integer FK to match the forms table's integer PK (MySQL requires the exact type). - def up - return if column_exists?(:registration_ticket_callouts, :form_id) - add_reference :registration_ticket_callouts, :form, type: :integer, foreign_key: true, null: true - end - - def down - return unless column_exists?(:registration_ticket_callouts, :form_id) - remove_reference :registration_ticket_callouts, :form, foreign_key: true - end -end From 6fb9d56d7b048cce3f7fbe09b513aa67a16acd93 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 30 Aug 2026 16:20:25 -0400 Subject: [PATCH 16/40] Regenerate schema.rb after re-rebase onto origin/main Picks up main's created_by/updated_by columns plus the branch's survey tables (form_field_resources, post_survey_completed_at). Co-Authored-By: Claude Opus 4.8 (1M context) --- db/schema.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/db/schema.rb b/db/schema.rb index 56f271e6b1..282a493f9f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -572,6 +572,7 @@ t.boolean "intends_to_pay", default: false, null: false t.boolean "invoice_requested", default: false, null: false t.boolean "payment_unresolved" + t.datetime "post_survey_completed_at" t.bigint "registrant_id", null: false t.boolean "scholarship_requested", default: false, null: false t.boolean "shoutout", default: false, null: false @@ -922,6 +923,17 @@ t.index ["updated_by_id"], name: "index_form_field_answer_options_on_updated_by_id" end + create_table "form_field_resources", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "form_field_id", null: false + t.integer "position" + t.integer "resource_id", null: false + t.datetime "updated_at", null: false + t.index ["form_field_id", "resource_id"], name: "index_form_field_resources_on_field_and_resource", unique: true + t.index ["form_field_id"], name: "index_form_field_resources_on_form_field_id" + t.index ["resource_id"], name: "index_form_field_resources_on_resource_id" + end + create_table "form_fields", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.integer "answer_type" t.datetime "created_at", precision: nil, null: false @@ -2356,6 +2368,8 @@ add_foreign_key "form_field_answer_options", "form_fields" add_foreign_key "form_field_answer_options", "users", column: "created_by_id" add_foreign_key "form_field_answer_options", "users", column: "updated_by_id" + add_foreign_key "form_field_resources", "form_fields" + add_foreign_key "form_field_resources", "resources" add_foreign_key "form_fields", "forms" add_foreign_key "form_fields", "users", column: "created_by_id" add_foreign_key "form_fields", "users", column: "updated_by_id" From ded7e048456b4933c0aae7abb9f66f9ecc3917ba Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 30 Aug 2026 17:34:56 -0400 Subject: [PATCH 17/40] Deliver post-event surveys as date-gated forms embedded in callouts Collapse the bespoke survey delivery onto main's generic callout-embedded form mechanism, generalized so a callout delivers MANY forms instead of one. - New RegistrationTicketCalloutForm join: a callout has_many forms, each row with its own display_from drip date. Replaces the single form_id column (migrated in). - On the ticket, each linked form renders per its own date: gated (an "Available " chip), open/fillable, or completed (a collapsed "Completed" toggle that expands to the answers + Edit). The editor's "Linked form" becomes cocoon "Linked forms" rows (form + date). - One "Post-event survey" built-in drips the Day 1, Day 2 (multi-day), and recipients survey forms, replacing the three old survey built-ins. - Survey side effects fold onto CalloutFormSubmission, gated by the form's survey role: per-resource clarity fan-out, profile write-through + Ahoy, staff FYI email, and the readiness completion stamp. SurveySubmission and the /survey routes/view are removed; delivery rides the generic /forms/:callout_id/:form_id path. - Roster shows one completion column per (callout, form). Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 10 +- app/controllers/events/callouts_controller.rb | 100 +++++------------ app/models/event.rb | 22 ++-- app/models/form.rb | 11 ++ app/models/registration_ticket_callout.rb | 20 +++- .../registration_ticket_callout_form.rb | 23 ++++ app/policies/event_policy.rb | 3 +- app/services/builtin_callout_cards.rb | 31 +----- app/services/builtin_callouts.rb | 78 +++++++------ .../callout_form_submission.rb | 105 ++++++++++++++---- .../survey_submission.rb | 98 ---------------- app/services/registrant_ce_form.rb | 7 +- .../events/_registrants_results.html.erb | 20 ++-- ...egistration_ticket_callout_fields.html.erb | 31 ++++-- ...ration_ticket_callout_form_fields.html.erb | 15 +++ .../events/callouts/_callout_form.html.erb | 69 ++++++++++++ .../events/callouts/_clarity_field.html.erb | 20 ++++ app/views/events/callouts/callout.html.erb | 59 ++-------- app/views/events/callouts/survey.html.erb | 94 ---------------- .../show.html.erb | 2 +- config/features.yml | 14 +++ config/routes.rb | 4 +- ...reate_registration_ticket_callout_forms.rb | 48 ++++++++ db/schema.rb | 20 +++- .../registration_ticket_callout_forms.rb | 7 ++ .../factories/registration_ticket_callouts.rb | 8 +- .../continuing_education_registration_spec.rb | 2 +- .../registration_ticket_callout_spec.rb | 26 +++-- spec/requests/events/ce_form_spec.rb | 2 +- .../registration_ticket_callouts_spec.rb | 2 +- spec/requests/events_spec.rb | 15 +-- spec/requests/registration_survey_spec.rb | 50 --------- .../registration_ticket_callouts_spec.rb | 73 +++++++++++- spec/services/builtin_callouts_spec.rb | 62 +++++------ ...vent_registration_readiness_survey_spec.rb | 9 +- .../callout_form_submission_spec.rb | 96 ++++++++++++++-- .../survey_submission_spec.rb | 96 ---------------- spec/services/registrant_ce_form_spec.rb | 6 +- spec/views/page_bg_class_alignment_spec.rb | 1 - 39 files changed, 697 insertions(+), 662 deletions(-) create mode 100644 app/models/registration_ticket_callout_form.rb delete mode 100644 app/services/event_registration_services/survey_submission.rb create mode 100644 app/views/events/_registration_ticket_callout_form_fields.html.erb create mode 100644 app/views/events/callouts/_callout_form.html.erb create mode 100644 app/views/events/callouts/_clarity_field.html.erb delete mode 100644 app/views/events/callouts/survey.html.erb create mode 100644 db/migrate/20260830203713_create_registration_ticket_callout_forms.rb create mode 100644 spec/factories/registration_ticket_callout_forms.rb delete mode 100644 spec/requests/registration_survey_spec.rb delete mode 100644 spec/services/event_registration_services/survey_submission_spec.rb diff --git a/AGENTS.md b/AGENTS.md index c66e5aa7f7..f00e548ccf 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 (`#dripping?`). 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,17 +253,16 @@ 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. When the form is a survey (`Form#survey?`, roles in `Form::SURVEY_ROLES`) it also: fans a per-resource "clarity" field out to one answer per `FormFieldResource` (nil `form_field`, full sentence snapshotted in `question_name_when_answered`); writes the anonymity + name-display questions through to the `Person` (`anonymous_contributions`, `display_name_preference`), exposing `#profile_changes` so the controller Ahoy-tracks real edits; and stamps `post_survey_completed_at` for a scholarship recipient's `post_event_survey`-role form. Drives `Events::CalloutsController#callout`/`#submit_callout` (the person-first `registration/:slug/forms/:callout_id` page and its `.../:form_id` submit) - `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 - `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 -- `EventRegistrationServices::SurveySubmission` — Records a post-event survey (delivered inline on the ticket via a survey callout) as a role-tagged `FormSubmission`: static answers plus per-resource "clarity" answers (one per `FormFieldResource`, nil `form_field`, full sentence snapshotted in `question_name_when_answered`). Writes the anonymity + name-display questions through to the `Person` profile (`anonymous_contributions`, `display_name_preference`), exposing `#profile_changes` so the controller Ahoy-tracks real edits, and stamps `post_survey_completed_at` for a scholarship recipient's recipients survey - `EventRegistrationReadiness` — Computes a registration's lifecycle `status` (`:not_ready` → `:ready` → `:survey_pending` → `:certificate_due` → `:completed`); `:survey_pending` is a scholarship recipient whose live (published + past-drip) post-event recipients survey is still unsubmitted (`post_survey_completed_at` blank), which gates the certificate. Status is computed from a pre-event "event ready" checklist, a post-event "completion work" checklist (attendance, scholarship tasks, the recipients survey), and certificate delivery, returning the specific outstanding reasons. Reads payment/certificate state via `Registerable` (`paid_in_full?`, `certificate_sent?`) on both the registration and its `continuing_education_registrations`. Drives the registrants roster's single far-right Status badge column (with a short reason under "Not ready" and a cert-type note under "Certificate pending") and its matching filter - `ReminderRecipientFilter` — Decides which event registrations stay checked on the bulk reminder page given the admin's filters (matches in memory, returns matching ids) - `BuiltinCalloutCards` — Renders the live, per-registration ticket callout cards (payment, certificate, scholarship, CE hours, videoconference), overlaying dynamic status (badge, colour, visibility guard, destination) on each materialized built-in row via `#card_for`. Rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `BuiltinCallouts`) so the two paths never double-render, and `#cards` serves as the fallback for events not yet seeded; `.editor_cards` builds the editor's preview cards. Handouts and FAQ are pure content cards with no builder here — they render from their row. Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized) -- `BuiltinCallouts` — Owns the built-in callout definitions and materializes them into `RegistrationTicketCallout` rows in canonical ticket order: `seed` persists (on create, and lazily on edit so older events heal with no backfill), `build` makes the same rows in memory for the new-event form (with `builtin_key` round-tripped through nested attributes), `reset`/`customized?` back the "Restore default" control. All seed **hidden** by default — admins publish the ones they want; there's no config-based auto-publish. The set includes three post-event survey built-ins (`day_1_survey`, `day_2_survey`, `scholarship_recipients_survey`), each carrying a `form_id` (its inline survey form, picked in the callout editor) and a relevant default drip; a `seed_if` gate keeps `day_2_survey` off single-day events. Their live ticket cards (and the recipients-only guard) live in `BuiltinCalloutCards`; the inline page is `Events::CalloutsController#survey`. Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Handouts, FAQ) render their own copy/resources on the generic callout page; "behavioral" cards render live status through `BuiltinCalloutCards#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Videoconference drips a week before start via `display_from`. CE hours is edited like every other built-in — its title/text live entirely on the row (the legacy `event_details*`/`ce_hours_details*` event columns were dropped); the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE page reads the row's title/description. Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`) +- `BuiltinCallouts` — Owns the built-in callout definitions and materializes them into `RegistrationTicketCallout` rows in canonical ticket order: `seed` persists (on create, and lazily on edit so older events heal with no backfill), `build` makes the same rows in memory for the new-event form (with `builtin_key` round-tripped through nested attributes), `reset`/`customized?` back the "Restore default" control. All seed **hidden** by default — admins publish the ones they want; there's no config-based auto-publish. The set includes one **`post_event_survey`** content built-in that links several survey forms through `RegistrationTicketCalloutForm` (a definition's `forms:` proc returns `[{ form:, display_from: }]`): the Day 1 evaluation, the Day 2 evaluation (multi-day events only), and the recipients survey (whose `post_event_survey`-role submission gates readiness), each dripping on its own date. It delivers through the generic callout→form page (`Events::CalloutsController#callout`), not a bespoke survey page. Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Handouts, FAQ) render their own copy/resources on the generic callout page; "behavioral" cards render live status through `BuiltinCalloutCards#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Videoconference drips a week before start via `display_from`. CE hours is edited like every other built-in — its title/text live entirely on the row (the legacy `event_details*`/`ce_hours_details*` event columns were dropped); the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE page reads the row's title/description. Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`) - `CalloutContent` — Parses admin-authored callout HTML into ordered segments so **every** callout content page renders the same way: plain rich text, with each standard `
` disclosure (the markup any HTML generator/LLM produces; `` and a `title` attribute are accepted aliases; `
` starts expanded) rebuilt into a styled collapsible card. `
`/`` are also on the `form_label_html` allowlist (`FORM_LABEL_TAGS`, plus the `open` attribute), so a disclosure is never stripped on save — the parser only upgrades its styling. Rendered through the shared `app/views/events/callouts/_rich_content.html.erb` partial (which wraps each disclosure in `_toggle.html.erb`), used by the CE hours, custom-callout, behavioural-card-intro, and FAQ pages. The FAQ page renders the editable `faq` callout `description` (each question a `
`); the default questions hydrate onto the row when it's materialized (from `BuiltinCallouts.faq_html`), so a blanked description shows blank with no render-time fallback. Content with no disclosure renders unchanged - `SampleTicketRegistration` — Builds the **unsaved, data-free** `EventRegistration` ("Sample Person") that the sample ticket and its admin-only callout-page previews render from; nothing is ever persisted, so the preview can't read from or write to a real registrant or leak into counts/revenue/rosters/reminders. `all_options:` mirrors the ticket's "Show all options" toggle (turns on scholarship/CE/W-9 so those cards and preview pages render). Shared by `EventsController#sample_ticket` and `Events::CalloutsController`'s sample mode (the `sample` param → admin-authed in-memory previews of the behavioral built-in pages, linked from the sample ticket via `EventHelper#sample_callout_path`) diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index de0f3e6e7a..e38c6f82aa 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -309,8 +309,9 @@ 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? @@ -318,64 +319,31 @@ def callout # 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) - @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? + callout_form = @callout.registration_ticket_callout_forms.find_by(form_id: params[:form_id]) + if @callout.hidden? || callout_form.nil? || callout_form.dripping? redirect_to registration_callout_form_path(@event_registration.slug, @callout) 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 - ) - - redirect_to registration_callout_form_path(@event_registration.slug, @callout), - notice: "Thanks! Your responses have been submitted." - end - - # builtin_key => the FormSubmission role. The recipients survey is tagged - # "post_event_survey" — the one that gates readiness. - SURVEY_ROLES = { - "day_1_survey" => "day_1_survey", - "day_2_survey" => "day_2_survey", - "scholarship_recipients_survey" => "post_event_survey" - }.freeze - - def survey - @callout = survey_callout - return redirect_to registration_ticket_path(@event_registration.slug) unless @callout - - @form = @callout.form - @role = SURVEY_ROLES.fetch(@callout.builtin_key) - @dripping = @callout.dripping? - @submission = survey_submission - @editing = @submission.nil? || params[:edit].present? - end - - def submit_survey - @callout = survey_callout - return redirect_to registration_ticket_path(@event_registration.slug) if @callout.nil? || @callout.dripping? - - service = EventRegistrationServices::SurveySubmission.call( - event_registration: @event_registration, - form: @callout.form, - role: SURVEY_ROLES.fetch(@callout.builtin_key), - field_params: survey_field_params, - clarity_params: survey_clarity_params + service = EventRegistrationServices::CalloutFormSubmission.call( + registration: @event_registration, callout: @callout, form: callout_form.form, + form_params: callout_form_params, clarity_params: callout_clarity_params ) - track_survey_profile_changes(service.profile_changes) - NotificationMailer.survey_submitted_fyi(service.submission).deliver_later + if callout_form.form.survey? + track_profile_changes(service.profile_changes) + NotificationMailer.survey_submitted_fyi(service.submission).deliver_later + end - redirect_to registration_survey_path(@event_registration.slug, @callout.builtin_key), + redirect_to registration_callout_form_path(@event_registration.slug, @callout, anchor: "form-#{callout_form.form_id}"), notice: "Thanks! Your responses have been submitted." end @@ -398,9 +366,12 @@ 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 # The CE callout's form is a post-training step gated on sign-out completion — @@ -416,6 +387,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 @@ -451,28 +427,8 @@ def sign_out_notice(entry) "Signed out for #{entry.attendance_date.strftime("%a, %b %-d")} at #{time}." end - # The published survey callout for :builtin_key once it carries a form; nil otherwise. - def survey_callout - callout = @event.registration_ticket_callouts.find_by(builtin_key: params[:builtin_key]) - return unless callout && callout.form && !callout.hidden? && SURVEY_ROLES.key?(callout.builtin_key) - callout - end - - def survey_submission - FormSubmission.find_by(person: @event_registration.registrant, form: @callout.form, - event: @event, role: SURVEY_ROLES.fetch(@callout.builtin_key)) - end - - def survey_field_params - params.dig(:survey, :fields)&.to_unsafe_h || {} - end - - def survey_clarity_params - params.dig(:survey, :clarity)&.to_unsafe_h || {} - end - # One Ahoy event per profile field the survey actually changed. - def track_survey_profile_changes(changes) + 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 diff --git a/app/models/event.rb b/app/models/event.rb index a9b077aeb3..8c2017b512 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -242,18 +242,26 @@ def videoconference_details_visible?(now = Time.current) # Memoized so readiness (which runs per registration) hits the callouts once per # event, not per row. - def scholarship_recipients_survey_callout - return @scholarship_recipients_survey_callout if defined?(@scholarship_recipients_survey_callout) - @scholarship_recipients_survey_callout = - registration_ticket_callouts.detect { |callout| callout.builtin_key == "scholarship_recipients_survey" } + def post_event_survey_callout + return @post_event_survey_callout if defined?(@post_event_survey_callout) + @post_event_survey_callout = + registration_ticket_callouts.detect { |callout| callout.builtin_key == "post_event_survey" } + end + + # The readiness-gating form row within the post-event survey callout — the one + # whose form carries the "post_event_survey" role (the recipients survey). + def readiness_survey_form_link + return @readiness_survey_form_link if defined?(@readiness_survey_form_link) + @readiness_survey_form_link = + post_event_survey_callout&.registration_ticket_callout_forms&.detect { |link| link.form&.role == Form::READINESS_SURVEY_ROLE } end # Published + past drip. Only then does an unsubmitted survey gate a recipient's # completion. def post_event_survey_open?(now = Time.current) - callout = scholarship_recipients_survey_callout - return false unless callout && !callout.hidden? - callout.display_from.blank? || callout.display_from <= now + link = readiness_survey_form_link + return false unless link && !post_event_survey_callout.hidden? + link.display_from.blank? || link.display_from <= now end def registerable? diff --git a/app/models/form.rb b/app/models/form.rb index 574218e7c8..71dbb5732f 100644 --- a/app/models/form.rb +++ b/app/models/form.rb @@ -15,6 +15,12 @@ class Form < ApplicationRecord # submissions index scenario filter. AGREEMENT_ROLES = %w[registration new_job reinstatement].freeze + # Post-event survey roles. A submission to any of these runs the survey side + # effects (per-resource clarity fan-out, profile write-through, staff FYI email); + # a "post_event_survey" submission is additionally the one that gates readiness. + SURVEY_ROLES = %w[day_1_survey day_2_survey post_event_survey].freeze + READINESS_SURVEY_ROLE = "post_event_survey".freeze + # The questions that identify a public respondent (used to build their Person). IDENTITY_IDENTIFIERS = %w[first_name last_name primary_email].freeze @@ -73,6 +79,11 @@ def requires_identity? role.in?(AGREEMENT_ROLES) end + # A post-event survey form — its submission runs the survey side effects. + def survey? + role.in?(SURVEY_ROLES) + end + # Derived, not stored: a public form invites anonymous responses when it asks # for name/email but requires none of them, so a respondent can skip # identifying themselves and still submit (see PublicFormSubmission). A form diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index 4fcaead3e0..1f73f4b378 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -13,14 +13,14 @@ class RegistrationTicketCallout < ApplicationRecord BUILTIN_KEYS = %w[ payment certificate scholarship ce_hours art_supplies videoconference staff handouts faq - day_1_survey day_2_survey scholarship_recipients_survey + post_event_survey ].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 post_event_survey ].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. @@ -57,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. @@ -66,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? } @@ -161,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..494ac0502e --- /dev/null +++ b/app/models/registration_ticket_callout_form.rb @@ -0,0 +1,23 @@ +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) } + + # Drips like a callout: hidden until its own date passes. A blank date is open. + def dripping?(now = Time.current) + display_from.present? && display_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_callout_cards.rb b/app/services/builtin_callout_cards.rb index cddf4f5528..309f055e48 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -70,10 +70,7 @@ def self.editor_cards(event) "ce_hours" => :ce_hours_card, "videoconference" => :videoconference_card, "staff" => :staff_card, - "certificate" => :certificate_card, - "day_1_survey" => :day_1_survey_card, - "day_2_survey" => :day_2_survey_card, - "scholarship_recipients_survey" => :scholarship_recipients_survey_card + "certificate" => :certificate_card }.freeze # Why a built-in card with this builtin_key can never appear on the given event's @@ -126,11 +123,8 @@ def initialize(event_registration, preview: false) # event has materialized into editable rows are omitted here — the ticket renders # those from the row (calling #card_for for behavioral ones), so this is both the # non-materialized set and the fallback for events not yet seeded. - # Survey cards only render from a seeded row via #card_for, never from this fallback. - FALLBACK_EXCLUDED_KEYS = %w[ day_1_survey day_2_survey scholarship_recipients_survey ].freeze - def cards - CARD_BUILDERS.reject { |builtin_key, _| materialized?(builtin_key) || FALLBACK_EXCLUDED_KEYS.include?(builtin_key) } + CARD_BUILDERS.reject { |builtin_key, _| materialized?(builtin_key) } .filter_map { |_, builder| send(builder) } end @@ -461,25 +455,4 @@ def videoconference_card href: registration_videoconference_path(registration.slug), target: nil, trailing_icon: "fa-solid fa-arrow-right") end - - # The day cards show for everyone; the recipients card only for scholarship recipients. - def day_1_survey_card - survey_card("day_1_survey") - end - - def day_2_survey_card - survey_card("day_2_survey") - end - - def scholarship_recipients_survey_card - return unless registration.scholarship? - survey_card("scholarship_recipients_survey") - end - - def survey_card(builtin_key) - Card.new(icon_class: "fa-solid fa-clipboard-list", color: "indigo", - title: "Survey", subtitle: "Share your feedback", - href: registration_survey_path(registration.slug, builtin_key), - target: nil, trailing_icon: "fa-solid fa-arrow-right") - end end diff --git a/app/services/builtin_callouts.rb b/app/services/builtin_callouts.rb index 863dea4e43..bc83f3eb67 100644 --- a/app/services/builtin_callouts.rb +++ b/app/services/builtin_callouts.rb @@ -154,9 +154,10 @@ def reset(callout) icon_class: definition[:icon_class], color_class: definition[:color_class], hidden: definition[:hidden].call(@event), - display_from: definition[:display_from]&.call(@event), - form_id: definition[:form]&.call(@event)&.id + 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 @@ -174,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 @@ -303,46 +305,31 @@ def definitions hidden: ->(_event) { true } }, { - builtin_key: "day_1_survey", - title: "Day 1 survey", - subtitle: "Share your feedback on day 1 of the training", - callout_type: "action", - icon_class: "fa-solid fa-clipboard-list", - color_class: "indigo", - hidden: ->(_event) { true }, - form: ->(_event) { Form.standalone.find_by(name: "Day 1 Survey") }, - display_from: ->(event) { survey_drip(event, 1) } - }, - { - builtin_key: "day_2_survey", - title: "Day 2 survey", - subtitle: "Share your feedback on day 2 of the training", - callout_type: "action", - icon_class: "fa-solid fa-clipboard-list", - color_class: "indigo", - hidden: ->(_event) { true }, - # Only seeds on multi-day events — a one-day training has no day 2. - seed_if: ->(event) { event.day_count >= 2 }, - form: ->(_event) { Form.standalone.find_by(name: "Day 2 Survey") }, - display_from: ->(event) { survey_drip(event, 2) } - }, - { - builtin_key: "scholarship_recipients_survey", - title: "Scholarship recipients survey", - subtitle: "Post-training questions for scholarship recipients", + builtin_key: "post_event_survey", + title: "Post-event survey", + subtitle: "Share your feedback on the training", callout_type: "action", icon_class: "fa-solid fa-clipboard-list", color_class: "fuchsia", hidden: ->(_event) { true }, - form: ->(_event) { Form.standalone.find_by(name: "Post-Training Recipients Survey") }, - # Opens 30 min before the event ends. - display_from: ->(event) { event.end_date - 30.minutes if event.end_date } + # One callout that drips several survey forms, each on its own date: the + # Day 1 evaluation, the Day 2 evaluation (multi-day events only), and the + # recipients survey (the one that gates readiness). Rows whose template + # isn't seeded yet resolve to nil and are skipped. + 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-Training Recipients 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 @@ -357,10 +344,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], @@ -371,8 +372,7 @@ def attributes_for(definition) icon_class: definition[:icon_class], color_class: definition[:color_class], hidden: definition[:hidden].call(@event), - display_from: definition[:display_from]&.call(@event), - form_id: definition[:form]&.call(@event)&.id + display_from: definition[:display_from]&.call(@event) } end @@ -396,6 +396,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_services/callout_form_submission.rb b/app/services/event_registration_services/callout_form_submission.rb index 7adbbc3acc..539de23b2e 100644 --- a/app/services/event_registration_services/callout_form_submission.rb +++ b/app/services/event_registration_services/callout_form_submission.rb @@ -1,40 +1,101 @@ 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 (#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 - # 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) + @submission.record_callout_collection!(@callout) + save_answers + if @form.survey? + save_clarity_answers + sync_profile(person) + stamp_completion + end + 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.name} #{link.resource.title}" + record = @submission.form_answers.find_or_initialize_by(form_field: nil, question_name_when_answered: question) + record.update!(submitted_answer: raw) end - submission end end + + # Write the two identified questions to the Person, recording only real changes. + def sync_profile(person) + apply_profile_change(person, :anonymous_contributions, + Person::ANONYMOUS_CONTRIBUTIONS_OPTIONS.invert[value_for("anonymous_contributions")]) + apply_profile_change(person, :display_name_preference, + Person::DISPLAY_NAME_PREFERENCE_LABELS.invert[value_for("display_name_preference")]) + person.save! if person.changed? + end + + def apply_profile_change(person, attribute, new_value) + return if new_value.nil? + current = person.public_send(attribute) + return if current == new_value + @profile_changes[attribute] = [ current, new_value ] + person.public_send("#{attribute}=", new_value) + end + + # The submitted label for a field identified by its field_identifier. + def value_for(field_identifier) + field = @form.form_fields.find_by(field_identifier: field_identifier) + field && @form_params[field.id.to_s] + end + + def stamp_completion + return unless @form.role == Form::READINESS_SURVEY_ROLE && @registration.scholarship? + return if @registration.post_survey_completed? + @registration.mark_post_survey_completed! + end end end diff --git a/app/services/event_registration_services/survey_submission.rb b/app/services/event_registration_services/survey_submission.rb deleted file mode 100644 index 4f6ccd4f7a..0000000000 --- a/app/services/event_registration_services/survey_submission.rb +++ /dev/null @@ -1,98 +0,0 @@ -module EventRegistrationServices - # Records a survey as a role-tagged FormSubmission: static answers plus dynamic - # "clarity" answers (a per-resource field fans out to one nil-form_field answer per - # resource, its sentence snapshotted in question_name_when_answered). The anonymity - # and name-display questions also write through to the Person (#profile_changes - # reports real changes for Ahoy). Stamps post_survey_completed_at for a recipient's - # recipients survey. Idempotent on re-submit. - class SurveySubmission - attr_reader :submission, :profile_changes - - def self.call(**kwargs) - instance = new(**kwargs) - instance.call - instance - end - - def initialize(event_registration:, form:, role:, field_params: {}, clarity_params: {}) - @event_registration = event_registration - @form = form - @role = role - @field_params = (field_params || {}).transform_keys(&:to_s) - @clarity_params = clarity_params || {} - @profile_changes = {} - end - - def call - person = @event_registration.registrant - ActiveRecord::Base.transaction do - @submission = FormSubmission.find_or_create_by!( - person: person, form: @form, event: @event_registration.event, role: @role - ) - save_static_answers - save_clarity_answers - sync_profile(person) - stamp_completion - end - @submission - end - - private - - def save_static_answers - @form.form_fields.each do |field| - next if field.answer_type == "group_header" || field.per_resource? - raw = @field_params[field.id.to_s] - next if raw.nil? - text = raw.is_a?(Array) ? raw.reject(&:blank?).join(", ") : raw - record = @submission.form_answers.find_or_initialize_by(form_field: field) - record.update!(submitted_answer: text, question_name_when_answered: field.name) - end - end - - # One answer per linked resource, keyed by the snapshotted sentence so re-submits - # update in place (form_field stays nil). - def save_clarity_answers - @clarity_params.each do |field_id, per_resource| - field = @form.form_fields.find_by(id: field_id) - next unless field&.per_resource? - field.form_field_resources.includes(:resource).each do |link| - raw = per_resource[link.resource_id.to_s] || per_resource[link.resource_id] - next if raw.blank? - question = "#{field.name} #{link.resource.title}" - record = @submission.form_answers.find_or_initialize_by(form_field: nil, question_name_when_answered: question) - record.update!(submitted_answer: raw) - end - end - end - - # Write the two identified questions to the Person, recording only real changes. - def sync_profile(person) - apply_profile_change(person, :anonymous_contributions, - Person::ANONYMOUS_CONTRIBUTIONS_OPTIONS.invert[value_for("anonymous_contributions")]) - apply_profile_change(person, :display_name_preference, - Person::DISPLAY_NAME_PREFERENCE_LABELS.invert[value_for("display_name_preference")]) - person.save! if person.changed? - end - - def apply_profile_change(person, attribute, new_value) - return if new_value.nil? - current = person.public_send(attribute) - return if current == new_value - @profile_changes[attribute] = [ current, new_value ] - person.public_send("#{attribute}=", new_value) - end - - # The submitted label for a field identified by its field_identifier. - def value_for(field_identifier) - field = @form.form_fields.find_by(field_identifier: field_identifier) - field && @field_params[field.id.to_s] - end - - def stamp_completion - return unless @role == "post_event_survey" && @event_registration.scholarship? - return if @event_registration.post_survey_completed? - @event_registration.mark_post_survey_completed! - end - end -end diff --git a/app/services/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/views/events/_registrants_results.html.erb b/app/views/events/_registrants_results.html.erb index efd4002200..dce94f8985 100644 --- a/app/views/events/_registrants_results.html.erb +++ b/app/views/events/_registrants_results.html.erb @@ -3,8 +3,8 @@ <% scholarship_on = @event.cost_cents.to_i > 0 %> <%# The event's visible callouts that deliver a form inline — one toggleable completion column each, under the single "Ticket forms" switch. %> - <% form_callouts = @event.registration_ticket_callouts.where.not(form_id: nil).visible.includes(:form).ordered.to_a %> - <% callout_roles = form_callouts.index_with { |callout| EventRegistrationServices::CalloutFormSubmission.role_for(callout) } %> + <% form_columns = @event.registration_ticket_callouts.visible.includes(registration_ticket_callout_forms: :form).ordered + .flat_map { |callout| callout.registration_ticket_callout_forms.map { |link| [ callout, link.form ] } } %>
<% current_filter = params[:attendance_status].present? ? nil : (params[:status_filter].presence || "active") %> @@ -174,7 +174,7 @@ <%# One switch reveals a completion column per form-bearing callout. %> - <% if form_callouts.any? %> + <% if form_columns.any? %>