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 @@ + + + + + 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