Skip to content

Commit 2c56c3f

Browse files
iHiDclaude
andauthored
Add shadow banning to hide users from mentoring (#8918)
* Add shadow banning to hide users from mentoring Shadow-banned users' mentoring requests are hidden from all mentors, and shadow-banned mentors see an empty request queue. Adds a moderation UI at /moderation/shadow_banned_users for moderators to manage bans. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Use User.shadow_banned scope and extract filter method Extract shadow ban filtering into User.shadow_banned scope and dedicated filter_shadow_banned! method in discussion retrieve. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Reset schema.rb to main and manually add shadow ban columns Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update schema version to match migration timestamp Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add moderator? role with staff fallback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 85064ce commit 2c56c3f

14 files changed

Lines changed: 211 additions & 1 deletion

File tree

app/commands/mentor/discussion/retrieve.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def initialize(mentor,
3333

3434
def call
3535
setup!
36+
filter_shadow_banned!
3637
filter_status!
3738
filter_track!
3839
filter_student!
@@ -57,6 +58,10 @@ def setup!
5758
where(mentor:)
5859
end
5960

61+
def filter_shadow_banned!
62+
@discussions = @discussions.joins(:solution).where.not(solutions: { user_id: User.shadow_banned.select(:id) })
63+
end
64+
6065
def filter_status!
6166
case status
6267
when :awaiting_mentor

app/commands/mentor/request/retrieve.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ def setup!
4646
@requests = Mentor::Request.
4747
pending.
4848
unlocked_for(mentor)
49+
50+
# Shadow-banned mentors see empty queues
51+
@requests = @requests.none if mentor&.shadow_banned?
4952
end
5053

5154
def filter!
@@ -60,6 +63,9 @@ def filter!
6063
select(:student_id)
6164
)
6265

66+
# Don't show shadow-banned students' requests
67+
@requests = @requests.where.not(student_id: User.shadow_banned.select(:id))
68+
6369
if exercise_slug.present?
6470
filter_exercises!
6571
else

app/controllers/application_controller.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ def ensure_staff!
8282
redirect_to maintaining_root_path
8383
end
8484

85+
def ensure_moderator!
86+
return if current_user&.moderator?
87+
88+
redirect_to root_path
89+
end
90+
8591
def ensure_iHiD! # rubocop:disable Naming/MethodName
8692
return true if Rails.env.development?
8793
return true if current_user&.id == User::IHID_USER_ID
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class Moderation::BaseController < ApplicationController
2+
before_action :ensure_moderator!
3+
4+
layout "admin"
5+
end
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
class Moderation::ShadowBannedUsersController < Moderation::BaseController
2+
def index
3+
@users = User.includes(:shadow_banned_by).where.not(shadow_banned_at: nil).order(shadow_banned_at: :desc)
4+
end
5+
6+
def create
7+
handle = params[:handle]&.strip
8+
user = User.find_by(handle: handle)
9+
10+
if user.nil?
11+
flash[:alert] = "Could not find user with handle '#{handle}'"
12+
elsif user.shadow_banned?
13+
flash[:alert] = "#{user.handle} is already shadow banned"
14+
else
15+
user.update!(shadow_banned_at: Time.current, shadow_banned_by_id: current_user.id)
16+
flash[:notice] = "#{user.handle} has been shadow banned from mentoring"
17+
end
18+
19+
redirect_to moderation_shadow_banned_users_path
20+
end
21+
22+
def destroy
23+
user = User.find_by!(handle: params[:id])
24+
user.update!(shadow_banned_at: nil, shadow_banned_by_id: nil)
25+
flash[:notice] = "Shadow ban removed for #{user.handle}"
26+
redirect_to moderation_shadow_banned_users_path
27+
end
28+
end

app/models/user.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ class User < ApplicationRecord
147147

148148
scope :with_data, -> { joins(:data) }
149149
scope :insiders, -> { with_data.where(user_data: { insiders_status: %i[active active_lifetime] }) }
150+
scope :shadow_banned, -> { where.not(shadow_banned_at: nil) }
150151

151152
# TODO: Validate presence of name
152153
validates :handle, uniqueness: { case_sensitive: false }, handle_format: true, length: { maximum: 190 }
@@ -344,9 +345,12 @@ def bought_course?
344345
def profile? = profile.present?
345346
def may_create_profile? = reputation >= User::Profile::MIN_REPUTATION
346347

348+
belongs_to :shadow_banned_by, class_name: "User", optional: true
349+
347350
def confirmed? = super && !disabled? && !blocked?
348351
def disabled? = !!disabled_at
349352
def blocked? = User::BlockDomain.blocked?(user: self)
353+
def shadow_banned? = !!shadow_banned_at
350354

351355
def github_auth? = uid.present?
352356
def captcha_required? = !github_auth? && Time.current - created_at < 2.days

app/models/user/roles.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ def admin? = roles.include?(:admin)
44
def staff? = roles.include?(:staff) || admin?
55
def maintainer? = roles.include?(:maintainer) || staff?
66
def supermentor? = roles.include?(:supermentor) || staff?
7+
def moderator? = roles.include?(:moderator) || staff?
78
def mentor? = became_mentor_at.present? || staff?
89
def roles = super.to_a.map(&:to_sym).to_set
910
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
.c-admin-table
2+
.lg-container
3+
%h1.text-h1.mb-8 Shadow Banned Users
4+
5+
- if flash[:notice]
6+
.mb-12.text-p-base.font-bold.text-darkSuccessGreen= flash[:notice]
7+
- if flash[:alert]
8+
.mb-12.text-p-base.font-bold.text-red= flash[:alert]
9+
10+
= form_with(url: moderation_shadow_banned_users_path, method: :post, class: "flex items-end gap-8 mb-16") do |form|
11+
.flex.flex-col
12+
= form.label :handle, "User handle", class: 'text-h6 mb-4'
13+
= form.text_field :handle, required: true
14+
%div
15+
= form.submit "Shadow Ban", class: 'btn btn-primary btn-base'
16+
17+
%table.mb-8{ style: "border-collapse: collapse; width: 100%; background: white;" }
18+
%tr
19+
%th{ style: "border: 1px solid #ddd; padding: 8px 12px; text-align: left;" } Handle
20+
%th{ style: "border: 1px solid #ddd; padding: 8px 12px; text-align: left;" } Email
21+
%th{ style: "border: 1px solid #ddd; padding: 8px 12px; text-align: left;" } Banned at
22+
%th{ style: "border: 1px solid #ddd; padding: 8px 12px; text-align: left;" } Banned by
23+
%th{ style: "border: 1px solid #ddd; padding: 8px 12px; text-align: left;" } Actions
24+
- @users.each do |user|
25+
%tr
26+
%td{ style: "border: 1px solid #ddd; padding: 8px 12px;" }= user.handle
27+
%td{ style: "border: 1px solid #ddd; padding: 8px 12px;" }= user.email
28+
%td{ style: "border: 1px solid #ddd; padding: 8px 12px;" }= user.shadow_banned_at.strftime("%Y-%m-%d %H:%M")
29+
%td{ style: "border: 1px solid #ddd; padding: 8px 12px;" }= user.shadow_banned_by&.handle || "Unknown"
30+
%td{ style: "border: 1px solid #ddd; padding: 8px 12px;" }
31+
= button_to "Remove", moderation_shadow_banned_user_path(user), method: :delete, class: 'btn btn-primary btn-base'

config/routes.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@
4646
resource :workflow_run_updates, only: [:create]
4747
end
4848

49+
# ########## #
50+
# Moderation #
51+
# ########## #
52+
namespace :moderation do
53+
root to: redirect('/moderation/shadow_banned_users')
54+
resources :shadow_banned_users, only: %i[index create destroy]
55+
end
56+
4957
# ##### #
5058
# Admin #
5159
# ##### #
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class AddShadowBannedToUsers < ActiveRecord::Migration[7.0]
2+
def change
3+
return if Rails.env.production?
4+
5+
add_column :users, :shadow_banned_at, :datetime, null: true
6+
add_column :users, :shadow_banned_by_id, :bigint, null: true
7+
add_foreign_key :users, :users, column: :shadow_banned_by_id
8+
end
9+
end

0 commit comments

Comments
 (0)