Skip to content
Draft
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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,11 @@ action, or `authorize! :workshop, to: :summary?`).

- `EventRegistrationServices::ProcessConfirmation` β€” Registration confirmation flow
- `EventRegistrationServices::PublicRegistration` β€” Public registration handling
- `EventRegistrationReadiness` β€” Computes a registration's lifecycle `status` (`:not_ready` β†’ `:ready` β†’ `:certificate_due` β†’ `:completed`) from a pre-event "event ready" checklist, a post-event "completion work" checklist (attendance, scholarship tasks), and certificate delivery, returning the specific outstanding reasons. Reads payment/certificate state via `Registerable` (`paid_in_full?`, `certificate_sent?`) on both the registration and its `continuing_education_registrations`. Drives the registrants roster's single far-right Status badge column (with a short reason under "Not ready" and a cert-type note under "Certificate pending") and its matching filter
- `EventRegistrationServices::SurveySubmission` β€” Records a post-event survey (delivered inline on the ticket via a survey callout) as a role-tagged `FormSubmission`: static answers plus per-resource "clarity" answers (one per `FormFieldResource`, nil `form_field`, full sentence snapshotted in `question_name_when_answered`). Writes the anonymity + name-display questions through to the `Person` profile (`anonymous_contributions`, `display_name_preference`), exposing `#profile_changes` so the controller Ahoy-tracks real edits, and stamps `post_survey_completed_at` for a scholarship recipient's recipients survey
- `EventRegistrationReadiness` β€” Computes a registration's lifecycle `status` (`:not_ready` β†’ `:ready` β†’ `:survey_pending` β†’ `:certificate_due` β†’ `:completed`); `:survey_pending` is a scholarship recipient whose live (published + past-drip) post-event recipients survey is still unsubmitted (`post_survey_completed_at` blank), which gates the certificate. Status is computed from a pre-event "event ready" checklist, a post-event "completion work" checklist (attendance, scholarship tasks, the recipients survey), and certificate delivery, returning the specific outstanding reasons. Reads payment/certificate state via `Registerable` (`paid_in_full?`, `certificate_sent?`) on both the registration and its `continuing_education_registrations`. Drives the registrants roster's single far-right Status badge column (with a short reason under "Not ready" and a cert-type note under "Certificate pending") and its matching filter
- `ReminderRecipientFilter` β€” Decides which event registrations stay checked on the bulk reminder page given the admin's filters (matches in memory, returns matching ids)
- `BuiltinCalloutCards` β€” Renders the live, per-registration ticket callout cards (payment, certificate, scholarship, CE hours, videoconference), overlaying dynamic status (badge, colour, visibility guard, destination) on each materialized built-in row via `#card_for`. Rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `BuiltinCallouts`) so the two paths never double-render, and `#cards` serves as the fallback for events not yet seeded; `.editor_cards` builds the editor's preview cards. Handouts and FAQ are pure content cards with no builder here β€” they render from their row. Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized)
- `BuiltinCallouts` β€” Owns the built-in callout definitions and materializes them into `RegistrationTicketCallout` rows in canonical ticket order: `seed` persists (on create, and lazily on edit so older events heal with no backfill), `build` makes the same rows in memory for the new-event form (with `builtin_key` round-tripped through nested attributes), `reset`/`customized?` back the "Restore default" control. All eight seed **hidden** by default β€” admins publish the ones they want; there's no config-based auto-publish. Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Handouts, FAQ) render their own copy/resources on the generic callout page; "behavioral" cards render live status through `BuiltinCalloutCards#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Videoconference drips a week before start via `display_from`. CE hours is edited like every other built-in β€” its title/text live entirely on the row (the legacy `event_details*`/`ce_hours_details*` event columns were dropped); the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE page reads the row's title/description. Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`)
- `BuiltinCallouts` β€” Owns the built-in callout definitions and materializes them into `RegistrationTicketCallout` rows in canonical ticket order: `seed` persists (on create, and lazily on edit so older events heal with no backfill), `build` makes the same rows in memory for the new-event form (with `builtin_key` round-tripped through nested attributes), `reset`/`customized?` back the "Restore default" control. All seed **hidden** by default β€” admins publish the ones they want; there's no config-based auto-publish. The set includes three post-event survey built-ins (`day_1_survey`, `day_2_survey`, `scholarship_recipients_survey`), each carrying a `form_id` (its inline survey form, picked in the callout editor) and a relevant default drip; a `seed_if` gate keeps `day_2_survey` off single-day events. Their live ticket cards (and the recipients-only guard) live in `BuiltinCalloutCards`; the inline page is `Events::CalloutsController#survey`. Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Handouts, FAQ) render their own copy/resources on the generic callout page; "behavioral" cards render live status through `BuiltinCalloutCards#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Videoconference drips a week before start via `display_from`. CE hours is edited like every other built-in β€” its title/text live entirely on the row (the legacy `event_details*`/`ce_hours_details*` event columns were dropped); the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE page reads the row's title/description. Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`)
- `CalloutContent` β€” Parses admin-authored callout HTML into ordered segments so **every** callout content page renders the same way: plain rich text, with each standard `<details><summary>…</summary>…</details>` disclosure (the markup any HTML generator/LLM produces; `<toggle>` and a `title` attribute are accepted aliases; `<details open>` starts expanded) rebuilt into a styled collapsible card. `<details>`/`<summary>` are also on the `form_label_html` allowlist (`FORM_LABEL_TAGS`, plus the `open` attribute), so a disclosure is never stripped on save β€” the parser only upgrades its styling. Rendered through the shared `app/views/events/callouts/_rich_content.html.erb` partial (which wraps each disclosure in `_toggle.html.erb`), used by the CE hours, custom-callout, behavioural-card-intro, and FAQ pages. The FAQ page renders the editable `faq` callout `description` (each question a `<details>`); the default questions hydrate onto the row when it's materialized (from `BuiltinCallouts.faq_html`), so a blanked description shows blank with no render-time fallback. Content with no disclosure renders unchanged
- `SampleTicketRegistration` β€” Builds the **unsaved, data-free** `EventRegistration` ("Sample Person") that the sample ticket and its admin-only callout-page previews render from; nothing is ever persisted, so the preview can't read from or write to a real registrant or leak into counts/revenue/rosters/reminders. `all_options:` mirrors the ticket's "Show all options" toggle (turns on scholarship/CE/W-9 so those cards and preview pages render). Shared by `EventsController#sample_ticket` and `Events::CalloutsController`'s sample mode (the `sample` param β†’ admin-authed in-memory previews of the behavioral built-in pages, linked from the sample ticket via `EventHelper#sample_callout_path`)

