From 3603ef2f76f03898bb4e184e6642a4dbc702f722 Mon Sep 17 00:00:00 2001 From: 1steve78 Date: Wed, 21 Oct 2026 12:20:33 +0530 Subject: [PATCH] Associate standalone survey submissions with their event so training feedback automatically tracks on the registrants roster (#2749) --- AGENTS.md | 4 +- app/controllers/forms_controller.rb | 7 +- app/controllers/public_forms_controller.rb | 18 ++- app/helpers/forms_helper.rb | 14 ++- app/services/event_survey_completions.rb | 21 ++++ app/services/public_form_submission.rb | 20 +++- app/views/events/_form_actions_menu.html.erb | 6 +- app/views/forms/edit.html.erb | 3 +- app/views/public_forms/show.html.erb | 4 +- app/views/public_forms/thank_you.html.erb | 2 +- ...2026-10-21-event-specific-survey-links.yml | 12 ++ spec/helpers/forms_helper_spec.rb | 26 +++++ spec/requests/forms_spec.rb | 11 ++ spec/requests/public_forms_spec.rb | 107 ++++++++++++++++++ .../services/event_survey_completions_spec.rb | 76 +++++++++++++ spec/services/public_form_submission_spec.rb | 35 ++++++ .../_form_actions_menu.html.erb_spec.rb | 14 +++ 17 files changed, 362 insertions(+), 18 deletions(-) create mode 100644 db/seeds/features/2026-10-21-event-specific-survey-links.yml diff --git a/AGENTS.md b/AGENTS.md index 6b00076c82..e751be82d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -228,7 +228,7 @@ comparison — add a rule if none fits (e.g. `PersonPolicy#own_profile?`). Conve - `AffiliationPeriods` — Merges an organization's affiliation date-intervals into periods, at year precision for "Affiliated since" (e.g. "2010-2012, 2026") or month precision for "Art program since" (e.g. "Aug 2015 – Jun 2018, Feb 2024"); rendered server-side on the org show/index/edit pages, with `affiliation_dates_controller.js` mirroring it only to live-update the edit form - `EventDashboard` — Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total). One population per event — `EventRegistration.active` — so the money and the people figures always reconcile; "who completed the training" is an attendance figure over that population (`#attended_count`), never a narrower population - `EventRevenueReport` — Cross-event revenue report grouped by calendar year (money in vs org subsidy vs net, CE fees, chart series) for the CEO revenue page -- `EventSurveyCompletions` — The survey-role forms (`Form::SURVEY_ROLES`) an event carries — offered to its kind of event (Day 1/Day 2: live facilitator trainings, Day 2 multi-day only; scholarship survey: paid events; post-event: all) and delivered on any of its callouts, or already answered for it — and each registrant's first submission of each, matched on the form's current role. Backs the Surveys columns on the registrants roster and the onboarding tracker (pages + both CSVs) +- `EventSurveyCompletions` — The survey-role forms (`Form::SURVEY_ROLES`) an event carries — offered to its kind of event (Day 1/Day 2: live facilitator trainings, Day 2 multi-day only; scholarship survey: paid events; post-event: all) and delivered on any of its callouts, or already answered for it (including standalone survey submissions carrying the event) — and each registrant's first submission of each, matched on the form's current role. Backs the Surveys columns on the registrants roster and the onboarding tracker (pages + both CSVs). Exposes `.survey_allowed_for_event?` and `.survey_forms_for` to check event survey eligibility and publicly fillable survey links - `EventRevenueFigures` — Batch-loads the per-event money components `EventRevenueReport` rows are built from (registration payments/outstanding, funded/unfunded scholarships, discounts, CE paid/outstanding) in a fixed number of grouped queries; mirrors the `EventDashboard` definitions - `EventScholarshipFigures` — Batch-loads the per-event scholarship figures `EventScholarshipReport` columns are built from (funded/unfunded dollars + counts, attended count) in a fixed number of grouped queries; optional `funder:` narrows to a donor's grants. Mirrors the `EventDashboard` funded/unfunded split, replacing the one-dashboard-per-event it used to build - `FeedRecordPreloader` — Preloads what a comment/communication record label reads (the person a feed chip names + their account, the event in the label) for a page of feed entries or a person's "Log it against" picker targets, so neither queries per row @@ -338,7 +338,7 @@ comparison — add a rule if none fits (e.g. `PersonPolicy#own_profile?`). Conve ### Forms -- `PublicFormSubmission` — Records a submission to a **standalone, published** `Form` filled out at its public pretty URL (`/f/:slug`, `PublicFormsController`). No event or role. A logged-in respondent's submission belongs to their account (`person:`) unless they leave name/email incomplete on a form set to `Form#anonymous?`; otherwise the respondent is find-or-created as a `Person` from the form's name/email answers (email + last-name match reuses an existing person; a form not set to `Form#anonymous?` rejects a submission without all of first name, last name, and email), a News `TopicSubscription` captured via `NewsSubscriptionCapture` when `communication_consent` is answered, the person's profile/contact fields filled via `PersonServices::CaptureFromSubmission`, answers stored as a `role: "public"` `FormSubmission` via `FormSubmission#persist_answer` (saved **first**, so the agreement linking core can read them and so a rejected answer aborts before any of the person's records are written), then `OtherResponses::CaptureFromSubmission` like every other submission path. The submitted organization takes one of two paths: an **agreement** submission (`FormSubmission#agreement_scenario?`) whose name resolves to exactly one org is processed on arrival through `OrganizationServices::LinkSubmittedOrganization` — the same core an admin's Link organization click runs, so scenario end-dating, the standing Facilitator affiliation, and the new-job start date all apply identically (an ambiguous or unmatched name stays in the admin's `org_link_status("pending")` queue); any other role runs `OrganizationServices::CaptureFromSubmission`, the same capture the event flow runs, so the typed position becomes a job affiliation either way. `PublicFormsController` sets `Current.form_submission_id` after the call so the person writes surface on the "What this submission changed" audit. Returns a `Result` (`success?`, `form_submission`, `person`, `errors`) +- `PublicFormSubmission` — Records a submission to a **standalone, published** `Form` filled out at its public pretty URL (`/f/:slug`, `PublicFormsController`). An optional `event:` parameter associates the submission with a validated event when submitting an allowed survey form (and stamps readiness on matching scholarship registrations). A logged-in respondent's submission belongs to their account (`person:`) unless they leave name/email incomplete on a form set to `Form#anonymous?`; otherwise the respondent is find-or-created as a `Person` from the form's name/email answers (email + last-name match reuses an existing person; a form not set to `Form#anonymous?` rejects a submission without all of first name, last name, and email), a News `TopicSubscription` captured via `NewsSubscriptionCapture` when `communication_consent` is answered, the person's profile/contact fields filled via `PersonServices::CaptureFromSubmission`, answers stored as a `role: "public"` `FormSubmission` via `FormSubmission#persist_answer` (saved **first**, so the agreement linking core can read them and so a rejected answer aborts before any of the person's records are written), then `OtherResponses::CaptureFromSubmission` like every other submission path. The submitted organization takes one of two paths: an **agreement** submission (`FormSubmission#agreement_scenario?`) whose name resolves to exactly one org is processed on arrival through `OrganizationServices::LinkSubmittedOrganization` — the same core an admin's Link organization click runs, so scenario end-dating, the standing Facilitator affiliation, and the new-job start date all apply identically (an ambiguous or unmatched name stays in the admin's `org_link_status("pending")` queue); any other role runs `OrganizationServices::CaptureFromSubmission`, the same capture the event flow runs, so the typed position becomes a job affiliation either way. `PublicFormsController` sets `Current.form_submission_id` after the call so the person writes surface on the "What this submission changed" audit. Returns a `Result` (`success?`, `form_submission`, `person`, `errors`) - `Honeypot` — The spam trap shared by all four public, account-free forms (public forms, public registrations, bulk payment, contact us). `FIELD_NAME` is the decoy param every one of them renders via `shared/_honeypot` (off-screen and zero-size rather than `display: none`, which bots skip); `.tripped?(params, scope)` is the guard each controller calls to drop a filled-in submission silently. The name must never match a real column or `FormField` identifier — change it here and all four follow - **Agreement scenario forms** (`Form::AGREEMENT_ROLES`, `Form.agreement_forms`) — the collaboration agreement intake scenarios are expressed through form **roles**, not a stored purpose: a standalone public **registration**-role form is the on-demand agreement, and **new_job** / **reinstatement** are their own roles (`FormSubmission#linking_scenario` derives the scenario — registration-role submissions count only when `role: "public"`, i.e. the standalone path). An agreement submission gets an admin **processing panel** on its detail page (`form_submissions/_processing_panel`): submitted organization vs. the person's affiliations (one-click end-dating for a new job via `AffiliationsController#end_affiliation`), plus portal-invite actions; the person edit page's affiliations card grows a collapsed "Agreement form links" panel that emails the right form's public link and shows how many times each has been sent (`PeopleController#send_form_link`, recorded as a `form_link_request` notification carrying the form name in `custom_subject` and the public URL in `custom_message`; `?agreement_links=1` reopens the panel on the way back) - **Form submissions index filters + org linking** — the submissions index filters through one grid filter bar handled by `FormSubmission.search_by_params` (form, role, event/person/organization remote-selects, submitted date range — plus name/email `search`, agreement `scenario`, `FormSubmission.org_link_status`, and `FormSubmission.account_status`, the same vocabulary as the registrants roster). `FormSubmissionsHelper#form_submission_carryover_params` threads the active filters through a submission's detail page and back. Each submission with an org answer links to a submission-scoped org-linking editor (`FormSubmissionsController#link_organization`/`select_organization`/`create_organization`), the event-less sibling of the registration one: "linked" means an org was explicitly linked to the submission (`FormSubmission#link_organization!`, metadata `linked_organization_ids` — written by this editor **and back-applied by the event registration editor** onto the submission its pinning rules say describes the org, so a typo'd name resolved to a differently-named org still reads as linked) or an active affiliation matches the submitted name. Both editors run the same linking core, **`OrganizationServices::LinkSubmittedOrganization`**, driven by one five-value **linking scenario** vocabulary (`SCENARIOS`: `on_demand` / `facilitator_training` / `reinstatement` / `new_job` confer the Facilitator affiliation, `non_facilitator_training` doesn't; scenarios are derived per call — from the form's role for a submission, from the event for a registration): scenario end-dating via `AffiliationServices::ApplyScenarioEndDating` (new_job only), fill-blanks `SyncProfile`/`UpsertAddress`, `AffiliationServices::CreateFromRegistration` (with `job_start_date` on a new job), plus `ProfileDiff` conflicts and the flash notice/warning text. The registration flow keeps its own wrapper (link row, pin, autofill notes) and never auto-ends affiliations. A **`close_program`** submission (the deactivation mirror of the agreement roles) takes a separate branch: `FormSubmissionsController#close_program_and_report` runs `AffiliationServices::CloseProgram` instead of the linking core, so linking the org *ends* the person's affiliations there rather than creating any. **ADR-0002** (`docs/adr/0002-org-linking-flows-and-agreement-scenarios.md`) pins the scenarios and their affiliation handling. The "Add an organization" block is shared via `shared/_link_organization_actions` diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb index e6f0195d32..d651457d5b 100644 --- a/app/controllers/forms_controller.rb +++ b/app/controllers/forms_controller.rb @@ -218,7 +218,12 @@ def set_form # so the link only ever points at a genuinely connected event. Fall back to # the sole connected event when there's exactly one. def set_dashboard_event - @dashboard_event = @form.events.find_by(id: params[:event_id]) if params[:event_id].present? + @dashboard_event = if @form.survey_role? && params[:event_id].present? + event = Event.find_by(id: params[:event_id]) + event if EventSurveyCompletions.survey_allowed_for_event?(event, @form) + elsif params[:event_id].present? + @form.events.find_by(id: params[:event_id]) + end @dashboard_event ||= @form.events.one? ? @form.events.first : nil end diff --git a/app/controllers/public_forms_controller.rb b/app/controllers/public_forms_controller.rb index 33abda6581..94d2d78d9c 100644 --- a/app/controllers/public_forms_controller.rb +++ b/app/controllers/public_forms_controller.rb @@ -6,6 +6,7 @@ class PublicFormsController < ApplicationController skip_before_action :authenticate_user!, only: %i[show create thank_you] before_action :noindex!, only: %i[show create thank_you] before_action :set_form + before_action :set_event, only: %i[show create thank_you] def show authorize! @form, to: :public_show? @@ -18,7 +19,7 @@ def create # A bot that fills the hidden field is silently bounced. if Honeypot.tripped?(params, :public_registration) - redirect_to public_form_path(@form.slug) + redirect_to public_form_path(@form.slug, event: @event&.id) return end @@ -34,9 +35,9 @@ def create end Current.source = "public_form" - result = PublicFormSubmission.call(form: @form, form_params: form_params, person: current_user&.person) + result = PublicFormSubmission.call(form: @form, form_params: form_params, person: current_user&.person, event: @event) if result.success? - redirect_to thank_you_public_form_path(@form.slug), notice: "Thank you — your response has been submitted!" + redirect_to thank_you_public_form_path(@form.slug, event: @event&.id), notice: "Thank you — your response has been submitted!" else flash.now[:alert] = result.errors.join(", ").presence || "Something went wrong. Please try again." render :show, status: :unprocessable_content @@ -58,6 +59,17 @@ def public_form_fields @public_form_fields ||= PublicFormFields.new(form: @form, person: current_user&.person) end + def set_event + raw_event_id = params[:event].presence || params[:event_id].presence + return if raw_event_id.blank? + + candidate_event = Event.find_by(id: raw_event_id) + return unless candidate_event + return unless EventSurveyCompletions.survey_allowed_for_event?(candidate_event, @form) + + @event = candidate_event + end + # A file input can't be repopulated, so on re-render after an error fall back to # the already-uploaded blob's signed id (carried in retained_uploads). def merge_retained_uploads(form_params) diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index df78651fd2..a0bd443ef8 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -42,14 +42,20 @@ def form_duplicate_button(form) end end - # The live public registration page for this form, shown on the editor pages. - # Only available when we know the event the admin came from — the preview - # (the subnav's "View") can't fill header tokens or run conditional logic + # The live public page for this form (standalone public form or event registration), + # shown on the editor pages. Only available when we know the event the admin came from — + # the preview (the subnav's "View") can't fill header tokens or run conditional logic # without an event. def form_live_view_link(event) return unless event - link_to "Live form", new_event_public_registration_path(event), + path = if @form&.publicly_fillable? + public_form_path(@form.slug, event: event.id) + else + new_event_public_registration_path(event) + end + + link_to "Live form", path, class: "text-sm #{eyebrow_link_class} px-2 py-1" end diff --git a/app/services/event_survey_completions.rb b/app/services/event_survey_completions.rb index 81de83fa5e..0671a2cc99 100644 --- a/app/services/event_survey_completions.rb +++ b/app/services/event_survey_completions.rb @@ -5,6 +5,21 @@ class EventSurveyCompletions Completion = Data.define(:submission_id, :submitted_at) + def self.survey_allowed_for_event?(event, form) + return false unless event && form&.survey_role? + + new(event, []).survey_allowed?(form) + end + + def self.survey_forms_for(event) + return [] unless event + + Form::SURVEY_ROLES.filter_map do |role| + form = Form.survey(role) + form if form&.publicly_fillable? && survey_allowed_for_event?(event, form) + end + end + def initialize(event, person_ids) @event = event @person_ids = person_ids @@ -22,6 +37,12 @@ def completion(person_id, role) completions[[ person_id, role ]] end + def survey_allowed?(form) + return false unless form&.survey_role? + + offered?(form.role) || delivered_roles.include?(form.role) + end + private def offered?(role) diff --git a/app/services/public_form_submission.rb b/app/services/public_form_submission.rb index d0927d4a0a..5e885e9762 100644 --- a/app/services/public_form_submission.rb +++ b/app/services/public_form_submission.rb @@ -14,14 +14,15 @@ class PublicFormSubmission "This form needs your name and email to complete your submission.".freeze ALREADY_SUBMITTED_MESSAGE = "This form was already submitted.".freeze - def self.call(form:, form_params:, person: nil) - new(form:, form_params:, person:).call + def self.call(form:, form_params:, person: nil, event: nil) + new(form:, form_params:, person:, event:).call end - def initialize(form:, form_params:, person: nil) + def initialize(form:, form_params:, person: nil, event: nil) @form = form @form_params = form_params || {} @person = person + @event = event end def call @@ -42,7 +43,7 @@ def submit person = find_or_create_person return Result.new(success?: false, errors: [ IDENTITY_REQUIRED_MESSAGE ]) if @form.requires_identity? && person.nil? - submission = FormSubmission.create!(person: person, form: @form, role: ROLE) + submission = FormSubmission.create!(person: person, form: @form, role: ROLE, event: @event) # Answers first: the agreement linking core reads the submitted org off # them, and a rejected answer (too long, unreadable upload) then aborts # before any of the person's own records have been written. @@ -53,6 +54,7 @@ def submit registration = register_for_on_demand_training(submission) capture_organization(submission, registration) PersonServices::CaptureFromSubmission.call(person: person, form: @form, form_params: @form_params) + stamp_scholarship_form_completion(submission) end OtherResponses::CaptureFromSubmission.call(submission) @@ -94,6 +96,16 @@ def register_for_on_demand_training(submission) registration end + def stamp_scholarship_form_completion(submission) + return unless @form.role == "recipient_survey" && @event && submission.person + + registration = EventRegistration.find_by(event: @event, registrant: submission.person) + return unless registration&.scholarship? + return if registration.post_survey_completed? + + registration.mark_post_survey_completed! + end + # A close-program submission end-dates the person's affiliations at the named # organization (see AffiliationServices::CloseProgram). We only auto-process # when the submitted organization name matches an org exactly (case-insensitive). diff --git a/app/views/events/_form_actions_menu.html.erb b/app/views/events/_form_actions_menu.html.erb index e872b78e44..87c93f9454 100644 --- a/app/views/events/_form_actions_menu.html.erb +++ b/app/views/events/_form_actions_menu.html.erb @@ -26,7 +26,8 @@ <% show_registration = @event.registration_form.present? %> <% show_scholarship = @event.scholarship_form.present? && has_cost %> <% show_bulk_payment = @event.bulk_payment_form.present? && has_cost %> - <% if @event.registration_form.present? || @event.scholarship_form || @event.bulk_payment_form %> + <% survey_forms = EventSurveyCompletions.survey_forms_for(@event) %> + <% if @event.registration_form.present? || @event.scholarship_form || @event.bulk_payment_form || survey_forms.any? %> <% if show_registration %> <%= link_to form_label.call("registration"), new_event_public_registration_path(@event), class: item_class, target: "_blank", rel: "noopener noreferrer" %> <% end %> @@ -36,6 +37,9 @@ <% if show_bulk_payment %> <%= link_to form_label.call("bulk payment"), new_event_bulk_payment_path(@event), class: item_class, target: "_blank", rel: "noopener noreferrer" %> <% end %> + <% survey_forms.each do |survey_form| %> + <%= link_to survey_form.display_name, public_form_path(survey_form.slug, event: @event.id), class: item_class, target: "_blank", rel: "noopener noreferrer" %> + <% end %>
<%= link_to manage_label, edit_event_path(@event, anchor: "registration_form_section"), class: item_class %> <% else %> diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb index a1c777386b..a14fa58644 100644 --- a/app/views/forms/edit.html.erb +++ b/app/views/forms/edit.html.erb @@ -163,7 +163,8 @@ <% if @form.publicly_fillable? %>
Live at - <%= link_to public_form_url(@form.slug), public_form_url(@form.slug), target: "_blank", rel: "noopener", + <% live_url = @dashboard_event ? public_form_url(@form.slug, event: @dashboard_event.id) : public_form_url(@form.slug) %> + <%= link_to live_url, live_url, target: "_blank", rel: "noopener", class: "text-green-700 underline break-all hover:text-green-900" %>
diff --git a/app/views/public_forms/show.html.erb b/app/views/public_forms/show.html.erb index 11c0da7976..a22ce3668f 100644 --- a/app/views/public_forms/show.html.erb +++ b/app/views/public_forms/show.html.erb @@ -35,7 +35,9 @@ <% end %> - <%= form_with url: public_form_path(@form.slug), method: :post, local: true, class: "space-y-2", data: { turbo: false } do %> + <%= form_with url: public_form_path(@form.slug, event: params[:event].presence || params[:event_id].presence), method: :post, local: true, class: "space-y-2", data: { turbo: false } do %> + <%= hidden_field_tag :event, params[:event] if params[:event].present? %> + <%= hidden_field_tag :event_id, params[:event_id] if params[:event_id].present? && params[:event].blank? %> <%= render "shared/honeypot", scope: "public_registration" %>
diff --git a/app/views/public_forms/thank_you.html.erb b/app/views/public_forms/thank_you.html.erb index 60339eec54..4f8ffc632b 100644 --- a/app/views/public_forms/thank_you.html.erb +++ b/app/views/public_forms/thank_you.html.erb @@ -16,7 +16,7 @@ Your response to <%= @form.display_name %> has been submitted.

- <%= link_to "Submit another response", public_form_path(@form.slug), + <%= link_to "Submit another response", public_form_path(@form.slug, event: params[:event].presence || params[:event_id].presence), class: "inline-flex items-center gap-2 rounded-xl border border-gray-300 px-5 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50" %>
diff --git a/db/seeds/features/2026-10-21-event-specific-survey-links.yml b/db/seeds/features/2026-10-21-event-specific-survey-links.yml new file mode 100644 index 0000000000..9ad4740578 --- /dev/null +++ b/db/seeds/features/2026-10-21-event-specific-survey-links.yml @@ -0,0 +1,12 @@ +name: "Share event-specific survey links" +area: registration +display_status: admin_facing +released_on: 2026-10-21 +pr_number: 2768 +action_path: "/events" +summary: >- + Staff can share standalone survey links with an event ID attached so respondent submissions + automatically associate with that event and count toward its survey completion columns. +pro_tips: + - "Find direct survey links with the event prefilled under the Forms button on the event dashboard and registrants roster." + - "Survey links keep the event ID across multiple responses from the thank-you page." diff --git a/spec/helpers/forms_helper_spec.rb b/spec/helpers/forms_helper_spec.rb index ffcf667c0d..a266d0247d 100644 --- a/spec/helpers/forms_helper_spec.rb +++ b/spec/helpers/forms_helper_spec.rb @@ -6,4 +6,30 @@ expect(helper.max_length_placeholder("Question text", 1000)).to eq("Question text (1,000 char max)") end end + + describe "#form_live_view_link" do + let(:event) { create(:event) } + + it "links to the public form path with event parameter for publicly fillable forms" do + form = create(:form, slug: "day-1", published: true, role: "day_1_survey") + assign(:form, form) + + link = helper.form_live_view_link(event) + expect(link).to include(public_form_path(form.slug, event: event.id)) + expect(link).to include("Live form") + end + + it "links to the event public registration path when form is not publicly fillable" do + form = create(:form, published: false) + assign(:form, form) + + link = helper.form_live_view_link(event) + expect(link).to include(new_event_public_registration_path(event)) + expect(link).to include("Live form") + end + + it "returns nil when event is nil" do + expect(helper.form_live_view_link(nil)).to be_nil + end + end end diff --git a/spec/requests/forms_spec.rb b/spec/requests/forms_spec.rb index b2c45eecbf..7dc0835771 100644 --- a/spec/requests/forms_spec.rb +++ b/spec/requests/forms_spec.rb @@ -288,6 +288,17 @@ end end + context "when an event_id for an allowed survey is given" do + let(:survey) { create(:form, role: "day_1_survey", slug: "eval-day-1", published: true) } + let(:training) { create(:event, facilitator_training: true) } + + it "links back to the event and includes the event parameter in the Live at link" do + get edit_form_path(survey, event_id: training.id) + expect(response.body).to include(dashboard_event_path(training)) + expect(response.body).to include(public_form_url(survey.slug, event: training.id)) + end + end + it "ignores an event_id that is not connected to the form" do get edit_form_path(form, event_id: event.id) expect(response.body).not_to include(dashboard_event_path(event)) diff --git a/spec/requests/public_forms_spec.rb b/spec/requests/public_forms_spec.rb index 9c90831234..6ba6115d73 100644 --- a/spec/requests/public_forms_spec.rb +++ b/spec/requests/public_forms_spec.rb @@ -432,5 +432,112 @@ def growth_params(answers) expect(response).to have_http_status(:ok) expect(response.body).to include("Thank you") end + + it "preserves the event parameter in the return link" do + event = create(:event, facilitator_training: true) + survey = create(:form, role: "day_1_survey", slug: "day-1-survey", published: true) + + get thank_you_public_form_path(survey.slug, event: event.id) + + expect(response).to have_http_status(:ok) + expect(response.body).to include(public_form_path(survey.slug, event: event.id)) + end + end + + describe "event parameter handling for survey forms" do + let(:training) { create(:event, facilitator_training: true) } + let(:survey) { create(:form, role: "day_1_survey", slug: "eval-day-1", published: true) } + let!(:name_field) { create(:form_field, form: survey, name: "Name", field_identifier: "first_name", required: true) } + let!(:last_field) { create(:form_field, form: survey, name: "Last", field_identifier: "last_name", required: true) } + let!(:email_field) { create(:form_field, form: survey, name: "Email", field_identifier: "primary_email", required: true) } + + def survey_params + { + public_registration: { + Honeypot::FIELD_NAME => "", + form_fields: { + name_field.id.to_s => "Morgan", + last_field.id.to_s => "Lee", + email_field.id.to_s => "morgan@example.com" + } + } + } + end + + describe "GET /f/:slug with event param" do + it "includes the event parameter in the form action and hidden field" do + get public_form_path(survey.slug, event: training.id) + + expect(response).to have_http_status(:ok) + expect(response.body).to include(%(action="#{public_form_path(survey.slug, event: training.id)}")) + expect(response.body).to include(%( "", + form_fields: { + f_name.id.to_s => "Morgan", + l_name.id.to_s => "Lee", + e_mail.id.to_s => "morgan@example.com" + } + } + } + + expect { + post public_form_path(non_survey.slug, event: training.id), params: params + }.to change(FormSubmission, :count).by(1) + + expect(FormSubmission.last.event).to be_nil + expect(response).to redirect_to(thank_you_public_form_path(non_survey.slug)) + end + end end end diff --git a/spec/services/event_survey_completions_spec.rb b/spec/services/event_survey_completions_spec.rb index aeea6b144b..2320bd210b 100644 --- a/spec/services/event_survey_completions_spec.rb +++ b/spec/services/event_survey_completions_spec.rb @@ -135,5 +135,81 @@ def completions = described_class.new(event, [ person.id ]) expect(completions.completion(person.id, "day_1_survey")).to be_nil end + + it "counts a standalone survey submission associated with the event" do + submission = create(:form_submission, form: day_1, event:, person:, role: "public") + + completion = completions.completion(person.id, "day_1_survey") + expect(completion.submission_id).to eq(submission.id) + expect(completions.roles).to include("day_1_survey") + end + + it "does not count a standalone survey submission associated with another event" do + other = create(:event) + create(:form_submission, form: day_1, event: other, person:, role: "public") + + expect(completions.completion(person.id, "day_1_survey")).to be_nil + end + + it "does not count an event-less standalone survey submission" do + create(:form_submission, form: day_1, event: nil, person:, role: "public") + + expect(completions.completion(person.id, "day_1_survey")).to be_nil + end + end + + describe ".survey_allowed_for_event?" do + let(:recipient) { create(:form, role: "recipient_survey") } + let(:general) { create(:form, role: "general") } + + it "returns true for offered survey roles on the event" do + expect(described_class.survey_allowed_for_event?(event, day_1)).to be true + expect(described_class.survey_allowed_for_event?(event, day_2)).to be true + expect(described_class.survey_allowed_for_event?(event, post_event)).to be true + expect(described_class.survey_allowed_for_event?(event, recipient)).to be true + end + + it "returns false for roles the event does not offer" do + event.update!(on_demand: true) + + expect(described_class.survey_allowed_for_event?(event, day_1)).to be false + expect(described_class.survey_allowed_for_event?(event, day_2)).to be false + expect(described_class.survey_allowed_for_event?(event, post_event)).to be true + end + + it "returns true for a non-offered survey when delivered by an event callout" do + event.update!(on_demand: true) + create(:registration_ticket_callout, event:, form: day_1) + + expect(described_class.survey_allowed_for_event?(event, day_1)).to be true + end + + it "returns false for non-survey forms" do + expect(described_class.survey_allowed_for_event?(event, general)).to be false + end + + it "returns false when event or form is nil" do + expect(described_class.survey_allowed_for_event?(nil, day_1)).to be false + expect(described_class.survey_allowed_for_event?(event, nil)).to be false + end + end + + describe ".survey_forms_for" do + before do + day_1.update!(slug: "day-1", published: true) + post_event.update!(slug: "post-event", published: true) + end + + it "returns publicly fillable survey forms offered for the event" do + forms = described_class.survey_forms_for(event) + + expect(forms).to include(day_1, post_event) + end + + it "excludes forms that are not published or lack slugs" do + day_1.update!(published: false) + + expect(described_class.survey_forms_for(event)).not_to include(day_1) + end end end diff --git a/spec/services/public_form_submission_spec.rb b/spec/services/public_form_submission_spec.rb index aa217d8ad0..8fe4464aca 100644 --- a/spec/services/public_form_submission_spec.rb +++ b/spec/services/public_form_submission_spec.rb @@ -118,6 +118,41 @@ def org_params expect(answer.submitted_answer).to eq("I care.") end + it "persists an associated event when provided" do + event = create(:event) + + result = described_class.call(form: form, form_params: params_for, event: event) + + expect(result.success?).to be(true) + expect(result.form_submission.event).to eq(event) + end + + describe "scholarship recipient survey completion" do + let(:event) { create(:event, cost_cents: 2000) } + let(:survey_form) { create(:form, role: "recipient_survey", slug: "scholarship-survey", published: true) } + let(:first_field) { create(:form_field, form: survey_form, field_identifier: "first_name") } + let(:last_field) { create(:form_field, form: survey_form, field_identifier: "last_name") } + let(:email_field) { create(:form_field, form: survey_form, field_identifier: "primary_email") } + + it "stamps post_survey_completed_at on the matching scholarship registration" do + person = create(:person, first_name: "Taylor", last_name: "Swift", email: "taylor@example.com") + registration = create(:event_registration, event: event, registrant: person) + scholarship = create(:scholarship, recipient: person, amount_cents: 2000) + create(:allocation, source: scholarship, allocatable: registration, amount: 2000) + + survey_params = { + first_field.id.to_s => "Taylor", + last_field.id.to_s => "Swift", + email_field.id.to_s => "taylor@example.com" + } + + result = described_class.call(form: survey_form, form_params: survey_params, event: event) + + expect(result.success?).to be(true) + expect(registration.reload.post_survey_completed?).to be(true) + end + end + it "reuses an existing person matched on email + last name" do existing = create(:person, first_name: "Sam", last_name: "Rivera", email: "sam@example.com") diff --git a/spec/views/events/_form_actions_menu.html.erb_spec.rb b/spec/views/events/_form_actions_menu.html.erb_spec.rb index c48c5301f0..0e7c24503f 100644 --- a/spec/views/events/_form_actions_menu.html.erb_spec.rb +++ b/spec/views/events/_form_actions_menu.html.erb_spec.rb @@ -13,4 +13,18 @@ expect(rendered).to include("topic_key=facilitator_trainings") expect(rendered).to include("return_to=registrants") end + + it "includes links to offered, publicly fillable survey forms with the event parameter" do + event = create(:event, facilitator_training: true) + day_1 = create(:form, role: "day_1_survey", name: "Day 1 Evaluation", slug: "eval-day-1", published: true) + post_event = create(:form, role: "post_event_survey", name: "Post-Event Survey", slug: "eval-post", published: true) + assign(:event, event.decorate) + + render partial: "events/form_actions_menu" + + expect(rendered).to include("Day 1 Evaluation") + expect(rendered).to include("/f/#{day_1.slug}?event=#{event.id}") + expect(rendered).to include("Post-Event Survey") + expect(rendered).to include("/f/#{post_event.slug}?event=#{event.id}") + end end