Skip to content
Open
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
57 changes: 57 additions & 0 deletions app/assets/javascripts/app/directives/report_button.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
angular.module('app').directive('reportButton', function ($modal, Report, $api) {
return {
restrict: 'E',
scope: {
targetType: '@',
targetId: '@'
},
template: '<button class="report-btn" ng-click="openModal()" title="Report as spam or inappropriate">' +
'<i class="fa fa-flag"></i> Report' +
'</button>',
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');
};
});
37 changes: 37 additions & 0 deletions app/assets/javascripts/app/issues/templates/report_modal.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<div class="modal-header">
<h3 ng-if="!submitted">Report Content</h3>
<h3 ng-if="submitted">Report Submitted</h3>
</div>

<div class="modal-body">
<div ng-if="!submitted">
<div class="alert alert-danger" ng-if="error">{{error}}</div>

<div class="form-group">
<label>Reason</label>
<select class="form-control" ng-model="selectedReason" ng-options="r for r in reasons"></select>
</div>

<div class="form-group">
<label>Additional details (optional)</label>
<textarea class="form-control" ng-model="description" rows="3" placeholder="Please describe the issue..."></textarea>
</div>
</div>

<div ng-if="submitted">
<p>Thank you for your report. Our team will review it shortly.</p>
</div>
</div>

<div class="modal-footer">
<button class="btn btn-default" ng-click="close()" ng-if="!submitting">
<span ng-if="!submitted">Cancel</span>
<span ng-if="submitted">Close</span>
</button>
<button class="btn btn-primary" ng-click="submit()" ng-if="!submitted && !submitting" ng-disabled="submitting">
Submit Report
</button>
<button class="btn btn-primary disabled" ng-if="submitting">
Submitting...
</button>
</div>
10 changes: 10 additions & 0 deletions app/assets/javascripts/common/resources/report.js
Original file line number Diff line number Diff line change
@@ -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() }
});

});
21 changes: 21 additions & 0 deletions app/assets/stylesheets/report_button.css.scss
Original file line number Diff line number Diff line change
@@ -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;
}
51 changes: 51 additions & 0 deletions app/controllers/api/v2/reports_controller.rb
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions app/models/report.rb
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions app/views/api/v2/reports/_base.json.jbuilder
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions app/views/api/v2/reports/index.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
json.array! @collection do |item|
json.partial! 'base', item: item
end
1 change: 1 addition & 0 deletions app/views/api/v2/reports/show.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
json.partial! 'base', item: @item
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
19 changes: 19 additions & 0 deletions db/migrate/20260515010001_create_reports.rb
Original file line number Diff line number Diff line change
@@ -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
97 changes: 97 additions & 0 deletions spec/controllers/api/v2/reports_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading