From 195b5d9898bc6827347a02499ce6097225707ed8 Mon Sep 17 00:00:00 2001 From: maebeale Date: Thu, 4 Jun 2026 20:41:02 -0400 Subject: [PATCH] Surface pending review queues on admin home Admins had to visit each ideas index to discover work waiting for them. This adds a dashboard banner that counts user-submitted ideas not yet promoted into their published counterparts, so the review backlog is visible at a glance on login. Closes #1524 Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 3 +- app/controllers/admin/home_controller.rb | 1 + app/models/story_idea.rb | 3 ++ app/models/workshop_idea.rb | 2 + app/models/workshop_variation_idea.rb | 2 + app/services/pending_review_summary.rb | 37 +++++++++++++ .../admin/home/_review_notifications.html.erb | 26 ++++++++++ app/views/admin/home/index.html.erb | 2 + spec/models/story_idea_spec.rb | 12 +++++ spec/models/workshop_idea_spec.rb | 13 +++++ spec/models/workshop_variation_idea_spec.rb | 13 +++++ spec/requests/admin/home_spec.rb | 19 +++++++ spec/services/pending_review_summary_spec.rb | 52 +++++++++++++++++++ 13 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 app/services/pending_review_summary.rb create mode 100644 app/views/admin/home/_review_notifications.html.erb create mode 100644 spec/services/pending_review_summary_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 6f769f00e..a0c97e69f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| | `app/models/` | ActiveRecord models | ~66 files | -| `app/services/` | Service objects for complex logic | ~21 files | +| `app/services/` | Service objects for complex logic | ~22 files | | `app/jobs/` | SolidQueue background jobs | 3 files | | `app/models/concerns/` | Shared model modules | 12 concerns | @@ -181,6 +181,7 @@ end - `ModelDeduper` — Deduplication logic - `RichTextMigrator` — Rich text migration utility - `DisplayImagePresenter` — Image display logic +- `PendingReviewSummary` — Counts unpromoted ideas per admin review queue (story/workshop/variation) for the admin dashboard ### Event Registrations diff --git a/app/controllers/admin/home_controller.rb b/app/controllers/admin/home_controller.rb index 07fd49fdb..3c2e13656 100644 --- a/app/controllers/admin/home_controller.rb +++ b/app/controllers/admin/home_controller.rb @@ -5,6 +5,7 @@ class HomeController < ApplicationController def index authorize! :home, to: :index? + @review_summary = PendingReviewSummary.new @system_cards = system_cards @user_content_cards = user_content_cards @reference_cards = reference_cards diff --git a/app/models/story_idea.rb b/app/models/story_idea.rb index 65155be0b..e25f3ef61 100644 --- a/app/models/story_idea.rb +++ b/app/models/story_idea.rb @@ -25,6 +25,9 @@ def self.search_by_params(params) has_many :notifications, as: :noticeable, dependent: :destroy has_many :stories + # Ideas awaiting admin review have not yet been promoted into a published Story. + scope :pending_review, -> { where.missing(:stories) } + # Asset associations has_one :primary_asset, -> { where(type: "PrimaryAsset") }, as: :owner, class_name: "PrimaryAsset", dependent: :destroy diff --git a/app/models/workshop_idea.rb b/app/models/workshop_idea.rb index a0d794c9f..a84f1d0c8 100644 --- a/app/models/workshop_idea.rb +++ b/app/models/workshop_idea.rb @@ -72,6 +72,8 @@ class WorkshopIdea < ApplicationRecord has_rich_text :rhino_extra_field_spanish # Scopes + # Ideas awaiting admin review have not yet been promoted into a Workshop. + scope :pending_review, -> { where.missing(:workshops) } scope :title, ->(title) { where("workshop_ideas.title like ?", "%#{ title }%") } scope :author_name, ->(author_name) { joins(:created_by). where("users.first_name like ? or users.last_name like ? or users.email like ?", diff --git a/app/models/workshop_variation_idea.rb b/app/models/workshop_variation_idea.rb index cabf89496..4c05f9892 100644 --- a/app/models/workshop_variation_idea.rb +++ b/app/models/workshop_variation_idea.rb @@ -46,6 +46,8 @@ def self.search_by_params(params) # Scopes scope :workshop_id, ->(workshop_id) { where(workshop_id: workshop_id) if workshop_id.present? } + # Ideas awaiting admin review have not yet been promoted into a WorkshopVariation. + scope :pending_review, -> { where.missing(:workshop_variations) } def title name diff --git a/app/services/pending_review_summary.rb b/app/services/pending_review_summary.rb new file mode 100644 index 000000000..c6b0c8af3 --- /dev/null +++ b/app/services/pending_review_summary.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Summarizes the admin review queues for the dashboard: user-submitted ideas +# that have not yet been promoted into their published counterparts. Each queue +# exposes its label, pending count, and originating model so the view can link +# to the relevant index. +class PendingReviewSummary + Queue = Data.define(:label, :count, :model) + + QUEUE_DEFINITIONS = [ + { model: StoryIdea, label: "Story ideas" }, + { model: WorkshopVariationIdea, label: "Workshop variation ideas" }, + { model: WorkshopIdea, label: "Workshop ideas" } + ].freeze + + def queues + @queues ||= QUEUE_DEFINITIONS.map do |definition| + Queue.new( + label: definition[:label], + count: definition[:model].pending_review.count, + model: definition[:model] + ) + end + end + + def pending_queues + queues.select { |queue| queue.count.positive? } + end + + def total_count + queues.sum(&:count) + end + + def any? + total_count.positive? + end +end diff --git a/app/views/admin/home/_review_notifications.html.erb b/app/views/admin/home/_review_notifications.html.erb new file mode 100644 index 000000000..8ebcac49e --- /dev/null +++ b/app/views/admin/home/_review_notifications.html.erb @@ -0,0 +1,26 @@ +<% if review_summary.any? %> +
+
+ +
+

