From 1af98f21c8afd66f6c838360a445765e52676a95 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sat, 22 Aug 2026 06:22:46 -0400 Subject: [PATCH 01/16] Add StaffTag: internal admin-only tagging for people Staff tags are admin-curated, internal labels for talent pipelines, rosters, and outreach (e.g. potential future trainers, cohort candidates). Modeled on the TopicSubscription shape but kept separate so these sensitive, never-public designations can't leak into any comms/export flow. - StaffTag + polymorphic StaffTagging join + StaffTaggable concern (Person) - Admin-only CRUD (mirrors category_types) with archive/unarchive - Assign on the person form; filter the people index by staff tag Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/people_controller.rb | 25 +++++ app/controllers/staff_tags_controller.rb | 84 +++++++++++++++ app/decorators/staff_tag_decorator.rb | 13 +++ app/models/concerns/staff_taggable.rb | 12 +++ app/models/person.rb | 7 +- app/models/staff_tag.rb | 34 ++++++ app/models/staff_tagging.rb | 11 ++ app/policies/staff_tag_policy.rb | 16 +++ app/views/people/_form.html.erb | 37 +++++++ app/views/people/_search_boxes.html.erb | 14 +++ app/views/staff_tags/_form.html.erb | 28 +++++ app/views/staff_tags/edit.html.erb | 19 ++++ app/views/staff_tags/index.html.erb | 101 ++++++++++++++++++ app/views/staff_tags/new.html.erb | 20 ++++ app/views/staff_tags/show.html.erb | 50 +++++++++ config/routes.rb | 6 ++ .../20260822101636_create_staff_tags.rb | 30 ++++++ db/schema.rb | 45 +++++--- lib/domain_theme.rb | 1 + 19 files changed, 537 insertions(+), 16 deletions(-) create mode 100644 app/controllers/staff_tags_controller.rb create mode 100644 app/decorators/staff_tag_decorator.rb create mode 100644 app/models/concerns/staff_taggable.rb create mode 100644 app/models/staff_tag.rb create mode 100644 app/models/staff_tagging.rb create mode 100644 app/policies/staff_tag_policy.rb create mode 100644 app/views/staff_tags/_form.html.erb create mode 100644 app/views/staff_tags/edit.html.erb create mode 100644 app/views/staff_tags/index.html.erb create mode 100644 app/views/staff_tags/new.html.erb create mode 100644 app/views/staff_tags/show.html.erb create mode 100644 db/migrate/20260822101636_create_staff_tags.rb diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 4314137b9..07ec1328a 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -249,6 +249,7 @@ def create respond_to do |format| if @person.save assign_associations(@person) if params.dig(:person, :category_ids) + assign_staff_tags(@person) format.html { redirect_to @person, notice: "Person was successfully created." } else set_form_variables @@ -276,6 +277,7 @@ def update if @person.save assign_associations(@person) if params.dig(:person, :category_ids) + assign_staff_tags(@person) redirect_to person_update_return_path, notice: "Person was successfully updated." else set_form_variables @@ -309,6 +311,23 @@ def check_duplicates private + # Sync a person's internal staff tags from the admin-only form section. Gated to + # admins (a non-admin can't see the section and a forged param is ignored), and + # only runs when the form actually submitted the field. Diffed by hand rather + # than a collection setter so each new tagging records which admin applied it. + def assign_staff_tags(person) + return unless allowed_to?(:manage?, StaffTag) + return unless params.dig(:person, :staff_tag_ids) + + desired = Array(params[:person][:staff_tag_ids]).reject(&:blank?).map(&:to_i) + existing = person.staff_taggings.index_by(&:staff_tag_id) + + (desired - existing.keys).each do |tag_id| + person.staff_taggings.create!(staff_tag_id: tag_id, created_by: current_user) + end + existing.each { |tag_id, tagging| tagging.destroy unless desired.include?(tag_id) } + end + # Showing anonymous content to anyone but the person and admins would tie an # "Anonymous" credit back to a name. def visible_authored_content(scope) @@ -376,6 +395,12 @@ 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 } + + # Internal, admin-only staff tags (talent pipeline / roster / outreach). The + # section only renders for admins; archived tags stay hidden from the picker + # but a person already carrying one keeps it (see the union in the form). + @staff_tags = StaffTag.active.ordered + @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) diff --git a/app/controllers/staff_tags_controller.rb b/app/controllers/staff_tags_controller.rb new file mode 100644 index 000000000..540dc6544 --- /dev/null +++ b/app/controllers/staff_tags_controller.rb @@ -0,0 +1,84 @@ +class StaffTagsController < ApplicationController + before_action :set_staff_tag, only: [ :show, :edit, :update, :destroy, :archive, :unarchive ] + + 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 + end + + def show + @staff_tag = @staff_tag.decorate + authorize! @staff_tag + 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 — archive it instead.", status: :see_other + end + end + + def archive + authorize! @staff_tag + @staff_tag.archive! + redirect_to staff_tags_path, notice: "Staff tag was archived.", status: :see_other + end + + def unarchive + authorize! @staff_tag + @staff_tag.unarchive! + redirect_to staff_tags_path, notice: "Staff tag was unarchived.", status: :see_other + end + + private + + def set_staff_tag + @staff_tag = StaffTag.find(params[:id]) + end + + def staff_tag_params + params.require(:staff_tag).permit(:name, :description) + end +end diff --git a/app/decorators/staff_tag_decorator.rb b/app/decorators/staff_tag_decorator.rb new file mode 100644 index 000000000..c7baf4816 --- /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 + archived? ? "Archived" : "Active" + end +end diff --git a/app/models/concerns/staff_taggable.rb b/app/models/concerns/staff_taggable.rb new file mode 100644 index 000000000..d7d05d65a --- /dev/null +++ b/app/models/concerns/staff_taggable.rb @@ -0,0 +1,12 @@ +# Mixed into models that can carry internal admin StaffTags (Person today). The +# join is polymorphic, so adding another taggable model later is just an include. +# StaffTags are admin-only and never surfaced publicly — the visibility boundary +# lives in StaffTagPolicy and the views, not here. +module StaffTaggable + extend ActiveSupport::Concern + + included do + has_many :staff_taggings, as: :staff_taggable, dependent: :destroy + has_many :staff_tags, through: :staff_taggings + 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..04d70bf60 --- /dev/null +++ b/app/models/staff_tag.rb @@ -0,0 +1,34 @@ +# An internal, admin-only label applied to people (and, via the polymorphic join, +# any StaffTaggable record). Used to mark folks for talent pipelines, rosters, and +# outreach — "Potential future trainer", "DV Leadership Cohort", "Highlight roster". +# These are staff judgments, never opt-in comms, and are never shown on public +# surfaces (see StaffTagPolicy and StaffTaggable). Admins CRUD the list in-app; +# archiving hides a tag from the pickers while keeping its history. +class StaffTag < ApplicationRecord + 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 :active, -> { where(archived_at: nil) } + scope :archived, -> { where.not(archived_at: nil) } + scope :ordered, -> { order(:name) } + + def archived? + archived_at.present? + end + + def archive! + update!(archived_at: Time.current) + end + + def unarchive! + update!(archived_at: nil) + end + + 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..9cc08685b --- /dev/null +++ b/app/models/staff_tagging.rb @@ -0,0 +1,11 @@ +# Join between a StaffTag and the record it tags (Person today; polymorphic so +# organizations/events/etc. can opt in later). created_by records which admin +# applied the tag, and when. +class StaffTagging < ApplicationRecord + belongs_to :staff_tag + belongs_to :staff_taggable, polymorphic: true, touch: true + belongs_to :created_by, class_name: "User", optional: true + + validates :staff_tag_id, + uniqueness: { scope: [ :staff_taggable_type, :staff_taggable_id ], message: "has already been added" } +end diff --git a/app/policies/staff_tag_policy.rb b/app/policies/staff_tag_policy.rb new file mode 100644 index 000000000..e26c8ed04 --- /dev/null +++ b/app/policies/staff_tag_policy.rb @@ -0,0 +1,16 @@ +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? + def archive? = admin? + def unarchive? = 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..132726bc7 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -225,6 +225,43 @@ <% end %> + + <% if allowed_to?(:manage?, StaffTag) %> + <%# Union of active tags and any the person already carries (archived + included), so saving the form never silently drops an archived tagging. %> + <% staff_tag_options = ((@staff_tags || []).to_a + @person.staff_tags.to_a).uniq.sort_by { |tag| tag.name.to_s.downcase } %> +
+
+

