Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
138d55d
Add affiliation↔registration link and event reconciled-at column
maebeale Aug 14, 2026
24bb0be
Add ReconcileFacilitatorAffiliation service
maebeale Aug 14, 2026
4356b37
Add Reconcile affiliations bulk action with preview and opt-out
maebeale Aug 14, 2026
9ccdb39
Date facilitator affiliation to the training day; heal missing affili…
maebeale Aug 14, 2026
ca38e45
Reconcile non-training facilitator affiliations and offer delete-instead
maebeale Aug 14, 2026
d01223d
Show full reconcile picture: reasons for no-action rows and attendanc…
maebeale Aug 14, 2026
d87fee0
Redesign reconcile page: group by person, per-affiliation, editable a…
maebeale Aug 14, 2026
d1d29d6
Editable attendance chip in skipped rows, Edit first, shorter dates
maebeale Aug 14, 2026
37e71f0
Reconcile all facilitator affiliations incl. hand-entered; clearer ac…
maebeale Aug 14, 2026
d126c7f
Action toggles as buttons with error-red on select, hover tooltips, h…
maebeale Aug 14, 2026
e3adcbb
Move Collapse all into the Not reconciled header; more space between …
maebeale Aug 14, 2026
dc09da6
Show checkbox inside action buttons; make deactivate/delete mutually …
maebeale Aug 14, 2026
ac2aac3
Two-step reconcile: Preview changes then confirmation screen
maebeale Aug 14, 2026
6f621c0
Keep attendance toggle on the reconcile page with a flash
maebeale Aug 14, 2026
8284904
Register exclusive-checkboxes controller; clearer deactivate instruction
maebeale Aug 14, 2026
a3f817b
Render deactivate row note as two lines
maebeale Aug 14, 2026
673ae91
Reword deactivate note to 'To keep Affiliation active: Mark as Attend…
maebeale Aug 14, 2026
404db4a
Fix Preview changes: turbo:false so the confirm page renders on POST;…
maebeale Aug 14, 2026
4ed38dc
Radio outcomes with Keep-active; fix nested-form bug; scroll to item …
maebeale Aug 14, 2026
895f4e2
Fix Brakeman: read outcome params as a plain hash instead of permit!
maebeale Aug 14, 2026
bdca426
Reorder deactivate outcomes: Keep active, Delete, Deactivate; clearer…
maebeale Aug 14, 2026
6ef75e3
Update reconcile specs for renamed 'Deactivate affiliation' label
maebeale Aug 14, 2026
2f1f8c5
Let an explicit inactive flag override the date-derived one
maebeale Aug 18, 2026
0eeb279
Move the reconcile rules into a per-person classifier
maebeale Aug 18, 2026
4ff34f7
Re-render the reconcile row when its attendance changes
maebeale Aug 18, 2026
21c8743
Put the Inactive control on the standalone affiliation editor
maebeale Aug 18, 2026
5eaa725
Seed one person's affiliation history across several years
maebeale Aug 19, 2026
6daae70
Let an explicitly supplied inactive flag survive a later date edit
maebeale Aug 20, 2026
c6187f7
Stop reconciliation rewriting history it did not create
maebeale Aug 20, 2026
1adfad4
Judge an organization active by its affiliations, never the legacy st…
maebeale Aug 20, 2026
d205a79
Name the dates-only readers active_by_date_on, and pin the arithmetic
maebeale Aug 20, 2026
ec9b0eb
Split the affiliation editor into Active and Inactive tabs
maebeale Aug 20, 2026
e540617
Anchor the affiliation redirect specs away from the zone boundary
maebeale Aug 20, 2026
fd97c05
Take "today" from the browser in the live-styling specs
maebeale Aug 20, 2026
f6f2fee
Use eyebrow_link_class on the reconcile pages
maebeale Aug 21, 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 @@ -255,6 +255,8 @@ action, or `authorize! :workshop, to: :summary?`).
### Affiliations

