Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions app/assets/stylesheets/pages/kitchen/_index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,36 @@
}
}
}

.banned-banner {
background: var(--color-red-100, #fee2e2);
border: 2px solid var(--color-red-500, #ef4444);
border-radius: var(--border-radius);
padding: var(--space-xl);
margin-bottom: var(--space-xl);
text-align: center;

&__content {
max-width: 600px;
margin: 0 auto;
}

&__title {
font-family: var(--font-family-subtitle);
font-size: var(--font-size-xxxl);
color: var(--color-red-700, #b91c1c);
margin-bottom: var(--space-m);
}

&__message {
font-size: var(--font-size-l);
color: var(--color-red-600, #dc2626);
margin-bottom: var(--space-s);
}

&__reason {
font-size: var(--font-size-m);
color: var(--color-red-600, #dc2626);
margin-top: var(--space-m);
}
}
2 changes: 1 addition & 1 deletion app/controllers/admin/fulfillment_dashboard_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def generate_type_stats(scope)
end

def ensure_authorized_user
unless current_user&.admin? || current_user&.fraud_team_member?
unless current_user&.admin?
redirect_to root_path, alert: "whomp whomp"
end
end
Expand Down
1 change: 1 addition & 0 deletions app/controllers/admin/magic_links_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ class Admin::MagicLinksController < Admin::ApplicationController
before_action :authenticate_admin

def show
authorize :admin, :generate_magic_links?
@user = User.find(params[:user_id])
@user.generate_magic_link_token!

Expand Down
18 changes: 17 additions & 1 deletion app/controllers/admin/shop_orders_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ def index

# Apply filters
orders = orders.where(shop_item_id: params[:shop_item_id]) if params[:shop_item_id].present?
orders = orders.where(aasm_state: params[:status]) if params[:status].present?

# Set default status for fraud dept
@default_status = "pending" if current_user.fraud_dept? && !current_user.admin?
status_filter = params[:status].presence || @default_status
orders = orders.where(aasm_state: status_filter) if status_filter.present?
orders = orders.where("created_at >= ?", params[:date_from]) if params[:date_from].present?
orders = orders.where("created_at <= ?", params[:date_to]) if params[:date_to].present?

Expand Down Expand Up @@ -118,6 +122,18 @@ def show

# Load user's order history for fraud dept or order review
@user_orders = @order.user.shop_orders.where.not(id: @order.id).order(created_at: :desc).limit(10)

# User's shop orders summary stats
user_orders = @order.user.shop_orders
@user_order_stats = {
total: user_orders.count,
fulfilled: user_orders.where(aasm_state: "fulfilled").count,
pending: user_orders.where(aasm_state: "pending").count,
rejected: user_orders.where(aasm_state: "rejected").count,
total_quantity: user_orders.sum(:quantity),
on_hold: user_orders.where(aasm_state: "on_hold").count,
awaiting_fulfillment: user_orders.where(aasm_state: "awaiting_periodical_fulfillment").count
}
end

def reveal_address
Expand Down
25 changes: 25 additions & 0 deletions app/controllers/admin/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,29 @@ def adjust_balance
flash[:notice] = "Balance adjusted by #{amount} for #{@user.display_name}."
redirect_to admin_user_path(@user)
end

def ban
authorize :admin, :ban_users?
@user = User.find(params[:id])
reason = params[:reason].presence

PaperTrail.request(whodunnit: current_user.id) do
@user.ban!(reason: reason)
end

flash[:notice] = "#{@user.display_name} has been banned."
redirect_to admin_user_path(@user)
end

def unban
authorize :admin, :ban_users?
@user = User.find(params[:id])

PaperTrail.request(whodunnit: current_user.id) do
@user.unban!
end

flash[:notice] = "#{@user.display_name} has been unbanned."
redirect_to admin_user_path(@user)
end
end
9 changes: 9 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ class ApplicationController < ActionController::Base
include Pundit::Authorization
include Pagy::Method

before_action :enforce_ban

rescue_from ActionController::InvalidAuthenticityToken, with: :handle_invalid_auth_token
rescue_from StandardError, with: :handle_error

Expand Down Expand Up @@ -46,4 +48,11 @@ def handle_error(exception)
format.json { render json: { error: "Internal server error", trace_id: @trace_id }, status: :internal_server_error }
end
end

def enforce_ban
return unless current_user&.banned?
return if controller_name == "kitchen" || controller_name == "sessions"

redirect_to kitchen_path, alert: "Your account has been banned."
end
end
25 changes: 22 additions & 3 deletions app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# Table name: projects
#
# id :bigint not null, primary key
# deleted_at :datetime
# demo_url :text
# description :text
# memberships_count :integer default(0), not null
Expand All @@ -15,14 +16,21 @@
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_projects_on_deleted_at (deleted_at)
#
class Project < ApplicationRecord
include AASM

# TODO: reflect the allowed content types in the html accept
ACCEPTED_CONTENT_TYPES = %w[image/jpeg image/png image/webp image/heic image/heif].freeze
MAX_BANNER_SIZE = 10.megabytes

PROJECT_TYPES = %w[game app website tool library hardware other].freeze
scope :kept, -> { where(deleted_at: nil) }
scope :deleted, -> { where.not(deleted_at: nil) }

default_scope { kept }

has_many :memberships, class_name: "Project::Membership", dependent: :destroy
has_many :users, through: :memberships
Expand Down Expand Up @@ -84,6 +92,17 @@ def time
OpenStruct.new(hours: hours, minutes: minutes)
end

def soft_delete!
update!(deleted_at: Time.current)
end

def restore!
update!(deleted_at: nil)
end

def deleted?
deleted_at.present?
end
def hackatime_keys
hackatime_projects.pluck(:name)
end
Expand All @@ -97,7 +116,7 @@ def total_hackatime_hours
hackatime_identity = owner.identities.find_by(provider: "hackatime")
return 0 unless hackatime_identity

project_times = HackatimeService.fetch_user_projects_with_time(hackatime_identity.uid)
project_times = HackatimeService.fetch_user_projects_with_time(hackatime_identity.uid, user: owner)
total_seconds = hackatime_projects.sum { |hp| project_times[hp.name].to_i }
(total_seconds / 3600.0).round(1)
end
Expand All @@ -109,7 +128,7 @@ def hackatime_projects_with_time
hackatime_identity = owner.identities.find_by(provider: "hackatime")
return [] unless hackatime_identity

project_times = HackatimeService.fetch_user_projects_with_time(hackatime_identity.uid)
project_times = HackatimeService.fetch_user_projects_with_time(hackatime_identity.uid, user: owner)
hackatime_projects.map do |hp|
{
name: hp.name,
Expand Down
23 changes: 23 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
# Table name: users
#
# id :bigint not null, primary key
# banned :boolean default(FALSE), not null
# banned_at :datetime
# banned_reason :text
# display_name :string
# email :string
# first_name :string
Expand Down Expand Up @@ -140,6 +143,26 @@ def balance
ledger_entries.sum(:amount)
end

def ban!(reason: nil)
update!(banned: true, banned_at: Time.current, banned_reason: reason)
reject_pending_orders!(reason: reason || "User banned")
soft_delete_projects!
end

def reject_pending_orders!(reason: "User banned")
shop_orders.where(aasm_state: %w[pending awaiting_periodical_fulfillment]).find_each do |order|
order.mark_rejected(reason)
order.save!
end
end

def soft_delete_projects!
projects.find_each(&:soft_delete!)
end

def unban!
update!(banned: false, banned_at: nil, banned_reason: nil)
end
Comment on lines +163 to +165
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: unban! does not restore soft-deleted projects, leaving them hidden after unbanning.
Severity: HIGH | Confidence: High

🔍 Detailed Analysis

The unban! method only reverses the banned status of a user by updating banned, banned_at, and banned_reason fields. It does not invoke any restore operation for projects that were soft-deleted when the user was banned via the ban! method's call to soft_delete_projects!. This results in projects remaining hidden even after a user is unbanned.

💡 Suggested Fix

Modify the unban! method to call restore! on all projects associated with the user that were soft-deleted during the ban process.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: app/models/user.rb#L163-L165

Potential issue: The `unban!` method only reverses the `banned` status of a user by
updating `banned`, `banned_at`, and `banned_reason` fields. It does not invoke any
restore operation for projects that were soft-deleted when the user was banned via the
`ban!` method's call to `soft_delete_projects!`. This results in projects remaining
hidden even after a user is unbanned.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 6038005

def cancel_shop_order(order_id)
order = shop_orders.find(order_id)
return { success: false, error: "Your order can not be canceled" } unless order.pending?
Expand Down
10 changes: 9 additions & 1 deletion app/policies/admin_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,18 @@ def access_shop_orders?
end

def access_payouts_dashboard?
user.admin?
user.admin? || user.fraud_dept?
end

def ban_users?
user.admin? || user.fraud_dept?
end

def access_reports?
user.admin? || user.fraud_dept?
end

def generate_magic_links?
user.admin?
end
end
20 changes: 16 additions & 4 deletions app/services/hackatime_service.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
class HackatimeService
BASE_URL = "https://hackatime.hackclub.com/api/v1"
START_DATE = "2025-11-05"
# fetch projects agnostic of slack id or hackatime uid
def self.fetch_user_projects(identifier)

def self.fetch_user_projects(identifier, user: nil)
url = "#{BASE_URL}/users/#{identifier}/stats?features=projects&start_date=#{START_DATE}"

response = Faraday.get(url) do |req|
Expand All @@ -11,6 +11,7 @@ def self.fetch_user_projects(identifier)

if response.success?
data = JSON.parse(response.body)
check_and_ban_if_hackatime_banned(data, user) if user
projects = data.dig("data", "projects") || []
projects.map { |p| p["name"] }.reject { |name| User::HackatimeProject::EXCLUDED_NAMES.include?(name) }
else
Expand All @@ -22,7 +23,7 @@ def self.fetch_user_projects(identifier)
[]
end

def self.fetch_user_projects_with_time(identifier)
def self.fetch_user_projects_with_time(identifier, user: nil)
url = "#{BASE_URL}/users/#{identifier}/stats?features=projects&start_date=#{START_DATE}"

response = Faraday.get(url) do |req|
Expand All @@ -31,6 +32,7 @@ def self.fetch_user_projects_with_time(identifier)

if response.success?
data = JSON.parse(response.body)
check_and_ban_if_hackatime_banned(data, user) if user
projects = data.dig("data", "projects") || []
projects.reject { |p| User::HackatimeProject::EXCLUDED_NAMES.include?(p["name"]) }
.to_h { |p| [ p["name"], p["total_seconds"].to_i ] }
Expand All @@ -43,8 +45,18 @@ def self.fetch_user_projects_with_time(identifier)
{}
end

def self.check_and_ban_if_hackatime_banned(data, user)
return unless user && !user.banned?

is_banned = data.dig("trust_factor", "trust_value") == 1
return unless is_banned

Rails.logger.warn "HackatimeService: User #{user.id} (#{user.slack_id}) is banned on Hackatime, auto-banning"
user.ban!(reason: "Automatically banned: User is banned on Hackatime")
end

def self.sync_user_projects(user, identifier)
project_names = fetch_user_projects(identifier)
project_names = fetch_user_projects(identifier, user: user)

project_names.each do |name|
user.hackatime_projects.find_or_create_by!(name: name)
Expand Down
4 changes: 2 additions & 2 deletions app/views/admin/application/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,15 @@
<%= link_to "Flipper", "/admin/flipper", class: button_class %>
<% end %>
<% if policy(:admin).manage_user_roles? %>
<%= link_to "Manage users", "/admin/user-perms", class: button_class %>
<%= link_to "Manage users w/ perms", "/admin/user-perms", class: button_class %>
<% end %>
<% if policy(:admin).access_jobs? %>
<%= link_to "Job queue", "/admin/jobs", class: button_class %>
<% end %>
<% if policy(:admin).access_payouts_dashboard? %>
<%= link_to "Payouts Dashboard", "/admin/payouts_dashboard", class: button_class %>
<% end %>
<% if current_user.admin? || current_user.fraud_dept? %>
<% if current_user.admin? %>
<%= link_to "Fulfillment Dashboard", admin_fulfillment_dashboard_index_path, class: button_class %>
<% end %>
<%= link_to "Help bot", "slack://app?team=T0266FRGM&id=A09NP19R7FX&tab=home", class: "btn-primary" %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/admin/shop_orders/_filters.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
['Fulfilled', 'fulfilled'],
['Rejected', 'rejected'],
['On Hold', 'on_hold']
], params[:status]), {}, { class: "form-control" } %>
], params[:status] || @default_status), {}, { class: "form-control" } %>
</div>
<% end %>
<div>
Expand Down
47 changes: 47 additions & 0 deletions app/views/admin/shop_orders/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,53 @@
</div>
</div>

<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
<div class="card" style="flex: 1; padding: 1rem;">
<h2 style="color: #a855f7; font-size: 1rem; margin-bottom: 0.75rem;">Shop Orders Summary</h2>
<div style="font-size: 0.875rem; line-height: 1.6;">
<div><strong>Total Orders:</strong> <%= @user_order_stats[:total] %></div>
<div><strong>Fulfilled:</strong> <%= @user_order_stats[:fulfilled] %></div>
<div><strong>Pending:</strong> <%= @user_order_stats[:pending] %></div>
<div><strong>Rejected:</strong> <%= @user_order_stats[:rejected] %></div>
<div><strong>Total Quantity:</strong> <%= @user_order_stats[:total_quantity] %></div>
<div><strong>On Hold:</strong> <%= @user_order_stats[:on_hold] %></div>
<div><strong>Awaiting Fulfillment:</strong> <%= @user_order_stats[:awaiting_fulfillment] %></div>
</div>
<% if @user_orders.present? %>
<details style="margin-top: 0.75rem; cursor: pointer;">
<summary style="font-size: 0.8rem; color: #6b7280;">▸ View All Orders (<%= @user_order_stats[:total] %>)</summary>
<div style="padding-top: 0.5rem; font-size: 0.75rem;">
<% @order.user.shop_orders.order(created_at: :desc).each do |order| %>
<div style="padding: 0.25rem 0; border-bottom: 1px solid #374151;">
<%= link_to "##{order.id}", admin_shop_order_path(order), style: "color: #3b82f6;" %>
- <%= truncate(order.shop_item.name, length: 15) %>
<span class="badge badge-<%= order.aasm_state %>" style="font-size: 0.6rem; padding: 0.1rem 0.25rem;"><%= order.aasm_state.humanize %></span>
</div>
<% end %>
</div>
</details>
<% end %>
</div>

<div class="card" style="flex: 1; padding: 1rem;">
<h2 style="color: #a855f7; font-size: 1rem; margin-bottom: 0.75rem;">shop item</h2>
<% item = @order.shop_item %>
<%= link_to item.name, "#", style: "color: #60a5fa; font-size: 0.95rem; text-decoration: underline;" %>
<div style="font-size: 0.875rem; line-height: 1.6; margin-top: 0.5rem;">
<div><strong>Description:</strong> <%= item.description %></div>
<div><strong>Internal description:</strong> <%= item.internal_description %></div>
<div><strong>USD cost:</strong> $<%= number_with_precision(item.usd_cost, precision: 2) %></div>
<div><strong>Ticket cost:</strong> <%= item.ticket_cost %></div>
<div><strong>Type:</strong> <%= item.type %></div>
</div>
<% if item.image.attached? %>
<div style="margin-top: 0.75rem;">
<%= image_tag item.image.variant(:carousel_sm), style: "max-width: 120px; border-radius: 4px;" %>
</div>
<% end %>
</div>
</div>

<% if @can_view_address %>
<div class="card">
<h2>Shipping Address</h2>
Expand Down
Loading