diff --git a/AGENTS.md b/AGENTS.md index ee9a265414..7c5802aaca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 3dd0dc50a0..1285ae73e2 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -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, diff --git a/app/models/organization.rb b/app/models/organization.rb index 76c3e66ac2..f224c336f5 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -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 @@ -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, @@ -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 @@ -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) diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 1e46c9ada8..217f56f7ae 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -11,9 +11,9 @@
- -
-
+ +
"> +
<%= f.input :name, label: "Organization Name", as: :text, @@ -25,6 +25,32 @@ focus:border-blue-500 focus:ring focus:ring-blue-200" } %>
+ <% 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 %>
diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb index a9efa54736..2dda9b2788 100644 --- a/app/views/organizations/show.html.erb +++ b/app/views/organizations/show.html.erb @@ -69,6 +69,14 @@ <%= program_since %>

<% end %> + <% if @organization.parent.present? %> +

+ + Part of + <%= link_to @organization.parent.name, organization_path(@organization.parent), + class: "text-primary font-semibold hover:underline" %> +

+ <% end %>
<% if @organization.profile_show_email? && @organization.email.present? %> @@ -185,6 +193,19 @@
<% end %>
+ + <% children = @organization.children.order(:name) %> + <% if children.any? %> +
+

Nested organizations

+
+ <% children.each do |child| %> + <%= link_to child.name, organization_path(child), class: button_classes(:secondary_outline) %> + <% end %> +
+
+ <% end %> <% if @organization.profile_show_description? && @organization.description.present? %>
- + 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." diff --git a/db/migrate/20260831220947_add_parent_to_organizations.rb b/db/migrate/20260831220947_add_parent_to_organizations.rb new file mode 100644 index 0000000000..eaa0ab7c1e --- /dev/null +++ b/db/migrate/20260831220947_add_parent_to_organizations.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index ed86d4c7ef..7ec3f1ebba 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 @@ -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 @@ -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 @@ -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" diff --git a/docs/adr/0004-nested-organizations.md b/docs/adr/0004-nested-organizations.md new file mode 100644 index 0000000000..2222ee93de --- /dev/null +++ b/docs/adr/0004-nested-organizations.md @@ -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. diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index f40b364bdd..ac94934846 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -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) } @@ -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") diff --git a/spec/requests/organizations_spec.rb b/spec/requests/organizations_spec.rb index e638da8e8f..0f73e9eba6 100644 --- a/spec/requests/organizations_spec.rb +++ b/spec/requests/organizations_spec.rb @@ -60,6 +60,19 @@ expect(response).to be_successful end + it "shows the parent link and nested children on the profile" do + parent = Organization.create!(valid_attributes.merge(name: "Roof Org")) + organization = Organization.create!(valid_attributes.merge(name: "Adult Windows")) + organization.update!(parent: parent) + child = Organization.create!(valid_attributes.merge(name: "Childrens Windows", parent: organization)) + + get organization_url(organization) + expect(response.body).to include("Part of") + expect(response.body).to include("Roof Org") + expect(response.body).to include("Nested organizations") + expect(response.body).to include(child.name) + end + it "shows age groups on the profile, gated by profile_show_age_ranges" do organization = Organization.create!(valid_attributes) age_type = create(:category_type, name: "AgeRange", published: true) @@ -168,6 +181,14 @@ expect(response.body).not_to include("Monthly reports") end + it "links the parent-organization label to the parent's profile when one is set" do + parent = Organization.create!(valid_attributes.merge(name: "Roof Org")) + organization = Organization.create!(valid_attributes.merge(parent: parent)) + get edit_organization_url(organization) + expect(response.body).to include(organization_path(parent)) + expect(response.body).to include("fa-arrow-up-right-from-square") + end + it "shows the Monthly reports row when monthly reports exist" do organization = Organization.create!(valid_attributes) create(:monthly_report, organization: organization) @@ -295,6 +316,13 @@ patch organization_url(organization), params: { organization: { high_profile: "1" } } expect(organization.reload.high_profile).to be(true) end + + it "nests the organization under a parent" do + parent = Organization.create!(valid_attributes.merge(name: "Roof Org")) + organization = Organization.create!(valid_attributes) + patch organization_url(organization), params: { organization: { parent_id: parent.id } } + expect(organization.reload.parent).to eq(parent) + end end context "with invalid parameters" do