Skip to content
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ This codebase (Rails 8.1)
| `StaffTag` | Internal, admin-only label for people (talent pipeline / roster / outreach β€” "Potential future trainer", "DV Leadership Cohort"). Admin-CRUD'd; `Publishable` (`published` flag) retires a tag from the pickers without deleting; never shown publicly (`StaffTagPolicy` gates every action + relation scope). Applied via the polymorphic `StaffTagging` join (`StaffTaggable` concern, Person today). Starter set seeded in db/seeds.rb |
| `StaffTagging` | Polymorphic join linking a `StaffTag` to the record it tags (`staff_taggable`); `created_by`/`updated_by` (stamped from `Current.user`) record which admin applied and last touched it. Has its own admin edit page (`StaffTaggingsController`, `/staff_taggings/:id/edit`) rendering the shared comments & communications section (`Communicable` + `commentable`) and a tag reassignment select, with communications keyed on the taggable person's email; plus a searchable/filterable index (`/staff_taggings`, `search_by_params` over tag + tagged person's name/email/org) |
| `OtherResponse` | A free-text "Other" typed on a form question, captured at submission time (registration, scholarship, bulk payment). Polymorphic `owner`: a **sector** "Other" is owned by the `Person` (promotable into a `Sector`, shown on their profile/edit chip); an **organization_type** "Other" is owned by the `Organization` (stored now, not promotable until `OrganizationType` is a model). `generic` questions aren't captured β€” that stays searchable in the form answers. `field_identifier` records the question; `kind` is derived. Curated at `/other_responses` (grouped by kind/question): `promote` (sectors only), `keep`, `dismiss`. `dismissed` hides the chip from the profile but stays in the review queue (still promotable later); only `promoted` leaves the queue. Admins deep-link there from a person's chip. |
| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage |
| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage; self-nests under a parent org (`parent_id` self-FK, `#ancestors`/`#descendants`/`#root`, structural only) per ADR-0004 |
| `Grant` | Funds (polymorphic `funder`: Organization or Person) with eligibility criteria, tasks, deadlines; parent of `Scholarship`. Scholarship totals cannot exceed the grant amount |
| `Scholarship` | Award to a `Person`; optionally drawn from a `Grant`, syncs to event registration `Allocation`. Tri-state `agreement_response_status` (pending/accepted/declined) drives the agreement; declined awards zero their allocation and drop out of all totals |
| `ScholarshipAgreementResponse` | Append-only history of a scholarship's accept ↔ decline back-and-forth (status, reason, responder, amount at the time); the scholarship's `agreement_response_status` is the denormalized latest row, and `responded_at`/reason are read from the latest response, not stored on the scholarship |
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/organizations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ def organization_params
params.require(:organization).permit(
:name, :description, :start_date, :end_date, :mission_vision_values,
:organization_type, :organization_type_other, :filemaker_code, :logo, :notes, :email, :website_url,
:organization_status_id, :location_id, :windows_type_id, :high_profile,
:organization_status_id, :location_id, :windows_type_id, :high_profile, :parent_id,
:profile_show_sectors, :profile_show_age_ranges, :profile_show_email, :profile_show_phone,
:profile_show_website, :profile_show_description, :profile_show_workshops,
:profile_show_stories, :profile_show_events_registered, :profile_show_workshop_logs,
Expand Down
44 changes: 44 additions & 0 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ class Organization < ApplicationRecord
belongs_to :organization_obligation, optional: true
belongs_to :location, optional: true # TODO - remove Location if unused
belongs_to :windows_type, optional: true
# Self-referential nesting: an org can sit under a parent "roof" org (mirrors
# FileMaker's Organization ParentID). Deleting a parent un-nests its children
# rather than cascading. See ADR-0004.
belongs_to :parent, class_name: "Organization", optional: true, inverse_of: :children
has_many :children, class_name: "Organization", foreign_key: :parent_id, inverse_of: :parent, dependent: :nullify
has_many :addresses, as: :addressable, dependent: :destroy
has_many :bookmarks, as: :bookmarkable, dependent: :destroy
has_many :other_responses, as: :owner, dependent: :destroy
Expand Down Expand Up @@ -68,6 +73,7 @@ def self.awbw
validates :website_url, length: { maximum: 255 }
validates :mission_vision_values, length: { maximum: 255 }
validate :affiliation_dates_locked, if: -> { affiliations.any? && !Current.user&.super_user? }
validate :parent_is_not_self_or_descendant, if: :parent_id_changed?

# Nested attributes
accepts_nested_attributes_for :addresses, allow_destroy: true,
Expand Down Expand Up @@ -240,6 +246,34 @@ def type_name
"#{name} #{ " (#{windows_type.short_name})" if windows_type}"
end

def nested?
parent_id.present?
end

# Every ancestor from the immediate parent up to the root, guarding against a
# malformed cycle so a bad row can't loop forever.
def ancestors
result = []
seen = [ id ]
current = parent
while current && seen.exclude?(current.id)
result << current
seen << current.id
current = current.parent
end
result
end

# The topmost org in this org's tree β€” itself when it has no parent.
def root
ancestors.last || self
end

# Every org nested under this one, at any depth.
def descendants
children.flat_map { |child| [ child ] + child.descendants }
end

def organization_description
locality = organization_locality
locality.present? ? "#{name}, #{locality}" : name
Expand Down Expand Up @@ -354,6 +388,16 @@ def affiliation_dates_locked
end
end

def parent_is_not_self_or_descendant
return if parent_id.blank?

if parent_id == id
errors.add(:parent_id, "can't be the organization itself")
elsif persisted? && descendants.any? { |org| org.id == parent_id }
errors.add(:parent_id, "can't be one of this organization's nested organizations")
end
end

def remove_duplicate_sectorable_items
sectorable_items
.group_by(&:sector_id)
Expand Down
32 changes: 29 additions & 3 deletions app/views/organizations/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
<div class="mb-8 grid grid-cols-1 items-start gap-6 md:grid-cols-[3fr_1fr]">
<!-- LEFT SIDE (3/4) -->
<div>
<!-- Organization Name + Hidden -->
<div class="mb-8">
<div class="md:col-span-2">
<!-- Organization Name + Parent -->
<div class="mb-8 grid grid-cols-1 items-start gap-4 <%= "md:grid-cols-[3fr_1fr]" if allowed_to?(:manage?, Organization) %>">
<div>
<%= f.input :name,
label: "Organization Name",
as: :text,
Expand All @@ -25,6 +25,32 @@
focus:border-blue-500 focus:ring focus:ring-blue-200"
} %>
</div>
<% if allowed_to?(:manage?, Organization) %>
<% parent = f.object.parent %>
<% parent_label = if parent
link_to organization_path(parent), target: "_blank", rel: "noopener",
title: "View #{parent.name}",
class: "inline-flex items-center gap-1 hover:underline #{DomainTheme.text_class_for(:organizations, intensity: 700)}" do
safe_join([ "Parent organization", tag.i(class: "fa-solid fa-arrow-up-right-from-square text-xs") ])
end
else
"Parent organization"
end %>
<%= f.input :parent_id,
label: parent_label,
collection: parent ? [ [ parent.remote_search_label[:label], parent.id ] ] : [],
include_blank: "None",
hint: "Nest under a parent β€œroof” org",
input_html: {
class: "w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500",
data: {
controller: "remote-select",
remote_select_model_value: "organization",
remote_select_exclude_value: ([ f.object.id ] + f.object.descendants.map(&:id)).compact.join(",")
}
},
prompt: "Type to search organizations…" %>
<% end %>
</div>

<!-- Sectors & Windows audience -->
Expand Down
21 changes: 21 additions & 0 deletions app/views/organizations/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@
<span class="text-primary font-semibold"><%= program_since %></span>
</p>
<% end %>
<% if @organization.parent.present? %>
<p class="mt-1 text-sm text-gray-600">
<i class="fa-solid fa-sitemap text-xs <%= DomainTheme.text_class_for(:organizations, intensity: 600) %>"></i>
Part of
<%= link_to @organization.parent.name, organization_path(@organization.parent),
class: "text-primary font-semibold hover:underline" %>
</p>
<% end %>
<!-- Contact info -->
<div class="mt-3 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 text-sm md:justify-start">
<% if @organization.profile_show_email? && @organization.email.present? %>
Expand Down Expand Up @@ -185,6 +193,19 @@
</div>
<% end %>
</div>
<!-- Nested organizations -->
<% children = @organization.children.order(:name) %>
<% if children.any? %>
<div class="<%= DomainTheme.bg_class_for(:organizations, intensity: 50) %>
border-2 <%= DomainTheme.border_class_for(:organizations) %> rounded-2xl p-5 sm:p-6">
<h2 class="mb-3 font-display text-2xl text-gray-900">Nested organizations</h2>
<div class="flex flex-wrap gap-2">
<% children.each do |child| %>
<%= link_to child.name, organization_path(child), class: button_classes(:secondary_outline) %>
<% end %>
</div>
</div>
<% end %>
<!-- Description -->
<% if @organization.profile_show_description? && @organization.description.present? %>
<div class="<%= DomainTheme.bg_class_for(:organizations, intensity: 50) %>
Expand Down
14 changes: 14 additions & 0 deletions config/features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3022,3 +3022,17 @@
- "Duplicates are suggested when two registrations for the same event have registrants with a matching name, email, or FileMaker code."
- "Merging combines the registrations only β€” the two people stay separate, so merge them in the People deduper too if they're the same person."
- "The preview shows every attendance, payment, and organization link that moves to the kept registration before you confirm, and blocks the merge if anything can't be moved safely."

- name: "Nest organizations under a parent"
area: people
display_status: admin_facing
released_on: 2026-08-31
pr_number: 2473
summary: >-
An organization can now sit under a parent β€œroof” organization β€” so a program
that used to be split into separate records (e.g. one for Adult Windows and
one for Children's Windows) can be grouped under one org.
pro_tips:
- "Set the parent from the β€œParent organization” picker on the org edit page."
- "The profile shows a β€œPart of …” link up to the parent and a β€œNested organizations” list down to the children."
- "Deleting a parent org un-nests its children rather than deleting them."
15 changes: 15 additions & 0 deletions db/migrate/20260831220947_add_parent_to_organizations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class AddParentToOrganizations < ActiveRecord::Migration[7.2]
def up
add_column :organizations, :parent_id, :integer unless column_exists?(:organizations, :parent_id)
add_index :organizations, :parent_id unless index_exists?(:organizations, :parent_id)
unless foreign_key_exists?(:organizations, column: :parent_id)
add_foreign_key :organizations, :organizations, column: :parent_id
end
end

def down
remove_foreign_key :organizations, column: :parent_id if foreign_key_exists?(:organizations, column: :parent_id)
remove_index :organizations, :parent_id if index_exists?(:organizations, :parent_id)
remove_column :organizations, :parent_id if column_exists?(:organizations, :parent_id)
end
end
5 changes: 4 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.1].define(version: 2026_08_31_220445) do
ActiveRecord::Schema[8.1].define(version: 2026_08_31_220947) do
create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "action_text_rich_text_id", null: false
t.datetime "created_at", null: false
Expand Down Expand Up @@ -1188,6 +1188,7 @@
t.integer "organization_status_id"
t.string "organization_type"
t.string "organization_type_other"
t.integer "parent_id"
t.boolean "profile_show_age_ranges", default: true, null: false
t.boolean "profile_show_description", default: true, null: false
t.boolean "profile_show_email", default: true, null: false
Expand All @@ -1206,6 +1207,7 @@
t.index ["created_by_id"], name: "index_organizations_on_created_by_id"
t.index ["location_id"], name: "index_organizations_on_location_id"
t.index ["organization_status_id"], name: "index_organizations_on_organization_status_id"
t.index ["parent_id"], name: "index_organizations_on_parent_id"
t.index ["updated_by_id"], name: "index_organizations_on_updated_by_id"
t.index ["windows_type_id"], name: "index_organizations_on_windows_type_id"
end
Expand Down Expand Up @@ -2405,6 +2407,7 @@
add_foreign_key "organization_statuses", "users", column: "updated_by_id"
add_foreign_key "organizations", "locations"
add_foreign_key "organizations", "organization_statuses"
add_foreign_key "organizations", "organizations", column: "parent_id"
add_foreign_key "organizations", "users", column: "created_by_id"
add_foreign_key "organizations", "users", column: "updated_by_id"
add_foreign_key "organizations", "windows_types"
Expand Down
54 changes: 54 additions & 0 deletions docs/adr/0004-nested-organizations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# ADR-0004 β€” Nested organizations (a self-referential org tree)

- **Status:** Accepted
- **Date:** 2026-08-31

## Context

FileMaker is the source of truth we're importing from (rubyforgood/awbw#508).
There, `Organization` records carry a `ParentID` self-reference and also own
`Project` records. Staff historically split one real organization into two
FileMaker Projects when it ran both Adult and Children's Windows programs; they
now want those grouped "under one roof." When the FileMaker Organizations import
lands, both the org→org parent chain and the org→project ownership collapse into
the single Rails `Organization` model, so that model needs a way to nest.

## Decisions

### D1 β€” Adjacency-list tree on `organizations`

Nesting is one nullable `organizations.parent_id` self-FK (`belongs_to :parent`,
`has_many :children`), not a join table, closure table, or STI. It's the
lightest seam that mirrors FileMaker's `ParentID` one-to-one and keeps re-parenting
a single column write. Arbitrary depth is allowed (matching FileMaker), traversed
with `#ancestors` / `#descendants` / `#root` helpers.

### D2 β€” Structural only for now

The parent/child link is navigation and grouping only. A parent org does **not**
aggregate its children's affiliations, sectors, age groups, workshops, or events
into its own profile/index rows. Roll-ups are a deliberate later change if the need
appears; keeping the first cut structural avoids touching every aggregate scope and
decorator before we know the import shape.

### D3 β€” Deleting a parent un-nests its children

`has_many :children, dependent: :nullify`. Removing a roof org must never cascade
and destroy the real programs nested under it; the children survive as top-level
orgs.

### D4 β€” Cycle protection at the model

`parent_id` can't be the org itself or one of its own descendants
(`parent_is_not_self_or_descendant`). The traversal helpers also guard against a
malformed stored cycle so a bad row can't loop forever.

### D5 β€” Set the parent from the edit form

Admins nest an org via a "Parent organization" remote-select picker on the org
edit form (admin-gated, same `remote-select` pattern as the workshop picker), which
excludes the org itself and its descendants. The profile shows the relationship
read-only in both directions: a "Part of …" link up to the parent and a "Nested
organizations" list down to the children. A dedicated "nest"/"demote" button is
deferred β€” the picker plus two-way profile links already cover moving through the
tree.
44 changes: 44 additions & 0 deletions spec/models/organization_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
it { should belong_to(:location).optional }
it { should belong_to(:windows_type).optional }
it { should belong_to(:organization_status) }
it { should belong_to(:parent).class_name("Organization").optional }
it { should have_many(:children).class_name("Organization").with_foreign_key(:parent_id).dependent(:nullify) }
it { should have_many(:affiliations) }
it { should have_many(:users).through(:people) }
it { should have_many(:reports) }
Expand All @@ -31,6 +33,48 @@
end
end

describe "nesting" do
let(:parent) { create(:organization) }
let(:child) { create(:organization, parent: parent) }
let(:grandchild) { create(:organization, parent: child) }

it "exposes children and reports nested?" do
expect(parent.children).to include(child)
expect(child.nested?).to be(true)
expect(parent.nested?).to be(false)
end

it "walks ancestors up to the root" do
expect(grandchild.ancestors).to eq([ child, parent ])
expect(grandchild.root).to eq(parent)
expect(parent.root).to eq(parent)
end

it "collects descendants at any depth" do
grandchild
expect(parent.descendants).to match_array([ child, grandchild ])
end

it "un-nests children when the parent is destroyed" do
child
parent.destroy
expect(child.reload.parent_id).to be_nil
end

it "rejects making an org its own parent" do
parent.parent = parent
expect(parent).not_to be_valid
expect(parent.errors[:parent_id]).to be_present
end

it "rejects nesting an org under one of its own descendants" do
grandchild
parent.parent = grandchild
expect(parent).not_to be_valid
expect(parent.errors[:parent_id]).to be_present
end
end

describe "#website_link_url" do
it "prepends https:// to a bare domain" do
org = build(:organization, website_url: "awbw.org")
Expand Down
Loading