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