Staff tags

+ <%= link_to "Manage tags", staff_tags_path, class: "text-sm #{eyebrow_link_class}", target: "_blank", rel: "noopener" %> +
+

Internal only — pipelines, rosters, and outreach lists. Never shown publicly.

+ <%# Always submit the key (blank included) so unchecking every tag clears it. %> + <%= hidden_field_tag "person[staff_tag_ids][]", "" %> + <% if staff_tag_options.any? %> +
+ <% staff_tag_options.each do |tag| %> + <% id = "person_staff_tag_ids_#{tag.id}" %> + + <% end %> +
+ <% else %> +

+ No staff tags yet. <%= link_to "Create one", new_staff_tag_path, class: eyebrow_link_class, target: "_blank", rel: "noopener" %>. +

+ <% end %> +
+ <% end %> + <% person = f.object.respond_to?(:object) ? f.object.object : f.object %> <% decorated = person.decorate %> diff --git a/app/views/people/_search_boxes.html.erb b/app/views/people/_search_boxes.html.erb index 7b110a3dc..1ad942725 100644 --- a/app/views/people/_search_boxes.html.erb +++ b/app/views/people/_search_boxes.html.erb @@ -49,6 +49,20 @@ + + <% if allowed_to?(:index?, StaffTag) %> + <% staff_tags = StaffTag.active.ordered %> + <% if staff_tags.any? %> +
+ <%= label_tag :staff_tag_ids, "Staff tag", class: search_label_class %> + <%= select_tag :staff_tag_ids, + options_from_collection_for_select(staff_tags, :id, :name, params[:staff_tag_ids]), + include_blank: "Any", + class: search_field_class %> +
+ <% end %> + <% end %> +
<%= render "shared/search_clear", url: people_path %> diff --git a/app/views/staff_tags/_form.html.erb b/app/views/staff_tags/_form.html.erb new file mode 100644 index 000000000..8f835bf43 --- /dev/null +++ b/app/views/staff_tags/_form.html.erb @@ -0,0 +1,28 @@ +<%= simple_form_for(@staff_tag) do |f| %> +
+ <%= f.error_notification %> + <%= render "shared/errors", resource: @staff_tag if @staff_tag.errors.any? %> + +
+ <%= f.input :name, + label: "Name", + hint: "Short, admin-facing label (e.g. \"Potential future trainer\").", + input_html: { class: "form-control" }, + required: true %> + <%= f.input :description, + label: "Description", + hint: "Optional note on what this tag means and how it's used.", + input_html: { class: "form-control", rows: 3 }, + as: :text %> +
+ +
+ <% if allowed_to?(:destroy?, f.object) %> + <%= link_to "Delete", @staff_tag, class: "btn btn-danger-outline", + data: { turbo_method: :delete, turbo_confirm: "Delete this staff tag? Archive it instead if it's still in use." } %> + <% end %> + <%= link_to "Cancel", staff_tags_path, class: "btn btn-secondary-outline" %> + <%= f.button :submit, class: "btn btn-primary" %> +
+
+<% end %> diff --git a/app/views/staff_tags/edit.html.erb b/app/views/staff_tags/edit.html.erb new file mode 100644 index 000000000..b35d7c0a2 --- /dev/null +++ b/app/views/staff_tags/edit.html.erb @@ -0,0 +1,19 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+
+
+ <%= link_to "Home", root_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %> + <%= link_to "Staff tags", staff_tags_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %> + <%= link_to "View", staff_tag_path(@staff_tag), class: "text-sm #{eyebrow_link_class} px-2 py-1" %> +
+