- `AffiliationServices::CreateFromRegistration` β€” On registration / org linking, creates a "job affiliation" with the typed title (when present) plus a standing "Facilitator" affiliation, in one transaction. Skips the facilitator one only when the person already has an active-or-pending affiliation titled exactly "Facilitator" with that org (a current one or one dated to a future training); an ended facilitator affiliation gets a fresh second one. Dedupe is by title + org + dates, so a job title like "Lead Facilitator" still gets its own Facilitator affiliation. Accepts an optional `organization_address:` and sets it on every affiliation it creates (the registrant's typed agency address, upserted onto the org); when an affiliation already exists and is skipped, it backfills that address onto the existing one only if it has none (an admin-set address is never overwritten)
- `AffiliationServices::ReconcilePerson` β€” **The single classifier** for facilitator affiliations, per `(person, organization)` in the context of one `event:`. `#plan` returns a `Decision` (`affiliation`, `action`, `reason`) per affiliation in scope, plus a create/no-create decision when the person has none β€” no writes. Actions: `:create` (pre-event for anyone, post-event only for attendees), `:deactivate` (**same-days** it β€” `end_date := start_date` plus an explicit `inactive: true`, since the model's date rule alone still reads a row ending today or later as active), `:reactivate`, `:delete` (non-training event: a row auto-created off it), or `:noop` with a reason. Completion is "any `attended` facilitator-training registration to that org", so no-showing one training but attending another keeps them active; deactivation waits for the governing training to end, so a pre-event run never deactivates. `#perform(action, affiliation:)` applies one decision, `#call` applies them all. `include_unowned:` is the auto-vs-manual gate β€” false (default) touches only rows the registration flow minted, true reconciles hand-entered rows too.
- `AffiliationServices::ReconcileEvent` β€” Event-level orchestration for the "Reconcile affiliations" bulk action. Walks the event's registrants and their linked orgs, iterating `ReconcilePerson` (with `include_unowned: true`, one memoized instance per person+org) and turning its decisions into individually-selectable rows β€” every rule lives in `ReconcilePerson`, every key/grouping/timestamp concern here. `#actionable_person_groups` groups actionable rows by person (with attendance registration and other-org facilitator affiliations for context); `#skipped_reason_sections` groups no-action rows by reason. `#planned_changes(outcome:)` and `#apply(outcome:)` take an `outcome` map `{ row.key => choice }` (choice is the action or "keep") β€” the confirm screen previews planned `Change`s, apply performs them and stamps `affiliations_reconciled_at`. Job affiliations are never touched. The controller is a two-step flow: `index` (edit) β†’ `confirm` (preview, no writes) β†’ `create` (perform).

### Sectors

Expand Down
6 changes: 4 additions & 2 deletions app/controllers/affiliations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ def edit

def update
authorize! @affiliation
# This form always posts the Inactive checkbox, so whatever it sends is deliberate.
@affiliation.inactive_supplied = affiliation_params.key?(:inactive)
@affiliation.assign_attributes(affiliation_params)
@affiliation.comments.select(&:new_record?).each { |c| c.created_by = current_user; c.updated_by = current_user }
@affiliation.comments.select { |c| c.persisted? && c.body_changed? }.each { |c| c.updated_by = current_user }
Expand Down Expand Up @@ -64,14 +66,14 @@ def set_affiliation

def affiliation_params
params.require(:affiliation).permit(
:person_id, :organization_id, :title, :start_date, :end_date, :primary_contact, :organization_address_id,
:person_id, :organization_id, :title, :start_date, :end_date, :inactive, :primary_contact, :organization_address_id,
comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ]
)
end

# Return to whichever edit page the gear was clicked from, scrolled to the row
# (or the affiliations section after a delete removes the row).
def affiliation_return_path(anchor: helpers.dom_id(@affiliation))
def affiliation_return_path(anchor: @affiliation.decorate.return_anchor)
case params[:return_to]
when "person"
edit_person_path(params[:origin_id], anchor: anchor)
Expand Down
1 change: 1 addition & 0 deletions app/controllers/event_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ def update
when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other
when "attendees" then redirect_to attendees_events_path, notice: notice, status: :see_other
when "roster" then redirect_to roster_event_path(@event_registration.event), notice: notice, status: :see_other
when "reconcile_affiliations" then redirect_to reconcile_affiliations_event_path(@event_registration.event, anchor: helpers.dom_id(@event_registration, :attendance_status)), notice: notice, status: :see_other
# Two ways back to the recipients page: the shout-outs section (the
# feature-a-shout-out flow) or the recipient's own card (their name).
when "recipients" then redirect_to recipients_event_path(@event_registration.event, anchor: "shout-outs"), notice: notice, status: :see_other
Expand Down
59 changes: 59 additions & 0 deletions app/controllers/events/reconcile_affiliations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
module Events
# The "Reconcile affiliations" bulk action: index (edit) β†’ confirm (preview, no
# writes) β†’ create (perform). `AffiliationServices::ReconcilePerson` holds the rules.
class ReconcileAffiliationsController < ApplicationController
include AhoyTracking
before_action :set_event

def index
authorize! @event, to: :reconcile_affiliations?
track_view("events.reconcile_affiliations", { event_id: @event.id })

reconcile = AffiliationServices::ReconcileEvent.new(@event)
@person_groups = reconcile.actionable_person_groups
@skipped_sections = reconcile.skipped_reason_sections
@has_rows = reconcile.any_rows?
# Restore the admin's per-row radio choices when they come back from confirm.
@pre_outcome = params[:outcome]
@event = @event.decorate
end

def confirm
authorize! @event, to: :reconcile_affiliations?

@outcome = outcome_params
@changes = AffiliationServices::ReconcileEvent.new(@event).planned_changes(outcome: @outcome)
@event = @event.decorate

redirect_to reconcile_affiliations_event_path(@event), notice: "Nothing selected to change." and return if @changes.empty?
end

def create
authorize! @event, to: :reconcile_affiliations?

changed = AffiliationServices::ReconcileEvent.new(@event).apply(outcome: outcome_params)
redirect_to registrants_event_path(@event), notice: reconcile_notice(changed)
end

private

def set_event
@event = Event.find(params[:id])
end

# Dynamic keys, so read as a plain string hash (never mass-assigned); the service
# only acts on known choices.
def outcome_params
raw = params[:outcome]
return {} unless raw.respond_to?(:each_pair)

raw.each_pair.map { |key, value| [ key.to_s, value.to_s ] }.to_h
end

def reconcile_notice(changed)
return "No affiliations needed reconciling." if changed.zero?

"Reconciled #{changed} #{'affiliation'.pluralize(changed)}."
end
end
end
2 changes: 1 addition & 1 deletion app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1098,7 +1098,7 @@ def event_registrations_csv_string
def event_registration_csv_row(registration, cost_required, include_ce = false)
person = registration.registrant
orgs = person.affiliations
.select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) }
.select(&:active?)
.map(&:organization).compact.uniq
org_names = orgs.map(&:name).join("; ")
total_cents = registration.allocations_sum
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/organizations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,7 @@ def set_form_variables
affiliations = affiliations.includes(:person) unless affiliations.loaded?
sorted = affiliations.to_a
.sort_by { |affiliation|
expired = affiliation.inactive? || (affiliation.end_date.present? && affiliation.end_date < Date.current)
[ expired ? 1 : 0,
[ affiliation.active? ? 0 : 1,
affiliation.person&.first_name.to_s.downcase,
affiliation.person&.last_name.to_s.downcase ]
}
Expand Down Expand Up @@ -252,6 +251,7 @@ def organization_params
:id,
:person_id,
:inactive,
:inactive_supplied,
:primary_contact,
:title,
:start_date,
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,7 @@ def set_form_variables
affiliations = affiliations.includes(:organization) unless affiliations.loaded?
sorted = affiliations.to_a
.sort_by { |affiliation|
expired = affiliation.inactive? || (affiliation.end_date.present? && affiliation.end_date < Date.current)
[ expired ? 1 : 0,
[ affiliation.active? ? 0 : 1,
affiliation.organization&.name.to_s.downcase ]
}
@person.affiliations.proxy_association.target.replace(sorted)
Expand Down Expand Up @@ -632,6 +631,7 @@ def person_params
:organization_id,
:title,
:inactive,
:inactive_supplied,
:primary_contact,
:start_date,
:end_date,
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ def user_params
#####

comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ],
affiliations_attributes: [ :id, :organization_id, :position, :title, :inactive, :primary_contact, :start_date, :end_date, :_destroy ],
affiliations_attributes: [ :id, :organization_id, :position, :title, :inactive, :inactive_supplied, :primary_contact, :start_date, :end_date, :_destroy ],
)
end
end
14 changes: 14 additions & 0 deletions app/decorators/affiliation_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,18 @@ class AffiliationDecorator < ApplicationDecorator
def detail(length: nil)
"#{person.full_name}: #{title.presence || position} - #{organization.name}"
end

