Skip to content

FIX: Use SolvedTopics to list posts in /activity/solved instead of user actions #376

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions app/controllers/discourse_solved/solved_topics_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

class DiscourseSolved::SolvedTopicsController < ::ApplicationController
requires_plugin DiscourseSolved::PLUGIN_NAME

def by_user
params.require(:username)
user =
fetch_user_from_params(
include_inactive:
current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts),
)
raise Discourse::NotFound unless guardian.public_can_see_profiles?
raise Discourse::NotFound unless guardian.can_see_profile?(user)

offset = [0, params[:offset].to_i].max
limit = params.fetch(:limit, 30).to_i

posts =
Post
.joins(
"INNER JOIN discourse_solved_solved_topics ON discourse_solved_solved_topics.answer_post_id = posts.id",
)
.joins(:topic)
.joins("LEFT JOIN categories ON categories.id = topics.category_id")
.where(user_id: user.id, deleted_at: nil)
.where(topics: { archetype: Archetype.default, deleted_at: nil })
.where(
"topics.category_id IS NULL OR NOT categories.read_restricted OR topics.category_id IN (:secure_category_ids)",
secure_category_ids: guardian.secure_category_ids,
)
.includes(:user, topic: %i[category tags])
.order("discourse_solved_solved_topics.created_at DESC")
.offset(offset)
.limit(limit)

render_serialized(posts, DiscourseSolved::SolvedPostSerializer, root: "user_solved_posts")
end
end
87 changes: 87 additions & 0 deletions app/serializers/discourse_solved/solved_post_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# frozen_string_literal: true

class DiscourseSolved::SolvedPostSerializer < ApplicationSerializer
attributes :created_at,
:archived,
:avatar_template,
:category_id,
:closed,
:cooked,
:excerpt,
:name,
:post_id,
:post_number,
:post_type,
:raw,
:slug,
:topic_id,
:topic_title,
:truncated,
:url,
:user_id,
:username

def archived
object.topic.archived
end

def avatar_template
object.user&.avatar_template
end

def category_id
object.topic.category_id
end

def closed
object.topic.closed
end

def excerpt
@excerpt ||= PrettyText.excerpt(cooked, 300, keep_emoji_images: true)
end

def name
object.user&.name
end

def include_name?
SiteSetting.enable_names?
end

def post_id
object.id
end

def slug
Slug.for(object.topic.title)
end

def include_slug?
object.topic.title.present?
end

def topic_title
object.topic.title
end

def truncated
true
end

def include_truncated?
cooked.length > 300
end

def url
"#{Discourse.base_url}#{object.url}"
end

def user_id
object.user_id
end

def username
object.user&.username
end
end
105 changes: 100 additions & 5 deletions assets/javascripts/discourse/routes/user-activity-solved.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,110 @@
import UserActivityStreamRoute from "discourse/routes/user-activity-stream";
import { tracked } from "@glimmer/tracking";
import EmberObject from "@ember/object";
import { service } from "@ember/service";
import { Promise } from "rsvp";
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";
import { i18n } from "discourse-i18n";