Edit staff tag

+ +
+
+ <%= render "form" %> +
+
+
+
+
diff --git a/app/views/staff_tags/index.html.erb b/app/views/staff_tags/index.html.erb new file mode 100644 index 000000000..4a2093bc7 --- /dev/null +++ b/app/views/staff_tags/index.html.erb @@ -0,0 +1,101 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+
+
+
+ +
+
+

+ Staff tags (<%= @count_display %>) +

+

+ Internal, admin-only labels for people — pipelines, rosters, and outreach lists + (e.g. potential future trainers, cohort candidates). Never shown publicly. +

+
+
+ <%= link_to "New staff tag", + new_staff_tag_path, + class: "admin-only bg-blue-100 btn btn-primary-outline" %> +
+
+ +
+ + + + + + + + + + + + + <% @staff_tags.each do |staff_tag| %> + "> + + + + + + + + + + + <% end %> + +
NameDescriptionStatusPeople taggedActions
+ <%= link_to staff_tag.name, staff_tag_path(staff_tag), + class: "#{DomainTheme.text_class_for(:staff_tags)} hover:underline" %> + + <%= truncate(staff_tag.description, length: 120) %> + + <% if staff_tag.archived? %> + Archived + <% else %> + Active + <% end %> + + <%= link_to staff_tag.people.count, + people_path(staff_tag_ids: staff_tag.id), + class: "btn btn-secondary-default" %> + +
+ <%= link_to "Edit", + edit_staff_tag_path(staff_tag), + class: "btn btn-secondary-outline" %> + <% if staff_tag.archived? %> + <%= link_to "Unarchive", unarchive_staff_tag_path(staff_tag), + class: "btn btn-secondary-outline", + data: { turbo_method: :patch } %> + <% else %> + <%= link_to "Archive", archive_staff_tag_path(staff_tag), + class: "btn btn-secondary-outline", + data: { turbo_method: :patch } %> + <% end %> +
+
+
+ + + <% unless @staff_tags.any? %> +

