diff --git a/app/assets/javascripts/app/directives/report_button.js b/app/assets/javascripts/app/directives/report_button.js
new file mode 100644
index 000000000..02d28cd63
--- /dev/null
+++ b/app/assets/javascripts/app/directives/report_button.js
@@ -0,0 +1,57 @@
+angular.module('app').directive('reportButton', function ($modal, Report, $api) {
+ return {
+ restrict: 'E',
+ scope: {
+ targetType: '@',
+ targetId: '@'
+ },
+ template: '',
+ link: function (scope) {
+ scope.openModal = function () {
+ $modal.open({
+ templateUrl: 'issues/templates/report_modal.html',
+ controller: 'ReportModalController',
+ resolve: {
+ targetType: function () { return scope.targetType; },
+ targetId: function () { return scope.targetId; }
+ }
+ });
+ };
+ }
+ };
+});
+
+angular.module('app').controller('ReportModalController', function ($scope, $modalInstance, Report, targetType, targetId, $window) {
+ $scope.targetType = targetType;
+ $scope.targetId = targetId;
+ $scope.reasons = ['spam', 'harassment', 'inappropriate', 'duplicate', 'other'];
+ $scope.selectedReason = 'spam';
+ $scope.description = '';
+ $scope.submitting = false;
+ $scope.submitted = false;
+ $scope.error = null;
+
+ $scope.submit = function () {
+ $scope.submitting = true;
+ $scope.error = null;
+
+ Report.create({
+ target_type: $scope.targetType,
+ target_id: $scope.targetId,
+ reason: $scope.selectedReason,
+ description: $scope.description
+ }).$promise.then(function () {
+ $scope.submitting = false;
+ $scope.submitted = true;
+ }).catch(function (response) {
+ $scope.submitting = false;
+ $scope.error = response.data.error || 'An error occurred while submitting the report.';
+ });
+ };
+
+ $scope.close = function () {
+ $modalInstance.dismiss('cancel');
+ };
+});
diff --git a/app/assets/javascripts/app/issues/templates/report_modal.html b/app/assets/javascripts/app/issues/templates/report_modal.html
new file mode 100644
index 000000000..b6544c2d0
--- /dev/null
+++ b/app/assets/javascripts/app/issues/templates/report_modal.html
@@ -0,0 +1,37 @@
+
+
+
+
+
{{error}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Thank you for your report. Our team will review it shortly.
+
+
+
+
diff --git a/app/assets/javascripts/common/resources/report.js b/app/assets/javascripts/common/resources/report.js
new file mode 100644
index 000000000..1ee6a8710
--- /dev/null
+++ b/app/assets/javascripts/common/resources/report.js
@@ -0,0 +1,10 @@
+angular.module('factories').factory('Report', function ($rootScope, $resource, $api) {
+
+ return $resource($rootScope.api_host + 'reports/:id', { id: '@id' }, {
+ query: { method: 'GET', headers: $api.v2_headers(), isArray: true },
+ get: { method: 'GET', headers: $api.v2_headers() },
+ create: { method: 'POST', headers: $api.v2_headers() },
+ update: { method: 'PATCH', headers: $api.v2_headers() }
+ });
+
+});
diff --git a/app/assets/stylesheets/report_button.css.scss b/app/assets/stylesheets/report_button.css.scss
new file mode 100644
index 000000000..55b10b664
--- /dev/null
+++ b/app/assets/stylesheets/report_button.css.scss
@@ -0,0 +1,21 @@
+/* Report button styles */
+.report-btn {
+ background: none;
+ border: 1px solid #ddd;
+ color: #999;
+ padding: 4px 10px;
+ font-size: 12px;
+ border-radius: 3px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.report-btn:hover {
+ color: #d9534f;
+ border-color: #d9534f;
+ background-color: #fdf7f7;
+}
+
+.report-btn i {
+ margin-right: 3px;
+}
diff --git a/app/controllers/api/v2/reports_controller.rb b/app/controllers/api/v2/reports_controller.rb
new file mode 100644
index 000000000..874836047
--- /dev/null
+++ b/app/controllers/api/v2/reports_controller.rb
@@ -0,0 +1,51 @@
+class Api::V2::ReportsController < Api::BaseController
+ before_action :require_auth, only: [:create]
+ before_action :set_item, only: [:show, :update]
+
+ def index
+ @collection = Report.includes(:reporter).recent
+ @collection = @collection.by_status(params[:status]) if params[:status]
+ @collection = @collection.where(target_type: params[:target_type]) if params[:target_type]
+ @collection = @collection.page(params[:page]).per(50)
+ end
+
+ def show
+ end
+
+ def create
+ require_params :target_type, :target_id
+
+ @item = Report.new(
+ reporter: current_user,
+ target_type: params[:target_type],
+ target_id: params[:target_id],
+ reason: params[:reason] || 'spam',
+ description: params[:description]
+ )
+
+ @item.save!
+ render :show
+ end
+
+ def update
+ # Only admins can update report status
+ require_auth
+
+ unless current_user.admin?
+ raise CanCan::AccessDenied
+ end
+
+ @item.update!(update_params)
+ render :show
+ end
+
+private
+
+ def set_item
+ @item = Report.find(params[:id])
+ end
+
+ def update_params
+ params.permit(:status, :description)
+ end
+end
diff --git a/app/models/report.rb b/app/models/report.rb
new file mode 100644
index 000000000..16c2df2ad
--- /dev/null
+++ b/app/models/report.rb
@@ -0,0 +1,45 @@
+# == Schema Information
+#
+# Table name: reports
+#
+# id :integer not null, primary key
+# reporter_id :integer not null
+# target_type :string not null
+# target_id :integer not null
+# reason :string not null, default('spam')
+# description :text
+# status :string not null, default('open')
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+class Report < ApplicationRecord
+ VALID_TARGET_TYPES = %w[Issue Comment Bounty Fundraiser Team Person].freeze
+ VALID_REASONS = %w[spam harassment inappropriate duplicate other].freeze
+ VALID_STATUSES = %w[open reviewed resolved dismissed].freeze
+
+ belongs_to :reporter, class_name: 'Person'
+
+ validates :target_type, presence: true, inclusion: { in: VALID_TARGET_TYPES }
+ validates :target_id, presence: true
+ validates :reason, presence: true, inclusion: { in: VALID_REASONS }
+ validates :status, inclusion: { in: VALID_STATUSES }
+ validates :reporter_id, uniqueness: { scope: [:target_type, :target_id], message: 'has already reported this item' }
+
+ scope :open, -> { where(status: 'open') }
+ scope :recent, -> { order(created_at: :desc) }
+ scope :by_status, ->(status) { where(status: status) if status.present? }
+
+ has_paper_trail
+
+ def target
+ target_type.constantize.find_by(id: target_id)
+ end
+
+ def open?
+ status == 'open'
+ end
+
+ def resolved?
+ status.in?(%w[resolved dismissed])
+ end
+end
diff --git a/app/views/api/v2/reports/_base.json.jbuilder b/app/views/api/v2/reports/_base.json.jbuilder
new file mode 100644
index 000000000..2a359af8e
--- /dev/null
+++ b/app/views/api/v2/reports/_base.json.jbuilder
@@ -0,0 +1,14 @@
+json.(item,
+ :id,
+ :reporter_id,
+ :target_type,
+ :target_id,
+ :reason,
+ :description,
+ :status,
+ :created_at,
+ :updated_at)
+
+json.reporter do
+ json.partial! 'api/v2/linked_accounts/base', item: item.reporter.display_name
+end if item.reporter
diff --git a/app/views/api/v2/reports/index.json.jbuilder b/app/views/api/v2/reports/index.json.jbuilder
new file mode 100644
index 000000000..df61180af
--- /dev/null
+++ b/app/views/api/v2/reports/index.json.jbuilder
@@ -0,0 +1,3 @@
+json.array! @collection do |item|
+ json.partial! 'base', item: item
+end
diff --git a/app/views/api/v2/reports/show.json.jbuilder b/app/views/api/v2/reports/show.json.jbuilder
new file mode 100644
index 000000000..584595d14
--- /dev/null
+++ b/app/views/api/v2/reports/show.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'base', item: @item
diff --git a/config/routes.rb b/config/routes.rb
index 780d7ee9a..68eff8627 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -516,6 +516,7 @@
get :global_summary
end
end
+ resources :reports, only: [:index, :show, :create, :update]
resources :stats, only: [:index]
resources :issue_suggestions, only: [:index, :create, :update]
post 'thumbs/index', to: 'thumbs#index'
diff --git a/db/migrate/20260515010001_create_reports.rb b/db/migrate/20260515010001_create_reports.rb
new file mode 100644
index 000000000..45faf55d8
--- /dev/null
+++ b/db/migrate/20260515010001_create_reports.rb
@@ -0,0 +1,19 @@
+class CreateReports < ActiveRecord::Migration[5.1]
+ def change
+ create_table :reports do |t|
+ t.integer :reporter_id, null: false
+ t.string :target_type, null: false
+ t.integer :target_id, null: false
+ t.string :reason, null: false, default: 'spam'
+ t.text :description
+ t.string :status, null: false, default: 'open'
+
+ t.timestamps
+ end
+
+ add_index :reports, [:target_type, :target_id]
+ add_index :reports, :reporter_id
+ add_index :reports, :status
+ add_index :reports, [:reporter_id, :target_type, :target_id], name: 'index_reports_on_reporter_and_target', unique: true
+ end
+end
diff --git a/spec/controllers/api/v2/reports_controller_spec.rb b/spec/controllers/api/v2/reports_controller_spec.rb
new file mode 100644
index 000000000..bb1830178
--- /dev/null
+++ b/spec/controllers/api/v2/reports_controller_spec.rb
@@ -0,0 +1,97 @@
+require 'spec_helper'
+
+describe Api::V2::ReportsController do
+
+ let(:person) { create(:person) }
+ let(:issue) { create(:issue) }
+
+ describe 'create' do
+ it 'requires authentication' do
+ post :create, params: { target_type: 'Issue', target_id: issue.id }
+ expect(response.status).to eq(401)
+ end
+
+ it 'creates a report when authenticated' do
+ post :create, params: {
+ access_token: person.access_token,
+ target_type: 'Issue',
+ target_id: issue.id,
+ reason: 'spam'
+ }
+ expect(response.status).to eq(200)
+ body = JSON.parse(response.body)
+ expect(body['target_type']).to eq('Issue')
+ expect(body['target_id']).to eq(issue.id)
+ expect(body['reason']).to eq('spam')
+ expect(body['status']).to eq('open')
+ end
+
+ it 'rejects duplicate reports' do
+ Report.create!(reporter: person, target_type: 'Issue', target_id: issue.id, reason: 'spam')
+ post :create, params: {
+ access_token: person.access_token,
+ target_type: 'Issue',
+ target_id: issue.id,
+ reason: 'spam'
+ }
+ expect(response.status).to eq(422)
+ end
+
+ it 'requires target_type and target_id' do
+ post :create, params: { access_token: person.access_token }
+ expect(response.status).to eq(422)
+ end
+ end
+
+ describe 'index' do
+ it 'returns reports list' do
+ Report.create!(reporter: person, target_type: 'Issue', target_id: issue.id, reason: 'spam')
+ get :index
+ expect(response.status).to eq(200)
+ body = JSON.parse(response.body)
+ expect(body.length).to eq(1)
+ end
+
+ it 'filters by status' do
+ Report.create!(reporter: person, target_type: 'Issue', target_id: issue.id, reason: 'spam', status: 'open')
+ Report.create!(reporter: person, target_type: 'Issue', target_id: 99999, reason: 'spam', status: 'resolved')
+ get :index, params: { status: 'open' }
+ expect(response.status).to eq(200)
+ body = JSON.parse(response.body)
+ expect(body.length).to eq(1)
+ end
+ end
+
+ describe 'show' do
+ it 'returns a single report' do
+ report = Report.create!(reporter: person, target_type: 'Issue', target_id: issue.id, reason: 'spam')
+ get :show, params: { id: report.id }
+ expect(response.status).to eq(200)
+ body = JSON.parse(response.body)
+ expect(body['id']).to eq(report.id)
+ end
+ end
+
+ describe 'update' do
+ it 'requires authentication' do
+ report = Report.create!(reporter: person, target_type: 'Issue', target_id: issue.id, reason: 'spam')
+ patch :update, params: { id: report.id, status: 'resolved' }
+ expect(response.status).to eq(401)
+ end
+
+ it 'rejects non-admin users' do
+ report = Report.create!(reporter: person, target_type: 'Issue', target_id: issue.id, reason: 'spam')
+ patch :update, params: { id: report.id, access_token: person.access_token, status: 'resolved' }
+ expect(response.status).to eq(401)
+ end
+
+ it 'allows admin to update status' do
+ admin = create(:person, admin: true)
+ report = Report.create!(reporter: person, target_type: 'Issue', target_id: issue.id, reason: 'spam')
+ patch :update, params: { id: report.id, access_token: admin.access_token, status: 'resolved' }
+ expect(response.status).to eq(200)
+ body = JSON.parse(response.body)
+ expect(body['status']).to eq('resolved')
+ end
+ end
+end
diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb
new file mode 100644
index 000000000..aa8072275
--- /dev/null
+++ b/spec/models/report_spec.rb
@@ -0,0 +1,56 @@
+require 'spec_helper'
+
+describe Report do
+ let(:person) { create(:person) }
+ let(:issue) { create(:issue) }
+
+ it 'creates a valid report' do
+ report = Report.create!(reporter: person, target_type: 'Issue', target_id: issue.id, reason: 'spam')
+ expect(report).to be_valid
+ expect(report.status).to eq('open')
+ end
+
+ it 'validates target_type inclusion' do
+ report = Report.new(reporter: person, target_type: 'Invalid', target_id: issue.id, reason: 'spam')
+ expect(report).not_to be_valid
+ end
+
+ it 'validates reason inclusion' do
+ report = Report.new(reporter: person, target_type: 'Issue', target_id: issue.id, reason: 'invalid')
+ expect(report).not_to be_valid
+ end
+
+ it 'prevents duplicate reports from same reporter' do
+ Report.create!(reporter: person, target_type: 'Issue', target_id: issue.id, reason: 'spam')
+ duplicate = Report.new(reporter: person, target_type: 'Issue', target_id: issue.id, reason: 'spam')
+ expect(duplicate).not_to be_valid
+ end
+
+ it 'allows different users to report same target' do
+ person2 = create(:person)
+ Report.create!(reporter: person, target_type: 'Issue', target_id: issue.id, reason: 'spam')
+ report2 = Report.new(reporter: person2, target_type: 'Issue', target_id: issue.id, reason: 'harassment')
+ expect(report2).to be_valid
+ end
+
+ describe 'scopes' do
+ it 'filters open reports' do
+ Report.create!(reporter: person, target_type: 'Issue', target_id: issue.id, reason: 'spam')
+ Report.create!(reporter: person, target_type: 'Comment', target_id: 1, reason: 'spam', status: 'resolved')
+ expect(Report.open.count).to eq(1)
+ end
+
+ it 'orders by recent' do
+ r1 = Report.create!(reporter: person, target_type: 'Issue', target_id: issue.id, reason: 'spam')
+ r2 = Report.create!(reporter: person, target_type: 'Comment', target_id: 1, reason: 'spam')
+ expect(Report.recent.first).to eq(r2)
+ end
+ end
+
+ describe '#target' do
+ it 'returns the target object' do
+ report = Report.create!(reporter: person, target_type: 'Issue', target_id: issue.id, reason: 'spam')
+ expect(report.target).to eq(issue)
+ end
+ end
+end