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 %> +
+
+

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.

+
+ <%= f.simple_fields_for :staff_taggings, staff_owner.staff_taggings do |sfi| %> + <%= render "people/staff_tag_item_fields", f: sfi %> + <% end %> + + <%= link_to_add_association "➕ Add staff tag", + f, + :staff_taggings, + partial: "people/staff_tag_item_fields", + render_options: { + locals: { collection: (@staff_tags_collection || []) + .reject { |_, id| (@current_staff_tag_ids || []).include?(id) } } }, + class: "btn btn-secondary-outline" %> +
+
+ <% 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 %>