+ No staff tags yet. Create one to start marking people for pipelines and rosters. +

+ <% end %> + + + +
+
+
+
+
diff --git a/app/views/staff_tags/new.html.erb b/app/views/staff_tags/new.html.erb new file mode 100644 index 000000000..ad60ae5e5 --- /dev/null +++ b/app/views/staff_tags/new.html.erb @@ -0,0 +1,20 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+
+
+ <%= link_to "Home", root_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %> + <%= link_to "Staff tags", staff_tags_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %> +
+

New staff tag

+ +
+ +
+
+ <%= render "form" %> +
+
+
+
+
diff --git a/app/views/staff_tags/show.html.erb b/app/views/staff_tags/show.html.erb new file mode 100644 index 000000000..a07678e38 --- /dev/null +++ b/app/views/staff_tags/show.html.erb @@ -0,0 +1,50 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+
+
+ <%= link_to "Home", root_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %> + <%= link_to "Staff tags", staff_tags_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %> + <%= link_to "Edit", edit_staff_tag_path(@staff_tag), class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1" %> +
+

+ Staff tag: <%= @staff_tag.name %> +

+ +
+

+ Status: + <%= @staff_tag.status_label %> +

+
+ + <% if @staff_tag.description.present? %> +

<%= simple_format(@staff_tag.description) %>

+ <% end %> + + +
+
+

+ Tagged people (<%= @staff_tag.people.count %>) +

+ <%= link_to "View as filtered roster", people_path(staff_tag_ids: @staff_tag.id), + class: "btn btn-secondary-outline" %> +
+ + <% if @staff_tag.people.any? %> +
    + <% @staff_tag.people.order(:last_name, :first_name).each do |person| %> +
  • + <%= link_to person.name, person_path(person), + class: "font-medium #{DomainTheme.text_class_for(:people)} hover:underline" %> +
  • + <% end %> +
+ <% else %> +

No one carries this tag yet.

