Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
7 changes: 6 additions & 1 deletion app/controllers/forms_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 15 additions & 3 deletions app/controllers/public_forms_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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)
Expand Down
14 changes: 10 additions & 4 deletions app/helpers/forms_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions app/services/event_survey_completions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
20 changes: 16 additions & 4 deletions app/services/public_form_submission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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).
Expand Down
6 changes: 5 additions & 1 deletion app/views/events/_form_actions_menu.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
Expand All @@ -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 %>
<div class="my-1 border-t border-gray-100"></div>
<%= link_to manage_label, edit_event_path(@event, anchor: "registration_form_section"), class: item_class %>
<% else %>
Expand Down
Loading
Loading