Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit f96aceb

Browse files
authored
FIX: Use SolvedTopics to list posts in /activity/solved instead of user actions (#376)
In #342 we moved solutions away from topic_custom_fields into proper tables, with the tables as the proper source of truth to a topic's solution. The user's /my/activity/solved route uses user_actions which is not accurate, and a user has reported a bug where their solution is not reflected there (user actions are not a good representation of what a topic's solution is). This commit introduces - a new route to get solutions, and is mindful `hide_user_profiles_from_public` and such settings - also mindful of PMs and private categories - a new template that makes use of the `<UserStream>` to load posts safely and avoid reimplementation
1 parent 041b58e commit f96aceb

File tree

8 files changed

+374
-9
lines changed

8 files changed

+374
-9
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# frozen_string_literal: true
2+
3+
class DiscourseSolved::SolvedTopicsController < ::ApplicationController
4+
requires_plugin DiscourseSolved::PLUGIN_NAME
5+
6+
def by_user
7+
params.require(:username)
8+
user =
9+
fetch_user_from_params(
10+
include_inactive:
11+
current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts),
12+
)
13+
raise Discourse::NotFound unless guardian.public_can_see_profiles?
14+
raise Discourse::NotFound unless guardian.can_see_profile?(user)
15+
16+
offset = [0, params[:offset].to_i].max
17+
limit = params.fetch(:limit, 30).to_i
18+
19+
posts =
20+
Post
21+
.joins(
22+
"INNER JOIN discourse_solved_solved_topics ON discourse_solved_solved_topics.answer_post_id = posts.id",
23+
)
24+
.joins(:topic)
25+
.joins("LEFT JOIN categories ON categories.id = topics.category_id")
26+
.where(user_id: user.id, deleted_at: nil)
27+
.where(topics: { archetype: Archetype.default, deleted_at: nil })
28+
.where(
29+
"topics.category_id IS NULL OR NOT categories.read_restricted OR topics.category_id IN (:secure_category_ids)",
30+
secure_category_ids: guardian.secure_category_ids,
31+
)
32+
.includes(:user, topic: %i[category tags])
33+
.order("discourse_solved_solved_topics.created_at DESC")
34+
.offset(offset)
35+
.limit(limit)
36+
37+
render_serialized(posts, DiscourseSolved::SolvedPostSerializer, root: "user_solved_posts")
38+
end
39+
end
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# frozen_string_literal: true
2+
3+
class DiscourseSolved::SolvedPostSerializer < ApplicationSerializer
4+
attributes :created_at,
5+
:archived,
6+
:avatar_template,
7+
:category_id,
8+
:closed,
9+
:cooked,
10+
:excerpt,
11+
:name,
12+
:post_id,
13+
:post_number,
14+
:post_type,
15+
:raw,
16+
:slug,
17+
:topic_id,
18+
:topic_title,
19+
:truncated,
20+
:url,
21+
:user_id,
22+
:username
23+
24+
def archived
25+
object.topic.archived
26+
end
27+
28+
def avatar_template
29+
object.user&.avatar_template
30+
end
31+
32+
def category_id
33+
object.topic.category_id
34+
end
35+
36+
def closed
37+
object.topic.closed
38+
end
39+
40+
def excerpt
41+
@excerpt ||= PrettyText.excerpt(cooked, 300, keep_emoji_images: true)
42+
end
43+
44+
def name
45+
object.user&.name
46+
end
47+
48+
def include_name?
49+
SiteSetting.enable_names?
50+
end
51+
52+
def post_id
53+
object.id
54+
end
55+
56+
def slug
57+
Slug.for(object.topic.title)
58+
end
59+
60+
def include_slug?
61+
object.topic.title.present?
62+
end
63+
64+
def topic_title
65+
object.topic.title
66+
end
67+
68+
def truncated
69+
true
70+
end
71+
72+
def include_truncated?
73+
cooked.length > 300
74+
end
75+
76+
def url
77+
"#{Discourse.base_url}#{object.url}"
78+
end
79+
80+
def user_id
81+
object.user_id
82+
end
83+
84+
def username
85+
object.user&.username
86+
end
87+
end

