Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
959d3ff
Track event-reg transfers via a back-link instead of a status
maebeale Aug 2, 2026
614130a
Add specs for the transfer trail and follow-up flow
maebeale Aug 2, 2026
29acfbc
Scope transfer destinations to the source event's kind
maebeale Aug 2, 2026
dc11fba
Drop transferred_in from the dashboard attendance breakdown
maebeale Aug 3, 2026
3971a9a
Surface transferred-in across roster, dashboard, filters, and exports
maebeale Aug 3, 2026
58af0f3
Keep a transferred-in reg's financials on the source; isolate reporting
maebeale Aug 4, 2026
645d5ba
Recognize a transferred-in registrant as a scholarship recipient
maebeale Aug 10, 2026
11ed108
Certify CE hours at the intended event, derived from the transfer link
maebeale Aug 10, 2026
1cc8209
Note transferred-in recipients on the recipient card; lock attendance…
maebeale Aug 10, 2026
d9dc5e0
Surface CE certification at the intended event on reporting/roster
maebeale Aug 10, 2026
d61d62b
Add transfer notices to the ticket and builtin callout pages
maebeale Aug 10, 2026
0785213
Collapse a double transfer to two live regs, not three
maebeale Aug 16, 2026
1b80669
Scope transfers by on-demand format; add recipient-card billed note
maebeale Aug 16, 2026
4126aa8
Resolve a transfer-in reg's money/records to its source
maebeale Aug 16, 2026
99bda92
Enforce same-format transfer at the endpoint, not just the picker
maebeale Aug 17, 2026
2a4c9d1
Split CE into two records on transfer so each event holds its own
maebeale Aug 17, 2026
cce4f21
Copy a transferring reg's org links onto the new registration
maebeale Aug 17, 2026
aa25c2f
Flag an unfinished transfer on the roster status badge
maebeale Aug 17, 2026
262d49b
Spell out transfer consequences on the interstitial for informed consent
maebeale Aug 17, 2026
3dd63d0
Read an incomplete transfer as the top not-ready reason
maebeale Aug 17, 2026
0c30a9b
Let admins edit and undo a transfer via a Manage-transfer hub
maebeale Aug 17, 2026
b95e061
Cover undoing a collapsed-chain transfer
maebeale Aug 18, 2026
99837b9
Tighten the transfer consent copy
maebeale Aug 18, 2026
4bfb4d4
Split transfer consent into two columns; add Manage link on the in reg
maebeale Aug 18, 2026
39af620
Copy the registrant's progress fields forward on transfer
maebeale Aug 18, 2026
63cc437
Lock: recipients shout-outs are active-only
maebeale Aug 18, 2026
d7e5691
Lock a transferred-out registration from editing
maebeale Aug 18, 2026
ac422ab
Surface the source CE payment and allow a new award on a transferred-…
maebeale Aug 18, 2026
68c4d2c
Catalog the event-registration transfer feature
maebeale Aug 18, 2026
d98efce
Make a transferred-out ticket read-only; show the transfer banner on …
maebeale Aug 18, 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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ action, or `authorize! :workshop, to: :summary?`).

- `EventRegistrationServices::ProcessConfirmation` β€” Registration confirmation flow
- `EventRegistrationServices::PublicRegistration` β€” Public registration handling
- `EventRegistrationServices::TransferContinuingEducation` β€” Splits/relocates a registrant's CE when they transfer events (issue #1944): a simple forward transfer leaves a paid, zero-hours **stub** on the source (its payments count at the original event) and creates a **live** record on the destination carrying the hours and the outstanding balance; when the reg being transferred out is itself a transfer-in (a collapsing double transfer, or a transfer back to the origin) its live record is relocated forward β€” merging back into the origin's stub β€” instead of split again, so no third record appears. Runs inside the transfer transaction, after the destination is saved and before a collapsing middle reg is destroyed
- `EventRegistrationServices::RevertTransfer` β€” Undoes a transfer-out (issue #1944): restores the reg to the status it held before the transfer (via `status_before_transfer`, or "registered"), and when a destination was already recorded, unlinks it (it becomes a normal standalone reg, nothing deleted) and re-merges its split CE back onto the source (`TransferContinuingEducation#revert`). Backs the "Manage transfer" hub's undo action
- `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)
- `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)
Expand Down
24 changes: 22 additions & 2 deletions app/controllers/continuing_education_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ def show

