diff --git a/AGENTS.md b/AGENTS.md
index 375ebb393..3494c84f7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -51,7 +51,7 @@ This codebase (Rails 8.1)
| `app/models/` | ActiveRecord models | ~90 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~69 files |
| `app/jobs/` | SolidQueue background jobs | 6 files |
-| `app/models/concerns/` | Shared model modules | 17 concerns |
+| `app/models/concerns/` | Shared model modules | 18 concerns |
### Presentation
@@ -103,6 +103,8 @@ This codebase (Rails 8.1)
| `Story` | Editorial content with facilitators, primary/gallery assets |
| `Resource` | Handouts, toolkits, templates with downloadable assets |
| `Person` | Organization affiliates with contacts, addresses, sectors |
+| `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 |
| `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 |
| `Grant` | Funds (polymorphic `funder`: Organization or Person) with eligibility criteria, tasks, deadlines; parent of `Scholarship`. Scholarship totals cannot exceed the grant amount |
@@ -147,6 +149,7 @@ This codebase (Rails 8.1)
| `RemoteSearchable` | AJAX remote search by column |
| `RichTextSearchable` | Full-text search on ActionText rich_text fields |
| `SectorsTaggable` | Enforces a single primary sector for sector-tagged owners |
+| `StaffTaggable` | Adds the polymorphic `staff_taggings`/`staff_tags` associations for internal admin StaffTags (Person today) |
| `TagFilterable` | Scope-based filtering by tag names |
| `Trendable` | Trending metrics tracking |
| `WindowsTypeFilterable` | Filter by WindowsType association |
diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb
index 4314137b9..662dbed2e 100644
--- a/app/controllers/people_controller.rb
+++ b/app/controllers/people_controller.rb
@@ -376,6 +376,9 @@ def set_form_variables
# of any other type (age ranges included, handled by nested attributes), so
# saving the form can't drop a person's other category connections.
@managed_category_type_ids = @person_categories_grouped.map { |type, _| type.id }
+
+ @staff_tags_collection = StaffTag.published.ordered.pluck(:name, :id)
+ @current_staff_tag_ids = @person.staff_tag_ids
end
def find_duplicate_people(first_name, last_name, email, legal_first_name: nil, email_2: nil)
@@ -575,6 +578,7 @@ def person_params
:twitter_url,
:created_by_id, :updated_by_id,
sectorable_items_attributes: [ :id, :sector_id, :is_leader, :is_primary, :_destroy ],
+ staff_taggings_attributes: [ :id, :staff_tag_id, :_destroy ],
age_range_categorizable_items_attributes: [ :id, :category_id, :is_primary, :_destroy ],
addresses_attributes: [
:id,
diff --git a/app/controllers/staff_tags_controller.rb b/app/controllers/staff_tags_controller.rb
new file mode 100644
index 000000000..f3d22f36c
--- /dev/null
+++ b/app/controllers/staff_tags_controller.rb
@@ -0,0 +1,79 @@
+class StaffTagsController < ApplicationController
+ before_action :set_staff_tag, only: [ :show, :edit, :update, :destroy ]
+
+ def index
+ authorize!
+ per_page = params[:number_of_items_per_page].presence || 25
+ base_scope = authorized_scope(StaffTag.all)
+ @count_display = base_scope.count
+ @staff_tags = base_scope.ordered.paginate(page: params[:page], per_page: per_page).decorate
+ @tagged_people_counts = StaffTagging
+ .where(staff_tag_id: @staff_tags.map(&:id), staff_taggable_type: "Person")
+ .group(:staff_tag_id)
+ .count
+ end
+
+ def show
+ authorize! @staff_tag
+ @taggings = @staff_tag.staff_taggings
+ .includes(:created_by, :updated_by, :staff_taggable)
+ .order(created_at: :desc)
+ @staff_tag = @staff_tag.decorate
+ end
+
+ def new
+ @staff_tag = StaffTag.new.decorate
+ authorize! @staff_tag
+ end
+
+ def edit
+ @staff_tag = @staff_tag.decorate
+ authorize! @staff_tag
+ end
+
+ def create
+ @staff_tag = StaffTag.new(staff_tag_params)
+ @staff_tag.created_by = current_user
+ @staff_tag.updated_by = current_user
+ authorize! @staff_tag
+
+ if @staff_tag.save
+ redirect_to @staff_tag, notice: "Staff tag was successfully created."
+ else
+ @staff_tag = @staff_tag.decorate
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def update
+ authorize! @staff_tag
+ @staff_tag.updated_by = current_user
+
+ if @staff_tag.update(staff_tag_params)
+ redirect_to @staff_tag, notice: "Staff tag was successfully updated.", status: :see_other
+ else
+ @staff_tag = @staff_tag.decorate
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ authorize! @staff_tag
+
+ if @staff_tag.destroy
+ redirect_to staff_tags_path, notice: "Staff tag was successfully deleted.", status: :see_other
+ else
+ redirect_to staff_tags_path, alert: "Can't delete a staff tag that's still in use — unpublish it instead.", status: :see_other
+ end
+ end
+
+ private
+
+ def set_staff_tag
+ @staff_tag = StaffTag.find(params[:id])
+ end
+
+ def staff_tag_params
+ params.require(:staff_tag).permit(:name, :description, :published)
+ end
+end
diff --git a/app/controllers/taggings_controller.rb b/app/controllers/taggings_controller.rb
index e3cacb3f9..4c6513c92 100644
--- a/app/controllers/taggings_controller.rb
+++ b/app/controllers/taggings_controller.rb
@@ -35,6 +35,7 @@ def index
.select("categories.*, category_types.name AS category_type_name")
.distinct
.order("category_type_name ASC, categories.name ASC")
+ @staff_tags = authorized_scope(StaffTag.all).published.ordered
track_view("taggings")
track_tagging_browse(@grouped_tagged_items) if browsing_intentionally?
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index b25e80282..95e71e1e9 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -12,6 +12,7 @@ def index
.distinct
.order("category_type_name ASC, categories.name ASC")
@categories_by_type = @categories.to_a.group_by(&:category_type_name)
+ @staff_tags = authorized_scope(StaffTag.all).published.ordered
track_view("tags", { page: "index" })
end
diff --git a/app/decorators/staff_tag_decorator.rb b/app/decorators/staff_tag_decorator.rb
new file mode 100644
index 000000000..793feb0e6
--- /dev/null
+++ b/app/decorators/staff_tag_decorator.rb
@@ -0,0 +1,13 @@
+class StaffTagDecorator < ApplicationDecorator
+ def title
+ name
+ end
+
+ def detail(length: nil)
+ description
+ end
+
+ def status_label
+ published? ? "Published" : "Unpublished"
+ end
+end
diff --git a/app/helpers/visibility_flags_helper.rb b/app/helpers/visibility_flags_helper.rb
index addb54561..e9912ccba 100644
--- a/app/helpers/visibility_flags_helper.rb
+++ b/app/helpers/visibility_flags_helper.rb
@@ -53,6 +53,13 @@ module VisibilityFlagsHelper
hint: "Hides all child categories",
description: "When off, this type and all of its child categories are hidden."
},
+ # Staff tags are admin-only regardless; `published` just controls whether the
+ # tag is offered in the pickers, so it uses this definition via definition_key.
+ staff_tag_published: {
+ label: "Published",
+ hint: "Offered in the tag pickers",
+ description: "When off, this tag is retired from the pickers but stays on anyone already carrying it. Staff tags are admin-only either way — never shown publicly."
+ },
story_specific: {
label: "Story specific",
hint: "Needed for story share subsite",
diff --git a/app/models/concerns/staff_taggable.rb b/app/models/concerns/staff_taggable.rb
new file mode 100644
index 000000000..58818acc0
--- /dev/null
+++ b/app/models/concerns/staff_taggable.rb
@@ -0,0 +1,10 @@
+module StaffTaggable
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :staff_taggings, as: :staff_taggable, dependent: :destroy
+ has_many :staff_tags, through: :staff_taggings
+ accepts_nested_attributes_for :staff_taggings, allow_destroy: true,
+ reject_if: ->(attrs) { attrs[:staff_tag_id].blank? }
+ end
+end
diff --git a/app/models/person.rb b/app/models/person.rb
index 1330784d7..a34dd99f7 100644
--- a/app/models/person.rb
+++ b/app/models/person.rb
@@ -1,5 +1,5 @@
class Person < ApplicationRecord
- include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable
+ include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable, StaffTaggable
pay_customer default_payment_processor: :stripe
@@ -188,6 +188,10 @@ class Person < ApplicationRecord
.distinct }
scope :sector_leaders, -> {
joins(:sectorable_items).where(sectorable_items: { is_leader: true }).distinct }
+ scope :staff_tagged_with, ->(ids) {
+ tag_ids = Array(ids).reject(&:blank?)
+ return all if tag_ids.empty?
+ joins(:staff_taggings).where(staff_taggings: { staff_tag_id: tag_ids }).distinct }
def self.search_by_params(params)
results = is_a?(ActiveRecord::Relation) ? self : all
@@ -197,6 +201,7 @@ def self.search_by_params(params)
results = results.category_names_all(params[:category_names_all]) if params[:category_names_all].present?
results = results.organization_name(params[:organization_name]) if params[:organization_name].present?
results = results.organization_id(params[:organization_id]) if params[:organization_id].present?
+ results = results.staff_tagged_with(params[:staff_tag_ids]) if params[:staff_tag_ids].present?
results = results.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present?
results
end
diff --git a/app/models/staff_tag.rb b/app/models/staff_tag.rb
new file mode 100644
index 000000000..1176a130c
--- /dev/null
+++ b/app/models/staff_tag.rb
@@ -0,0 +1,16 @@
+class StaffTag < ApplicationRecord
+ include Publishable
+
+ belongs_to :created_by, class_name: "User", optional: true
+ belongs_to :updated_by, class_name: "User", optional: true
+ has_many :staff_taggings, dependent: :restrict_with_error
+ has_many :people, through: :staff_taggings, source: :staff_taggable, source_type: "Person"
+
+ validates :name, presence: true, uniqueness: { case_sensitive: false }, length: { maximum: 255 }
+
+ scope :ordered, -> { order(:name) }
+
+ def to_s
+ name
+ end
+end
diff --git a/app/models/staff_tagging.rb b/app/models/staff_tagging.rb
new file mode 100644
index 000000000..ed56a0668
--- /dev/null
+++ b/app/models/staff_tagging.rb
@@ -0,0 +1,22 @@
+class StaffTagging < ApplicationRecord
+ belongs_to :staff_tag
+ belongs_to :staff_taggable, polymorphic: true, touch: true
+ belongs_to :created_by, class_name: "User", optional: true
+ belongs_to :updated_by, class_name: "User", optional: true
+
+ validates :staff_tag_id,
+ uniqueness: { scope: [ :staff_taggable_type, :staff_taggable_id ], message: "has already been added" }
+
+ before_create :stamp_created_by
+ before_save :stamp_updated_by
+
+ private
+
+ def stamp_created_by
+ self.created_by ||= Current.user
+ end
+
+ def stamp_updated_by
+ self.updated_by = Current.user if Current.user
+ end
+end
diff --git a/app/policies/staff_tag_policy.rb b/app/policies/staff_tag_policy.rb
new file mode 100644
index 000000000..9902472d1
--- /dev/null
+++ b/app/policies/staff_tag_policy.rb
@@ -0,0 +1,14 @@
+class StaffTagPolicy < ApplicationPolicy
+ # Staff tags are internal, admin-only. Every action is gated to admins, and the
+ # relation scope hides them entirely from everyone else.
+ def index? = admin?
+ def show? = admin?
+ def create? = admin?
+ def update? = admin?
+ def destroy? = record.persisted? && admin?
+
+ relation_scope do |relation|
+ next relation if admin?
+ relation.none
+ end
+end
diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb
index b395fb5ef..e9d28f249 100644
--- a/app/views/people/_form.html.erb
+++ b/app/views/people/_form.html.erb
@@ -199,6 +199,32 @@
<% end %>
+
+ <% if allowed_to?(:manage?, StaffTag) %>
+ <% staff_owner = f.object.respond_to?(:object) ? f.object.object : f.object %>
+
+ <% end %>
+
<% other_category_types = (@person_categories_grouped || {}).reject { |type, _| type.name == "AgeRange" } %>
<% if other_category_types.present? %>
@@ -287,7 +313,7 @@
<% end %>
" data-affiliation-dates-target="affiliatedNote">
-
+
<%= decorated.affiliated_since_note %>
diff --git a/app/views/people/_search_boxes.html.erb b/app/views/people/_search_boxes.html.erb
index 7b110a3dc..83c9afa90 100644
--- a/app/views/people/_search_boxes.html.erb
+++ b/app/views/people/_search_boxes.html.erb
@@ -6,7 +6,7 @@
autocomplete: "off",
class: "space-y-4") do |f| %>
<%= hidden_field_tag "organization_id", params[:organization_id] if params[:organization_id].present? %>
-
+
<%= label_tag :contact_info, "Name, email, or phone", class: search_label_class %>
@@ -41,16 +41,30 @@
-
-