Skip to content
Open
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
1 change: 1 addition & 0 deletions app/services/sqlstore/dbEntities/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type Post struct {
Description string `db:"description"`
CreatedAt time.Time `db:"created_at"`
Search []byte `db:"search"`
SearchResponse []byte `db:"search_response"`
User *User `db:"user"`
HasVoted bool `db:"has_voted"`
VotesCount int `db:"votes_count"`
Expand Down
33 changes: 30 additions & 3 deletions app/services/sqlstore/postgres/post.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package postgres
package postgres

import (
"context"
Expand Down Expand Up @@ -70,6 +70,7 @@ var (
p.description,
p.created_at,
p.search,
p.search_response,
COALESCE(agg_s.all, 0) as votes_count,
COALESCE(agg_c.all, 0) as comments_count,
COALESCE(agg_s.recent, 0) AS recent_votes_count,
Expand Down Expand Up @@ -449,10 +450,33 @@ func searchPosts(ctx context.Context, q *query.SearchPosts) error {
tsQuerySimple := "to_tsquery('simple', regexp_replace(regexp_replace($3, '\\\\s+', ':* & ', 'g'), '$', ':*'))"

score := fmt.Sprintf("ts_rank_cd(q.search, %s) + ts_rank_cd(q.search, %s)", tsQueryExpr, tsQuerySimple)

searchPredicate := fmt.Sprintf(`q.search @@ %s OR q.search @@ %s`, tsQueryExpr, tsQuerySimple)

condition, statuses, _ := getViewData(*q, 4)
// Collaborators and administrators can also match against the response text
// (e.g. version numbers written when closing a post).
// We use websearch_to_tsquery($4) with the raw query so that version
// strings like "v9.9.9" are preserved as a single token rather than
// being split by the ToTSQuery sanitizer.
isCollaboratorSearch := user != nil && user.IsCollaborator()
if isCollaboratorSearch {
wsQueryExpr := fmt.Sprintf("websearch_to_tsquery('%s', $4)", tsConfig)
wsQuerySimple := "websearch_to_tsquery('simple', $4)"
score += fmt.Sprintf(
" + ts_rank_cd(coalesce(q.search_response, ''::tsvector), %s)"+
" + ts_rank_cd(coalesce(q.search_response, ''::tsvector), %s)",
wsQueryExpr, wsQuerySimple,
)
searchPredicate += fmt.Sprintf(
` OR q.search_response @@ %s OR q.search_response @@ %s`,
wsQueryExpr, wsQuerySimple,
)
}

tagsPlaceholder := 4
if isCollaboratorSearch {
tagsPlaceholder = 5
}
condition, statuses, _ := getViewData(*q, tagsPlaceholder)

if q.MyPostsOnly && user != nil {
condition += " AND user_id = " + strconv.Itoa(user.ID)
Expand All @@ -466,6 +490,9 @@ func searchPosts(ctx context.Context, q *query.SearchPosts) error {
`, innerQuery, searchPredicate, condition, score, q.Limit)

params := []interface{}{tenant.ID, pq.Array(statuses), tsQuery}
if isCollaboratorSearch {
params = append(params, q.Query) // $4 = raw query for websearch_to_tsquery
}
if len(q.Tags) > 0 && !q.NoTagsOnly {
params = append(params, pq.Array(q.Tags))
}
Expand Down
85 changes: 85 additions & 0 deletions app/services/sqlstore/postgres/post_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1140,3 +1140,88 @@ func TestViewReactions_AnonymousUser(t *testing.T) {
Expect(commentByID.Result[0].ReactionCounts[0].Count).Equals(1)
Expect(commentByID.Result[0].ReactionCounts[0].IncludesMe).IsFalse()
}

func TestPostStorage_SearchInResponse_AdminFindsByResponseText(t *testing.T) {
SetupDatabaseTest(t)
defer TeardownDatabaseTest()

newPost := &cmd.AddNewPost{Title: "Add dark mode", Description: "Please support a dark theme"}
err := bus.Dispatch(jonSnowCtx, newPost)
Expect(err).IsNil()

err = bus.Dispatch(jonSnowCtx, &cmd.SetPostResponse{
Post: newPost.Result,
Text: "Shipped in v9.9.9",
Status: enum.PostCompleted,
})
Expect(err).IsNil()

// jonSnow is an Administrator → IsCollaborator() == true
searchVersion := &query.SearchPosts{Query: "v9.9.9", Statuses: []enum.PostStatus{enum.PostCompleted}}
err = bus.Dispatch(jonSnowCtx, searchVersion)
Expect(err).IsNil()
Expect(searchVersion.Result).HasLen(1)
Expect(searchVersion.Result[0].Slug).Equals("add-dark-mode")
}

func TestPostStorage_SearchInResponse_VisitorDoesNotFindByResponseText(t *testing.T) {
SetupDatabaseTest(t)
defer TeardownDatabaseTest()

newPost := &cmd.AddNewPost{Title: "Add dark mode", Description: "Please support a dark theme"}
err := bus.Dispatch(jonSnowCtx, newPost)
Expect(err).IsNil()

err = bus.Dispatch(jonSnowCtx, &cmd.SetPostResponse{
Post: newPost.Result,
Text: "Shipped in v9.9.9",
Status: enum.PostCompleted,
})
Expect(err).IsNil()

// aryaStark is a Visitor → IsCollaborator() == false
searchVersion := &query.SearchPosts{Query: "v9.9.9", Statuses: []enum.PostStatus{enum.PostCompleted}}
err = bus.Dispatch(aryaStarkCtx, searchVersion)
Expect(err).IsNil()
Expect(searchVersion.Result).HasLen(0)
}

func TestPostStorage_SearchInResponse_TitleStillFindableForVisitor(t *testing.T) {
SetupDatabaseTest(t)
defer TeardownDatabaseTest()

newPost := &cmd.AddNewPost{Title: "Kanban board feature", Description: "We need a kanban view"}
err := bus.Dispatch(jonSnowCtx, newPost)
Expect(err).IsNil()

err = bus.Dispatch(jonSnowCtx, &cmd.SetPostResponse{
Post: newPost.Result,
Text: "Shipped in v1.0.0",
Status: enum.PostCompleted,
})
Expect(err).IsNil()

// Visitor searching for term in title must still find the post (regression guard).
searchTitle := &query.SearchPosts{Query: "kanban", Statuses: []enum.PostStatus{enum.PostCompleted}}
err = bus.Dispatch(aryaStarkCtx, searchTitle)
Expect(err).IsNil()
Expect(searchTitle.Result).HasLen(1)
Expect(searchTitle.Result[0].Slug).Equals("kanban-board-feature")
}

func TestPostStorage_SearchInResponse_PostWithoutResponseDoesNotBreakAdminSearch(t *testing.T) {
SetupDatabaseTest(t)
defer TeardownDatabaseTest()

newPost := &cmd.AddNewPost{Title: "Improve onboarding", Description: "Make signup smoother"}
err := bus.Dispatch(jonSnowCtx, newPost)
Expect(err).IsNil()

// No SetPostResponse — search_response column is NULL for this row.
// Admin search must still work and return the post by title match.
searchOnboarding := &query.SearchPosts{Query: "onboarding"}
err = bus.Dispatch(jonSnowCtx, searchOnboarding)
Expect(err).IsNil()
Expect(searchOnboarding.Result).HasLen(1)
Expect(searchOnboarding.Result[0].Slug).Equals("improve-onboarding")
}
16 changes: 16 additions & 0 deletions migrations/202605061200_add_response_search_column.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Add a tsvector column derived from posts.response so that collaborators/administrators
-- can search across status responses (e.g. "wpada w v2.3.1").
-- Mirrors the structure of posts.search (migration 202601191200) but operates on response.

ALTER TABLE posts ADD search_response tsvector GENERATED ALWAYS AS (
CASE
WHEN response IS NULL THEN NULL
WHEN language <> 'simple' THEN
setweight(to_tsvector(map_language_to_tsvector(language), response), 'A') ||
setweight(to_tsvector('simple'::regconfig, response), 'B')
ELSE
setweight(to_tsvector('simple'::regconfig, response), 'A')
END
) STORED;

CREATE INDEX idx_posts_search_response_gin ON posts USING GIN (search_response);
Loading