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