Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,14 @@ end

### Affiliations

**Facilitator affiliations are bedrock**: an organization's standing with AWBW is *derived from its affiliations*, not a manually maintained field. The exact, case-sensitive title `"Facilitator"` (`Affiliation::FACILITATOR_TITLE`, matched by the `facilitators` scope / `facilitator?` predicate) is what counts; variants like "Lead Facilitator" don't.

**Two activeness scopes** (mirrored by `active?` / `active_or_pending?` for preloaded records):
- `active_or_pending` — not inactive, not past its end date. **Includes future-dated** affiliations (a Facilitator dated to an upcoming training's month is "pending"). Use for forward-looking questions: org program-status (New/Ongoing/Reinstate), whether an org is active/published, the event dashboard's org list, end-date/"ended" displays, and de-dupe.
- `active` — `active_or_pending` **and** the start_date has arrived. Use for true present-tense state: a person's current-affiliations summary, "active facilitator" names, and profile searchability.

`Affiliation#sync_organization_status_from_affiliations` (an `after_save`/`after_destroy` callback) keeps the org's `organization_status` in sync with whether it has any `active_or_pending` affiliations — symmetric (activates on gain, deactivates on loss) but only ever toggles between Active and Inactive, leaving manual states (Pending/Reinstate/Suspended/Unknown) alone. Tracked via Ahoy `autochange.organization`.

- `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

### Notifications
Expand Down
6 changes: 4 additions & 2 deletions app/controllers/organizations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ def index
logo_attachment: :blob
))
filtered = base_scope.search_by_params(params).order(:name)
# Pending affiliations count toward "active people" only for admins.
people_scope = helpers.include_pending_affiliations? ? Affiliation.active_or_pending : Affiliation.active
@organizations_count = filtered.count
@active_people_count = Affiliation.active.where(organization_id: filtered.select(:id)).count("DISTINCT person_id, organization_id")
@active_people_count = people_scope.where(organization_id: filtered.select(:id)).count("DISTINCT person_id, organization_id")
@organizations = filtered.paginate(page: params[:page], per_page: per_page)
org_ids = @organizations.map(&:id)
@affiliated_since = Affiliation.where(organization_id: org_ids)
.group(:organization_id)
.minimum(:start_date)
@active_people_counts = Affiliation.active
@active_people_counts = people_scope
.where(organization_id: org_ids)
.group(:organization_id)
.distinct
Expand Down
4 changes: 2 additions & 2 deletions app/decorators/organization_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def affiliated_since_date
end

def affiliation_end_date
return nil if affiliations.active.exists?
return nil if affiliations.active_or_pending.exists?
affiliations.maximum(:end_date)
end

Expand All @@ -72,7 +72,7 @@ def facilitator_since_date

def facilitation_end_date
facilitator_affiliations = affiliations.facilitators
return nil if facilitator_affiliations.active.exists?
return nil if facilitator_affiliations.active_or_pending.exists?
facilitator_affiliations.maximum(:end_date)
end

Expand Down
4 changes: 2 additions & 2 deletions app/decorators/person_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def default_display_image
end

def affiliation_end_date
return nil if affiliations.active.exists?
return nil if affiliations.active_or_pending.exists?
affiliations.maximum(:end_date)
end

Expand All @@ -44,7 +44,7 @@ def active_facilitator_organization_names

def facilitation_end_date
facilitator_affiliations = affiliations.facilitators
return nil if facilitator_affiliations.active.exists?
return nil if facilitator_affiliations.active_or_pending.exists?
facilitator_affiliations.maximum(:end_date)
end

Expand Down
9 changes: 9 additions & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
module ApplicationHelper
# Pending (future-dated) affiliations are visible only to admins; everyone else
# sees only the currently-active set. Single source of truth for the
# active vs. active_or_pending split on the people/organization index & profile
# pages, which are becoming visible to non-admins. In controllers, call via
# `helpers.include_pending_affiliations?`.
def include_pending_affiliations?
current_user&.super_user?
end

# Tags an admin may use in a form field name / group header that should
# render (rather than escape) on the public form. Block + inline formatting,
# links, line breaks, and font sizing/coloring (via <font> or inline style).
Expand Down
55 changes: 39 additions & 16 deletions app/models/affiliation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ class Affiliation < ApplicationRecord
.where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", Date.current)
}

scope :active, -> { active_or_pending }
# Currently active: active-or-pending AND its start_date has arrived (or is
# unset). A future-dated affiliation (e.g. a Facilitator dated to an upcoming
# training's month) is therefore "pending" — counted by active_or_pending but
# not yet active.
scope :active, -> {

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: The crux: active now excludes future-dated affiliations (start_date not yet arrived). Sites that should still count an incoming facilitator were moved to active_or_pending; only present-tense checks (person summary, searchability, "active facilitator") keep this stricter scope.

active_or_pending
.where("affiliations.start_date IS NULL OR affiliations.start_date <= ?", Date.current)
}

# Only the exact, case-sensitive title "Facilitator" counts — variants like
# "Lead Facilitator" or "facilitator" are deliberately excluded. BINARY forces
Expand All @@ -31,9 +38,9 @@ class Affiliation < ApplicationRecord

before_validation :skip_if_duplicate
before_save :set_inactive_from_dates
after_save :deactivate_organization_if_no_active_people
after_save :sync_organization_status_from_affiliations
after_save :sync_organization_affiliation_dates
after_destroy :deactivate_organization_if_no_active_people
after_destroy :sync_organization_status_from_affiliations
after_destroy :sync_organization_affiliation_dates

# Methods
Expand All @@ -45,13 +52,20 @@ def facilitator?
title.to_s.strip == "Facilitator"
end

# Current: not flagged inactive and not past its end date. Mirrors the `active`
# scope so already-loaded affiliations can be filtered in Ruby without another
# query (e.g. on list pages that preload affiliations).
def active?
# Active-or-pending: not flagged inactive and not past its end date. A future
# start_date still counts (the affiliation is pending, not ended). Mirrors the
# `active_or_pending` scope for in-memory filtering of preloaded affiliations.
def active_or_pending?
!inactive? && (end_date.nil? || end_date >= Date.current)
end

# Currently active: active-or-pending AND its start_date has arrived. Mirrors
# the `active` scope so already-loaded affiliations can be filtered in Ruby
# without another query (e.g. on list pages that preload affiliations).
def active?
active_or_pending? && (start_date.nil? || start_date <= Date.current)
end

def name
"#{person.name}" if person
end
Expand Down Expand Up @@ -93,7 +107,7 @@ def sync_organization_affiliation_dates
affiliations = org.affiliations.where.not(id: destroyed_by_association ? id : nil)

earliest_start = affiliations.minimum(:start_date)
has_active = affiliations.active.exists?
has_active = affiliations.active_or_pending.exists?

updates = {}
updates[:start_date] = earliest_start if org.start_date != earliest_start
Expand All @@ -107,22 +121,31 @@ def sync_organization_affiliation_dates
org.update_columns(updates) if updates.any?
end

def deactivate_organization_if_no_active_people
return if organization.affiliations.active.exists?
# Keep the organization's Active/Inactive status in sync with whether it has any
# active-or-pending affiliations (an incoming facilitator counts). Symmetric:
# activates an Inactive org that gains people and deactivates one that loses
# them. Only ever toggles between Active and Inactive — manual states (Pending,
# Reinstate, Suspended, Unknown) are left untouched so they stick.
def sync_organization_status_from_affiliations

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: Now symmetric (was deactivate-only). Guarded to toggle only Active↔Inactive — a manually set Pending/Reinstate/Suspended/Unknown is left alone, so admins keep control of those. Uses active_or_pending so an incoming facilitator activates the org.

has_people = organization.affiliations.active_or_pending.exists?
current_name = organization.organization_status&.name
target_name = has_people ? "Active" : "Inactive"

return if current_name == target_name
return unless [ "Active", "Inactive", nil ].include?(current_name)

inactive_status = OrganizationStatus.find_by(name: "Inactive")
return unless inactive_status
return if organization.organization_status_id == inactive_status.id
target_status = OrganizationStatus.find_by(name: target_name)
return unless target_status

organization.update_column(:organization_status_id, inactive_status.id)
organization.update_column(:organization_status_id, target_status.id)

Ahoy::Tracker.new(user: Current.user).track(
"autochange.organization",
resource_type: "Organization",
resource_id: organization.id,
resource_title: organization.name,
change: "status_set_to_inactive",
reason: "no_active_affiliations"
change: has_people ? "status_set_to_active" : "status_set_to_inactive",
reason: has_people ? "active_affiliation_present" : "no_active_affiliations"
)
end
end
2 changes: 1 addition & 1 deletion app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ def remote_search_label
private

def snapshot_registrant_organizations
registrant.affiliations.active.includes(:organization).find_each do |aff|
registrant.affiliations.active_or_pending.includes(:organization).find_each do |aff|
event_registration_organizations.create(organization: aff.organization)
end
end
Expand Down
8 changes: 4 additions & 4 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class Organization < ApplicationRecord
# 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))
affiliation_active = where(id: Affiliation.active_or_pending.select(:organization_id))
status_active.or(affiliation_active)
}
scope :address, ->(address) do
Expand Down Expand Up @@ -191,7 +191,7 @@ def program_location
def program_status(recipient = nil)
facilitators = affiliations.select(&:facilitator?)
return "New" if facilitators.empty?
return "Reinstate" if facilitators.none?(&:active?)
return "Reinstate" if facilitators.none?(&:active_or_pending?)

prior = recipient ? facilitators.reject { |a| a.person_id == recipient.id } : facilitators
prior.any? ? "Ongoing" : "New"
Expand All @@ -205,7 +205,7 @@ def program_status(recipient = nil)
def self.program_statuses_by_id(org_ids)
facilitator_scope = Affiliation.facilitators.where(organization_id: org_ids)
with_facilitators = facilitator_scope.distinct.pluck(:organization_id).to_set
with_active = facilitator_scope.active.distinct.pluck(:organization_id).to_set
with_active = facilitator_scope.active_or_pending.distinct.pluck(:organization_id).to_set
org_ids.index_with do |id|
next :new if with_facilitators.exclude?(id)
with_active.include?(id) ? :ongoing : :reinstated
Expand All @@ -231,7 +231,7 @@ def organization_locality

def published? # needed for my_bookmarks
return true if organization_status&.name == "Active"
affiliations.active.exists?
affiliations.active_or_pending.exists?
end

def sector_list
Expand Down
6 changes: 3 additions & 3 deletions app/services/event_dashboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ def organization_registrant_ids_by_org
.joins(:event_registration)
.where(event_registration_id: active_registration_ids)
.pluck(:organization_id, "event_registrations.registrant_id")
affiliated = Affiliation.active
affiliated = Affiliation.active_or_pending
.where(person_id: registrant_ids)
.pluck(:organization_id, :person_id)
(snapshot + affiliated).each_with_object(Hash.new { |hash, key| hash[key] = Set.new }) do |(organization_id, person_id), map|
Expand Down Expand Up @@ -663,7 +663,7 @@ def program_status_for(organization)
# This event's active registrants' active affiliations, grouped by organization
# id — the reference points for the program-status breakdown.
def registrant_affiliations_by_org
@registrant_affiliations_by_org ||= Affiliation.active
@registrant_affiliations_by_org ||= Affiliation.active_or_pending
.where(person_id: registrant_ids)
.includes(:organization)
.group_by(&:organization_id)
Expand Down Expand Up @@ -779,7 +779,7 @@ def organization_ids
snapshot_ids = EventRegistrationOrganization
.where(event_registration_id: active_registration_ids)
.pluck(:organization_id)
affiliated_ids = Affiliation.active
affiliated_ids = Affiliation.active_or_pending
.where(person_id: registrant_ids)
.pluck(:organization_id)
(snapshot_ids + affiliated_ids).compact.uniq
Expand Down
2 changes: 1 addition & 1 deletion app/views/organizations/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@

<div class="rounded-lg border border-gray-200 <%= DomainTheme.bg_class_for(:people) %> p-4 mb-4 shadow-sm">
<% org_earliest_aff = f.object.persisted? ? f.object.affiliations.minimum(:start_date) : nil %>
<% org_aff_ended = f.object.persisted? && f.object.affiliations.any? && !f.object.affiliations.active.exists? %>
<% org_aff_ended = f.object.persisted? && f.object.affiliations.any? && !f.object.affiliations.active_or_pending.exists? %>
<% org_latest_end = f.object.persisted? ? f.object.affiliations.maximum(:end_date) : nil %>
<% org_end_date = org_aff_ended ? org_latest_end : f.object.end_date %>
<% org_decorated = f.object.decorate %>
Expand Down
4 changes: 2 additions & 2 deletions app/views/organizations/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
<% end %>
<% org_earliest_affiliation = @organization.affiliations.minimum(:start_date) %>
<% org_affiliated_since = org_earliest_affiliation || @organization.start_date %>
<% org_show_aff_ended = @organization.affiliations.any? && !@organization.affiliations.active.exists? %>
<% org_show_aff_ended = @organization.affiliations.any? && !(include_pending_affiliations? ? @organization.affiliations.active_or_pending : @organization.affiliations.active).exists? %>
<% org_show_end_date = org_show_aff_ended ? @organization.affiliations.maximum(:end_date) : @organization.end_date %>
<% if org_affiliated_since.present? %>
<p class="text-gray-500 text-sm mt-1">
Expand Down Expand Up @@ -120,7 +120,7 @@
Affiliations
</h2>
<% active_affiliations = @organization.affiliations
.select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) && a.person.present? }
.select { |a| (include_pending_affiliations? ? a.active_or_pending? : a.active?) && a.person.present? }
.sort_by { |a| [a.person.first_name.to_s.downcase, a.person.last_name.to_s.downcase] } %>
<% grouped = active_affiliations.group_by(&:person) %>
<% if grouped.any? %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/people/people_results.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
</td>
<!-- Affiliations -->
<td class="px-4 py-2 text-sm text-gray-800">
<% affiliations = person.affiliations.select { |a| a.organization.present? && !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) } %>
<% affiliations = person.affiliations.select { |a| a.organization.present? && (include_pending_affiliations? ? a.active_or_pending? : a.active?) } %>
<% if affiliations.any? %>
<div class="flex flex-wrap gap-2">
<% affiliations.first(3).each do |affiliation| %>
Expand Down
73 changes: 73 additions & 0 deletions spec/models/affiliation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,79 @@
described_class.active.joins(:organization).to_a
}.not_to raise_error
end

it 'excludes a future-dated (pending) affiliation' do
pending_aff = create(:affiliation, inactive: false, start_date: 1.month.from_now)
expect(described_class.active).not_to include(pending_aff)
end
end

describe '.active_or_pending' do
it 'includes a future-dated affiliation that .active excludes' do
pending_aff = create(:affiliation, inactive: false, start_date: 1.month.from_now)

expect(described_class.active_or_pending).to include(pending_aff)
expect(described_class.active).not_to include(pending_aff)
end

it 'excludes inactive and ended affiliations like .active does' do
inactive_aff = create(:affiliation, inactive: true)
ended_aff = create(:affiliation, inactive: false, end_date: 1.day.ago)

expect(described_class.active_or_pending).not_to include(inactive_aff, ended_aff)
end
end

describe '#active_or_pending?' do
it 'is true for a future start date (where #active? is false)' do
affiliation = build(:affiliation, inactive: false, start_date: 1.month.from_now)

expect(affiliation.active_or_pending?).to be true
expect(affiliation.active?).to be false
end

it 'is false once ended' do
expect(build(:affiliation, inactive: false, end_date: 1.day.ago).active_or_pending?).to be false
end
end

describe 'organization status sync' do
let!(:active_status) { create(:organization_status, name: "Active") }
let!(:inactive_status) { create(:organization_status, name: "Inactive") }

it 'reactivates an Inactive organization when it gains an active affiliation' do
org = create(:organization, organization_status: inactive_status)

create(:affiliation, organization: org, inactive: false, end_date: nil)

expect(org.reload.organization_status).to eq(active_status)
end

it 'activates on a pending (future-dated) affiliation too' do
org = create(:organization, organization_status: inactive_status)

create(:affiliation, organization: org, inactive: false, start_date: 1.month.from_now)

expect(org.reload.organization_status).to eq(active_status)
end

it 'deactivates an Active organization when its last affiliation ends' do
org = create(:organization, organization_status: active_status)
affiliation = create(:affiliation, organization: org, inactive: false, end_date: nil)

affiliation.update!(end_date: 1.day.ago)

expect(org.reload.organization_status).to eq(inactive_status)
end

it 'leaves a manually set status (e.g. Suspended) untouched' do
suspended = create(:organization_status, name: "Suspended")
org = create(:organization, organization_status: suspended)

create(:affiliation, organization: org, inactive: false, end_date: nil)

expect(org.reload.organization_status).to eq(suspended)
end
end

describe '#facilitator?' do
Expand Down
13 changes: 13 additions & 0 deletions spec/models/organization_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,12 @@
it "is New when the org has no facilitator affiliations" do
expect(org.program_status(recipient)).to eq("New")
end

it "is Ongoing (not Reinstate) when the org's only facilitator is pending (future-dated)" do
create(:affiliation, person: create(:person), organization: org, title: "Facilitator", start_date: 1.month.from_now)

expect(org.reload.program_status(recipient)).to eq("Ongoing")
end
end

describe ".program_statuses_by_id" do
Expand Down Expand Up @@ -363,5 +369,12 @@

expect(Organization.program_statuses_by_id([ org.id ])).to eq(org.id => :new)
end

it "treats a pending (future-dated) facilitator as ongoing, not reinstated" do
org = create(:organization)
create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.month.from_now)

expect(Organization.program_statuses_by_id([ org.id ])).to eq(org.id => :ongoing)
end
end
end