Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0f807a8
Materialize ticket callouts: schema, seeder, multi-resource links
maebeale Jul 8, 2026
cea9f5e
Wire callout seeding into create/edit, render + edit materialized rows
maebeale Jul 8, 2026
21a6935
Materialize behavioral magic callouts (Certificate, Videoconference)
maebeale Jul 8, 2026
78c3307
Materialize the remaining behavioral callouts (Payment, Scholarship, …
maebeale Jul 8, 2026
8eb5ba0
Make the W-9 a removable resource on the materialized Forms card
maebeale Jul 8, 2026
a86f5af
Include the W-9 on the Forms card only for paid events
maebeale Jul 9, 2026
524dabd
Edit built-in callouts in the same row as custom ones
maebeale Jul 10, 2026
83dd53e
Edit callout resources one dropdown at a time (add-another)
maebeale Jul 10, 2026
3774be3
Polish callout page-text editor: hints, copy, collapsible resources
maebeale Jul 10, 2026
c5aa46e
Let every built-in callout link resources too
maebeale Jul 10, 2026
c4ec7d0
Fold CE hours and Event details into the sortable callout list
maebeale Jul 10, 2026
f0fdc90
Stack callout Title and Subtitle as full-width rows
maebeale Jul 10, 2026
2f1349c
Fold callout page-text hint into its label
maebeale Jul 10, 2026
99cb0ca
Use the basic-HTML hint as the callout page-text placeholder
maebeale Jul 10, 2026
ae6ae0c
Shorten callout page-text placeholder to 'Accepts basic HTML.'
maebeale Jul 10, 2026
7256d10
Drop period from callout page-text placeholder
maebeale Jul 10, 2026
c38fa47
Show callout visibility as a right-aligned Published toggle
maebeale Jul 10, 2026
767a1c7
Move CE hours and Art supplies onto their rows like every other built-in
maebeale Jul 10, 2026
1cec31b
Collapse callout type/colour/icon under a toggle like linked resources
maebeale Jul 10, 2026
eb21436
Align callout type/colour/icon and resources toggles on one row
maebeale Jul 10, 2026
173d5f4
Apply Restore default on the main form save, not a separate request
maebeale Jul 10, 2026
c76b342
Move Matches/Restore default beside the Published toggle on the right
maebeale Jul 11, 2026
5836fd3
Keep Payment's live-status colour; note the new-event callout flow
maebeale Jul 11, 2026
3947e5a
Turn Scholarship and CE cards amber when action is needed
maebeale Jul 11, 2026
9b93c2b
Merge branch 'main' into callout-templates
jmilljr24 Jul 12, 2026
08ab050
make index unique
jmilljr24 Jul 12, 2026
5e3c735
show/hidde callout edit on publish checkbox
jmilljr24 Jul 12, 2026
b1fe8fe
ui- add callout name to checkbox row
jmilljr24 Jul 12, 2026
9a32c1c
allow all callouts to be used on any event - let admin decide vs hard…
jmilljr24 Jul 12, 2026
0e81c22
rubocop
jmilljr24 Jul 12, 2026
98bfdda
clean up ce callout
jmilljr24 Jul 12, 2026
2ec50e6
guard
jmilljr24 Jul 12, 2026
5f6dd1a
spec
jmilljr24 Jul 12, 2026
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
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ This codebase (Rails 8.1)
| `Event` | Events with registrations, featured/published states |
| `EventStaff` | Join model connecting `Person` to `Event` as staff (title, `expected_to_attend`); drives the "Meet the staff" roster and "My events" |
| `EventRegistrationChecklistCompletion` | Audited completion row for one manual onboarding step on an `EventRegistration` (`step` from `EventRegistration::CHECKLIST_STEPS`, `completed_by` User, `completed_at`); row-exists = done. Powers the event Onboarding tab's checkbox matrix |
| `RegistrationTicketCallout` | Admin-configured call-outs shown on an event's registration ticket (title, subtitle, HTML description, `callout_type` action/reference, icon/colour, `payment_access_gated` — only shown once the registrant has `payment_access_granted?` (paid or intends to pay), draggable `position` via the positioning gem); each links to its own public detail page |
| `RegistrationTicketCallout` | Call-outs shown on an event's registration ticket (title, subtitle, HTML description, `callout_type` action/reference, icon/colour, `payment_access_gated` — only shown once the registrant has `payment_access_granted?`, draggable `position`, `hidden` draft/opt-out, `display_from` drip date, and `has_many :resources` through `RegistrationTicketCalloutResource`); each links to its own public detail page. A nil `magic_key` is an admin-authored callout; a set `magic_key` is a built-in card materialized by `DefaultTicketCallouts` (hidden instead of deleted, restorable to default) |
| `RegistrationTicketCalloutResource` | Ordered join linking a `RegistrationTicketCallout` to the `Resource`s shown on its detail page |
| `Story` | Editorial content with facilitators, primary/gallery assets |
| `Resource` | Handouts, toolkits, templates with downloadable assets |
| `Person` | Organization affiliates with contacts, addresses, sectors |
Expand Down Expand Up @@ -204,7 +205,8 @@ end
- `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
- `ReminderRecipientFilter` — Decides which event registrations stay checked on the bulk reminder page given the admin's filters (matches in memory, returns matching ids)
- `MagicTicketCallouts` — Code-defined ("magic") ticket callout cards (payment, certificate, scholarship, CE hours, art supplies, forms, handouts, portal, videoconference, FAQ), each with its own visibility rule; rendered through the same `_callout_card` partial as admin-configured `RegistrationTicketCallout`s. Their public show pages live under `app/views/events/callouts/` and are served by `Events::CalloutsController` (slug-authorized, no login)
- `MagicTicketCallouts` — Code-defined ("magic") ticket callout cards (payment, certificate, scholarship, CE hours, art supplies, forms, handouts, portal, videoconference, FAQ), each with its own visibility rule; rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `DefaultTicketCallouts`) so the two paths never double-render, and serves as the fallback for events not yet seeded. Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized)
- `DefaultTicketCallouts` — Materializes all built-in callouts into `RegistrationTicketCallout` rows in canonical ticket order, seeded on event create and lazily on edit, each gated by the event's config via `seed_if` (idempotent, no backfill). 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; "behavioral" cards render live status through `MagicTicketCallouts#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. Certificate is opt-in (default off except trainings); Videoconference drips a week before start via `display_from`. CE hours and Event details are edited like every other built-in — their title/text seed from the event's columns once (migrating existing content) then live on the row; the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE/details pages read the row (falling back to the event columns pre-seed). 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`)

### Affiliations

Expand Down
45 changes: 36 additions & 9 deletions app/controllers/events/callouts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ class CalloutsController < ApplicationController
before_action :set_event_registration
before_action :authorize_callout
before_action :set_event
# These pages carry an editable intro (the built-in row's "Callout page text")
# above the app-controlled content, plus any resources linked to the row.
before_action :set_builtin_content, only: %i[ payment scholarship certificate videoconference forms ]

# Hidden Resource (by title) backing the handout links, in display order.
# Missing ones (e.g. not seeded in an environment) are silently skipped.
Expand Down Expand Up @@ -177,6 +180,16 @@ def set_event
@event = @event_registration.event
end

# The editable intro and linked resources for a built-in page, from the
# materialized callout row for this action's magic_key. Nil/empty when the
# event hasn't materialized the card. Forms renders its own document list
# (W-9 + invoice/receipt) via #build_form_cards, so it skips the resources here.
def set_builtin_content
callout = @event.registration_ticket_callouts.find_by(magic_key: action_name)
@builtin_intro = callout&.description.presence
@builtin_resources = callout && action_name != "forms" ? callout.resources.to_a : []
end

# Update form submission if ce record is updated via callout
def record_ce_license_answer(number)
form = @event.continuing_education_form
Expand All @@ -188,16 +201,16 @@ def record_ce_license_answer(number)
answer.update!(submitted_answer: number.to_s, question_name_when_answered: field.name)
end

# Builds the callout-card links shown on the forms page. The W-9 opens in its
# own resource page (preview + download) when seeded; the invoice and the
# paid-in-full receipt (once settled) show for paid events. Each returns to forms.
# Builds the callout-card links shown on the forms page. The document links
# come from the materialized Forms callout's resources (the W-9 by default,
# editable/removable per event) — or the hard-coded W-9 for events not yet
# materialized. The invoice and paid-in-full receipt (once settled) show for
# paid events. Each returns to forms.
def build_form_cards
cards = []
w9 = Resource.find_by(title: "W-9")
if w9
cards << resource_card(icon: "fa-solid fa-file-pdf", title: "W-9",
subtitle: "AWBW's W-9 tax form for your records",
href: registration_resource_path(@event_registration.slug, w9, return_to: "forms"), target: nil)
cards = form_document_resources.map do |resource|
resource_card(icon: "fa-solid fa-file-pdf", title: resource.title,
subtitle: form_resource_subtitle(resource),
href: registration_resource_path(@event_registration.slug, resource, return_to: "forms"), target: nil)
end
if @event_registration.invoice_available?
cards << resource_card(icon: "fa-solid fa-file-invoice-dollar", title: "View invoice",
Expand All @@ -212,6 +225,20 @@ def build_form_cards
cards
end

# The forms callout's linked documents. Uses the materialized Forms row's
# resources when present (so admins can add/remove them), else the W-9 for
# events not yet materialized.
def form_document_resources
forms_callout = @event.registration_ticket_callouts.find_by(magic_key: "forms")
return forms_callout.resources.to_a if forms_callout
Resource.where(title: "W-9").to_a
end

def form_resource_subtitle(resource)
return "AWBW's W-9 tax form for your records" if resource.title == "W-9"
"Open this document"
end

# A blue callout card linking to a document. External/static links open in a
# new tab (target: "_blank"); registrant resource pages stay in-tab so the
# back-to-ticket eyebrow works (pass target: nil).
Expand Down
13 changes: 12 additions & 1 deletion app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ def new

def edit
authorize! @event
# Materialize any missing built-in callouts so the editor shows them all
# (idempotent; heals events created before a built-in existed).
DefaultTicketCallouts.seed(@event)
set_form_variables
end

Expand Down Expand Up @@ -188,7 +191,11 @@ def onboarding
def details
authorize! @event, to: :details?

if @event.event_details.blank?
callout = @event.registration_ticket_callouts.find_by(magic_key: "event_details")
@event_details_title = callout&.title.presence || @event.event_details_label
@event_details_body = callout&.description.presence || @event.event_details

if @event_details_body.blank?
redirect_to event_path(@event, reg: params[:reg].presence)
return
end
Expand Down Expand Up @@ -445,6 +452,7 @@ def create
if params.dig(:library_asset, :new_assets).present?
update_asset_owner(@event)
end
DefaultTicketCallouts.seed(@event)
success = true
end
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e
Expand Down Expand Up @@ -473,6 +481,9 @@ def update
@event.event_forms.reset
if @event.update(event_params)
assign_associations(@event)
# Lazily materialize built-in callouts for events created before this
# existed — heals on first edit, no data backfill. Idempotent.
DefaultTicketCallouts.seed(@event)
success = true
else
raise ActiveRecord::Rollback
Expand Down
8 changes: 7 additions & 1 deletion app/controllers/registration_ticket_callouts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ def show
@callout = @event.registration_ticket_callouts.find(params[:id])
authorize! @callout, to: :show?

if @callout.description.blank? && @callout.resource.nil?
# A hidden (draft/opted-out) or not-yet-dripped callout has no public page.
if @callout.hidden? || @callout.dripping?
redirect_to event_path(@event, reg: params[:reg].presence)
return
end

if @callout.description.blank? && @callout.resources.empty?
redirect_to event_path(@event, reg: params[:reg].presence)
return
end
Expand Down
5 changes: 4 additions & 1 deletion app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ class Event < ApplicationRecord
accepts_nested_attributes_for :event_staffs, allow_destroy: true,
reject_if: proc { |attrs| attrs["person_id"].blank? }
accepts_nested_attributes_for :registration_ticket_callouts, allow_destroy: true,
reject_if: proc { |attrs| attrs["title"].blank? }
# Reject only blank *new* callouts; existing rows (with an id) must always
# process so a control-only magic row's hidden/position toggle persists even
# though it submits no title.
reject_if: proc { |attrs| attrs["id"].blank? && attrs["title"].blank? }

# Callbacks
after_commit :build_public_registration_form, if: :public_registration_just_enabled?
Expand Down
114 changes: 106 additions & 8 deletions app/models/registration_ticket_callout.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,59 @@ class RegistrationTicketCallout < ApplicationRecord
# group them on the ticket later.
CALLOUT_TYPES = %w[ action reference ].freeze

# Hidden identifiers for the built-in ("magic") callouts. A row carrying one of
# these was seeded from a code-defined default (see DefaultTicketCallouts) and
# keeps its ticket behavior — badges, per-registration visibility — driven by
# that key. Admin-authored callouts have a nil magic_key. Magic callouts are
# hidden rather than destroyed so they can be restored to their default.
MAGIC_KEYS = %w[
payment certificate scholarship ce_hours event_details
videoconference forms handouts faq
].freeze

# "Content" magic callouts render their own editable copy/resources (like custom
# callouts). "Behavioral" magic callouts (the rest) render live per-registration
# status through MagicTicketCallouts#card_for — the row still owns the editable
# title/subtitle/text, order, visibility, and resources.
CONTENT_MAGIC_KEYS = %w[ handouts faq ].freeze

# Behavioral built-ins that also carry event-level config edited inline in their
# row (CE hours offered / cost); their text lives on the row like everything else.
CONFIG_MAGIC_KEYS = %w[ ce_hours ].freeze

# Built-ins whose card colour the app sets from live status — Payment turns
# orange while a balance is due, and Scholarship / CE hours turn amber while the
# registrant has something outstanding. For these, the app colour overrides the
# selected one (see MagicTicketCallouts#card_for).
APP_COLORED_MAGIC_KEYS = %w[ payment scholarship ce_hours ].freeze

# Per-type fallbacks for the icon and colour. These are callout-specific (unlike
# the generic colour swatches and palette, which live in DomainTheme so the whole
# app can reuse them for tinted boxes — amount-due, scholarship box, etc.).
DEFAULT_ICONS = { "action" => "fa-solid fa-arrow-right", "reference" => "fa-solid fa-circle-info" }.freeze
# The leading icon defaults to the info "i"; the trailing "go to page" arrow
# lives on the right (see _callout_card). Both types share the same leading
# fallback so a callout without a custom icon always reads as informational.
DEFAULT_ICONS = { "action" => "fa-solid fa-circle-info", "reference" => "fa-solid fa-circle-info" }.freeze
DEFAULT_COLORS = { "action" => "orange", "reference" => "indigo" }.freeze

# New callouts start with the arrow icon pre-filled (the "action" default) so
# admins see a sensible value rather than an empty field. Loaded records keep
# their stored value; a blank one still falls back via #display_icon_class.
# New callouts start with the info icon pre-filled so admins see a sensible
# value rather than an empty field. Loaded records keep their stored value; a
# blank one still falls back via #display_icon_class.
attribute :icon_class, :string, default: -> { DEFAULT_ICONS["action"] }

belongs_to :event

# Optionally links the callout to a Resource. When present, the callout's
# detail page renders the resource's display (PDF first-page preview, etc.)
# and a download button beneath the callout's own title/subtitle/content.
belongs_to :resource, optional: true
# A callout can link many resources, shown in order on its detail page (PDF
# previews + download buttons) beneath its own title/subtitle/content — e.g.
# the Handouts card's worksheets, or a custom callout's supporting documents.
has_many :registration_ticket_callout_resources, -> { ordered }, dependent: :destroy,
inverse_of: :registration_ticket_callout
has_many :resources, through: :registration_ticket_callout_resources

# Linked resources are added one dropdown at a time in the editor (cocoon
# add/remove), like Sectors on a Person. Blank picks are dropped.
accepts_nested_attributes_for :registration_ticket_callout_resources, allow_destroy: true,
reject_if: proc { |attrs| attrs["resource_id"].blank? }

# Per-event ordering, drag-reordered after save via the shared `sortable`
# Stimulus controller (a per-row PUT to #update). The gem reflows the other
Expand All @@ -33,13 +69,75 @@ class RegistrationTicketCallout < ApplicationRecord
validates :callout_type, inclusion: { in: CALLOUT_TYPES }
validates :color_class, inclusion: { in: DomainTheme::SWATCH_COLORS.map(&:to_s) }, allow_blank: true
validates :position, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
validates :magic_key, inclusion: { in: MAGIC_KEYS }, allow_nil: true
validates :magic_key, uniqueness: { scope: :event_id }, allow_nil: true

scope :ordered, -> { order(:position, :id) }
scope :visible, -> { where(hidden: false) }
scope :magic, -> { where.not(magic_key: nil) }
scope :custom, -> { where(magic_key: nil) }

# Reset flagged rows to their template after the save that set the flag. The
# flag is cleared first so the reset's own update doesn't recurse.
after_save :apply_reset_to_default, if: :reset_to_default?

def apply_reset_to_default
self.reset_to_default = nil
DefaultTicketCallouts.reset(self)
end

def action?
callout_type == "action"
end

# The editor exposes visibility as "Published" — the inverse of the stored
# `hidden` flag — so a checked box means the callout shows on the ticket.
def published
!hidden
end
alias_method :published?, :published

def published=(value)
self.hidden = !ActiveModel::Type::Boolean.new.cast(value)
end

# Set from the editor's "Restore default" checkbox. When checked, the row is
# reset to its built-in template as part of the normal event save — no separate
# request — overriding whatever else was submitted for it.
attr_accessor :reset_to_default

def reset_to_default?
magic? && ActiveModel::Type::Boolean.new.cast(reset_to_default)
end

# A seeded built-in callout (Handouts, FAQ, …) rather than an admin-authored
# one. Magic callouts hide instead of delete and can be reset to default.
def magic?
magic_key.present?
end

# A behavioral built-in callout whose card is rendered by MagicTicketCallouts
# (live status), as opposed to a content callout that renders from its own row.
def behavioral_magic?
magic? && CONTENT_MAGIC_KEYS.exclude?(magic_key)
end

# Whether the row carries the inline CE config fields (hours offered / cost).
def ce_config?
CONFIG_MAGIC_KEYS.include?(magic_key.to_s)
end

# Whether the app sets this card's colour from live status, overriding the
# selected colour (so the editor notes it under the colour picker).
def app_colored?
APP_COLORED_MAGIC_KEYS.include?(magic_key.to_s)
end

# Whether the callout is drip-scheduled to appear only from a future date.
def dripping?(now = Time.current)
display_from.present? && display_from > now
end

# Font Awesome class for the leading icon, falling back to a sensible default
# per callout type so a callout never renders without an icon.
def display_icon_class
Expand Down
14 changes: 14 additions & 0 deletions app/models/registration_ticket_callout_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class RegistrationTicketCalloutResource < ApplicationRecord
# Ordered join between a callout and the resources it links to (e.g. the
# Handouts card's worksheets, or a custom callout's supporting documents).
# Reordered like the callouts themselves via the positioning gem.
belongs_to :registration_ticket_callout
belongs_to :resource

positioned on: :registration_ticket_callout_id

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

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