Skip to content

Reconcile facilitator affiliations against attendance (admin bulk action) - #2195

Open
maebeale wants to merge 37 commits into
mainfrom
maebeale/facilitator-affiliation-reconciliation
Open

Reconcile facilitator affiliations against attendance (admin bulk action)#2195
maebeale wants to merge 37 commits into
mainfrom
maebeale/facilitator-affiliation-reconciliation

Conversation

@maebeale

@maebeale maebeale commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

🤖 suggested review level: 5 Inspect 🔬 new reconciliation service + admin bulk action that creates, deactivates and deletes affiliations across an event's registrants, plus a model-callback change

What is the goal of this PR and why is this important?

Facilitator affiliations are minted optimistically at registration and go Active by date alone — no-shows, cancellations, partial attendance, and never-marked registrants all silently become active facilitators.

The foundation (gating creation on training type + linking the affiliation to its registration) landed separately in #2194. This PR adds the reconciliation layer: an admin-driven step that makes affiliations match who registered and attended.

How did you approach the change?

Rules in one place

  • ReconcilePerson — the single classifier, per (person, organization) in the context of one event. #plan returns a Decision per affiliation (:create / :deactivate / :reactivate / :delete / :noop + reason) with no writes; #perform applies one; #call applies them all.
  • Completion is "any attended facilitator-training registration for 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.
  • include_unowned: is the auto-vs-manual gate — the bulk page passes true, an automatic caller wouldn't.

The bulk action

  • ReconcileEvent iterates ReconcilePerson across the event's registrants and turns decisions into individually-selectable rows. Keys, grouping, and the affiliations_reconciled_at stamp live here; no rules do.
  • Under Bulk actions: a preview-and-confirm page (indexconfirm → perform). Each row is a radio group with a leave-as-is option; deactivate rows can delete instead.
  • Re-runnable, with a "stale since last reconciled" nudge.