+ <% end %> +
+
+
+
diff --git a/config/routes.rb b/config/routes.rb index b4ad751b3..7318f28aa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -66,6 +66,12 @@ end end resources :category_types + resources :staff_tags do + member do + patch :archive + patch :unarchive + end + end resources :categories do collection do get :dedupe_index diff --git a/db/migrate/20260822101636_create_staff_tags.rb b/db/migrate/20260822101636_create_staff_tags.rb new file mode 100644 index 000000000..6c642f1da --- /dev/null +++ b/db/migrate/20260822101636_create_staff_tags.rb @@ -0,0 +1,30 @@ +class CreateStaffTags < ActiveRecord::Migration[7.2] + def change + create_table :staff_tags do |t| + t.string :name, null: false + t.text :description + t.datetime :archived_at + # created_by/updated_by point at users, whose PK is an int, so we keep plain + # reference columns (no DB FK) to match the app's audit-column convention. + t.bigint :created_by_id + t.bigint :updated_by_id + t.timestamps + end + add_index :staff_tags, :name, unique: true + add_index :staff_tags, :archived_at + add_index :staff_tags, :created_by_id + add_index :staff_tags, :updated_by_id + + create_table :staff_taggings do |t| + t.references :staff_tag, null: false, foreign_key: true + t.references :staff_taggable, polymorphic: true, null: false + t.bigint :created_by_id + t.timestamps + end + add_index :staff_taggings, :created_by_id + add_index :staff_taggings, + [ :staff_tag_id, :staff_taggable_type, :staff_taggable_id ], + unique: true, + name: "index_staff_taggings_uniqueness" + end +end diff --git a/db/schema.rb b/db/schema.rb index b09c5395b..3c100aa84 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_22_112435) do +ActiveRecord::Schema[8.1].define(version: 2026_08_22_101636) 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 @@ -1143,27 +1143,17 @@ create_table "quotes", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "age" - t.string "author_credit_preference" - t.bigint "author_id" - t.text "body", size: :long t.datetime "created_at", precision: nil, null: false - t.integer "created_by_id" t.string "gender", limit: 1 t.boolean "inactive", default: true t.boolean "legacy", default: false t.integer "legacy_id" - t.text "original_body", size: :long t.boolean "published", default: false, null: false + t.text "quote", size: :long t.string "speaker_name" - t.boolean "standout", default: false, null: false t.datetime "updated_at", precision: nil, null: false - t.integer "updated_by_id" t.integer "workshop_id" - t.index ["author_id"], name: "index_quotes_on_author_id" - t.index ["created_by_id"], name: "index_quotes_on_created_by_id" t.index ["published"], name: "index_quotes_on_published" - t.index ["standout"], name: "index_quotes_on_standout" - t.index ["updated_by_id"], name: "index_quotes_on_updated_by_id" t.index ["workshop_id"], name: "index_quotes_on_workshop_id" end @@ -1343,6 +1333,33 @@ t.index ["story_share_position"], name: "index_sectors_on_story_share_position" end + create_table "staff_taggings", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "created_by_id" + t.bigint "staff_tag_id", null: false + t.bigint "staff_taggable_id", null: false + t.string "staff_taggable_type", null: false + t.datetime "updated_at", null: false + t.index ["created_by_id"], name: "index_staff_taggings_on_created_by_id" + t.index ["staff_tag_id", "staff_taggable_type", "staff_taggable_id"], name: "index_staff_taggings_uniqueness", unique: true + t.index ["staff_tag_id"], name: "index_staff_taggings_on_staff_tag_id" + t.index ["staff_taggable_type", "staff_taggable_id"], name: "index_staff_taggings_on_staff_taggable" + end + + create_table "staff_tags", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "archived_at" + t.datetime "created_at", null: false + t.bigint "created_by_id" + t.text "description" + t.string "name", null: false + t.datetime "updated_at", null: false + t.bigint "updated_by_id" + t.index ["archived_at"], name: "index_staff_tags_on_archived_at" + t.index ["created_by_id"], name: "index_staff_tags_on_created_by_id" + t.index ["name"], name: "index_staff_tags_on_name", unique: true + t.index ["updated_by_id"], name: "index_staff_tags_on_updated_by_id" + end + create_table "stories", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "author_credit_preference" t.bigint "author_id" @@ -1920,9 +1937,6 @@ add_foreign_key "people", "users", column: "updated_by_id" add_foreign_key "professional_licenses", "people" add_foreign_key "quotable_item_quotes", "quotes" - add_foreign_key "quotes", "people", column: "author_id" - add_foreign_key "quotes", "users", column: "created_by_id" - add_foreign_key "quotes", "users", column: "updated_by_id" add_foreign_key "quotes", "workshops" add_foreign_key "registration_ticket_callout_resources", "registration_ticket_callouts", on_delete: :cascade add_foreign_key "registration_ticket_callout_resources", "resources", on_delete: :cascade @@ -1942,6 +1956,7 @@ add_foreign_key "scholarships", "grants" add_foreign_key "scholarships", "people", column: "recipient_id" add_foreign_key "sectorable_items", "sectors" + add_foreign_key "staff_taggings", "staff_tags" add_foreign_key "stories", "organizations" add_foreign_key "stories", "people", column: "author_id" add_foreign_key "stories", "people", column: "spotlighted_facilitator_id" diff --git a/lib/domain_theme.rb b/lib/domain_theme.rb index 42dc09076..64f1d83f9 100644 --- a/lib/domain_theme.rb +++ b/lib/domain_theme.rb @@ -18,6 +18,7 @@ module DomainTheme sectors: :lime, categories: :lime, category_types: :lime, + staff_tags: :rose, forms: :purple, faqs: :pink, From b51027adefd942f867afe07f74b286f3a9a7a0c2 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sat, 22 Aug 2026 06:26:48 -0400 Subject: [PATCH 02/16] Add StaffTag specs, docs, and feature-catalog entry Model/join/policy/decorator/request specs (incl. person form assignment, people-index filter, archived-tag preservation, and admin gating), the Features & tips seed entry, and AGENTS.md model/concern catalog updates. Tailwind class order normalized on touched views. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 5 +- app/views/people/_form.html.erb | 8 +-- app/views/people/_search_boxes.html.erb | 8 +-- app/views/staff_tags/edit.html.erb | 8 +-- app/views/staff_tags/index.html.erb | 24 +++---- app/views/staff_tags/new.html.erb | 10 +-- app/views/staff_tags/show.html.erb | 18 +++--- config/features.yml | 19 ++++++ spec/decorators/staff_tag_decorator_spec.rb | 18 ++++++ spec/factories/staff_tags.rb | 15 +++++ spec/models/staff_tag_spec.rb | 52 +++++++++++++++ spec/models/staff_tagging_spec.rb | 24 +++++++ spec/policies/staff_tag_policy_spec.rb | 33 ++++++++++ spec/requests/people_staff_tags_spec.rb | 71 +++++++++++++++++++++ spec/requests/staff_tags_spec.rb | 61 ++++++++++++++++++ spec/views/page_bg_class_alignment_spec.rb | 4 ++ 16 files changed, 339 insertions(+), 39 deletions(-) create mode 100644 spec/decorators/staff_tag_decorator_spec.rb create mode 100644 spec/factories/staff_tags.rb create mode 100644 spec/models/staff_tag_spec.rb create mode 100644 spec/models/staff_tagging_spec.rb create mode 100644 spec/policies/staff_tag_policy_spec.rb create mode 100644 spec/requests/people_staff_tags_spec.rb create mode 100644 spec/requests/staff_tags_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 375ebb393..574870775 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; `archived_at` 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) | +| `StaffTagging` | Polymorphic join linking a `StaffTag` to the record it tags (`staff_taggable`); `created_by` records which admin applied 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/views/people/_form.html.erb b/app/views/people/_form.html.erb index 132726bc7..3e7a1f90a 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -230,7 +230,7 @@ <%# Union of active tags and any the person already carries (archived included), so saving the form never silently drops an archived tagging. %> <% staff_tag_options = ((@staff_tags || []).to_a + @person.staff_tags.to_a).uniq.sort_by { |tag| tag.name.to_s.downcase } %> -
+

Staff tags

<%= link_to "Manage tags", staff_tags_path, class: "text-sm #{eyebrow_link_class}", target: "_blank", rel: "noopener" %> @@ -243,12 +243,12 @@ <% staff_tag_options.each do |tag| %> <% id = "person_staff_tag_ids_#{tag.id}" %> @@ -324,7 +324,7 @@ <% end %>

" data-affiliation-dates-target="affiliatedNote"> - + <%= decorated.affiliated_since_note %>