Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
8217c55
Add affiliation↔registration link and event reconciled-at column
maebeale Aug 14, 2026
4a07477
Add ReconcileFacilitatorAffiliation service
maebeale Aug 14, 2026
2fae6a9
Add Reconcile affiliations bulk action with preview and opt-out
maebeale Aug 14, 2026
059ebd3
Date facilitator affiliation to the training day; heal missing affili…
maebeale Aug 14, 2026
8e6e59c
Reconcile non-training facilitator affiliations and offer delete-instead
maebeale Aug 14, 2026
a603fd1
Show full reconcile picture: reasons for no-action rows and attendanc…
maebeale Aug 14, 2026
cdb8109
Redesign reconcile page: group by person, per-affiliation, editable a…
maebeale Aug 14, 2026
903187c
Editable attendance chip in skipped rows, Edit first, shorter dates
maebeale Aug 14, 2026
06d0f6e
Reconcile all facilitator affiliations incl. hand-entered; clearer ac…
maebeale Aug 14, 2026
a6ef0ea
Action toggles as buttons with error-red on select, hover tooltips, h…
maebeale Aug 14, 2026
8a27968
Move Collapse all into the Not reconciled header; more space between …
maebeale Aug 14, 2026
14737ce
Show checkbox inside action buttons; make deactivate/delete mutually …
maebeale Aug 14, 2026
2996f6e
Two-step reconcile: Preview changes then confirmation screen
maebeale Aug 14, 2026
ed4a896
Keep attendance toggle on the reconcile page with a flash
maebeale Aug 14, 2026
0ad3a5a
Register exclusive-checkboxes controller; clearer deactivate instruction
maebeale Aug 14, 2026
f610f50
Render deactivate row note as two lines
maebeale Aug 14, 2026
18b2517
Reword deactivate note to 'To keep Affiliation active: Mark as Attend…
maebeale Aug 14, 2026
c11f15c
Fix Preview changes: turbo:false so the confirm page renders on POST;…
maebeale Aug 14, 2026
6524f7f
Radio outcomes with Keep-active; fix nested-form bug; scroll to item …
maebeale Aug 14, 2026
4d60e11
Fix Brakeman: read outcome params as a plain hash instead of permit!
maebeale Aug 14, 2026
7307b80
Reorder deactivate outcomes: Keep active, Delete, Deactivate; clearer…
maebeale Aug 14, 2026
2b6d04f
Update reconcile specs for renamed 'Deactivate affiliation' label
maebeale Aug 14, 2026
0d4dcc8
Let an explicit inactive flag override the date-derived one
maebeale Aug 18, 2026
e865e0f
Move the reconcile rules into a per-person classifier
maebeale Aug 18, 2026
fc4fcb9
Re-render the reconcile row when its attendance changes
maebeale Aug 18, 2026
e779385
Put the Inactive control on the standalone affiliation editor
maebeale Aug 18, 2026
1142262
Seed one person's affiliation history across several years
maebeale Aug 19, 2026
6a7e2f7
Let an explicitly supplied inactive flag survive a later date edit
maebeale Aug 20, 2026
395e323
Stop reconciliation rewriting history it did not create
maebeale Aug 20, 2026
2e300f3
Judge an organization active by its affiliations, never the legacy st…
maebeale Aug 20, 2026
d178a30
Name the dates-only readers active_by_date_on, and pin the arithmetic
maebeale Aug 20, 2026
990c787
Split the affiliation editor into Active and Inactive tabs
maebeale Aug 20, 2026
5e8f6d7
Anchor the affiliation redirect specs away from the zone boundary
maebeale Aug 20, 2026
aee813c
Take "today" from the browser in the live-styling specs
maebeale Aug 20, 2026
edf566f
Use eyebrow_link_class on the reconcile pages
maebeale Aug 21, 2026
ceac6bb
Keep the "by date" name on the scope only
maebeale Aug 22, 2026
cd051c2
Add an admin Data health page
maebeale Aug 22, 2026
f004b2b
Delete the affiliation a training minted, rather than deactivating it
maebeale Aug 22, 2026
21999bb
Renumber the affiliations ADR to 0003 and adopt main's period_label
maebeale Aug 23, 2026
9c13960
Restore the Inactive checkbox wiring lost in the rebase
maebeale Aug 23, 2026
7fb14c1
Report a blank roster instead of acting on it; show the sign-in sheet
maebeale Aug 23, 2026
670113f
Show the drift check's two statuses as colored columns
maebeale Aug 23, 2026
adc77cf
Move a transferred-out registrant's affiliation instead of ending it
maebeale Aug 23, 2026
15c1a2a
Cross-reference the two end-dating conventions
maebeale Aug 23, 2026
0eaffda
Drop the case for ending some rows on the training date
maebeale Aug 23, 2026
fbf9827
Stop the comment-tooltip spec flaking on CI
maebeale Aug 24, 2026
87c4240
Test the tooltip's group scoping structurally, not by hovering
maebeale Aug 24, 2026
09227f3
Reopen an ending reconciliation made, instead of adding a second affi…
maebeale Aug 24, 2026
873a675
Name the reconcile buttons by what they do, not what will happen
maebeale Aug 24, 2026
05d4e3a
Tick the days from the reconcile page when attendance was never recorded
maebeale Aug 24, 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 @@ -202,6 +202,7 @@ action, or `authorize! :workshop, to: :summary?`).
- `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)
- `Analytics::EventReferenceLoader` β€” Batch-loads the records referenced in a page of Ahoy event properties (association changes, associated records), one query per type, so the activity table's Details column links each to its show page without an N+1

- `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 @@ -264,6 +265,8 @@ action, or `authorize! :workshop, to: :summary?`).

