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