def new
authorize!
return if redirect_transferred_in_ce

@ce_registration = @event_registration.continuing_education_registrations.build(
professional_license: @event_registration.registrant.professional_licenses.first,
hours: @event_registration.event.ce_hours_offered,
Expand All @@ -30,6 +32,7 @@ def new

def create
authorize!
return if redirect_transferred_in_ce

@ce_registration = @event_registration.continuing_education_registrations.build(professional_license: license_for_create)

Expand Down Expand Up @@ -95,6 +98,19 @@ def set_event_registration
redirect_to root_path, alert: "Registration not found.", status: :see_other unless @event_registration
end

# A transferred-in reg's CE record is created by the transfer itself (carried
# from the source), so admins don't add one manually β€” send them to the source,
# where any additional CE belongs. The transfer's system-created record is exempt
# (it's built by the service, not this controller). (#1944)
def redirect_transferred_in_ce
return false unless @event_registration.transferred_in?

redirect_to edit_event_registration_path(@event_registration.transferred_from_registration),
alert: "This registrant transferred in from another event β€” manage their CE on the original registration.",
status: :see_other
true
end

def license_for_create
@event_registration.registrant.professional_licenses.first ||
@event_registration.registrant.professional_licenses.build
Expand All @@ -107,8 +123,12 @@ def apply_ce_params(ce_registration)
expires_on: params.dig(:continuing_education_registration, :license_expires_on),
license_id: params.dig(:continuing_education_registration, :professional_license_id))
ce_registration.hours = params.dig(:continuing_education_registration, :hours)
cost = params.dig(:continuing_education_registration, :cost_dollars)
ce_registration.cost_cents = (cost.to_d * 100).round if cost.present?
# A transfer-created record's cost is snapshotted from the source's outstanding
# balance and admin-locked, so ignore any submitted cost for it. (#1944)
unless ce_registration.transfer_created?
cost = params.dig(:continuing_education_registration, :cost_dollars)
ce_registration.cost_cents = (cost.to_d * 100).round if cost.present?
end

comments = params.fetch(:continuing_education_registration, {})
.permit(comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ])[:comments_attributes]
Expand Down
176 changes: 174 additions & 2 deletions app/controllers/event_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ 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, :transfer, :process_transfer, :revert_transfer ]
# A transferred-out reg is locked (issue #1944): its inline write endpoints are
# blocked with a warning rather than silently ignored. The full-form `update` is
# handled separately (it keeps comments/communications editable).
before_action :block_locked_registration, only: [ :update_onboarding, :toggle_certificate_issued, :update_attendance ]

def index
authorize!
Expand Down Expand Up @@ -85,6 +89,7 @@ def create

def update
authorize! @event_registration
warn_if_locked_fields_submitted
@event_registration.assign_attributes(event_registration_update_params)
@event_registration.comments.select(&:new_record?).each { |c| c.created_by = current_user; c.updated_by = current_user }
@event_registration.comments.select { |c| c.persisted? && c.body_changed? }.each { |c| c.updated_by = current_user }
Expand All @@ -94,6 +99,16 @@ def update
@event_registration.notifications.select(&:new_record?).each { |n| n.recipient_email = recipient_email }

if @event_registration.save
# Marking transferred out β€” from the edit-form save OR the inline roster/
# onboarding status chip (Turbo) β€” with no destination yet sends the admin
# to the transfer screen to create/link the incoming registration. Handled
# before respond_to so both the HTML and Turbo paths redirect (issue #1944).
if @event_registration.saved_change_to_status? &&
@event_registration.transfer_destination_pending? &&
allowed_to?(:transfer?, @event_registration)
return redirect_to transfer_event_registration_path(@event_registration, return_to: params[:return_to]), status: :see_other
end

notice = "Registration was successfully updated."
respond_to do |format|
format.turbo_stream
Expand Down Expand Up @@ -195,6 +210,118 @@ def update_attendance
redirect_to attendance_report_path(date, reopen: true), status: :see_other
end

# Follow-up screen shown after a registration is marked "transferred out":
# pick the destination event so the incoming registration is created/linked
# and the transfer trail is preserved (issue #1944).
def transfer
authorize! @event_registration, to: :transfer?
@return_to = params[:return_to]
@events = transfer_destination_events
end

def process_transfer
authorize! @event_registration, to: :transfer?
destination_event = Event.find(params[:destination_event_id])

