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