# Where a back link should land on the person/organization editor. An inactive
# row sits on the Inactive tab, so jumping to the row itself would scroll to
# something the page isn't showing β€” land on the section instead.
def return_anchor
active? ? h.dom_id(object) : "affiliations"
end

# e.g. "Oct 13, 2026 – present"
def date_range
start = start_date ? start_date.strftime("%b %-d, %Y") : "no start date"
finish = end_date ? end_date.strftime("%b %-d, %Y") : "present"
"#{start} – #{finish}"
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,47 @@ import { Controller } from "@hotwired/stimulus";
// is the saturation (active = full, inactive = super-light). Inactive rows also
// strike their fields (.aff-ended).
export default class extends Controller {
static targets = ["endDate", "title", "row", "accentBar", "valueField"]
static targets = ["endDate", "title", "row", "accentBar", "valueField", "inactiveField", "suppliedField", "inactiveCheckbox"]
static values = { expired: Boolean }

connect() {
// A row flagged inactive whose dates still read as current is one where the
// flag is doing real work, so mark it authoritative up front β€” otherwise an
// unrelated date edit would let the server re-derive it away.
if (this.expiredValue && !this.endsOnOrBeforeToday()) this.markSupplied();
if (this.hasTitleTarget) this.updateBorder();
else this.apply();
}

// Entering an end date of today or earlier ticks Inactive for you, so the flag
// travels with the form β€” the date rule alone compares strictly and would still
// call today "active". Clearing the date (or a future one) unticks it again.
//
// Only the end date drives this. Ticking the box by hand has to stick, which it
// would not if the checkbox's own action recomputed it from the dates.
endDateChanged() {
const ended = this.endsOnOrBeforeToday();
if (this.hasInactiveCheckboxTarget) this.inactiveCheckboxTarget.checked = ended;
if (this.hasInactiveFieldTarget) this.inactiveFieldTarget.value = ended ? "1" : "0";
this.markSupplied();
this.apply();
}

toggle() {
this.apply();
}

markSupplied() {
if (this.hasSuppliedFieldTarget) this.suppliedFieldTarget.value = "1";
}

endsOnOrBeforeToday() {
const value = this.hasEndDateTarget ? this.endDateTarget.value : "";
if (!value) return false;

return new Date(value) <= new Date(new Date().toDateString());
}

updateBorder() {
if (!this.hasTitleTarget) return;
if (this.hasAccentBarTarget) {
Expand Down Expand Up @@ -87,6 +116,12 @@ export default class extends Controller {
// With an end date, compute from it (live); without one, the JS can't see the
// server's inactive flag, so trust the server-rendered `expired` value.
isPast() {
// The standalone editor has an explicit Inactive checkbox, and on that form it
// is the whole truth: ticked, or ended on/before today.
if (this.hasInactiveCheckboxTarget) {
return this.inactiveCheckboxTarget.checked || this.endsOnOrBeforeToday();
}

const value = this.hasEndDateTarget ? this.endDateTarget.value : "";
if (value) return new Date(value) < new Date(new Date().toDateString());
return this.expiredValue;
Expand Down
16 changes: 15 additions & 1 deletion app/models/affiliation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ class Affiliation < ApplicationRecord
# have this link.
belongs_to :event_registration, optional: true, inverse_of: :affiliations

# Set by a caller that supplied `inactive` deliberately (the standalone editor's
# checkbox, or a nested row whose end date the admin just changed). Re-submitting
# the value it already holds isn't a change, so without this the date rule below
# would quietly undo a hand-set flag on the next date edit. Cast because it
# arrives from a form as "0"/"1", and "0" is truthy in Ruby.
attr_reader :inactive_supplied

def inactive_supplied=(value)
@inactive_supplied = ActiveModel::Type::Boolean.new.cast(value)
end

has_many :comments, -> { newest_first }, as: :commentable, dependent: :destroy
accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? }

Expand All @@ -44,7 +55,7 @@ class Affiliation < ApplicationRecord
# when a view must reflect a fixed point in time β€” e.g. the event dashboard
# reporting organizations as they stood at the time of the event, so the
# numbers don't drift as affiliations end after the fact.
scope :active_on, ->(date) {
scope :active_by_date_on, ->(date) {
where("affiliations.start_date IS NULL OR affiliations.start_date <= ?", date)
.where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", date)
}
Expand Down Expand Up @@ -163,7 +174,10 @@ def sole_address_id_for_new_organization
addresses.first.id if addresses&.one?
end

# An explicit assignment wins: the date rule alone still reads a row ending today
# or later as active.
def set_inactive_from_dates
return if inactive_changed? || inactive_supplied
return unless end_date_changed? || start_date_changed?

self.inactive = end_date.present? && end_date < Date.current
Expand Down
7 changes: 7 additions & 0 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ def ended?
end_date < Time.current
end

# A registrant changed since the last reconciliation, so it's worth re-running.
def affiliations_reconciliation_stale?
return false unless affiliations_reconciled_at

event_registrations.where("event_registrations.updated_at > ?", affiliations_reconciled_at).exists?
end

# Whether the event shows as a full card on the events index. Unpublished
# events and events that ended more than a month ago collapse into the compact
# archive list instead of taking up a card.
Expand Down
17 changes: 8 additions & 9 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,9 @@ def self.awbw

# Scopes
# See TagFilterable, Trendable, WindowsTypeFilterable
scope :active, -> {
status_active = joins(:organization_status).where(organization_statuses: { name: "Active" })
affiliation_active = where(id: Affiliation.active.select(:organization_id))
status_active.or(affiliation_active)
}
# An org is active because someone is affiliated there, not because the legacy
# status column says so (ADR-0001 D3, ADR-0002 D4).
scope :active, -> { where(id: Affiliation.active.select(:organization_id)) }
scope :address, ->(address) do
return all if address.blank?
terms = address.to_s.strip.split(/[\s,]+/).reject(&:blank?)
Expand Down Expand Up @@ -249,10 +247,11 @@ def organization_locality
end
end

def published? # needed for my_bookmarks
return true if organization_status&.name == "Active"
# #active? is the in-memory twin of the `active` scope, so a list page that
# preloaded affiliations doesn't query once per row.
# Needed for my_bookmarks. Keys off affiliations only β€” the stored
# organization_status has drifted and is never consulted (ADR-0002 D4).
# The loaded branch is the in-memory twin of the `active` scope, so a list page
# that preloaded affiliations doesn't query once per row.
def published?
return affiliations.any?(&:active?) if affiliations.loaded?

affiliations.active.exists?
Expand Down
4 changes: 4 additions & 0 deletions app/policies/event_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ def bulk_payments?
manage?
end

def reconcile_affiliations?
manage?
end

def invoice?
manage?
end
Expand Down
Loading
Loading