From 9b8c7d48a7b3686e9d7e786f12c83d3ee274ffce Mon Sep 17 00:00:00 2001 From: Adam Kaput Date: Wed, 6 May 2026 13:44:29 +0200 Subject: [PATCH 1/3] feat(search): add tsvector column for post response --- .../202605061200_add_response_search_column.sql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 migrations/202605061200_add_response_search_column.sql diff --git a/migrations/202605061200_add_response_search_column.sql b/migrations/202605061200_add_response_search_column.sql new file mode 100644 index 000000000..1f907d331 --- /dev/null +++ b/migrations/202605061200_add_response_search_column.sql @@ -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); From 225b00ca61f4db967d9b25ed6917cc000a8ab7ff Mon Sep 17 00:00:00 2001 From: Adam Kaput Date: Wed, 6 May 2026 13:48:24 +0200 Subject: [PATCH 2/3] feat(search): select search_response column in post queries --- app/services/sqlstore/dbEntities/post.go | 1 + app/services/sqlstore/postgres/post.go | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/services/sqlstore/dbEntities/post.go b/app/services/sqlstore/dbEntities/post.go index b600f42dd..2a6518022 100644 --- a/app/services/sqlstore/dbEntities/post.go +++ b/app/services/sqlstore/dbEntities/post.go @@ -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"` diff --git a/app/services/sqlstore/postgres/post.go b/app/services/sqlstore/postgres/post.go index e88588860..f85ec6642 100644 --- a/app/services/sqlstore/postgres/post.go +++ b/app/services/sqlstore/postgres/post.go @@ -1,4 +1,4 @@ -package postgres +package postgres import ( "context" @@ -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, From b850cf00f0814b31163746dc8ed0b8b2b050aa3b Mon Sep 17 00:00:00 2001 From: Adam Kaput Date: Wed, 6 May 2026 14:01:40 +0200 Subject: [PATCH 3/3] feat(search): include response text in search for collaborators --- app/services/sqlstore/postgres/post.go | 30 +++++++- app/services/sqlstore/postgres/post_test.go | 85 +++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/app/services/sqlstore/postgres/post.go b/app/services/sqlstore/postgres/post.go index f85ec6642..3e1f3ff18 100644 --- a/app/services/sqlstore/postgres/post.go +++ b/app/services/sqlstore/postgres/post.go @@ -450,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) @@ -467,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)) } diff --git a/app/services/sqlstore/postgres/post_test.go b/app/services/sqlstore/postgres/post_test.go index 61d0658e4..6bee01812 100644 --- a/app/services/sqlstore/postgres/post_test.go +++ b/app/services/sqlstore/postgres/post_test.go @@ -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") +}