- `AffiliationServices::ApplyScenarioEndDating` β€” What an agreement scenario means for the person's *existing* affiliations, run by the linking core before creation (see ADR-0002): a **new_job** ends their active affiliations at other orgs (job + facilitator alike, sparing the linked org's rows); every other scenario ends nothing β€” reinstatement reconciles registration-style, creating affiliations only where no active one exists. Ends are dated the day before the agreement takes effect so the fresh affiliations (starting on the submission date) don't overlap a row still counting as active until end of day
- `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 @@ -8,6 +8,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 @@ -100,7 +102,7 @@ def set_registration_choices

def affiliation_params
params.require(:affiliation).permit(
:person_id, :organization_id, :title, :start_date, :end_date, :primary_contact,
:person_id, :organization_id, :title, :start_date, :end_date, :inactive, :primary_contact,
:organization_address_id, :filemaker_code, :event_registration_id,
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 ]
Expand All @@ -119,7 +121,7 @@ def end_affiliation_return_path

# 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, admin: params[:admin].presence)
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 @@ -117,6 +117,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 @@ -1108,7 +1108,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 @@ -406,8 +406,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 @@ -704,6 +703,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 @@ -443,7 +443,7 @@ def user_params

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 ],
affiliations_attributes: [ :id, :organization_id, :title, :inactive, :primary_contact, :start_date, :end_date, :_destroy ],
affiliations_attributes: [ :id, :organization_id, :title, :inactive, :inactive_supplied, :primary_contact, :start_date, :end_date, :_destroy ],
)
end
end
7 changes: 7 additions & 0 deletions app/decorators/affiliation_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,11 @@ def period_label
end
"Dates not recorded"
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
end
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,47 @@ import { isFacilitatorTitle } from "../lib/affiliation";
// 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 @@ -88,6 +117,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
16 changes: 15 additions & 1 deletion app/models/affiliation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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

# A communication logged on an affiliation is addressed to the affiliated person.
Expand Down Expand Up @@ -50,7 +61,7 @@ def communications_email
# 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 @@ -172,7 +183,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 @@ -186,6 +186,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