diff --git a/assets/javascripts/discourse/connectors/category-custom-settings/enable-qa.js.es6 b/assets/javascripts/discourse/connectors/category-custom-settings/enable-qa.js.es6 new file mode 100644 index 0000000..5345a3a --- /dev/null +++ b/assets/javascripts/discourse/connectors/category-custom-settings/enable-qa.js.es6 @@ -0,0 +1,7 @@ +export default { + setupComponent(attrs) { + if (!attrs.category.custom_fields) { + attrs.category.custom_fields = {}; + } + } +}; diff --git a/assets/javascripts/discourse/initializers/qa-edits.js.es6 b/assets/javascripts/discourse/initializers/qa-edits.js.es6 index 49f0f55..4979866 100644 --- a/assets/javascripts/discourse/initializers/qa-edits.js.es6 +++ b/assets/javascripts/discourse/initializers/qa-edits.js.es6 @@ -4,6 +4,8 @@ import { h } from 'virtual-dom'; import { avatarImg, avatarFor } from 'discourse/widgets/post'; import { dateNode, numberNode } from 'discourse/helpers/node'; import { REPLY } from "discourse/models/composer"; +import { undoVote, whoVoted, voteActionId } from '../lib/qa-utilities'; +import { avatarAtts } from 'discourse/widgets/actions-summary'; export default { name: 'qa-edits', @@ -144,14 +146,45 @@ export default { }); api.attachWidgetAction('post', 'undoPostAction', function(typeId) { - const post = this.model; - if (typeId === 5) { + + if (typeId === voteActionId) { + const post = this.model; + const user = this.currentUser; + post.set('topic.voted', false); + + let vote = { + user_id: user.id, + post_id: post.id, + direction: 'up' + } + + undoVote({ vote }); + } else { + this._super(typeId); + } + }); + + api.reopenWidget('actions-summary-item', { + whoActed() { + const attrs = this.attrs; + + if (attrs.id === voteActionId) { + whoVoted({ + post_id: attrs.postId + }).then(result => { + if (result.voters) { + this.state.users = result.voters.map(avatarAtts); + this.scheduleRerender(); + } + }); + } else { + this._super(); + } } - return post.get('actions_summary').findBy('id', typeId).undo(post); }); - api.modifyClass('model:topic', { + api.modifyClass('model:topic', { @computed('qa_enabled') showQaTip(qaEnabled) { return qaEnabled && this.siteSettings.qa_show_topic_tip; diff --git a/assets/javascripts/discourse/lib/qa-utilities.js.es6 b/assets/javascripts/discourse/lib/qa-utilities.js.es6 new file mode 100644 index 0000000..b00891b --- /dev/null +++ b/assets/javascripts/discourse/lib/qa-utilities.js.es6 @@ -0,0 +1,28 @@ +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import { ajax } from 'discourse/lib/ajax'; + +const voteActionId = 100; + +const vote = function(type, data) { + return ajax('/qa/vote', { + type, + data + }).catch(popupAjaxError) +} + +const undoVote = function(data) { + return vote('DELETE', data); +} + +const castVote = function(data) { + return vote('POST', data); +} + +const whoVoted = function(data) { + return ajax('/qa/voters', { + type: 'GET', + data + }).catch(popupAjaxError) +} + +export { undoVote, castVote, voteActionId, whoVoted }; diff --git a/assets/javascripts/discourse/widgets/qa-button.js.es6 b/assets/javascripts/discourse/widgets/qa-button.js.es6 index c7d1a31..7f777e2 100644 --- a/assets/javascripts/discourse/widgets/qa-button.js.es6 +++ b/assets/javascripts/discourse/widgets/qa-button.js.es6 @@ -8,6 +8,6 @@ export default createWidget('qa-button', { }, click() { - this.sendWidgetAction('vote'); + this.sendWidgetAction('vote', this.attrs.direction); } }); diff --git a/assets/javascripts/discourse/widgets/qa-post.js.es6 b/assets/javascripts/discourse/widgets/qa-post.js.es6 index eedf724..51cc5f6 100644 --- a/assets/javascripts/discourse/widgets/qa-post.js.es6 +++ b/assets/javascripts/discourse/widgets/qa-post.js.es6 @@ -1,4 +1,5 @@ import { createWidget } from 'discourse/widgets/widget'; +import { castVote } from '../lib/qa-utilities'; import { h } from 'virtual-dom'; export default createWidget('qa-post', { @@ -17,17 +18,27 @@ export default createWidget('qa-post', { return contents; }, - vote() { + vote(direction) { const post = this.attrs.post; + const user = this.currentUser; + if (post.get('topic.voted')) { return bootbox.alert(I18n.t('vote.already_voted')); } - if (!this.currentUser) { + + if (!user) { return this.sendShowLogin(); } + post.set('topic.voted', true); - const voteAction = post.get('actions_summary').findBy('id', 5); - voteAction.act(post); + + let vote = { + user_id: user.id, + post_id: post.id, + direction + }; + + castVote({ vote }); } }); diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 82db028..5e6b6b3 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1,5 +1,8 @@ en: js: + category: + enable_qa: "Make all topics in this category QnA." + composer: composer_actions: reply_to_question: @@ -8,6 +11,24 @@ en: comment_on_answer: label: Comment on answer of %{postUsername} desc: Comment on an answer + + post: + actions: + undo: + vote: "Undo vote" + people: + vote: "voted for this" + by_you: + vote: "You voted for this post" + by_you_and_others: + vote: + one: "You and 1 other voted for this post" + other: "You and {{count}} other people voted for this post" + by_others: + vote: + one: "1 person voted for this post" + other: "{{count}} people voted for this post" + topic: answer: title: 'Answer' @@ -22,8 +43,7 @@ en: details: "Users can vote for the response that best answers the initial post. " - category: - enable_qa: "Make all topics in this category QnA." + vote: already_voted: "You can only vote once per question." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index d9bc6ca..1ec3f55 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -5,5 +5,16 @@ en: qa_enabled: "Enable QA Plugin" qa_disable_like_on_answers: "Disables like button on answers in QnA topics" qa_undo_vote_action_window: "Number of minutes users are allowed to undo votes in QnA topics (enter 0 for no limit)" + + post_action_types: + vote: + title: 'Vote' + description: 'Vote for this post' + short_description: 'Vote for this post' + long_form: 'voted for this post' + vote: - already_voted: "You can only vote once per question." + error: + user_has_not_voted: "User has not voted." + already_voted: "You can only vote once per question." + undo_vote_action_window: "You can only undo votes %{minutes} after voting." diff --git a/db/migrate/20180712004204_save_existing_post_vote_counts_to_custom_fields.rb b/db/migrate/20180712004204_save_existing_post_vote_counts_to_custom_fields.rb new file mode 100644 index 0000000..d4951c3 --- /dev/null +++ b/db/migrate/20180712004204_save_existing_post_vote_counts_to_custom_fields.rb @@ -0,0 +1,47 @@ +class SaveExistingPostVoteCountsToCustomFields < ActiveRecord::Migration[5.2] + def up + vote_totals = {} + + PostAction.where(post_action_type_id: 5).each do |action| + if post = Post.find_by(id: action[:post_id]) + votes = post.vote_history + + votes.push( + "direction": QuestionAnswer::Vote::UP, + "action": QuestionAnswer::Vote::CREATE, + "user_id": action[:user_id].to_s, + "created_at": action[:created_at] + ) + + post.custom_fields['vote_history'] = votes.to_json + post.save_custom_fields(true) + end + + total = vote_totals[action[:post_id]] + total = { count: 0, voted: [] } if total == nil + + total[:count] += 1 + + voted = total[:voted] + voted.push(action[:user_id]) + total[:voted] = voted + + vote_totals[action[:post_id]] = total + end + + if vote_totals.any? + vote_totals.each do |k, v| + if post = Post.find_by(id: k) + post.custom_fields['vote_history'] = post.vote_history.to_json + post.custom_fields['vote_count'] = v[:count].to_i + post.custom_fields['voted'] = v[:voted] + post.save_custom_fields(true) + end + end + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/lib/qa.rb b/lib/qa.rb new file mode 100644 index 0000000..9c8c860 --- /dev/null +++ b/lib/qa.rb @@ -0,0 +1,177 @@ +module ::QuestionAnswer + class Engine < ::Rails::Engine + engine_name 'question_answer' + isolate_namespace QuestionAnswer + end +end + +QuestionAnswer::Engine.routes.draw do + resource :vote + get 'voters' => 'votes#voters' +end + +Discourse::Application.routes.append do + mount ::QuestionAnswer::Engine, at: 'qa' +end + +require_dependency 'post_action_user_serializer' +class QuestionAnswer::VoterSerializer < ::PostActionUserSerializer + def post_url + nil + end +end + +Voter = Struct.new(:user) + +require_dependency 'application_controller' +class QuestionAnswer::VotesController < ::ApplicationController + before_action :ensure_logged_in + before_action :find_vote_post + before_action :find_vote_user, only: [:create, :destroy] + before_action :ensure_qa_enabled, only: [:create, :destroy] + before_action :ensure_can_act, only: [:create, :destroy] + + def create + if QuestionAnswer::Vote.vote(@post, @user, vote_args) + render json: success_json.merge( + vote_count: @post.vote_count + ) + else + render json: failed_json, status: 422 + end + end + + def destroy + if QuestionAnswer::Vote.vote(@post, @user, vote_args) + render json: success_json.merge( + vote_count: @post.vote_count + ) + else + render json: failed_json, status: 422 + end + end + + def voters + voters = [] + + if @post.voted.any? + @post.voted.each do |user_id| + if user = User.find_by(id: user_id) + voters.push(Voter.new(user)) + end + end + end + + render_json_dump(voters: serialize_data(voters, QuestionAnswer::VoterSerializer)) + end + + private + + def vote_params + params.require(:vote).permit(:post_id, :user_id, :direction) + end + + def vote_args + { + direction: vote_params[:direction], + action: self.action_name + } + end + + def find_vote_post + if params[:vote].present? + post_id = vote_params[:post_id] + else + params.require(:post_id) + post_id = params[:post_id] + end + + if post = Post.find_by(id: post_id) + @post = post + else + raise Discourse::NotFound + end + end + + def find_vote_user + if vote_params[:user_id] && user = User.find_by(id: vote_params[:user_id]) + @user = user + else + raise Discourse::NotFound + end + end + + def ensure_qa_enabled + Topic.qa_enabled(@post.topic) + end + + def ensure_can_act + if Topic.voted(@post.topic, @user) + if self.action_name === QuestionAnswer::Vote::CREATE + raise Discourse::InvalidAccess.new, I18n.t('vote.error.alread_voted') + end + + if self.action_name === QuestionAnswer::Vote::DESTROY && !QuestionAnswer::Vote.can_undo(@post, @user) + raise Discourse::InvalidAccess.new, I18n.t('vote.error.undo_vote_action_window', + minutes: SiteSetting.qa_undo_vote_action_window + ) + end + elsif self.action_name === QuestionAnswer::Vote::DESTROY + raise Discourse::InvalidAccess.new, I18n.t('vote.error.user_has_not_voted') + end + end +end + +class QuestionAnswer::Vote + CREATE = 'create' + DESTROY = 'destroy' + UP = 'up' + DOWN = 'down' + + def self.vote(post, user, args) + modifier = 0 + + if args[:direction] === UP + modifier = args[:action] === CREATE ? 1 : -1 + end + + post.custom_fields['vote_count'] = post.vote_count + modifier + + voted = post.voted + + if args[:direction] === UP + if args[:action] === CREATE + voted.push(user.id) + elsif args[:action] === DESTROY + voted.delete(user.id) + end + end + + post.custom_fields['voted'] = voted + + votes = post.vote_history + + votes.push( + direction: args[:direction], + action: args[:action], + user_id: user.id, + created_at: Time.now + ) + + post.custom_fields['vote_history'] = votes.to_json + + if post.save_custom_fields(true) + Topic.update_vote_order(post.topic) + post.publish_change_to_clients! :acted + + true + else + false + end + end + + def self.can_undo(post, user) + window = SiteSetting.qa_undo_vote_action_window.to_i + window === 0 || post.last_voted(user.id).to_i > window.minutes.ago.to_i + end +end diff --git a/lib/qa_helper.rb b/lib/qa_helper.rb deleted file mode 100644 index 6e56699..0000000 --- a/lib/qa_helper.rb +++ /dev/null @@ -1,48 +0,0 @@ -module QAHelper - class << self - def qa_enabled(topic) - return false if !SiteSetting.qa_enabled - return false if !topic || !topic.respond_to?(:is_category_topic?) || topic.is_category_topic? - - tags = topic.tags.map(&:name) - has_qa_tag = !(tags & SiteSetting.qa_tags.split('|')).empty? - is_qa_category = topic.category && topic.category.custom_fields["qa_enabled"] - is_qa_subtype = topic.subtype == 'question' - has_qa_tag || is_qa_category || is_qa_subtype - end - - ## This should be replaced with a :voted? property in TopicUser - but how to do this properly in a plugin? - def user_has_voted(topic, user) - return nil if !user || !SiteSetting.qa_enabled - - PostAction.exists?(post_id: topic.posts.map(&:id), - user_id: user.id, - post_action_type_id: PostActionType.types[:vote]) - end - - def update_order(topic_id) - return if !SiteSetting.qa_enabled - - posts = Post.where(topic_id: topic_id) - - answers = posts.where(reply_to_post_number: [nil, '']) - .where.not(post_number: 1) - .order("vote_count DESC, post_number ASC") - - count = 1 - answers.each do |a| - a.update(sort_order: count) - comments = posts.where(reply_to_post_number: a.post_number) - .order("post_number ASC") - if comments.any? - comments.each do |c| - count += 1 - c.update(sort_order: count) - end - else - count += 1 - end - end - end - end -end diff --git a/lib/qa_post_edits.rb b/lib/qa_post_edits.rb index 1462d89..df94138 100644 --- a/lib/qa_post_edits.rb +++ b/lib/qa_post_edits.rb @@ -1,72 +1,103 @@ -::PostSerializer.class_eval do - attributes :vote_count -end +module PostSerializerQAExtension + def actions_summary + summaries = super.reject { |s| s[:id] === PostActionType.types[:vote]} + + if object.qa_enabled + user = scope.current_user + summary = { + id: PostActionType.types[:vote], + count: object.vote_count + } + + voted = object.voted.include?(user.id) -require 'post_actions_controller' -class ::PostActionsController - before_action :check_if_voted, only: :create + if voted + summary[:acted] = true + summary[:can_undo] = ::QuestionAnswer::Vote.can_undo(object, user) + else + summary[:can_act] = true + end - def check_if_voted - if current_user && params[:post_action_type_id].to_i === PostActionType.types[:vote] && - QAHelper.qa_enabled(@post.topic) && QAHelper.user_has_voted(@post.topic, current_user) - raise Discourse::InvalidAccess.new, I18n.t('vote.alread_voted') + summary.delete(:count) if summary[:count] == 0 + + if summary[:can_act] || summary[:count] + summaries + [summary] + else + summaries + end + else + summaries end end end -class ::Post - after_create :update_qa_order, if: :qa_enabled +require_dependency 'post_serializer' +class ::PostSerializer + prepend PostSerializerQAExtension - def qa_enabled - QAHelper.qa_enabled(topic) + attributes :vote_count, :voted + + def vote_count + object.vote_count end - def update_qa_order - QAHelper.update_order(topic_id) + def voted + object.voted end end -class ::PostAction - after_commit :update_qa_order, if: :is_vote? +## 'vote_count' and 'voted' are used for quick access, whereas 'vote_history' is used for record keeping +## See QuestionAnswer::Vote for how these fields are saved / updated + +Post.register_custom_field_type('vote_count', :integer) +Post.register_custom_field_type('vote_history', :json) + +class ::Post + after_create :update_vote_order, if: :qa_enabled + + self.ignored_columns = %w(vote_count) - def is_vote? - post_action_type_id == PostActionType.types[:vote] + def vote_count + if custom_fields['vote_count'].present? + custom_fields['vote_count'].to_i + else + 0 + end end - def notify_subscribers - if (is_like? || is_flag? || is_vote?) && post - post.publish_change_to_clients! :acted + def voted + if custom_fields['voted'].present? + [*custom_fields['voted']].map(&:to_i) + else + [] end end - def update_qa_order - topic_id = Post.where(id: post_id).pluck(:topic_id).first - QAHelper.update_order(topic_id) + def vote_history + if custom_fields['vote_history'].present? + [*custom_fields['vote_history']] + else + [] + end end -end -module PostGuardianVoteExtension - def can_delete_post_action?(post_action) - # only use extension if post_action is a vote - return super(post_action) unless post_action.post_action_type_id == PostActionType.types[:vote] + def qa_enabled + ::Topic.qa_enabled(topic) + end - # You can only undo your own actions - return false unless is_my_own?(post_action) && not(post_action.is_private_message?) + def update_vote_order + ::Topic.update_vote_order(topic_id) + end - # Apply vote action window if it exists - vote_window = SiteSetting.qa_undo_vote_action_window.to_i - if vote_window == 0 - return true - elsif vote_window.present? - return post_action.created_at > vote_window.minutes.ago + def last_voted(user_id) + user_votes = vote_history.select do |v| + v['user_id'].to_i === user_id && v['action'] === 'create' end - # Use post action setting as default - post_action.created_at > SiteSetting.post_undo_action_window_mins.minutes.ago + if user_votes.any? + user_votes.sort_by { |v| v['created_at'].to_i }.first['created_at'].to_datetime + else + nil + end end end - -require_dependency 'guardian' -class ::Guardian - prepend PostGuardianVoteExtension -end diff --git a/lib/qa_topic_edits.rb b/lib/qa_topic_edits.rb index 3b66209..7c852e5 100644 --- a/lib/qa_topic_edits.rb +++ b/lib/qa_topic_edits.rb @@ -1,4 +1,4 @@ -module QATopicExtension +module TopicQAExtension def reload(options = nil) @answers = nil @comments = nil @@ -40,7 +40,6 @@ def last_commented_on def last_answer_post_number if answers.any? - puts "HERE ARE THE ANSWERS: #{answers.inspect}" answers.last[:post_number] else nil @@ -49,7 +48,7 @@ def last_answer_post_number def last_answerer if answers.any? - @last_answerer ||= User.find(answers.last[:user_id]) + @last_answerer ||= ::User.find(answers.last[:user_id]) else nil end @@ -58,17 +57,67 @@ def last_answerer require_dependency 'topic' class ::Topic - prepend QATopicExtension + prepend TopicQAExtension + + def self.voted(topic, user) + return nil if !user || !SiteSetting.qa_enabled + + PostCustomField.exists?(post_id: topic.posts.map(&:id), + name: 'voted', + value: user.id) + end + + def self.qa_enabled(topic) + return false if !SiteSetting.qa_enabled + return false if !topic || !topic.respond_to?(:is_category_topic?) || topic.is_category_topic? + + tags = topic.tags.map(&:name) + has_qa_tag = !(tags & SiteSetting.qa_tags.split('|')).empty? + is_qa_category = topic.category && topic.category.custom_fields["qa_enabled"] + is_qa_subtype = topic.subtype == 'question' + + has_qa_tag || is_qa_category || is_qa_subtype + end + + def self.update_vote_order(topic_id) + return if !SiteSetting.qa_enabled + + posts = Post.where(topic_id: topic_id) + + answers = posts.where(reply_to_post_number: [nil, '']) + .where.not(post_number: 1) + .order("( + SELECT COALESCE (( + SELECT value::integer FROM post_custom_fields + WHERE post_id = posts.id AND name = 'vote_count' + ), 0) + ) DESC, post_number ASC") + + count = 1 + answers.each do |a| + a.update(sort_order: count) + comments = posts.where(reply_to_post_number: a.post_number) + .order("post_number ASC") + if comments.any? + comments.each do |c| + count += 1 + c.update(sort_order: count) + end + else + count += 1 + end + end + end end module TopicViewQAExtension def qa_enabled - QAHelper.qa_enabled(@topic) + ::Topic.qa_enabled(@topic) end def filter_posts_by_ids(post_ids) if qa_enabled - posts = Post.where(id: post_ids, topic_id: @topic.id) + posts = ::Post.where(id: post_ids, topic_id: @topic.id) .includes(:user, :reply_to_user, :incoming_email) @posts = posts.order("case when post_number = 1 then 0 else 1 end, sort_order ASC") @posts = filter_post_types(@posts) @@ -80,11 +129,12 @@ def filter_posts_by_ids(post_ids) end end -class ::TopicView +class TopicView prepend TopicViewQAExtension end -require 'topic_view_serializer' +require_dependency 'topic_view_serializer' +require_dependency 'basic_user_serializer' class ::TopicViewSerializer attributes :qa_enabled, :voted, @@ -96,11 +146,11 @@ class ::TopicViewSerializer :last_answerer def qa_enabled - @qa_enabled ||= QAHelper.qa_enabled(object.topic) + object.qa_enabled end def voted - scope.current_user && QAHelper.user_has_voted(object.topic, scope.current_user) + scope.current_user && ::Topic.voted(object.topic, scope.current_user) end def last_answered_at @@ -144,7 +194,7 @@ def include_last_answer_post_number? end def last_answerer - BasicUserSerializer.new(object.topic.last_answerer, scope: scope, root: false) + ::BasicUserSerializer.new(object.topic.last_answerer, scope: scope, root: false) end def include_last_answerer diff --git a/plugin.rb b/plugin.rb index 7d523a4..0d5ee56 100644 --- a/plugin.rb +++ b/plugin.rb @@ -14,7 +14,9 @@ Category.register_custom_field_type('qa_enabled', :boolean) add_to_serializer(:basic_category, :qa_enabled) { object.custom_fields["qa_enabled"] } - load File.expand_path('../lib/qa_helper.rb', __FILE__) + PostActionType.types[:vote] = 100 + + load File.expand_path('../lib/qa.rb', __FILE__) load File.expand_path('../lib/qa_post_edits.rb', __FILE__) load File.expand_path('../lib/qa_topic_edits.rb', __FILE__) end