+ <%= pluralize(review_summary.total_count, "item") %> waiting for review +

+
    + <% review_summary.pending_queues.each do |queue| %> +
  • + <%= link_to polymorphic_path(queue.model), + class: "inline-flex items-center gap-2 font-medium text-yellow-900 hover:underline" do %> + + <%= queue.count %> + + <%= queue.label %> + <% end %> +
  • + <% end %> +
+
+
+
+<% end %> diff --git a/app/views/admin/home/index.html.erb b/app/views/admin/home/index.html.erb index e3a0cc7bb..56ffa93be 100644 --- a/app/views/admin/home/index.html.erb +++ b/app/views/admin/home/index.html.erb @@ -5,6 +5,8 @@

Admin home

+ <%= render "review_notifications", review_summary: @review_summary %> +
diff --git a/spec/models/story_idea_spec.rb b/spec/models/story_idea_spec.rb index 663e12a8a..618f97994 100644 --- a/spec/models/story_idea_spec.rb +++ b/spec/models/story_idea_spec.rb @@ -46,6 +46,18 @@ end end + describe ".pending_review" do + it "includes ideas that have not been promoted to a story" do + idea = create(:story_idea) + expect(StoryIdea.pending_review).to include(idea) + end + + it "excludes ideas that have been promoted to a story" do + idea = create(:story_idea, :with_story) + expect(StoryIdea.pending_review).not_to include(idea) + end + end + describe '.search_by_params' do let!(:idea_alpha) { create(:story_idea, title: 'Art Healing Journey') } let!(:idea_beta) { create(:story_idea, title: 'Community Impact Report') } diff --git a/spec/models/workshop_idea_spec.rb b/spec/models/workshop_idea_spec.rb index b74cff0cd..43a45b00a 100644 --- a/spec/models/workshop_idea_spec.rb +++ b/spec/models/workshop_idea_spec.rb @@ -2,4 +2,17 @@ RSpec.describe WorkshopIdea, type: :model do it_behaves_like "author_creditable", factory: :workshop_idea + + describe ".pending_review" do + it "includes ideas that have not been promoted to a workshop" do + idea = create(:workshop_idea) + expect(WorkshopIdea.pending_review).to include(idea) + end + + it "excludes ideas that have been promoted to a workshop" do + idea = create(:workshop_idea) + create(:workshop, workshop_idea: idea) + expect(WorkshopIdea.pending_review).not_to include(idea) + end + end end diff --git a/spec/models/workshop_variation_idea_spec.rb b/spec/models/workshop_variation_idea_spec.rb index 8b72ca3e6..338d9cc22 100644 --- a/spec/models/workshop_variation_idea_spec.rb +++ b/spec/models/workshop_variation_idea_spec.rb @@ -2,4 +2,17 @@ RSpec.describe WorkshopVariationIdea, type: :model do it_behaves_like "author_creditable", factory: :workshop_variation_idea + + describe ".pending_review" do + it "includes ideas that have not been promoted to a workshop variation" do + idea = create(:workshop_variation_idea) + expect(WorkshopVariationIdea.pending_review).to include(idea) + end + + it "excludes ideas that have been promoted to a workshop variation" do + idea = create(:workshop_variation_idea) + create(:workshop_variation, workshop_variation_idea: idea) + expect(WorkshopVariationIdea.pending_review).not_to include(idea) + end + end end diff --git a/spec/requests/admin/home_spec.rb b/spec/requests/admin/home_spec.rb index e1333ccfa..4f236479c 100644 --- a/spec/requests/admin/home_spec.rb +++ b/spec/requests/admin/home_spec.rb @@ -42,5 +42,24 @@ get admin_path expect(response).to have_http_status(:ok) end + + it "surfaces a summary of ideas pending review" do + create(:story_idea) + create(:workshop_variation_idea) + + get admin_path + + expect(response.body).to include("waiting for review") + expect(response.body).to include("Story ideas") + expect(response.body).to include("Workshop variation ideas") + end + + it "omits the review summary when nothing is pending" do + create(:story_idea, :with_story) + + get admin_path + + expect(response.body).not_to include("waiting for review") + end end end diff --git a/spec/services/pending_review_summary_spec.rb b/spec/services/pending_review_summary_spec.rb new file mode 100644 index 000000000..fa67aa428 --- /dev/null +++ b/spec/services/pending_review_summary_spec.rb @@ -0,0 +1,52 @@ +require "rails_helper" + +RSpec.describe PendingReviewSummary do + describe "#queues" do + it "counts the unpromoted ideas in each review queue" do + create(:story_idea) + create(:story_idea, :with_story) + create(:workshop_variation_idea) + create(:workshop_idea) + + summary = described_class.new + counts = summary.queues.to_h { |queue| [ queue.model, queue.count ] } + + expect(counts).to eq( + StoryIdea => 1, + WorkshopVariationIdea => 1, + WorkshopIdea => 1 + ) + end + end + + describe "#pending_queues" do + it "only returns queues with at least one pending item" do + create(:story_idea) + + summary = described_class.new + + expect(summary.pending_queues.map(&:model)).to eq([ StoryIdea ]) + end + end + + describe "#total_count and #any?" do + it "sums the pending items across all queues" do + create(:story_idea) + create(:workshop_idea) + + summary = described_class.new + + expect(summary.total_count).to eq(2) + expect(summary.any?).to be(true) + end + + it "reports nothing pending when every idea is promoted" do + create(:story_idea, :with_story) + + summary = described_class.new + + expect(summary.total_count).to eq(0) + expect(summary.any?).to be(false) + end + end +end