Expand Down
15 changes: 14 additions & 1 deletion app/controllers/event_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ class EventRegistrationsController < ApplicationController
require "csv"

# show redirects to slug URL; kept for backwards compatibility
before_action :set_event_registration, only: [ :show, :edit, :update, :destroy, :update_onboarding, :toggle_certificate_issued, :update_attendance ]
before_action :set_event_registration, only: [ :show, :edit, :update, :destroy, :update_onboarding, :toggle_certificate_issued, :update_attendance, :toggle_post_survey ]

def index
authorize!
Expand Down Expand Up @@ -195,6 +195,19 @@ def update_attendance
redirect_to attendance_report_path(date, reopen: true), status: :see_other
end

# Admin toggle for whether the post-event (scholarship recipients) survey is in.
# Independent of the certificate: clears/sets only its own timestamp.
def toggle_post_survey
authorize! @event_registration, to: :update?
if @event_registration.post_survey_completed?
@event_registration.clear_post_survey_completed!
else
@event_registration.mark_post_survey_completed!
end
redirect_back fallback_location: edit_event_registration_path(@event_registration),
notice: "Post-event survey updated."
end

def confirm
@event_registration = EventRegistration.includes(registrant: :user, event: :location).find(params[:id])
authorize! @event_registration, to: :confirm?
Expand Down
70 changes: 70 additions & 0 deletions app/controllers/events/callouts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,47 @@ def faq
@faq_content = callout&.description
end

# Maps each survey built-in to the FormSubmission role it records under. The
# scholarship recipients survey is the one that gates readiness, tagged
# "post_event_survey".
SURVEY_ROLES = {
"day_1_survey" => "day_1_survey",
"day_2_survey" => "day_2_survey",
"scholarship_recipients_survey" => "post_event_survey"
}.freeze

