Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
b14f7bd
Add affiliation↔registration link and event reconciled-at column
maebeale Aug 14, 2026
7581c04
Add ReconcileFacilitatorAffiliation service
maebeale Aug 14, 2026
25af9f3
Add Reconcile affiliations bulk action with preview and opt-out
maebeale Aug 14, 2026
2a618a6
Date facilitator affiliation to the training day; heal missing affili…
maebeale Aug 14, 2026
6863343
Reconcile non-training facilitator affiliations and offer delete-instead
maebeale Aug 14, 2026
a0cbd02
Show full reconcile picture: reasons for no-action rows and attendanc…
maebeale Aug 14, 2026
c4db320
Redesign reconcile page: group by person, per-affiliation, editable a…
maebeale Aug 14, 2026
303939e
Editable attendance chip in skipped rows, Edit first, shorter dates
maebeale Aug 14, 2026
98a8c4f
Reconcile all facilitator affiliations incl. hand-entered; clearer ac…
maebeale Aug 14, 2026
438dc23
Action toggles as buttons with error-red on select, hover tooltips, h…
maebeale Aug 14, 2026
35efe36
Move Collapse all into the Not reconciled header; more space between …
maebeale Aug 14, 2026
8247cf4
Show checkbox inside action buttons; make deactivate/delete mutually …
maebeale Aug 14, 2026
c51b625
Two-step reconcile: Preview changes then confirmation screen
maebeale Aug 14, 2026
53b62df
Keep attendance toggle on the reconcile page with a flash
maebeale Aug 14, 2026
1c34240
Register exclusive-checkboxes controller; clearer deactivate instruction
maebeale Aug 14, 2026
bdedbf7
Render deactivate row note as two lines
maebeale Aug 14, 2026
ff53003
Reword deactivate note to 'To keep Affiliation active: Mark as Attend…
maebeale Aug 14, 2026
5556789
Fix Preview changes: turbo:false so the confirm page renders on POST;…
maebeale Aug 14, 2026
e727595
Radio outcomes with Keep-active; fix nested-form bug; scroll to item …
maebeale Aug 14, 2026
3781f7b
Fix Brakeman: read outcome params as a plain hash instead of permit!
maebeale Aug 14, 2026
619953b
Reorder deactivate outcomes: Keep active, Delete, Deactivate; clearer…
maebeale Aug 14, 2026
3cf438c
Update reconcile specs for renamed 'Deactivate affiliation' label
maebeale Aug 14, 2026
0e2313c
Let an explicit inactive flag override the date-derived one
maebeale Aug 18, 2026
787f7fc
Move the reconcile rules into a per-person classifier
maebeale Aug 18, 2026
ea6500c
Re-render the reconcile row when its attendance changes
maebeale Aug 18, 2026
6890bbc
Put the Inactive control on the standalone affiliation editor
maebeale Aug 18, 2026
039d3f5
Seed one person's affiliation history across several years
maebeale Aug 19, 2026
4d94b66
Let an explicitly supplied inactive flag survive a later date edit
maebeale Aug 20, 2026
540f347
Stop reconciliation rewriting history it did not create
maebeale Aug 20, 2026
5b95df9
Judge an organization active by its affiliations, never the legacy st…
maebeale Aug 20, 2026
6596c03
Name the dates-only readers active_by_date_on, and pin the arithmetic
maebeale Aug 20, 2026
6ea1586
Split the affiliation editor into Active and Inactive tabs
maebeale Aug 20, 2026
7ae2760
Anchor the affiliation redirect specs away from the zone boundary
maebeale Aug 20, 2026
52fdd09
Take "today" from the browser in the live-styling specs
maebeale Aug 20, 2026
507ec39
Use eyebrow_link_class on the reconcile pages
maebeale Aug 21, 2026
3ddf7d8
Keep the "by date" name on the scope only
maebeale Aug 22, 2026
b556798
Add an admin Data health page
maebeale Aug 22, 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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ action, or `authorize! :workshop, to: :summary?`).
- `Analytics::AhoyTracker` β€” Coordinates ahoy event tracking
- `Analytics::PersonActivityEvents` β€” Aggregates Ahoy events for a person, their user, and associated data (powers the person edit History card + `person_id` filter on the Ahoy activities index)

- `DataHealth` + `DataHealth::Check` subclasses β€” Consistency checks spanning the whole database, rendered on the admin Data health page (`/admin/data_health`). Each subclass supplies `scope` (a relation, so counting doesn't load the table), `title`/`explanation`, and either a `repair!` or nothing β€” report-only is the default, because a wrong row isn't always one we know how to put right. Register a new check in `DataHealth::CHECKS`. Current checks: facilitator affiliations minted by non-training registrations (deletes), affiliations whose minting registration belongs to another org (unlinks, restoring ADR-0002 D2a's invariant), and legacy organization-status drift (reports only)
### Business Logic

- `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
Expand Down Expand Up @@ -255,6 +256,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
24 changes: 24 additions & 0 deletions app/controllers/admin/data_health_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module Admin
# Data health: consistency checks that span the whole database, each with a count
# and β€” where a correct fix exists β€” a button to apply it. See DataHealth::Check.
class DataHealthController < ApplicationController
include AhoyTracking

def index
authorize! :data_health, to: :index?
track_view("admin.data_health")

@checks = DataHealth.checks
end

def repair
authorize! :data_health, to: :repair?

check = DataHealth.find(params[:check])
return redirect_to admin_data_health_path, alert: "Unknown check." unless check&.repairable?

repaired = check.repair!
redirect_to admin_data_health_path, notice: check.repaired_message(repaired)
end
end
end
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
1 change: 1 addition & 0 deletions app/helpers/admin_cards_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def system_cards
def user_content_cards
[
custom_card("Portal activity", admin_activities_counts_path, icon: "πŸ“Š"),
custom_card("Data health", admin_data_health_path, icon: "🩺", color: :sky, intensity: 100),
custom_card("Bookmarks tally", tally_bookmarks_path, icon: "πŸ”–"),
model_card(:notifications, icon: "πŸ””", title: t("communications.title")),
custom_card("Event reports", reports_events_path, icon: "πŸ“Š", color: :blue),
Expand Down
18 changes: 16 additions & 2 deletions 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 All @@ -53,7 +64,7 @@ class Affiliation < ApplicationRecord
# "Lead Facilitator" or "facilitator" are deliberately excluded. BINARY forces
# a case-sensitive comparison under MySQL's default case-insensitive collation;
# TRIM mirrors the in-memory #facilitator? strip so stray whitespace still matches.
scope :facilitators, -> { where("BINARY TRIM(title) = ?", "Facilitator") }
scope :facilitators, -> { where("BINARY TRIM(affiliations.title) = ?", "Facilitator") }

# Affiliations whose #status_on(date) equals the given status, expressed in SQL
# so it composes as a subquery (e.g. person-id narrowing). Kept in lock-step with
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
Loading
Loading