Inactive is now settable

  • Affiliation#set_inactive_from_dates yields to an explicit assignment. Without this, same-daying a row whose start date is today (a one-day training reconciled the day it ends) left it active — the date rule reads a row ending today as active.
  • The standalone affiliation editor (the gear, Affiliation edit gear: standalone editor with comments + reassign #2235) gains an Inactive control. The column was already permitted but had no field anywhere, and ticking it during a date edit was silently overwritten.

Smaller

  • Attendance toggles on the reconcile page opt out of Turbo so the row's actions re-render; other pages keep the inline chip swap.
  • Facilitator affiliation starts on the actual training date (was first-of-month).
  • affiliations_reconciled_at column on events; entry added to the Features & tips seed.

Dev seed

  • db:seed:affiliation_history gives the owner of affiliation 1 seven years of interleaved trainings, memberships, affiliation edits, comments and emails, so the person History card has something multi-year to render.
  • Two pre-existing filters kept those rows invisible and are fixed alongside it: affiliation comments were missing from PersonCommentAggregator (Affiliation became commentable in Affiliation edit gear: standalone editor with comments + reassign #2235 without being added), and payment lifecycle events record the STI subclass (CashPayment), which the person's Payment filter never matched.

Anything else to add?

  • Deactivating the last active facilitator affiliation can flip its organization to Inactive (existing cascade, traceable via Ahoy) — accepted.
  • Hand-entered (unlinked) facilitator affiliations are reconciled by the bulk page, by explicit decision — guarded by the post-event gate and the per-row opt-out.
  • Full suite green: 6,910 examples, 0 failures.

Copilot AI lite review requested due to automatic review settings August 14, 2026 04:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@maebeale
maebeale force-pushed the maebeale/facilitator-affiliation-reconciliation branch from 68913c9 to e765a96 Compare August 14, 2026 04:58
Copilot AI review requested due to automatic review settings August 14, 2026 04:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@maebeale maebeale changed the title Reconcile facilitator affiliations against attendance Reconcile facilitator affiliations against attendance (admin bulk action) Aug 14, 2026
@maebeale
maebeale marked this pull request as ready for review August 14, 2026 05:11
Copilot AI review requested due to automatic review settings August 14, 2026 05:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.


def facilitator_start_date
(@training_date || Date.current).to_date.beginning_of_month
(@training_date || Date.current).to_date

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Intentional shift toward accurate dates: this starts the facilitator affiliation on the actual training date rather than the first of its month (changing code that just landed in #2194). Reconciliation works either way; flagging since it edits a freshly-merged file.

Copilot AI review requested due to automatic review settings August 14, 2026 12:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 14, 2026 12:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 14, 2026 13:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 14, 2026 13:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 14, 2026 13:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 14, 2026 13:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 14, 2026 14:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@maebeale maebeale changed the title Reconcile facilitator affiliations against attendance (admin bulk action) MAYBE: Reconcile facilitator affiliations against attendance (admin bulk action) Aug 14, 2026
@maebeale maebeale changed the title MAYBE: Reconcile facilitator affiliations against attendance (admin bulk action) EXTRA: Reconcile facilitator affiliations against attendance (admin bulk action) Aug 14, 2026
Copilot AI review requested due to automatic review settings August 14, 2026 17:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 14, 2026 23:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

maebeale and others added 29 commits August 21, 2026 20:42
…tion controls

Reconcile every facilitator affiliation for a linked org (not just app-created),
gated to post-event so a pre-event run never deactivates and with per-row opt-out.
Move the include checkbox into the action chip so it's clear checking it performs
that action, move the other-org facilitator note below the rows, link org/dates to
the specific affiliation anchor and names to the registration, and strengthen the
Not reconciled section headers (open by default, expand/collapse all).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eader note

Style the include/delete-instead controls as buttons that only turn error-red when
selected (peer-checked, no JS); add hover tooltips explaining deactivate/delete
(delete as bullets: this affiliation only, job + other-org affiliations untouched).
Move the 'Also a facilitator at …' note beside the name, truncated and linking to
the single affiliation anchor (or the affiliations section when several). Order the
Not reconciled sections with 'Active — attended' second-to-last.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sections

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…exclusive

Move the checkbox back inside each button (has-[:checked] colors the whole button
on select, error-red for deactivate/delete). Add an exclusive-checkboxes Stimulus
controller so checking 'Delete instead' clears 'Will be deactivated' and vice versa;
apply now treats a delete key as delete regardless of the include key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'Preview changes' now posts to a confirmation screen that shows exactly which
affiliations get created/reactivated/deactivated/deleted (actioned rows only),
with Go back to edit (selections restored) or Perform changes. Add per-row
instructions under the action buttons and a header row with a warning that checked
boxes change affiliations. New exclusive-checkboxes controller registered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Thread return_to through the attendance status badge and add a reconcile case to
EventRegistrations#update so toggling attendance from the reconcile page reloads it
(with fresh attendance) and a success flash, instead of jumping to the roster.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Stimulus manifest is explicit, so the new exclusive-checkboxes controller was
never loaded — register it and use an explicit change event so Delete instead and
Will be deactivated actually clear each other. Reword the deactivate row note to
spell out the two options (mark Attended = permanent, uncheck = one-time).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… note says 'both boxes'

Turbo ignores a 200 HTML render on a form POST (only 4xx/5xx render), so the
confirmation screen never showed. Submit the preview form with turbo disabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on attendance toggle

Replace the deactivate/delete checkboxes with a radio group per row
(Deactivate/Delete/Keep active, and action/keep for the others), styled as the same
buttons via has-[:checked]. Radios are natively mutually exclusive, so remove the
exclusive-checkboxes Stimulus controller and the per-row instruction note.

Fix the real reason 'Preview changes' did nothing: the attendance chip's form was
nested inside the reconcile form (invalid HTML), so the submit/inputs fell outside
it. Render the reconcile form standalone and join the radios/submit via the HTML
form= attribute. Switch the params to an outcome map { row.key => choice }.

Toggling attendance now scrolls back to that item's anchor, not the top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… labels

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same-daying an affiliation on or after its start date left it active: the
callback always recomputed `inactive` from the dates, and a row ending today
still reads as active. Admins also had no way to set it — the column was
permitted everywhere but had no field, and ticking it alongside a date edit was
silently overwritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The classification lived in ReconcileEvent while ReconcileFacilitatorAffiliation
held a second, owned-only copy that nothing but its own spec reached — two
implementations of the same rules, already disagreeing on hand-entered rows.
ReconcilePerson is now the only place a decision is made; ReconcileEvent
iterates it and keeps the keys, grouping and timestamp. Owned-vs-all becomes an
argument, so the per-person reconciler is callable on its own (e.g. from an
attendance change) without re-deriving anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The badge's Turbo submit answered with a stream that swapped only the status
chip, so the row kept offering its pre-toggle action and the new return_to
redirect never ran. Pages that pass a return_to now opt out of Turbo and get the
full re-render; the registrants and onboarding pages keep the inline swap.

Also lists the bulk action on the Features & tips seed and drops the "uncheck"
wording left over from before the row controls became radios.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Main moved per-affiliation editing to the gear editor, so that — not the dense
inline row — is where the flag belongs. Trims the comments added across this
branch down to the ones carrying a why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The person History card and activity timeline had nothing multi-year to render,
so affiliation edits, trainings, memberships and comments couldn't be seen
interleaved. Two gaps kept the seeded rows invisible: affiliation comments were
missing from PersonCommentAggregator (Affiliation became commentable in #2235
without being added), and payment lifecycle events record the STI subclass
("CashPayment"), which the person's Payment filter never matched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Inactive checkbox only held until the next time anyone touched a date. The
guard tested `inactive_changed?`, which is false when a form re-submits the value
the record already holds, so the date rule ran and derived the flag away — an
unrelated start-date edit silently reactivated a row an admin had ended.

`inactive_supplied` records that a caller set the value on purpose. The standalone
editor always posts the checkbox, so the controller sets it from the params; the
nested rows set it when their end date changes. It is a cast writer because forms
send "0", which is truthy in Ruby and would otherwise suppress the date rule on
every nested row.

An end date of today or earlier now ticks the box for you. The date rule compares
strictly, so today alone still reads as active — the flag is what carries it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deactivating same-dayed every row, including ones minted years earlier by a
different training. That erased the whole period a person actually facilitated,
and the anchored program status moved with it: an organization that read Ongoing
at its 2026 training read Reinstated afterwards, changing figures that back grant
applications.

Only the row this training minted collapses to its start date — it recorded an
assumption that never came true, and a strict `<` already excludes it from its own
anchor. Anything older ends on this training's date instead, so the years before
it survive.

Reactivation had the mirror problem: clearing an end date swallowed the gap, so
"Art program since" collapsed `Jan 2023 – Jan 2024, Aug 2026` into `Jan 2023`. A
return is now a new row, which is what CreateFromRegistration has always done, so
`:reactivate` is gone entirely.

Why a row changed is recorded as a comment on the affiliation rather than a new
column — the edit page already surfaces them, and the comment topic is enough to
stop labelling an admin-ended row "didn't attend".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…atus column

`Organization.active` counted a stored "Active" status as enough on its own, and
`#published?` short-circuited on it before ever looking at affiliations — so an
org whose column had drifted read as active with nobody facilitating there. ADR-0001
D3 says the column plays no part; these two were the exceptions.

Also replaces nine open-coded copies of `!inactive? && (end_date.nil? || end_date
>= today)` with `active?`. The rule now lives in one place, which matters more now
that the flag can disagree with the dates.

Expect orgs with a stale "Active" column and no active affiliation to start
rendering as unpublished. That is the drift ADR-0001 D3a warns about, surfaced
rather than introduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`active?` and `active_on` sat two characters apart while answering different
questions with different inputs — one reads the dates and the inactive flag to say
what is true now, the other reads dates alone to say what was true on a date. The
new name says which input it uses.

ADR-0002 writes down what ADR-0001 left implicit: the two relationships the one
table carries, that `inactive` is now an override rather than a cache, what
`event_registration_id` does and does not mean, and the two rules above about not
erasing history.

The arithmetic behind the grant figures is covered directly rather than inferred
from single-affiliation cases — several people at one anchor, Jan 1 vs Dec 31 in
both directions, a full new → ongoing → reinstated → ongoing walk, and that
reconciling a no-show leaves an anchored verdict where it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Long affiliation lists mix people who facilitate now with rows that ended years
ago. The server already knows which is which, so it renders both groups and two
radios drive the visibility through :has() — no JavaScript, and the person and
organization forms share one partial instead of two copies.

A row you end while editing stays exactly where it is and just restyles; its
bucket only moves once you save. That is why this is not the registrants page's
server-round-trip filter: switching tabs must not discard unsaved edits.

Two Tailwind traps shape the markup. Radio ids cannot contain underscores —
Tailwind reads `_` as a space inside an arbitrary value, so the selector matches
nothing. And the group is named, because `group-hover:` matches any `.group`
ancestor and an unnamed one made hovering pop every row's comment tooltip at once.

The standalone editor now uses the same live styling, its comment icon opens the
comments it is previewing, and a back link to an ended row lands on the section
rather than a row hidden on the other tab.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An end date of "yesterday" computed in UTC is still today in the viewer's zone for
part of each day, and ApplicationController sets the zone per user — so the row
read as active and the redirect landed on it instead of the affiliations section.
A month back removes the ambiguity. Same failure mode #2264 just fixed on the
recipients program-status spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
inactive-toggle decides "ended" from the browser's local date, while Ruby's
Date.current follows the Rails zone — for part of each day they are different
dates, and the end-date-of-today examples failed on the difference rather than on
the behaviour. Asking the browser for its own today tests what the controller
actually compares against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#2292 centralized the muted gray for back-nav links while this branch was in
flight, so the two reconcile screens were the only ones still hardcoding it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FacilitatorProgramStatus#active_on_anchor is private to a file upstream edits
often — #2295 rewrote comments in it during this branch's life — so renaming it
here bought a conflict on every rebase and no clarity at any call site. That file
is byte-identical to main again.

The scope keeps the name: Affiliation.active_by_date_on is what a new caller
sees, and it is the one that has to be distinguishable from #active?.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Some inconsistencies only show up across the whole table. The reconcile page finds
facilitator affiliations minted by a non-training registration one event at a time,
and the org edit form warns about legacy-status drift one organization at a time —
neither answers "how many are there, everywhere".

Three checks to start:

  - facilitator affiliations from non-training events, which count toward program
    status without anyone having trained (deletes them, via destroy so the
    organization's status and dates stay in step)
  - affiliations whose minting registration belongs to a different organization,
    which breaks ADR-0002 D2a's invariant and makes reconciliation treat the row as
    auto-created for an org it never belonged to (unlinks them, the conservative
    direction: the row becomes hand-entered, which reconciliation spares)
  - legacy organization-status drift, which reports only. The stored vocabulary has
    no value meaning "never active", and the affiliation callbacks only ever write
    Active/Inactive, so any automatic rewrite would drift straight back.

Report-only is the base class default rather than an exception, because a wrong row
is not always one we know how to put right. The repair route resolves its param
against the registered checks and refuses anything else, so a report-only check
can't be coaxed into running one.

Also qualifies `affiliations.title` in the `.facilitators` scope. It broke as soon
as the scope was joined to `events`, which has a title of its own — the same
ambiguity `.active` already guards against for `end_date`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maebeale
maebeale force-pushed the maebeale/facilitator-affiliation-reconciliation branch from f6f2fee to b556798 Compare August 22, 2026 08:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants