FEATURE: allow marking solutions in group messages (#37635)

PM support for the solved plugin was intentionally removed in
https://github.com/discourse/discourse-solved/pull/334, but multiple
communities rely on solved in group messages for private support
workflows (e.g. using Discourse as an internal ticket system).

Add a new `allow_solved_in_groups` site setting (group list, default
empty) that enables the solved UI for group messages belonging to the
configured groups. Regular 1-on-1 PMs remain excluded.

The implementation adds an early return in `allow_accepted_answers?` for
private messages that checks whether the topic's allowed groups
intersect with the configured setting. The category/tag logic is
extracted into `solved_enabled_for_category?` so callers without a topic
object (e.g. the category-change diff in plugin.rb) still work. All
existing PM exclusions (reports, badges, filters, directory columns) are
intentionally left unchanged.

https://meta.discourse.org/t/370496
This commit is contained in:
Régis Hanol
2026-02-11 07:51:22 +01:00
committed by GitHub
parent 9a250d8dd1
commit 39a426505d
7 changed files with 78 additions and 19 deletions
@@ -16,8 +16,8 @@ module DiscourseSolved
def can_have_answer
return true if SiteSetting.allow_solved_on_all_topics
return false if object.closed || object.archived
scope.allow_accepted_answers?(object.category_id, object.tags.map(&:name))
return false if !object.private_message? && (object.closed || object.archived)
scope.allow_accepted_answers?(object)
end
def include_can_have_answer?
@@ -4,6 +4,7 @@ en:
site_settings:
solved_enabled: "Enable solved plugin, allow users to select solutions for topics"
allow_solved_on_all_topics: "Allow users to select solutions on all topics (when unchecked, solutions can be enabled per category or tag)"
allow_solved_in_groups: "Allow marking solutions in group messages for these groups."
accept_all_solutions_trust_level: "Minimum trust level required to accept solutions on any topic (even when not OP)"
accept_all_solutions_allowed_groups: "Groups that are allowed to accept solutions on any topic (even when not OP). Admins and moderators are always allowed."
empty_box_on_unsolved: "Display an empty box next to unsolved topics"
@@ -8,6 +8,11 @@ discourse_solved:
allow_solved_on_all_topics:
default: false
client: true
allow_solved_in_groups:
type: group_list
default: ""
allow_any: false
refresh: true
accept_all_solutions_trust_level:
default: 4
client: true
@@ -19,8 +19,7 @@ class DiscourseSolved::BeforeHeadClose
return "" if SiteSetting.solved_add_schema_markup == "never"
allowed =
controller.guardian.allow_accepted_answers?(topic.category_id, topic.tags.pluck(:name))
allowed = controller.guardian.allow_accepted_answers?(topic)
return "" if !allowed
first_post = topic_view.posts&.first
@@ -2,35 +2,47 @@
module DiscourseSolved
module GuardianExtensions
def allow_accepted_answers?(category_id, tag_names = [])
def allow_accepted_answers?(topic)
return true if SiteSetting.allow_solved_on_all_topics
if topic.private_message?
return(
SiteSetting.allow_solved_in_groups_map.present? &&
topic.allowed_groups.exists?(id: SiteSetting.allow_solved_in_groups_map)
)
end
solved_enabled_for_category?(topic.category_id, topic.tags.map(&:name))
end
def solved_enabled_for_category?(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
return true if (tag_names & SiteSetting.enable_solved_tags.split("|")).present?
end
return false if category_id.blank?
if !DiscourseSolved::AcceptedAnswerCache.allowed
DiscourseSolved::AcceptedAnswerCache.reset_accepted_answer_cache
end
DiscourseSolved::AcceptedAnswerCache.allowed.include?(category_id)
end
def can_accept_answer?(topic, post)
return false if !authenticated?
if !topic || topic.private_message? || !post || post.post_number <= 1 || post.whisper?
return false
end
return false if !allow_accepted_answers?(topic.category_id, topic.tags.map(&:name))
return false if !topic || !post || post.post_number <= 1 || post.whisper?
return false if !allow_accepted_answers?(topic)
return true if is_staff?
if current_user.in_any_groups?(SiteSetting.accept_all_solutions_allowed_groups_map)
return true
end
return true if is_category_group_moderator?(topic.category)
return true if !topic.private_message? && is_category_group_moderator?(topic.category)
topic.user_id == current_user.id && !topic.closed && SiteSetting.accept_solutions_topic_author
end
+2 -2
View File
@@ -300,8 +300,8 @@ after_initialize do
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])
old_allowed = Guardian.new.solved_enabled_for_category?(category_id_changes[0], tag_changes[0])
new_allowed = Guardian.new.solved_enabled_for_category?(category_id_changes[1], tag_changes[1])
if old_allowed != new_allowed
options[:refresh_stream] = true
@@ -24,9 +24,51 @@ describe DiscourseSolved::GuardianExtensions do
expect(guardian.can_accept_answer?(topic, post)).to eq(false)
end
it "returns false for private messages" do
topic.update!(user:, category_id: nil, archetype: Archetype.private_message)
expect(guardian.can_accept_answer?(topic, post)).to eq(false)
it "returns false for regular private messages" do
pm = Fabricate(:private_message_topic, user: user)
pm_post = Fabricate(:post, topic: pm, user: other_user)
expect(guardian.can_accept_answer?(pm, pm_post)).to eq(false)
end
context "with group messages" do
fab!(:group)
fab!(:pm_topic) do
Fabricate(:group_private_message_topic, user: user, recipient_group: group)
end
fab!(:pm_op) { Fabricate(:post, topic: pm_topic, user: user) }
fab!(:pm_post) { Fabricate(:post, topic: pm_topic, user: other_user) }
before { SiteSetting.allow_solved_on_all_topics = false }
it "returns false when allow_solved_in_groups is empty" do
SiteSetting.allow_solved_in_groups = ""
expect(guardian.can_accept_answer?(pm_topic, pm_post)).to eq(false)
end
it "returns false when the group is not in allow_solved_in_groups" do
other_group = Fabricate(:group)
SiteSetting.allow_solved_in_groups = other_group.id.to_s
expect(guardian.can_accept_answer?(pm_topic, pm_post)).to eq(false)
end
it "returns true for topic author when the group is in allow_solved_in_groups" do
SiteSetting.allow_solved_in_groups = group.id.to_s
SiteSetting.accept_solutions_topic_author = true
expect(guardian.can_accept_answer?(pm_topic, pm_post)).to eq(true)
end
it "returns true for staff when the group is in allow_solved_in_groups" do
SiteSetting.allow_solved_in_groups = group.id.to_s
admin = Fabricate(:admin, refresh_auto_groups: true)
expect(Guardian.new(admin).can_accept_answer?(pm_topic, pm_post)).to eq(true)
end
it "returns false for non-author non-staff users" do
SiteSetting.allow_solved_in_groups = group.id.to_s
non_author = Fabricate(:user, refresh_auto_groups: true)
SiteSetting.accept_all_solutions_allowed_groups = Fabricate(:group).id
expect(Guardian.new(non_author).can_accept_answer?(pm_topic, pm_post)).to eq(false)
end
end
it "returns false if accepted answers are not allowed" do