# Enforce the same-format rule server-side, not just in the picker: an event
# only transfers to another of its own format (on-demand ↔ on-demand). (#1944)
unless transfer_destination_events.exists?(destination_event.id)
redirect_to transfer_event_registration_path(@event_registration, return_to: params[:return_to].presence),
alert: "You can only transfer to another #{@event_registration.event.on_demand? ? "on-demand" : "scheduled"} event.",
status: :see_other
return
end

# The registrant may already be registered for the destination event, which
# would collide with the (registrant, event) uniqueness rule β€” link that
# record as the transfer target instead of creating a duplicate.
destination = EventRegistration.find_or_initialize_by(
registrant_id: @event_registration.registrant_id,
event_id: destination_event.id
)
# Collapse a double transfer (A→B→C) to two live regs: when the reg being
# transferred out is itself a transfer-in, its predecessor is the real origin,
# so the new reg points straight there and the middle stop is dropped. (#1944)
source = @event_registration.transferred_from_registration || @event_registration

if destination == source
# Transferring back to the origin event undoes the whole chain: restore the
# origin to the status it held before it was transferred out, instead of
# linking it to itself.
destination.status = destination.status_before_transfer.presence || "registered"
destination.status_before_transfer = nil
else
destination.transferred_from_registration = source
# Copy the registrant's progress/profile state forward so the new reg reflects
# where they left off (money & CE resolve separately). Days attended, expected
# payment method, buddy-pay, and the recipients-page feature/shout-out flag. (#1944)
copied = {
expected_payment_method: @event_registration.expected_payment_method,
someone_else_will_pay: @event_registration.someone_else_will_pay,
shoutout: @event_registration.shoutout
}
EventRegistration::DAY_FIELDS.each { |field| copied[field] = @event_registration[field] }
destination.assign_attributes(copied)
end

saved = ActiveRecord::Base.transaction do
# Re-pointing a completed transfer to a different event: unlink the previously
# recorded destination (it becomes a standalone reg) and re-merge its CE back
# to the source before re-splitting to the newly chosen event. (#1944)
previous = @event_registration.transferred_to_registration
if previous && previous.event_id != destination_event.id
EventRegistrationServices::TransferContinuingEducation.new(
transferred_out: @event_registration, destination: previous
).revert
previous.update!(transferred_from_registration: nil)
end
next false unless destination.save
# Carry the transferring reg's org links onto the destination so the new reg
# shares the same linked organizations β€” copied, not moved, so the source
# keeps its own. Read before the middle reg is dropped below. (#1944)
@event_registration.organizations.each do |organization|
destination.event_registration_organizations.find_or_create_by!(organization: organization)
end
# Split/relocate CE before dropping a collapsing middle reg, so its record
# moves forward instead of being cascade-destroyed with the reg. (#1944)
EventRegistrationServices::TransferContinuingEducation.new(
transferred_out: @event_registration, destination: destination
).call
@event_registration.destroy! if @event_registration.transferred_in?
true
end

if saved
redirect_to edit_event_registration_path(destination, return_to: params[:return_to].presence),
notice: "Transfer recorded β€” #{source.registrant.full_name} is now registered for #{destination_event.title}.",
status: :see_other
else
@return_to = params[:return_to]
@events = transfer_destination_events
flash.now[:alert] = destination.errors.full_messages.to_sentence
render :transfer, status: :unprocessable_content
end
rescue ActiveRecord::RecordNotFound
redirect_to transfer_event_registration_path(@event_registration, return_to: params[:return_to].presence),
alert: "Select a destination event to transfer to.", status: :see_other
end

# Undo a transfer-out: restore the reg to its pre-transfer status, and (when a
# destination was already recorded) unlink that destination and re-merge its CE
# back to the source. (#1944)
def revert_transfer
authorize! @event_registration, to: :transfer?
unless EventRegistrationServices::RevertTransfer.call(registration: @event_registration)
redirect_to edit_event_registration_path(@event_registration, return_to: params[:return_to].presence),
alert: "This registration isn't transferred out.", status: :see_other
return
end

redirect_to edit_event_registration_path(@event_registration, return_to: params[:return_to].presence),
notice: "Transfer undone β€” #{@event_registration.registrant.full_name} is back to #{@event_registration.attendance_status_label.downcase} on #{@event_registration.event.title}.",
status: :see_other
end