# The inline survey page for a survey callout: renders the drip notice before
# the drip date, the form to fill, or the submitted answers (read-only) with an
# edit affordance.
def survey
@callout = survey_callout
return redirect_to registration_ticket_path(@event_registration.slug) unless @callout

@form = @callout.form
@role = SURVEY_ROLES.fetch(@callout.builtin_key)
@dripping = @callout.dripping?
@submission = survey_submission
@editing = @submission.nil? || params[:edit].present?
end

def submit_survey
@callout = survey_callout
return redirect_to registration_ticket_path(@event_registration.slug) if @callout.nil? || @callout.dripping?

service = EventRegistrationServices::SurveySubmission.call(
event_registration: @event_registration,
form: @callout.form,
role: SURVEY_ROLES.fetch(@callout.builtin_key),
field_params: survey_field_params,
clarity_params: survey_clarity_params
)
track_survey_profile_changes(service.profile_changes)
NotificationMailer.survey_submitted_fyi(service.submission).deliver_later

redirect_to registration_survey_path(@event_registration.slug, @callout.builtin_key),
notice: "Thanks! Your responses have been submitted."
end

private

# Attendance sign-in/out follows the CE payment β€” it's the CE sign-in sheet. Any-of
Expand Down Expand Up @@ -296,6 +337,35 @@ def sign_out_notice(entry)
"Signed out for #{entry.attendance_date.strftime("%a, %b %-d")} at #{time}."
end

# The published survey callout named by :builtin_key, once it actually carries a
# form. Nil (β†’ back to the ticket) for anything else.
def survey_callout
callout = @event.registration_ticket_callouts.find_by(builtin_key: params[:builtin_key])
return unless callout && callout.form && !callout.hidden? && SURVEY_ROLES.key?(callout.builtin_key)
callout
end

def survey_submission
FormSubmission.find_by(person: @event_registration.registrant, form: @callout.form,
event: @event, role: SURVEY_ROLES.fetch(@callout.builtin_key))
end

def survey_field_params
params.dig(:survey, :fields)&.to_unsafe_h || {}
end

def survey_clarity_params
params.dig(:survey, :clarity)&.to_unsafe_h || {}
end

# One Ahoy event per profile field the survey actually changed (anonymity / name
# display), so the change is auditable.
def track_survey_profile_changes(changes)
changes.each do |attribute, (from, to)|
ahoy.track("profile.#{attribute}", person_id: @event_registration.registrant_id, from: from, to: to)
end
end

# Whether the event's built-in callout for this key is materialized and
# published (visible). These public pages gate on that alone now β€” the admin's
# published/hidden choice on the row decides whether the page is reachable, so
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ def registrants
@readiness = @event_registrations.to_h do |registration|
[ registration.id, EventRegistrationReadiness.new(registration) ]
end
if params[:readiness].in?(%w[ not_ready ready certificate_due completed ])
if params[:readiness].in?(%w[ not_ready ready survey_pending certificate_due completed ])
@event_registrations.select! { |r| @readiness[r.id].status.to_s == params[:readiness] }
end

Expand Down Expand Up @@ -1217,6 +1217,7 @@ def set_form_variables
@scholarship_forms = Form.standalone.where(role: "scholarship").order(:name)
@bulk_payment_forms = Form.standalone.where(role: "bulk_payment").order(:name)
@continuing_education_forms = Form.standalone.where(role: "continuing_education").order(:name)
@survey_forms = Form.standalone.where(role: %w[ day_1_survey day_2_survey post_event_survey ]).order(:name)
@categories_grouped =
Category
.includes(:category_type)
Expand Down
13 changes: 13 additions & 0 deletions app/mailers/notification_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ def event_registration_confirmation_fyi(notification)
)
end

# Staff heads-up that a registrant submitted a post-event survey. Takes the
# FormSubmission directly (no Notification record) and goes to the default system
# address.
def survey_submitted_fyi(form_submission)
@form_submission = form_submission
@person = form_submission.person
@event = form_submission.event
@form = form_submission.form
@answers = form_submission.form_answers.order(:id)

mail(subject: "#{FYI_PREFIX} New #{@form.name} submission from #{@person.full_name}")
end

