# frozen_string_literal: true # name: discourse-solved # about: Add a solved button to answers on Discourse # version: 0.1 # authors: Sam Saffron # url: https://github.com/discourse/discourse-solved # transpile_js: true enabled_site_setting :solved_enabled if respond_to?(:register_svg_icon) register_svg_icon "far fa-check-square" register_svg_icon "check-square" register_svg_icon "far fa-square" end PLUGIN_NAME = "discourse_solved" register_asset "stylesheets/solutions.scss" register_asset "stylesheets/mobile/solutions.scss", :mobile after_initialize do SeedFu.fixture_paths << Rails.root.join("plugins", "discourse-solved", "db", "fixtures").to_s %w[ ../app/lib/first_accepted_post_solution_validator.rb ../app/serializers/concerns/topic_answer_mixin.rb ].each { |path| load File.expand_path(path, __FILE__) } skip_db = defined?(GlobalSetting.skip_db?) && GlobalSetting.skip_db? # we got to do a one time upgrade if !skip_db && defined?(UserAction::SOLVED) unless Discourse.redis.get("solved_already_upgraded") unless UserAction.where(action_type: UserAction::SOLVED).exists? Rails.logger.info("Upgrading storage for solved") sql = < 0 if p2 = Post.find_by(id: accepted_id) p2.custom_fields.delete(IS_ACCEPTED_ANSWER_CUSTOM_FIELD) p2.save! if defined?(UserAction::SOLVED) UserAction.where(action_type: UserAction::SOLVED, target_post_id: p2.id).destroy_all end end end post.custom_fields[IS_ACCEPTED_ANSWER_CUSTOM_FIELD] = "true" topic.custom_fields[ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD] = post.id if defined?(UserAction::SOLVED) UserAction.log_action!( action_type: UserAction::SOLVED, user_id: post.user_id, acting_user_id: acting_user.id, target_post_id: post.id, target_topic_id: post.topic_id, ) end notification_data = { message: "solved.accepted_notification", display_username: acting_user.username, topic_title: topic.title, title: "solved.notification.title", }.to_json unless acting_user.id == post.user_id Notification.create!( notification_type: Notification.types[:custom], user_id: post.user_id, topic_id: post.topic_id, post_number: post.post_number, data: notification_data, ) end if SiteSetting.notify_on_staff_accept_solved && acting_user.id != topic.user_id Notification.create!( notification_type: Notification.types[:custom], user_id: topic.user_id, topic_id: post.topic_id, post_number: post.post_number, data: notification_data, ) end auto_close_hours = 0 if topic&.category.present? auto_close_hours = topic.category.custom_fields["solved_topics_auto_close_hours"].to_i auto_close_hours = 175_200 if auto_close_hours > 175_200 # 20 years end auto_close_hours = SiteSetting.solved_topics_auto_close_hours if auto_close_hours == 0 if (auto_close_hours > 0) && !topic.closed topic_timer = topic.set_or_create_timer( TopicTimer.types[:silent_close], nil, based_on_last_post: true, duration_minutes: auto_close_hours * 60, ) topic.custom_fields[AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD] = topic_timer.id MessageBus.publish("/topic/#{topic.id}", reload_topic: true) end topic.save! post.save! if WebHook.active_web_hooks(:accepted_solution).exists? payload = WebHook.generate_payload(:post, post) WebHook.enqueue_solved_hooks(:accepted_solution, post, payload) end DiscourseEvent.trigger(:accepted_solution, post) end end def self.unaccept_answer!(post, topic: nil) topic ||= post.topic DistributedMutex.synchronize("discourse_solved_toggle_answer_#{topic.id}") do post.custom_fields.delete(IS_ACCEPTED_ANSWER_CUSTOM_FIELD) topic.custom_fields.delete(ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD) if timer_id = topic.custom_fields[AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD] topic_timer = TopicTimer.find_by(id: timer_id) topic_timer.destroy! if topic_timer topic.custom_fields.delete(AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD) end topic.save! post.save! # TODO remove_action! does not allow for this type of interface if defined?(UserAction::SOLVED) UserAction.where(action_type: UserAction::SOLVED, target_post_id: post.id).destroy_all end # yank notification notification = Notification.find_by( notification_type: Notification.types[:custom], user_id: post.user_id, topic_id: post.topic_id, post_number: post.post_number, ) notification.destroy! if notification if WebHook.active_web_hooks(:unaccepted_solution).exists? payload = WebHook.generate_payload(:post, post) WebHook.enqueue_solved_hooks(:unaccepted_solution, post, payload) end DiscourseEvent.trigger(:unaccepted_solution, post) end end end require_dependency "application_controller" class DiscourseSolved::AnswerController < ::ApplicationController def accept limit_accepts post = Post.find(params[:id].to_i) topic = post.topic topic ||= Topic.with_deleted.find(post.topic_id) if guardian.is_staff? guardian.ensure_can_accept_answer!(topic, post) DiscourseSolved.accept_answer!(post, current_user, topic: topic) render json: success_json end def unaccept limit_accepts post = Post.find(params[:id].to_i) topic = post.topic topic ||= Topic.with_deleted.find(post.topic_id) if guardian.is_staff? guardian.ensure_can_accept_answer!(topic, post) DiscourseSolved.unaccept_answer!(post, topic: topic) render json: success_json end def limit_accepts return if current_user.staff? RateLimiter.new(nil, "accept-hr-#{current_user.id}", 20, 1.hour).performed! RateLimiter.new(nil, "accept-min-#{current_user.id}", 4, 30.seconds).performed! end end DiscourseSolved::Engine.routes.draw do post "/accept" => "answer#accept" post "/unaccept" => "answer#unaccept" end Discourse::Application.routes.append { mount ::DiscourseSolved::Engine, at: "solution" } on(:post_destroyed) do |post| if post.custom_fields[::DiscourseSolved::IS_ACCEPTED_ANSWER_CUSTOM_FIELD] == "true" ::DiscourseSolved.unaccept_answer!(post) end end add_api_key_scope( :solved, { answer: { actions: %w[discourse_solved/answer#accept discourse_solved/answer#unaccept] } }, ) topic_view_post_custom_fields_allowlister { [::DiscourseSolved::IS_ACCEPTED_ANSWER_CUSTOM_FIELD] } def get_schema_text(post) post.excerpt(nil, keep_onebox_body: true).presence || post.excerpt(nil, keep_onebox_body: true, keep_quotes: true) end def before_head_close_meta(controller) return "" if !controller.instance_of? TopicsController topic_view = controller.instance_variable_get(:@topic_view) topic = topic_view&.topic return "" if !topic # note, we have canonicals so we only do this for page 1 at the moment # it can get confusing to have this on every page and it should make page 1 # a bit more prominent + cut down on pointless work return "" if SiteSetting.solved_add_schema_markup == "never" allowed = controller.guardian.allow_accepted_answers?(topic.category_id, topic.tags.pluck(:name)) return "" if !allowed first_post = topic_view.posts&.first return "" if first_post&.post_number != 1 question_json = { "@type" => "Question", "name" => topic.title, "text" => get_schema_text(first_post), "upvoteCount" => first_post.like_count, "answerCount" => 0, "dateCreated" => topic.created_at, "author" => { "@type" => "Person", "name" => topic.user&.name, }, } if accepted_answer = Post.find_by( id: topic.custom_fields[::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD], ) question_json["answerCount"] = 1 question_json[:acceptedAnswer] = { "@type" => "Answer", "text" => get_schema_text(accepted_answer), "upvoteCount" => accepted_answer.like_count, "dateCreated" => accepted_answer.created_at, "url" => accepted_answer.full_url, "author" => { "@type" => "Person", "name" => accepted_answer.user&.username, }, } else return "" if SiteSetting.solved_add_schema_markup == "answered only" end [ '", ].join("") end register_html_builder("server:before-head-close-crawler") do |controller| before_head_close_meta(controller) end register_html_builder("server:before-head-close") do |controller| before_head_close_meta(controller) end if Report.respond_to?(:add_report) Report.add_report("accepted_solutions") do |report| report.data = [] accepted_solutions = TopicCustomField.where(name: ::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD) category_id, include_subcategories = report.add_category_filter if category_id if include_subcategories accepted_solutions = accepted_solutions.joins(:topic).where( "topics.category_id IN (?)", Category.subcategory_ids(category_id), ) else accepted_solutions = accepted_solutions.joins(:topic).where("topics.category_id = ?", category_id) end end accepted_solutions .where("topic_custom_fields.created_at >= ?", report.start_date) .where("topic_custom_fields.created_at <= ?", report.end_date) .group("DATE(topic_custom_fields.created_at)") .order("DATE(topic_custom_fields.created_at)") .count .each { |date, count| report.data << { x: date, y: count } } report.total = accepted_solutions.count report.prev30Days = accepted_solutions .where("topic_custom_fields.created_at >= ?", report.start_date - 30.days) .where("topic_custom_fields.created_at <= ?", report.start_date) .count end end if respond_to?(:register_modifier) register_modifier(:search_rank_sort_priorities) do |priorities, search| if SiteSetting.prioritize_solved_topics_in_search condition = <<~SQL EXISTS ( SELECT 1 FROM topic_custom_fields f WHERE topics.id = f.topic_id AND f.name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}' ) SQL priorities.push([condition, 1.1]) else priorities end end end if defined?(UserAction::SOLVED) require_dependency "user_summary" class ::UserSummary def solved_count UserAction.where(user: @user).where(action_type: UserAction::SOLVED).count end end require_dependency "user_summary_serializer" class ::UserSummarySerializer attributes :solved_count def solved_count object.solved_count end end end class ::WebHook def self.enqueue_solved_hooks(event, post, payload = nil) if active_web_hooks(event).exists? && post.present? payload ||= WebHook.generate_payload(:post, post) WebHook.enqueue_hooks( :solved, event, id: post.id, category_id: post.topic&.category_id, tag_ids: post.topic&.tags&.pluck(:id), payload: payload, ) end end end require_dependency "topic_view_serializer" class ::TopicViewSerializer attributes :accepted_answer def include_accepted_answer? accepted_answer_post_id end def accepted_answer if info = accepted_answer_post_info { post_number: info[0], username: info[1], excerpt: info[2], name: info[3] } end end def accepted_answer_post_info post_info = if post = object.posts.find { |p| p.post_number == accepted_answer_post_id } [post.post_number, post.user.username, post.cooked, post.user.name] else Post .where(id: accepted_answer_post_id, topic_id: object.topic.id) .joins(:user) .pluck("post_number", "username", "cooked", "name") .first end if post_info post_info[2] = if SiteSetting.solved_quote_length > 0 PrettyText.excerpt(post_info[2], SiteSetting.solved_quote_length, keep_emoji_images: true) else nil end post_info[3] = nil if !SiteSetting.enable_names || !SiteSetting.display_name_on_posts post_info end end def accepted_answer_post_id id = object.topic.custom_fields[::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD] # a bit messy but race conditions can give us an array here, avoid begin id && id.to_i rescue StandardError nil end end end class ::Category after_save :reset_accepted_cache protected def reset_accepted_cache ::Guardian.reset_accepted_answer_cache end end class ::Guardian @@allowed_accepted_cache = DistributedCache.new("allowed_accepted") def self.reset_accepted_answer_cache @@allowed_accepted_cache["allowed"] = begin Set.new( CategoryCustomField.where( name: ::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD, value: "true", ).pluck(:category_id), ) end end def allow_accepted_answers?(category_id, tag_names = []) return true if SiteSetting.allow_solved_on_all_topics if SiteSetting.enable_solved_tags.present? && tag_names.present? allowed_tags = SiteSetting.enable_solved_tags.split("|") is_allowed = (tag_names & allowed_tags).present? return true if is_allowed end return false if category_id.blank? self.class.reset_accepted_answer_cache unless @@allowed_accepted_cache["allowed"] @@allowed_accepted_cache["allowed"].include?(category_id) end def can_accept_answer?(topic, post) return false if !authenticated? return false if !topic || !post || post.whisper? return false if !allow_accepted_answers?(topic.category_id, topic.tags.map(&:name)) return true if is_staff? return true if current_user.trust_level >= SiteSetting.accept_all_solutions_trust_level if respond_to? :can_perform_action_available_to_group_moderators? return true if can_perform_action_available_to_group_moderators?(topic) end topic.user_id == current_user.id && !topic.closed && SiteSetting.accept_solutions_topic_author end end require_dependency "post_serializer" class ::PostSerializer attributes :can_accept_answer, :can_unaccept_answer, :accepted_answer, :topic_accepted_answer def can_accept_answer scope.can_accept_answer?(topic, object) && object.post_number > 1 && !accepted_answer end def can_unaccept_answer scope.can_accept_answer?(topic, object) && accepted_answer end def accepted_answer post_custom_fields[::DiscourseSolved::IS_ACCEPTED_ANSWER_CUSTOM_FIELD] == "true" end def topic_accepted_answer topic&.custom_fields[::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD].present? end def topic topic_view&.topic || object.topic end end require_dependency "search" #TODO Remove when plugin is 1.0 if Search.respond_to? :advanced_filter Search.advanced_filter(/status:solved/) do |posts| posts.where( "topics.id IN ( SELECT tc.topic_id FROM topic_custom_fields tc WHERE tc.name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}' AND tc.value IS NOT NULL )", ) end Search.advanced_filter(/status:unsolved/) do |posts| if SiteSetting.allow_solved_on_all_topics posts.where( "topics.id NOT IN ( SELECT tc.topic_id FROM topic_custom_fields tc WHERE tc.name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}' AND tc.value IS NOT NULL )", ) else posts.where( "topics.id NOT IN ( SELECT tc.topic_id FROM topic_custom_fields tc WHERE tc.name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}' AND tc.value IS NOT NULL ) AND topics.id IN ( SELECT top.id FROM topics top INNER JOIN category_custom_fields cc ON top.category_id = cc.category_id WHERE cc.name = '#{::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD}' AND cc.value = 'true' )", ) end end end if Discourse.has_needed_version?(Discourse::VERSION::STRING, "1.8.0.beta6") require_dependency "topic_query" TopicQuery.add_custom_filter(:solved) do |results, topic_query| if topic_query.options[:solved] == "yes" results = results.where( "topics.id IN ( SELECT tc.topic_id FROM topic_custom_fields tc WHERE tc.name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}' AND tc.value IS NOT NULL )", ) elsif topic_query.options[:solved] == "no" results = results.where( "topics.id NOT IN ( SELECT tc.topic_id FROM topic_custom_fields tc WHERE tc.name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}' AND tc.value IS NOT NULL )", ) end results end end require_dependency "topic_list_item_serializer" require_dependency "search_topic_list_item_serializer" require_dependency "suggested_topic_serializer" require_dependency "user_summary_serializer" class ::TopicListItemSerializer include TopicAnswerMixin end class ::SearchTopicListItemSerializer include TopicAnswerMixin end class ::SuggestedTopicSerializer include TopicAnswerMixin end class ::UserSummarySerializer::TopicSerializer include TopicAnswerMixin end class ::ListableTopicSerializer include TopicAnswerMixin end if TopicList.respond_to? :preloaded_custom_fields TopicList.preloaded_custom_fields << ::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD end if Site.respond_to? :preloaded_category_custom_fields Site.preloaded_category_custom_fields << ::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD end if Search.respond_to? :preloaded_topic_custom_fields Search.preloaded_topic_custom_fields << ::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD end if CategoryList.respond_to?(:preloaded_topic_custom_fields) CategoryList.preloaded_topic_custom_fields << ::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD end on(:filter_auto_bump_topics) { |_category, filters| filters.push(->(r) { r.where(<<~SQL) }) } NOT EXISTS( SELECT 1 FROM topic_custom_fields WHERE topic_id = topics.id AND name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}' AND value IS NOT NULL ) SQL on(:before_post_publish_changes) do |post_changes, topic_changes, options| category_id_changes = topic_changes.diff["category_id"].to_a tag_changes = topic_changes.diff["tags"].to_a old_allowed = Guardian.new.allow_accepted_answers?(category_id_changes[0], tag_changes[0]) new_allowed = Guardian.new.allow_accepted_answers?(category_id_changes[1], tag_changes[1]) options[:refresh_stream] = true if old_allowed != new_allowed end on(:after_populate_dev_records) do |records, type| next unless SiteSetting.solved_enabled if type == :category next if SiteSetting.allow_solved_on_all_topics solved_category = DiscourseDev::Record.random( Category.where(read_restricted: false, id: records.pluck(:id), parent_category_id: nil), ) CategoryCustomField.create!( category_id: solved_category.id, name: ::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD, value: "true", ) puts "discourse-solved enabled on category '#{solved_category.name}' (#{solved_category.id})." elsif type == :topic topics = Topic.where(id: records.pluck(:id)) unless SiteSetting.allow_solved_on_all_topics solved_category_id = CategoryCustomField .where(name: ::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD, value: "true") .first .category_id unless topics.exists?(category_id: solved_category_id) topics.last.update(category_id: solved_category_id) end topics = topics.where(category_id: solved_category_id) end solved_topic = DiscourseDev::Record.random(topics) post = nil if solved_topic.posts_count > 1 post = DiscourseDev::Record.random(solved_topic.posts.where.not(post_number: 1)) else post = DiscourseDev::Post.new(solved_topic, 1).create! end DiscourseSolved.accept_answer!(post, post.topic.user, topic: post.topic) end end query = " WITH x AS (SELECT u.id user_id, COUNT(DISTINCT ua.id) AS solutions FROM users AS u LEFT OUTER JOIN user_actions AS ua ON ua.user_id = u.id AND ua.action_type = #{UserAction::SOLVED} AND COALESCE(ua.created_at, :since) > :since WHERE u.active AND u.silenced_till IS NULL AND u.id > 0 GROUP BY u.id ) UPDATE directory_items di SET solutions = x.solutions FROM x WHERE x.user_id = di.user_id AND di.period_type = :period_type AND di.solutions <> x.solutions " add_directory_column("solutions", query: query) if respond_to?(:add_directory_column) add_to_class(:composer_messages_finder, :check_topic_is_solved) do return if !SiteSetting.solved_enabled || SiteSetting.disable_solved_education_message return if !replying? || @topic.blank? || @topic.private_message? return if @topic.custom_fields[::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD].blank? { id: "solved_topic", templateName: "education", wait_for_typing: true, extraClass: "education-message", hide_if_whisper: true, body: PrettyText.cook(I18n.t("education.topic_is_solved", base_url: Discourse.base_url)), } end if defined?(UserAction::SOLVED) add_to_serializer(:user_card, :accepted_answers) do UserAction.where(user_id: object.id).where(action_type: UserAction::SOLVED).count end end if respond_to?(:register_topic_list_preload_user_ids) class ::Topic attr_accessor :accepted_answer_user_id end register_topic_list_preload_user_ids do |topics, user_ids, topic_list| answer_post_ids = TopicCustomField .select("value::INTEGER") .where(name: ::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD) .where(topic_id: topics.map(&:id)) answer_user_ids = Post.where(id: answer_post_ids).pluck(:topic_id, :user_id).to_h topics.each { |topic| topic.accepted_answer_user_id = answer_user_ids[topic.id] } user_ids.concat(answer_user_ids.values) end module AddSolvedToTopicPostersSummary def descriptions_by_id if !defined?(@descriptions_by_id) super(ids: old_user_ids) if id = topic.accepted_answer_user_id @descriptions_by_id[id] ||= [] @descriptions_by_id[id] << I18n.t(:accepted_answer) end end super end def last_poster_is_topic_creator? super || topic.accepted_answer_user_id == topic.last_post_user_id end def user_ids if id = topic.accepted_answer_user_id super.insert(1, id) else super end end end TopicPostersSummary.class_eval do alias old_user_ids user_ids prepend AddSolvedToTopicPostersSummary end end if defined?(DiscourseAutomation) if respond_to?(:add_triggerable_to_scriptable) on(:accepted_solution) do |post| # testing directly automation is prone to issues # we prefer to abstract logic in service object and test this next if Rails.env.test? name = "first_accepted_solution" DiscourseAutomation::Automation .where(trigger: name, enabled: true) .find_each do |automation| maximum_trust_level = automation.trigger_field("maximum_trust_level")&.dig("value") if FirstAcceptedPostSolutionValidator.check(post, trust_level: maximum_trust_level) automation.trigger!( "kind" => name, "accepted_post_id" => post.id, "usernames" => [post.user.username], "placeholders" => { "post_url" => Discourse.base_url + post.url, }, ) end end end TRUST_LEVELS = [ { id: 1, name: "discourse_automation.triggerables.first_accepted_solution.max_trust_level.tl1", }, { id: 2, name: "discourse_automation.triggerables.first_accepted_solution.max_trust_level.tl2", }, { id: 3, name: "discourse_automation.triggerables.first_accepted_solution.max_trust_level.tl3", }, { id: 4, name: "discourse_automation.triggerables.first_accepted_solution.max_trust_level.tl4", }, { id: "any", name: "discourse_automation.triggerables.first_accepted_solution.max_trust_level.any", }, ] add_triggerable_to_scriptable(:first_accepted_solution, :send_pms) DiscourseAutomation::Triggerable.add(:first_accepted_solution) do placeholder :post_url field :maximum_trust_level, component: :choices, extra: { content: TRUST_LEVELS, }, required: true end end end end