diff --git a/app/app.go b/app/app.go index 8bf1206..58462ca 100644 --- a/app/app.go +++ b/app/app.go @@ -355,3 +355,12 @@ func IsUniqueViolation(err error) (column string, ok bool) { return pgErr.ColumnName, true } + +// Value dereferences a pointer and returns the value, or zero value if nil. +func Value[T any](p *T) (v T) { + if p == nil { + return + } + + return *p +} diff --git a/app/ds/book.go b/app/ds/book.go index 6f77517..7549728 100644 --- a/app/ds/book.go +++ b/app/ds/book.go @@ -117,6 +117,8 @@ func BookFromContext(ctx context.Context) *Book { // BooksFilter is used to filter and paginate user queries. type BooksFilter struct { EntitiesFilter + + Author string } // BookAuthor represents an author of a book. diff --git a/app/ds/ds.go b/app/ds/ds.go index af5f4eb..4fc1e64 100644 --- a/app/ds/ds.go +++ b/app/ds/ds.go @@ -72,11 +72,12 @@ func DtBetween(from, to time.Time) *FilterDT { // FilterString defines options for filtering string-type data. type FilterString struct { - NotNull *bool - NotEmpty *bool - ExactMatch *string - Contains *string - NotContains *string - StartsWith *string - EndsWith *string + Null *bool + Empty *bool + Equal *string + Contains *string + StartsWith *string + EndsWith *string + Not bool + CaseSensitive bool } diff --git a/app/ds/topic.go b/app/ds/topic.go index a0e0aed..fa97786 100644 --- a/app/ds/topic.go +++ b/app/ds/topic.go @@ -28,6 +28,7 @@ type TopicsFilter struct { Page int PerPage int Type EntityType + Name *FilterString PublicIDs []string WithCount bool OrderBy string diff --git a/app/repo/book_repo.go b/app/repo/book_repo.go index ca51e5a..e9d5423 100644 --- a/app/repo/book_repo.go +++ b/app/repo/book_repo.go @@ -128,6 +128,11 @@ func (r *Repo) FilterBooks(ctx context.Context, f ds.BooksFilter) (books []ds.Bo )` } + var whereAuthor string + if f.Author != "" { + whereAuthor = `EXISTS (SELECT 1 FROM jsonb_to_recordset(b.authors) AS a(name text) WHERE a.name = ?)` + } + count, err = r.filter("entities e", "e"). columns(` e.id AS id, @@ -152,6 +157,8 @@ func (r *Repo) FilterBooks(ctx context.Context, f ds.BooksFilter) (books []ds.Bo join("LEFT JOIN users u ON e.owner_id = u.id"). where("e.type", ds.EntityTypeBook). whereRaw(whereTopics, f.Topics). + whereRaw(whereAuthor, f.Author). + filterString("e.title", f.Title). paginate(f.Page, f.PerPage). createdAt(f.CreatedAt). deletedAt(f.DeletedAt). @@ -164,6 +171,11 @@ func (r *Repo) FilterBooks(ctx context.Context, f ds.BooksFilter) (books []ds.Bo withCount(f.WithCount). scan(ctx, &books) + if err != nil { + err = fmt.Errorf("filter books: %w", err) + return + } + // topics if len(books) > 0 { ids := make([]ds.ID, len(books)) @@ -196,3 +208,30 @@ func (r *Repo) FilterBooks(ctx context.Context, f ds.BooksFilter) (books []ds.Bo return } + +// SearchBookAuthors searches book authors by name. +func (r *Repo) SearchBookAuthors(ctx context.Context, query string) ([]ds.BookAuthor, error) { + _, span := r.tracer.Start(ctx, "SearchBookAuthors") + defer span.End() + + sql := ` + SELECT DISTINCT a.name + FROM entities e + JOIN books b ON b.id = e.id + JOIN LATERAL jsonb_to_recordset(b.authors) AS a(name text) ON true + WHERE e.type = 'book' + AND e.status = 'approved' + AND e.visibility = 'public' + AND e.deleted_at IS NULL + AND a.name ILIKE $1 + LIMIT 25 + ` + + var rows []ds.BookAuthor + err := pgxscan.Select(ctx, r.db, &rows, sql, "%"+query+"%") + if err != nil { + return nil, fmt.Errorf("search book authors: %w", err) + } + + return rows, nil +} diff --git a/app/repo/repo.go b/app/repo/repo.go index 0238e2c..2958807 100644 --- a/app/repo/repo.go +++ b/app/repo/repo.go @@ -343,6 +343,61 @@ func (b *filterBuilder) withCount(ok bool) *filterBuilder { return b } +func (b *filterBuilder) filterString(col string, f *ds.FilterString) *filterBuilder { + if f == nil { + return b + } + + if app.Value(f.Null) { + if f.Not { + b.qb = b.qb.Where(col + " IS NOT NULL") + } else { + b.qb = b.qb.Where(col + " IS NULL") + } + } + + if app.Value(f.Empty) { + if f.Not { + b.qb = b.qb.Where(col + " != ''") + } else { + b.qb = b.qb.Where(col + " = ''") + } + } + + if v := app.Value(f.Equal); v != "" { + if f.Not { + b.qb = b.qb.Where(col+" != ?", v) + } else { + b.qb = b.qb.Where(col+" = ?", v) + } + } + + likeOP := "ILIKE" + if f.CaseSensitive { + likeOP = "LIKE" + } + if f.Not { + likeOP = "NOT " + likeOP + } + + if v := app.Value(f.Contains); v != "" { + v = "%" + v + "%" + b.qb = b.qb.Where(col+" "+likeOP+" ?", v) + } + + if v := app.Value(f.StartsWith); v != "" { + v += "%" + b.qb = b.qb.Where(col+" "+likeOP+" ?", v) + } + + if v := app.Value(f.EndsWith); v != "" { + v = "%" + v + b.qb = b.qb.Where(col+" "+likeOP+" ?", v) + } + + return b +} + func (b *filterBuilder) sql() (sql string, args []any, err error) { lb := *b if !lb.columnsSet { diff --git a/app/repo/topic_repo.go b/app/repo/topic_repo.go index b290298..726120e 100644 --- a/app/repo/topic_repo.go +++ b/app/repo/topic_repo.go @@ -2,6 +2,7 @@ package repo import ( "context" + "fmt" "github.com/georgysavva/scany/v2/pgxscan" "github.com/gopl-dev/server/app" @@ -20,17 +21,21 @@ func (r *Repo) FilterTopics(ctx context.Context, f ds.TopicsFilter) (data []ds.T if f.OrderBy == "" { f.OrderBy = "name" - f.OrderDirection = "asc" + f.OrderDirection = "ASC" } count, err = r.filter("topics"). columns(`*`). where("type", f.Type). + filterString("name", f.Name). paginate(f.Page, f.PerPage). order(f.OrderBy, f.OrderDirection). withCount(f.WithCount). apply(whereIn("public_id", f.PublicIDs)). scan(ctx, &data) + if err != nil { + err = fmt.Errorf("filter topics: %w", err) + } return } diff --git a/app/service/book_service.go b/app/service/book_service.go index 031ecb9..18e0d32 100644 --- a/app/service/book_service.go +++ b/app/service/book_service.go @@ -12,6 +12,7 @@ import ( "github.com/gopl-dev/server/app/ds/prop" "github.com/gopl-dev/server/app/repo" "github.com/gopl-dev/server/email" + "golang.org/x/sync/errgroup" ) var ( @@ -588,3 +589,98 @@ func (s *Service) GetBookByRef(ctx context.Context, ref any) (*ds.Book, error) { return nil, ErrInvalidRefID } + +// SearchBookType represents the type of a search result. +type SearchBookType string + +const ( + // SearchBookTypeBook is a book result. + SearchBookTypeBook SearchBookType = "book" + // SearchBookTypeAuthor is an author result. + SearchBookTypeAuthor SearchBookType = "author" + // SearchBookTypeTopic is a topic result. + SearchBookTypeTopic SearchBookType = "topic" +) + +// SearchBooksResult represents a single search result item. +type SearchBooksResult struct { + Type SearchBookType `json:"type"` + Name string `json:"name"` + URL string `json:"url"` +} + +// SearchBooks searches books, books authors and books topics by query string. +func (s *Service) SearchBooks(ctx context.Context, query string) ([]SearchBooksResult, error) { + ctx, span := s.tracer.Start(ctx, "SearchBooks") + defer span.End() + + if query == "" { + return []SearchBooksResult{}, nil + } + + var ( + books []ds.Book + authors []ds.BookAuthor + topics []ds.Topic + ) + + g, ctx := errgroup.WithContext(ctx) + + g.Go(func() (err error) { + books, _, err = s.db.FilterBooks(ctx, ds.BooksFilter{EntitiesFilter: ds.EntitiesFilter{ + PerPage: 25, //nolint:mnd + WithCount: false, + Title: &ds.FilterString{Contains: &query}, + Visibility: []ds.EntityVisibility{ds.EntityVisibilityPublic}, + Status: []ds.EntityStatus{ds.EntityStatusApproved}, + }}) + return + }) + + g.Go(func() (err error) { + authors, err = s.db.SearchBookAuthors(ctx, query) + return + }) + + g.Go(func() (err error) { + topics, _, err = s.db.FilterTopics(ctx, ds.TopicsFilter{ + PerPage: 25, //nolint:mnd + Type: ds.EntityTypeBook, + Name: &ds.FilterString{Contains: &query}, + }) + return + }) + + err := g.Wait() + if err != nil { + return nil, err + } + + results := make([]SearchBooksResult, 0, len(books)+len(authors)+len(topics)) + + for _, r := range books { + results = append(results, SearchBooksResult{ + Type: SearchBookTypeBook, + Name: r.Title, + URL: "/books/" + r.PublicID + "/", + }) + } + + for _, r := range authors { + results = append(results, SearchBooksResult{ + Type: SearchBookTypeAuthor, + Name: r.Name, + URL: "/books/?author=" + r.Name, + }) + } + + for _, r := range topics { + results = append(results, SearchBooksResult{ + Type: SearchBookTypeTopic, + Name: r.Name, + URL: "/books/?topics=" + r.PublicID, + }) + } + + return results, nil +} diff --git a/frontend/page/filter_books.templ b/frontend/page/filter_books.templ index e397e5f..b307049 100644 --- a/frontend/page/filter_books.templ +++ b/frontend/page/filter_books.templ @@ -15,10 +15,17 @@ templ FilterBooksPage() { topics: [], loadedOnce: false, + search: '', + searchResults: [], + searchLoading: false, + searchOpen: false, + searchDebounce: null, + filters: { page: 1, per_page: 10, topic_ids: [], + author: "", }, coverURL(fileID) { @@ -41,6 +48,9 @@ templ FilterBooksPage() { const topicIDs = url.searchParams.getAll('topics') this.filters.topic_ids = topicIDs ?? [] + + const author = url.searchParams.get('author') + this.filters.author = author ?? "" }, writeToURL() { @@ -65,6 +75,41 @@ templ FilterBooksPage() { this.load({ syncURL: false, scrollTop: true }) }, + // ---------- search ---------- + + async onSearchInput() { + clearTimeout(this.searchDebounce) + if (this.search.length < 3) { + this.searchResults = [] + this.searchOpen = false + return + } + this.searchDebounce = setTimeout(async () => { + this.searchLoading = true + try { + const { resp, data } = await HTTP.requestJSON( + '/api/books/search/?search=' + encodeURIComponent(this.search) + ) + if (resp.status === 200) { + this.searchResults = data ?? [] + this.searchOpen = this.searchResults.length > 0 + } + } catch (e) { + console.error(e) + } finally { + this.searchLoading = false + } + }, 300) + }, + + onSearchSelect(result) { + window.location.href = result.url + }, + + closeSearch() { + this.searchOpen = false + }, + // ---------- pagination ui ---------- scrollToTop() { @@ -107,6 +152,7 @@ templ FilterBooksPage() { const qs = new URLSearchParams({ page: String(this.filters.page), per_page: String(this.filters.per_page), + author: this.filters.author, }) for (const id of (this.filters.topic_ids ?? [])) { @@ -185,12 +231,55 @@ templ FilterBooksPage() {
-
-

Books

- Add book +
+

Books

+ +
+
+ + + + + +
+ + Add book +
-