def confirm
@event_registration = EventRegistration.includes(registrant: :user, event: :location).find(params[:id])
authorize! @event_registration, to: :confirm?
Expand Down Expand Up @@ -271,6 +398,7 @@ def link_organization
def select_organization
@event_registration = EventRegistration.find(params[:id])
authorize! @event_registration, to: :select_organization?
return deny_locked_org_edit if @event_registration.editing_locked?
@person = @event_registration.registrant
organization = Organization.find(params[:organization_id])

Expand All @@ -282,6 +410,7 @@ def select_organization
def create_organization
@event_registration = EventRegistration.find(params[:id])
authorize! @event_registration, to: :create_organization?
return deny_locked_org_edit if @event_registration.editing_locked?
@person = @event_registration.registrant
# Build the org from a name the registrant actually typed on the form, so the
# button can't be used to create an arbitrary org β€” it only resolves a pending
Expand Down Expand Up @@ -310,6 +439,7 @@ def create_organization
def unlink_organization
@event_registration = EventRegistration.find(params[:id])
authorize! @event_registration, to: :unlink_organization?
return deny_locked_org_edit if @event_registration.editing_locked?
organization = Organization.find(params[:organization_id])

# Intentional UX choice: "Unlink" only removes the org from this registration and
Expand Down Expand Up @@ -382,6 +512,16 @@ def attendance_report_path(date, reopen: false)
edit: (cell if reopen), anchor: cell)
end

# Events a registrant can be transferred into: published events of the same
# format as the one they're leaving β€” an on-demand event only transfers to
# another on-demand event, and a scheduled (non-on-demand) event only to
# another scheduled event β€” excluding the source event, most recent first.
def transfer_destination_events
Event.where(published: true, on_demand: @event_registration.event.on_demand)
.where.not(id: @event_registration.event_id)
.order(start_date: :desc)
end

# Creates the audited completion row for a checklist step (recording who/when),
# or removes it β€” so an unchecked step leaves no trace.
def toggle_checklist_step(step, completed)
Expand Down Expand Up @@ -412,13 +552,45 @@ def event_registration_params
end

def event_registration_update_params
return locked_editable_params if @event_registration.editing_locked?
if allowed_to?(:manage?, with: EventRegistrationPolicy)
event_registration_params
else
params.require(:event_registration).permit(:status)
end
end

# A transferred-out reg keeps only comments and communications editable (#1944).
LOCKED_EDITABLE_KEYS = %w[ comments_attributes notifications_attributes ].freeze

def locked_editable_params
params.fetch(:event_registration, {}).permit(
comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ],
notifications_attributes: [ :id, :channel, :sender_id, :email_subject, :email_body_text, :direction, :responded, :noticeable_type, :noticeable_id, :_destroy ]
)
end

# Warn rather than silently swallow: if a locked reg's form somehow submits fields
# beyond comments/communications, tell the admin they were ignored.
def warn_if_locked_fields_submitted
return unless @event_registration.editing_locked?
ignored = params.fetch(:event_registration, {}).keys.map(&:to_s) - LOCKED_EDITABLE_KEYS
return if ignored.empty?
flash[:alert] = "This registration was transferred out and is locked β€” only comments and communications were saved. Undo the transfer to edit anything else."
end

def block_locked_registration
return unless @event_registration.editing_locked?
redirect_to(request.referer.presence || edit_event_registration_path(@event_registration),
alert: "#{@event_registration.registrant&.full_name} was transferred out β€” this registration is locked. Undo the transfer to make changes.",
status: :see_other)
end

def deny_locked_org_edit
redirect_to link_organization_event_registration_path(@event_registration, return_to: params[:return_to].presence),
alert: "This registration was transferred out and is locked. Undo the transfer to change linked organizations."
end

def csv_export(registrations)
CSV.generate(headers: true) do |csv|
csv << [ "First name", "Last name", "Email", "Phone", "Event", "Status", "Scholarship", "Scholarship completed", "Payment status", "Intends to pay", "Payment total", "CE status", "CE paid", "CE due" ]
Expand All @@ -433,7 +605,7 @@ def csv_export(registrations)
r&.preferred_email.to_s,
r&.phone_number.to_s,
e&.title.to_s,
er.attendance_status_label,
er.attendance_status_report_label,
er.scholarships.any? ? "Yes" : "No",
er.scholarships.any?(&:tasks_completed?) ? "Yes" : "No",
cost_required ? er.payment_status_label : "",
Expand Down
Loading