export default class UserActivitySolved extends UserActivityStreamRoute {
userActionType = 15;
noContentHelpKey = "solved.no_solutions";
class SolvedPostsStream {
@tracked content = [];
@tracked loading = false;
@tracked loaded = false;
@tracked itemsLoaded = 0;
@tracked canLoadMore = true;

constructor({ username, siteCategories }) {
this.username = username;
this.siteCategories = siteCategories;
}

get noContent() {
return this.loaded && this.content.length === 0;
}

findItems() {
if (this.loading || !this.canLoadMore) {
return Promise.resolve();
}

this.loading = true;

const limit = 20;
return ajax(
`/solution/by_user.json?username=${this.username}&offset=${this.itemsLoaded}&limit=${limit}`
)
.then((result) => {
const userSolvedPosts = result.user_solved_posts || [];

if (userSolvedPosts.length === 0) {
this.canLoadMore = false;
return;
}

const posts = userSolvedPosts.map((p) => {
const post = EmberObject.create(p);
post.set("titleHtml", post.topic_title);
post.set("postUrl", post.url);

if (post.category_id && this.siteCategories) {
post.set(
"category",
this.siteCategories.find((c) => c.id === post.category_id)
);
}
return post;
});

this.content = [...this.content, ...posts];
this.itemsLoaded = this.itemsLoaded + userSolvedPosts.length;

if (userSolvedPosts.length < limit) {
this.canLoadMore = false;
}
})
.finally(() => {
this.loaded = true;
this.loading = false;
});
}
}

export default class UserActivitySolved extends DiscourseRoute {
@service site;
@service currentUser;

model() {
const user = this.modelFor("user");

const stream = new SolvedPostsStream({
username: user.username,
siteCategories: this.site.categories,
});

return stream.findItems().then(() => {
return {
stream,
emptyState: this.emptyState(),
};
});
}

setupController(controller, model) {
controller.setProperties({
model,
emptyState: this.emptyState(),
});
}

renderTemplate() {
this.render("user-activity-solved");
}

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

let title, body;
if (this.isCurrentUser(user)) {
if (this.currentUser && user.id === this.currentUser.id) {
title = i18n("solved.no_solved_topics_title");
body = i18n("solved.no_solved_topics_body");
} else {
Expand Down
16 changes: 16 additions & 0 deletions assets/javascripts/discourse/templates/user-activity-solved.gjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import RouteTemplate from "ember-route-template";
import EmptyState from "discourse/components/empty-state";
import UserStream from "discourse/components/user-stream";

export default RouteTemplate(
<template>
{{#if @controller.model.stream.noContent}}
<EmptyState
@title={{@controller.model.emptyState.title}}
@body={{@controller.model.emptyState.body}}
/>
{{else}}
<UserStream @stream={{@controller.model.stream}} />
{{/if}}
</template>
);
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
DiscourseSolved::Engine.routes.draw do
post "/accept" => "answer#accept"
post "/unaccept" => "answer#unaccept"

get "/by_user" => "solved_topics#by_user"
end

Discourse::Application.routes.draw { mount ::DiscourseSolved::Engine, at: "solution" }
117 changes: 117 additions & 0 deletions spec/requests/solved_topics_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# frozen_string_literal: true

describe DiscourseSolved::SolvedTopicsController do
fab!(:user)
fab!(:another_user) { Fabricate(:user) }
fab!(:admin)
fab!(:topic)
fab!(:post) { Fabricate(:post, topic:) }
fab!(:answer_post) { Fabricate(:post, topic:, user:) }
fab!(:solved_topic) { Fabricate(:solved_topic, topic:, answer_post:) }

describe "#by_user" do
context "when accessing with username" do
it "returns solved posts for the specified user" do
sign_in(admin)

get "/solution/by_user.json", params: { username: user.username }

expect(response.status).to eq(200)
result = response.parsed_body
expect(result["user_solved_posts"]).to be_present
expect(result["user_solved_posts"].length).to eq(1)
expect(result["user_solved_posts"][0]["post_id"]).to eq(answer_post.id)
end

it "returns 404 for a non-existent user" do
sign_in(admin)

get "/solution/by_user.json", params: { username: "non-existent-user" }

expect(response.status).to eq(404)
end

it "correctly handles the offset parameter" do
sign_in(admin)

get "/solution/by_user.json", params: { username: user.username, offset: 1 }

expect(response.status).to eq(200)
result = response.parsed_body
expect(result["user_solved_posts"]).to be_empty
end

it "correctly handles the limit parameter" do
Fabricate(:solved_topic, answer_post: Fabricate(:post, user:))

sign_in(admin)

get "/solution/by_user.json", params: { username: user.username, limit: 1 }

expect(response.status).to eq(200)
result = response.parsed_body
expect(result["user_solved_posts"].length).to eq(1)
end
end

context "when accessing without username" do
it "returns 400 for the current user" do
sign_in(user)

get "/solution/by_user.json"

expect(response.status).to eq(400)
end

it "returns 400 if not logged in" do
get "/solution/by_user.json"

expect(response.status).to eq(400)
end
end

context "with visibility restrictions" do
context "with private category solved topic" do
fab!(:group) { Fabricate(:group).tap { |g| g.add(user) } }
fab!(:private_category) { Fabricate(:private_category, group:) }
fab!(:private_topic) { Fabricate(:topic, category: private_category) }
fab!(:private_post) { Fabricate(:post, topic: private_topic) }
fab!(:private_answer_post) { Fabricate(:post, topic: private_topic, user: user) }
fab!(:private_solved_topic) do
Fabricate(:solved_topic, topic: private_topic, answer_post: private_answer_post)
end

it "respects category permissions" do
sign_in(another_user)

get "/solution/by_user.json", params: { username: user.username }

expect(response.status).to eq(200)
result = response.parsed_body
# admin sees both solutions
expect(result["user_solved_posts"].length).to eq(1)

sign_in(user)

get "/solution/by_user.json", params: { username: user.username }

expect(response.status).to eq(200)
result = response.parsed_body
expect(result["user_solved_posts"].length).to eq(2)
end
end

it "does not return PMs" do
topic.update(archetype: Archetype.private_message, category: nil)

sign_in(user)

get "/solution/by_user.json", params: { username: user.username }

expect(response.status).to eq(200)
result = response.parsed_body
expect(result["user_solved_posts"]).to be_empty
end
end
end
end
Loading