def event_registration_cancelled_fyi(notification)
@event_registration = notification.noticeable
@event = @event_registration.event.decorate
Expand Down
17 changes: 17 additions & 0 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,23 @@ def videoconference_details_visible?(now = Time.current)
from.blank? || now >= from
end

# The scholarship recipients survey callout, if seeded. Memoized so readiness
# (which runs per registration) hits the callouts once per event, not per row.
def scholarship_recipients_survey_callout
return @scholarship_recipients_survey_callout if defined?(@scholarship_recipients_survey_callout)
@scholarship_recipients_survey_callout =
registration_ticket_callouts.detect { |callout| callout.builtin_key == "scholarship_recipients_survey" }
end

# Whether the scholarship recipients survey is live β€” published and past its drip.
# Only then does an unsubmitted survey count against a recipient's completion; an
# unpublished (or not-yet-dripped) survey blocks no one.
def post_event_survey_open?(now = Time.current)
callout = scholarship_recipients_survey_callout
return false unless callout && !callout.hidden?
callout.display_from.blank? || callout.display_from <= now
end

def registerable?
!ended? && (registration_close_date.nil? || registration_close_date >= Time.current)
end
Expand Down
16 changes: 16 additions & 0 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,22 @@ def attended?
status == "attended"
end

# The post-event (scholarship recipients) survey is "in" once this timestamp is
# set β€” by the registrant submitting it or an admin toggling it. Mirrors the
# Certifiable certificate_sent_at pattern so the roster's readiness reads a plain
# column with no extra query.
def post_survey_completed?
post_survey_completed_at.present?
end

def mark_post_survey_completed!(at: Time.current)
update!(post_survey_completed_at: at)
end

def clear_post_survey_completed!
update!(post_survey_completed_at: nil)
end

# The certificate of completion unlocks once the training has happened, the
# registrant attended, and any scholarship tasks are complete. Issuing a CE
# certificate (an admin marking the credit sent) is itself an affirmation that
Expand Down
14 changes: 14 additions & 0 deletions app/models/form_field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ class FormField < ApplicationRecord
has_many :form_answers, dependent: :nullify
has_many :childs, foreign_key: "parent_id", class_name: "FormField"

# A field can fan out over resources: with any linked here it becomes a
# "per-resource" question rendering one input per resource (see FormFieldResource).
has_many :form_field_resources, -> { ordered }, dependent: :destroy, inverse_of: :form_field
has_many :resources, through: :form_field_resources

# has_many through
has_many :answer_options, through: :form_field_answer_options

Expand Down Expand Up @@ -163,6 +168,9 @@ class FormField < ApplicationRecord
accepts_nested_attributes_for :form_field_answer_options, allow_destroy: true,
reject_if: ->(attrs) { attrs[:option_name].blank? }

accepts_nested_attributes_for :form_field_resources, allow_destroy: true,
reject_if: ->(attrs) { attrs[:resource_id].blank? }

scope :published, -> { where(status: "active") }

# Methods
Expand All @@ -172,6 +180,12 @@ def selectable?
answer_type.in?(SELECTABLE_ANSWER_TYPES)
end

# True when this field fans out over linked resources β€” rendered once per resource
# on the survey page, with the resource's title appended to the prompt.
def per_resource?
form_field_resources.any?
end

# True for fields whose answer options are tied to backend logic (currently the
# payment-method field's Stripe wiring) and so should be shown read-only in the
# form builder rather than freely edited.
Expand Down
16 changes: 16 additions & 0 deletions app/models/form_field_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class FormFieldResource < ApplicationRecord
# Ordered join between a form field and the resources it fans out over. A field
# with any of these is a "per-resource" question: on the survey page it renders
# one input per linked resource (e.g. the post-event survey clarity question,
# asked once per training topic/handout). The field owns the prompt wording and
# answer options; each resource just supplies the item the prompt is asked about.
belongs_to :form_field
belongs_to :resource

positioned on: :form_field_id

validates :resource_id, uniqueness: { scope: :form_field_id }
validates :position, numericality: { only_integer: true, greater_than: 0, allow_nil: true }

scope :ordered, -> { order(:position, :id) }
end
Loading
Loading