assets/javascripts/discourse/routes/user-activity-solved.js

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,110 @@
1-
import UserActivityStreamRoute from "discourse/routes/user-activity-stream";
1+
import { tracked } from "@glimmer/tracking";
2+
import EmberObject from "@ember/object";
3+
import { service } from "@ember/service";
4+
import { Promise } from "rsvp";
5+
import { ajax } from "discourse/lib/ajax";
6+
import DiscourseRoute from "discourse/routes/discourse";
27
import { i18n } from "discourse-i18n";
38

4-
export default class UserActivitySolved extends UserActivityStreamRoute {
5-
userActionType = 15;
6-
noContentHelpKey = "solved.no_solutions";
9+
class SolvedPostsStream {
10+
@tracked content = [];
11+
@tracked loading = false;
12+
@tracked loaded = false;
13+
@tracked itemsLoaded = 0;
14+
@tracked canLoadMore = true;
15+
16+
constructor({ username, siteCategories }) {
17+
this.username = username;
18+
this.siteCategories = siteCategories;
19+
}
20+
21+
get noContent() {
22+
return this.loaded && this.content.length === 0;
23+
}
24+
25+
findItems() {
26+
if (this.loading || !this.canLoadMore) {
27+
return Promise.resolve();
28+
}
29+
30+
this.loading = true;
31+
32+
const limit = 20;
33+
return ajax(
34+
`/solution/by_user.json?username=${this.username}&offset=${this.itemsLoaded}&limit=${limit}`
35+
)
36+
.then((result) => {
37+
const userSolvedPosts = result.user_solved_posts || [];
38+
39+
if (userSolvedPosts.length === 0) {
40+
this.canLoadMore = false;
41+
return;
42+
}
43+
44+
const posts = userSolvedPosts.map((p) => {
45+
const post = EmberObject.create(p);
46+
post.set("titleHtml", post.topic_title);
47+
post.set("postUrl", post.url);
48+
49+
if (post.category_id && this.siteCategories) {
50+
post.set(
51+
"category",
52+
this.siteCategories.find((c) => c.id === post.category_id)
53+
);
54+
}
55+
return post;
56+
});
57+
58+
this.content = [...this.content, ...posts];
59+
this.itemsLoaded = this.itemsLoaded + userSolvedPosts.length;
60+
61+
if (userSolvedPosts.length < limit) {
62+
this.canLoadMore = false;
63+
}
64+
})
65+
.finally(() => {
66+
this.loaded = true;
67+
this.loading = false;
68+
});
69+
}
70+
}
71+
72+
export default class UserActivitySolved extends DiscourseRoute {
73+
@service site;
74+
@service currentUser;
75+
76+
model() {
77+
const user = this.modelFor("user");
78+
79+
const stream = new SolvedPostsStream({
80+
username: user.username,
81+
siteCategories: this.site.categories,
82+
});
83+
84+
return stream.findItems().then(() => {
85+
return {
86+
stream,
87+
emptyState: this.emptyState(),
88+
};
89+
});
90+
}
91+
92+
setupController(controller, model) {
93+
controller.setProperties({
94+
model,
95+
emptyState: this.emptyState(),
96+
});
97+
}
98+
99+
renderTemplate() {
100+
this.render("user-activity-solved");
101+
}
7102

8103
emptyState() {
9104
const user = this.modelFor("user");
10105

11106
let title, body;
12-
if (this.isCurrentUser(user)) {
107+
if (this.currentUser && user.id === this.currentUser.id) {
13108
title = i18n("solved.no_solved_topics_title");
14109
body = i18n("solved.no_solved_topics_body");
15110
} else {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import RouteTemplate from "ember-route-template";
2+
import EmptyState from "discourse/components/empty-state";
3+
import UserStream from "discourse/components/user-stream";
4+
5+
export default RouteTemplate(
6+
<template>
7+
{{#if @controller.model.stream.noContent}}
8+
<EmptyState
9+
@title={{@controller.model.emptyState.title}}
10+
@body={{@controller.model.emptyState.body}}
11+
/>
12+
{{else}}
13+
<UserStream @stream={{@controller.model.stream}} />
14+
{{/if}}
15+
</template>
16+
);

config/routes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
DiscourseSolved::Engine.routes.draw do
44
post "/accept" => "answer#accept"
55
post "/unaccept" => "answer#unaccept"
6+
7+
get "/by_user" => "solved_topics#by_user"
68
end
79

810
Discourse::Application.routes.draw { mount ::DiscourseSolved::Engine, at: "solution" }
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# frozen_string_literal: true
2+
3+
describe DiscourseSolved::SolvedTopicsController do
4+
fab!(:user)
5+
fab!(:another_user) { Fabricate(:user) }
6+
fab!(:admin)
7+
fab!(:topic)
8+
fab!(:post) { Fabricate(:post, topic:) }
9+
fab!(:answer_post) { Fabricate(:post, topic:, user:) }
10+
fab!(:solved_topic) { Fabricate(:solved_topic, topic:, answer_post:) }
11+
12+
describe "#by_user" do
13+
context "when accessing with username" do
14+
it "returns solved posts for the specified user" do
15+
sign_in(admin)
16+
17+
get "/solution/by_user.json", params: { username: user.username }
18+
19+
expect(response.status).to eq(200)
20+
result = response.parsed_body
21+
expect(result["user_solved_posts"]).to be_present
22+
expect(result["user_solved_posts"].length).to eq(1)
23+
expect(result["user_solved_posts"][0]["post_id"]).to eq(answer_post.id)
24+
end
25+
26+
it "returns 404 for a non-existent user" do
27+
sign_in(admin)
28+
29+
get "/solution/by_user.json", params: { username: "non-existent-user" }
30+
31+
expect(response.status).to eq(404)
32+
end
33+
34+
it "correctly handles the offset parameter" do
35+
sign_in(admin)
36+
37+
get "/solution/by_user.json", params: { username: user.username, offset: 1 }
38+
39+
expect(response.status).to eq(200)
40+
result = response.parsed_body
41+
expect(result["user_solved_posts"]).to be_empty
42+
end
43+
44+
it "correctly handles the limit parameter" do
45+
Fabricate(:solved_topic, answer_post: Fabricate(:post, user:))
46+
47+
sign_in(admin)
48+
49+
get "/solution/by_user.json", params: { username: user.username, limit: 1 }
50+
51+
expect(response.status).to eq(200)
52+
result = response.parsed_body
53+
expect(result["user_solved_posts"].length).to eq(1)
54+
end
55+
end
56+
57+
context "when accessing without username" do
58+
it "returns 400 for the current user" do
59+
sign_in(user)
60+
61+
get "/solution/by_user.json"
62+
63+
expect(response.status).to eq(400)
64+
end
65+
66+
it "returns 400 if not logged in" do
67+
get "/solution/by_user.json"
68+
69+
expect(response.status).to eq(400)
70+
end
71+
end
72+
73+
context "with visibility restrictions" do
74+
context "with private category solved topic" do
75+
fab!(:group) { Fabricate(:group).tap { |g| g.add(user) } }
76+
fab!(:private_category) { Fabricate(:private_category, group:) }
77+
fab!(:private_topic) { Fabricate(:topic, category: private_category) }
78+
fab!(:private_post) { Fabricate(:post, topic: private_topic) }
79+
fab!(:private_answer_post) { Fabricate(:post, topic: private_topic, user: user) }
80+
fab!(:private_solved_topic) do
81+
Fabricate(:solved_topic, topic: private_topic, answer_post: private_answer_post)
82+
end
83+
84+
it "respects category permissions" do
85+
sign_in(another_user)
86+
87+
get "/solution/by_user.json", params: { username: user.username }
88+
89+
expect(response.status).to eq(200)
90+
result = response.parsed_body
91+
# admin sees both solutions
92+
expect(result["user_solved_posts"].length).to eq(1)
93+
94+
sign_in(user)
95+
96+
get "/solution/by_user.json", params: { username: user.username }
97+
98+
expect(response.status).to eq(200)
99+
result = response.parsed_body
100+
expect(result["user_solved_posts"].length).to eq(2)
101+
end
102+
end
103+
104+
it "does not return PMs" do
105+
topic.update(archetype: Archetype.private_message, category: nil)
106+
107+
sign_in(user)
108+
109+
get "/solution/by_user.json", params: { username: user.username }
110+
111+
expect(response.status).to eq(200)
112+
result = response.parsed_body
113+
expect(result["user_solved_posts"]).to be_empty
114+
end
115+
end
116+
end
117+
end

0 commit comments

Comments
 (0)