+
@@ -48,10 +48,12 @@
:results="results"
:term="term"
:active-tab="activeTab"
+ :sorts="sorts"
:show-clear-filters="hasActiveFilters(filters)"
:aria-busy="loading"
:class="{ 'pointer-events-none opacity-60': loading }"
@update:active-tab="selectTab"
+ @change-sort="changeSort"
@change-page="changePage"
@clear-filters="filters = emptyFilters()"
/>
@@ -100,17 +102,22 @@ const router = useRouter()
const emptyPage = () => ({
results: [],
- total: 0,
page: 1,
per_page: DEFAULT_PER_PAGE,
- total_pages: 0
+ has_more: false,
+ next_cursor: ''
})
const emptyResults = () => ({ conversations: emptyPage(), messages: emptyPage() })
+const emptyCursorHistory = () => ({
+ conversations: new Map([[1, '']]),
+ messages: new Map([[1, '']])
+})
const inputRef = ref(null)
const term = ref(String(route.query.q || ''))
const filters = ref(filtersFromQuery(route.query))
const activeTab = ref(TABS.includes(route.query.tab) ? route.query.tab : 'conversations')
+const sorts = ref({ conversations: 'newest', messages: 'newest' })
const tabSelectedByUser = ref(TABS.includes(route.query.tab))
const results = ref(emptyResults())
const loading = ref(false)
@@ -118,13 +125,15 @@ const error = ref(null)
const searchPerformed = ref(false)
let debounceTimer = null
let searchRequestId = 0
+let cursorsByPage = emptyCursorHistory()
-const totalResults = computed(
- () => results.value.conversations.total + results.value.messages.total
+const resultCount = computed(
+ () => results.value.conversations.results.length + results.value.messages.results.length
)
-const searchParams = (page, perPage) => {
- const params = { query: term.value, page, page_size: perPage }
+const searchParams = (type, perPage, cursor = '') => {
+ const params = { query: term.value, page_size: perPage, sort: sorts.value[type] }
+ if (cursor) params.cursor = cursor
const filtersJSON = toFiltersJSON(filters.value)
if (filtersJSON) params.filters = filtersJSON
return params
@@ -138,6 +147,7 @@ const fetchers = {
const reset = () => {
searchRequestId++
results.value = emptyResults()
+ cursorsByPage = emptyCursorHistory()
searchPerformed.value = false
loading.value = false
error.value = null
@@ -155,13 +165,21 @@ const search = async () => {
const requestId = ++searchRequestId
try {
const perPage = Object.fromEntries(TABS.map((type) => [type, results.value[type].per_page]))
- const pages = await Promise.all(
- TABS.map((type) => fetchers[type](searchParams(1, perPage[type])))
+ const responses = await Promise.all(
+ TABS.map((type) => fetchers[type](searchParams(type, perPage[type])))
)
if (requestId !== searchRequestId) return
- results.value = Object.fromEntries(TABS.map((type, i) => [type, pages[i].data.data]))
- if (!tabSelectedByUser.value && results.value[activeTab.value].total === 0) {
- const populatedTab = TABS.find((type) => results.value[type].total > 0)
+ cursorsByPage = emptyCursorHistory()
+ results.value = Object.fromEntries(
+ TABS.map((type, i) => [type, { ...responses[i].data.data, page: 1 }])
+ )
+ for (const type of TABS) {
+ if (results.value[type].has_more) {
+ cursorsByPage[type].set(2, results.value[type].next_cursor)
+ }
+ }
+ if (!tabSelectedByUser.value && results.value[activeTab.value].results.length === 0) {
+ const populatedTab = TABS.find((type) => results.value[type].results.length > 0)
if (populatedTab) activeTab.value = populatedTab
}
} catch (err) {
@@ -173,13 +191,25 @@ const search = async () => {
}
const fetchPage = async (type, page, perPage) => {
+ if (perPage !== results.value[type].per_page) {
+ cursorsByPage[type] = new Map([[1, '']])
+ }
+ const cursor = cursorsByPage[type].get(page)
+ if (cursor === undefined) return
+
loading.value = true
error.value = null
const requestId = ++searchRequestId
try {
- const response = await fetchers[type](searchParams(page, perPage))
+ const response = await fetchers[type](searchParams(type, perPage, cursor))
if (requestId !== searchRequestId) return
- results.value[type] = response.data.data
+ results.value[type] = { ...response.data.data, page }
+ if (response.data.data.has_more) {
+ cursorsByPage[type].set(page + 1, response.data.data.next_cursor)
+ }
+ for (const knownPage of cursorsByPage[type].keys()) {
+ if (knownPage > page + 1) cursorsByPage[type].delete(knownPage)
+ }
} catch (err) {
if (requestId !== searchRequestId) return
error.value = handleHTTPError(err).message
@@ -190,6 +220,13 @@ const fetchPage = async (type, page, perPage) => {
const changePage = ({ type, page, perPage }) => fetchPage(type, page, perPage)
+const changeSort = ({ type, sort }) => {
+ if (sorts.value[type] === sort) return
+ sorts.value = { ...sorts.value, [type]: sort }
+ cursorsByPage[type] = new Map([[1, '']])
+ fetchPage(type, 1, results.value[type].per_page)
+}
+
const selectTab = (tab) => {
tabSelectedByUser.value = true
activeTab.value = tab
diff --git a/i18n/en-US.json b/i18n/en-US.json
index 93919bf7..da9443a6 100644
--- a/i18n/en-US.json
+++ b/i18n/en-US.json
@@ -1494,7 +1494,7 @@
"role.select": "Select roles",
"search.adjustSearchTerms": "Try adjusting your search terms or filters.",
"search.minQueryLength": "Please enter at least {length} characters to search.",
- "search.searchBy": "Search by reference number, contact email or message text",
+ "search.searchBy": "Search by reference number, contact email address or messages in conversations.",
"setup.completeYourSetup": "Complete your setup",
"setup.createFirstInbox": "Create your first inbox",
"setup.inviteTeammates": "Invite teammates",
diff --git a/internal/search/models/models.go b/internal/search/models/models.go
index b5832b45..ff18573a 100644
--- a/internal/search/models/models.go
+++ b/internal/search/models/models.go
@@ -7,6 +7,15 @@ import (
"github.com/volatiletech/null/v9"
)
+const (
+ SortNewest Sort = "newest"
+ SortOldest Sort = "oldest"
+ SortStartedFirst Sort = "started_first"
+ SortStartedLast Sort = "started_last"
+)
+
+type Sort string
+
// ReadScope carries an agent's conversation read permissions for filtering search results.
type ReadScope struct {
UserID int
@@ -19,11 +28,11 @@ type ReadScope struct {
ReadUnassigned bool
}
-// Query is a search term plus the list filters and page the caller wants applied.
type Query struct {
Term string
Filters string
- Page int
+ Cursor string
+ Sort Sort
PageSize int
}
@@ -46,7 +55,8 @@ type Sender struct {
}
type ConversationResult struct {
- Total int `db:"total" json:"-"`
+ ID int `db:"id" json:"-"`
+ ReferenceMatch bool `db:"reference_match" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UUID string `db:"uuid" json:"uuid"`
ReferenceNumber string `db:"reference_number" json:"reference_number"`
@@ -66,7 +76,7 @@ type ConversationResult struct {
}
type MessageResult struct {
- Total int `db:"total" json:"-"`
+ ID int `db:"id" json:"-"`
UUID string `db:"uuid" json:"uuid"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
Type string `db:"type" json:"type"`
diff --git a/internal/search/queries.sql b/internal/search/queries.sql
index e9656d42..e055d833 100644
--- a/internal/search/queries.sql
+++ b/internal/search/queries.sql
@@ -12,7 +12,8 @@ WITH matched_conversations AS (
WHERE users.email ILIKE $10 ESCAPE '\'
)
SELECT
- COUNT(*) OVER() AS total,
+ conversations.id,
+ conversations.reference_number = $1 AS reference_match,
conversations.created_at,
conversations.uuid,
conversations.reference_number,
@@ -56,7 +57,7 @@ WHERE conversations.id IN (SELECT id FROM matched_conversations)
-- name: search-messages
SELECT
- COUNT(*) OVER() AS total,
+ conversation_messages.id,
conversation_messages.uuid,
conversation_messages.created_at,
conversation_messages.type,
diff --git a/internal/search/search.go b/internal/search/search.go
index d219c67a..13e3f2d5 100644
--- a/internal/search/search.go
+++ b/internal/search/search.go
@@ -3,7 +3,10 @@ package search
import (
"embed"
+ "encoding/base64"
+ "encoding/json"
"fmt"
+ "time"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
@@ -11,6 +14,7 @@ import (
"github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n"
"github.com/lib/pq"
+ "github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
@@ -18,9 +22,6 @@ const (
maxPageSize = 100
maxConversationFirstPageSize = 1000
maxMessageFirstPageSize = 30
-
- conversationResultOrder = "(conversations.reference_number = $1) DESC, conversations.last_message_at DESC NULLS LAST"
- messageResultOrder = "conversation_messages.created_at DESC NULLS LAST"
)
var (
@@ -30,6 +31,21 @@ var (
messageAllowedFields = []string{"created_at"}
)
+type cursorCondition func(string, models.Sort, int) (string, []any, error)
+
+type conversationCursor struct {
+ ReferenceMatch bool `json:"reference_match"`
+ Sort models.Sort `json:"sort"`
+ SortAt *time.Time `json:"sort_at"`
+ ID int `json:"id"`
+}
+
+type messageCursor struct {
+ Sort models.Sort `json:"sort"`
+ CreatedAt time.Time `json:"created_at"`
+ ID int `json:"id"`
+}
+
// Manager is the search manager
type Manager struct {
q queries
@@ -75,23 +91,21 @@ func New(opts Opts) (*Manager, error) {
}, nil
}
-// Conversations searches conversations the agent is allowed to read, returning the page and the total match count.
-func (s *Manager) Conversations(query models.Query, scope models.ReadScope) ([]models.ConversationResult, int, error) {
+func (s *Manager) Conversations(query models.Query, scope models.ReadScope) ([]models.ConversationResult, bool, string, error) {
return s.searchConversations(query, scope, maxPageSize)
}
func (s *Manager) ConversationFirstPage(term string, scope models.ReadScope, limit int) ([]models.ConversationResult, error) {
- results, _, err := s.searchConversations(models.Query{Term: term, Page: 1, PageSize: limit}, scope, maxConversationFirstPageSize)
+ results, _, _, err := s.searchConversations(models.Query{Term: term, PageSize: limit}, scope, maxConversationFirstPageSize)
return results, err
}
-// Messages searches messages in conversations the agent is allowed to read, returning the page and the total match count.
-func (s *Manager) Messages(query models.Query, scope models.ReadScope) ([]models.MessageResult, int, error) {
+func (s *Manager) Messages(query models.Query, scope models.ReadScope) ([]models.MessageResult, bool, string, error) {
return s.searchMessages(query, scope, maxPageSize)
}
func (s *Manager) MessageFirstPage(term string, scope models.ReadScope, limit int) ([]models.MessageResult, error) {
- results, _, err := s.searchMessages(models.Query{Term: term, Page: 1, PageSize: limit}, scope, maxMessageFirstPageSize)
+ results, _, _, err := s.searchMessages(models.Query{Term: term, PageSize: limit}, scope, maxMessageFirstPageSize)
return results, err
}
@@ -109,59 +123,74 @@ func NormalizeQuery(query models.Query) models.Query {
return normalizeQuery(query, maxPageSize)
}
-func (s *Manager) searchConversations(query models.Query, scope models.ReadScope, pageSizeLimit int) ([]models.ConversationResult, int, error) {
- sql, args, err := s.buildQuery(s.q.SearchConversations, query, scope, conversationResultOrder, s.filterFields, pageSizeLimit)
+func (s *Manager) searchConversations(query models.Query, scope models.ReadScope, pageSizeLimit int) ([]models.ConversationResult, bool, string, error) {
+ query = normalizeQuery(query, pageSizeLimit)
+ orderBy, err := conversationResultOrder(query.Sort)
if err != nil {
- return nil, 0, err
+ return nil, false, "", envelope.NewError(envelope.InputError, s.i18n.T("globals.messages.invalidFilters"), nil)
+ }
+ sql, args, err := s.buildQuery(s.q.SearchConversations, query, scope, orderBy, s.filterFields, conversationCursorCondition)
+ if err != nil {
+ return nil, false, "", err
}
var results = make([]models.ConversationResult, 0)
if err := s.db.Select(&results, sql, args...); err != nil {
s.lo.Error("error searching conversations", "error", err)
- return nil, 0, envelope.NewError(envelope.GeneralError, s.i18n.T("globals.messages.somethingWentWrong"), nil)
+ return nil, false, "", envelope.NewError(envelope.GeneralError, s.i18n.T("globals.messages.somethingWentWrong"), nil)
}
- total := 0
- if len(results) > 0 {
- total = results[0].Total
+ results, hasMore := trimResults(results, query.PageSize)
+ if !hasMore {
+ return results, false, "", nil
}
- return results, total, nil
+ nextCursor, err := encodeCursor(conversationCursor{
+ ReferenceMatch: results[len(results)-1].ReferenceMatch,
+ Sort: query.Sort,
+ SortAt: conversationSortAt(results[len(results)-1], query.Sort),
+ ID: results[len(results)-1].ID,
+ })
+ if err != nil {
+ s.lo.Error("error encoding conversation search cursor", "error", err)
+ return nil, false, "", envelope.NewError(envelope.GeneralError, s.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+ return results, true, nextCursor, nil
}
-func (s *Manager) searchMessages(query models.Query, scope models.ReadScope, pageSizeLimit int) ([]models.MessageResult, int, error) {
+func (s *Manager) searchMessages(query models.Query, scope models.ReadScope, pageSizeLimit int) ([]models.MessageResult, bool, string, error) {
+ query = normalizeQuery(query, pageSizeLimit)
+ orderBy, err := messageResultOrder(query.Sort)
+ if err != nil {
+ return nil, false, "", envelope.NewError(envelope.InputError, s.i18n.T("globals.messages.invalidFilters"), nil)
+ }
fields := dbutil.AllowedFields{"conversation_messages": messageAllowedFields}
for model, f := range s.filterFields {
fields[model] = f
}
- sql, args, err := s.buildQuery(s.q.SearchMessages, query, scope, messageResultOrder, fields, pageSizeLimit)
+ sql, args, err := s.buildQuery(s.q.SearchMessages, query, scope, orderBy, fields, messageCursorCondition)
if err != nil {
- return nil, 0, err
+ return nil, false, "", err
}
var results = make([]models.MessageResult, 0)
if err := s.db.Select(&results, sql, args...); err != nil {
s.lo.Error("error searching messages", "error", err)
- return nil, 0, envelope.NewError(envelope.GeneralError, s.i18n.T("globals.messages.somethingWentWrong"), nil)
+ return nil, false, "", envelope.NewError(envelope.GeneralError, s.i18n.T("globals.messages.somethingWentWrong"), nil)
}
- total := 0
- if len(results) > 0 {
- total = results[0].Total
+ results, hasMore := trimResults(results, query.PageSize)
+ if !hasMore {
+ return results, false, "", nil
}
- return results, total, nil
+ nextCursor, err := encodeCursor(messageCursor{
+ Sort: query.Sort,
+ CreatedAt: results[len(results)-1].CreatedAt,
+ ID: results[len(results)-1].ID,
+ })
+ if err != nil {
+ s.lo.Error("error encoding message search cursor", "error", err)
+ return nil, false, "", envelope.NewError(envelope.GeneralError, s.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+ return results, true, nextCursor, nil
}
-func normalizeQuery(query models.Query, pageSizeLimit int) models.Query {
- if query.Page < 1 {
- query.Page = 1
- }
- if query.PageSize < 1 || query.PageSize > pageSizeLimit {
- query.PageSize = pageSizeLimit
- }
- if query.Filters == "" {
- query.Filters = "[]"
- }
- return query
-}
-
-func (s *Manager) buildQuery(base string, query models.Query, scope models.ReadScope, orderBy string, fields dbutil.AllowedFields, pageSizeLimit int) (string, []any, error) {
- query = normalizeQuery(query, pageSizeLimit)
+func (s *Manager) buildQuery(base string, query models.Query, scope models.ReadScope, orderBy string, fields dbutil.AllowedFields, cursorWhere cursorCondition) (string, []any, error) {
baseArgs := append([]any{query.Term}, scopeArgs(scope)...)
baseArgs = append(baseArgs, dbutil.ContainsPattern(query.Term))
sql, args, err := dbutil.BuildFilterQuery(base, baseArgs, query.Filters, fields, s.filterRenderers, s.filterLocation())
@@ -169,12 +198,168 @@ func (s *Manager) buildQuery(base string, query models.Query, scope models.ReadS
s.lo.Error("error building search query", "error", err)
return "", nil, envelope.NewError(envelope.InputError, s.i18n.T("globals.messages.invalidFilters"), nil)
}
+ if query.Cursor != "" {
+ condition, cursorArgs, err := cursorWhere(query.Cursor, query.Sort, len(args)+1)
+ if err != nil {
+ s.lo.Error("error decoding search cursor", "error", err)
+ return "", nil, envelope.NewError(envelope.InputError, s.i18n.T("globals.messages.invalidFilters"), nil)
+ }
+ sql += " AND " + condition
+ args = append(args, cursorArgs...)
+ }
sql += " ORDER BY " + orderBy
- sql += fmt.Sprintf(" LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2)
- args = append(args, query.PageSize, dbutil.PageOffset(query.Page, query.PageSize))
+ sql += fmt.Sprintf(" LIMIT $%d", len(args)+1)
+ args = append(args, query.PageSize+1)
return sql, args, nil
}
+func normalizeQuery(query models.Query, pageSizeLimit int) models.Query {
+ if query.PageSize < 1 || query.PageSize > pageSizeLimit {
+ query.PageSize = pageSizeLimit
+ }
+ if query.Filters == "" {
+ query.Filters = "[]"
+ }
+ if query.Sort == "" {
+ query.Sort = models.SortNewest
+ }
+ return query
+}
+
+func conversationResultOrder(sort models.Sort) (string, error) {
+ switch sort {
+ case models.SortNewest:
+ return "(conversations.reference_number = $1) DESC, conversations.last_message_at DESC NULLS LAST, conversations.id DESC", nil
+ case models.SortOldest:
+ return "(conversations.reference_number = $1) DESC, conversations.last_message_at ASC NULLS LAST, conversations.id ASC", nil
+ case models.SortStartedFirst:
+ return "(conversations.reference_number = $1) DESC, conversations.created_at ASC, conversations.id ASC", nil
+ case models.SortStartedLast:
+ return "(conversations.reference_number = $1) DESC, conversations.created_at DESC, conversations.id DESC", nil
+ default:
+ return "", fmt.Errorf("invalid conversation sort: %q", sort)
+ }
+}
+
+func messageResultOrder(sort models.Sort) (string, error) {
+ switch sort {
+ case models.SortNewest:
+ return "conversation_messages.created_at DESC, conversation_messages.id DESC", nil
+ case models.SortOldest:
+ return "conversation_messages.created_at ASC, conversation_messages.id ASC", nil
+ default:
+ return "", fmt.Errorf("invalid message sort: %q", sort)
+ }
+}
+
+func conversationCursorCondition(raw string, sort models.Sort, firstPlaceholder int) (string, []any, error) {
+ var cursor conversationCursor
+ if err := decodeCursor(raw, &cursor); err != nil || cursor.ID < 1 || cursor.Sort != sort {
+ return "", nil, fmt.Errorf("invalid conversation cursor")
+ }
+
+ switch sort {
+ case models.SortNewest:
+ condition := fmt.Sprintf(
+ "((conversations.reference_number = $1), (conversations.last_message_at IS NOT NULL), COALESCE(conversations.last_message_at, '-infinity'::timestamptz), conversations.id) < ($%d::boolean, $%d::boolean, COALESCE($%d::timestamptz, '-infinity'::timestamptz), $%d)",
+ firstPlaceholder,
+ firstPlaceholder+1,
+ firstPlaceholder+2,
+ firstPlaceholder+3,
+ )
+ return condition, []any{cursor.ReferenceMatch, cursor.SortAt != nil, cursor.SortAt, cursor.ID}, nil
+ case models.SortOldest:
+ condition := fmt.Sprintf(
+ "((conversations.reference_number != $1), (conversations.last_message_at IS NULL), COALESCE(conversations.last_message_at, 'infinity'::timestamptz), conversations.id) > ($%d::boolean, $%d::boolean, COALESCE($%d::timestamptz, 'infinity'::timestamptz), $%d)",
+ firstPlaceholder,
+ firstPlaceholder+1,
+ firstPlaceholder+2,
+ firstPlaceholder+3,
+ )
+ return condition, []any{!cursor.ReferenceMatch, cursor.SortAt == nil, cursor.SortAt, cursor.ID}, nil
+ case models.SortStartedFirst, models.SortStartedLast:
+ if cursor.SortAt == nil {
+ return "", nil, fmt.Errorf("invalid conversation cursor")
+ }
+ operator := ">"
+ referenceExpression := "(conversations.reference_number != $1)"
+ referenceValue := !cursor.ReferenceMatch
+ if sort == models.SortStartedLast {
+ operator = "<"
+ referenceExpression = "(conversations.reference_number = $1)"
+ referenceValue = cursor.ReferenceMatch
+ }
+ condition := fmt.Sprintf(
+ "(%s, conversations.created_at, conversations.id) %s ($%d::boolean, $%d::timestamptz, $%d)",
+ referenceExpression,
+ operator,
+ firstPlaceholder,
+ firstPlaceholder+1,
+ firstPlaceholder+2,
+ )
+ return condition, []any{referenceValue, cursor.SortAt, cursor.ID}, nil
+ default:
+ return "", nil, fmt.Errorf("invalid conversation cursor")
+ }
+}
+
+func messageCursorCondition(raw string, sort models.Sort, firstPlaceholder int) (string, []any, error) {
+ var cursor messageCursor
+ if err := decodeCursor(raw, &cursor); err != nil || cursor.ID < 1 || cursor.CreatedAt.IsZero() || cursor.Sort != sort {
+ return "", nil, fmt.Errorf("invalid message cursor")
+ }
+ operator := "<"
+ if sort == models.SortOldest {
+ operator = ">"
+ } else if sort != models.SortNewest {
+ return "", nil, fmt.Errorf("invalid message cursor")
+ }
+ condition := fmt.Sprintf(
+ "(conversation_messages.created_at, conversation_messages.id) %s ($%d::timestamptz, $%d)",
+ operator,
+ firstPlaceholder,
+ firstPlaceholder+1,
+ )
+ return condition, []any{cursor.CreatedAt, cursor.ID}, nil
+}
+
+func encodeCursor(cursor any) (string, error) {
+ data, err := json.Marshal(cursor)
+ if err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(data), nil
+}
+
+func decodeCursor(raw string, cursor any) error {
+ data, err := base64.RawURLEncoding.DecodeString(raw)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(data, cursor)
+}
+
+func nullTimePointer(value null.Time) *time.Time {
+ if !value.Valid {
+ return nil
+ }
+ return &value.Time
+}
+
+func conversationSortAt(result models.ConversationResult, sort models.Sort) *time.Time {
+ if sort == models.SortStartedFirst || sort == models.SortStartedLast {
+ return &result.CreatedAt
+ }
+ return nullTimePointer(result.LastMessageAt)
+}
+
+func trimResults[T any](results []T, pageSize int) ([]T, bool) {
+ if len(results) <= pageSize {
+ return results, false
+ }
+ return results[:pageSize], true
+}
+
func scopeArgs(scope models.ReadScope) []any {
return []any{
scope.UserID,
diff --git a/internal/search/search_test.go b/internal/search/search_test.go
index 81ec40a6..2f988afb 100644
--- a/internal/search/search_test.go
+++ b/internal/search/search_test.go
@@ -13,28 +13,35 @@ import (
)
func TestNormalizeQuery(t *testing.T) {
- query := NormalizeQuery(models.Query{Page: 0, PageSize: 500})
+ query := NormalizeQuery(models.Query{Cursor: "cursor", PageSize: 500})
- if query.Page != 1 {
- t.Fatalf("page = %d, want 1", query.Page)
- }
if query.PageSize != maxPageSize {
t.Fatalf("page size = %d, want %d", query.PageSize, maxPageSize)
}
+ if query.Cursor != "cursor" {
+ t.Fatalf("cursor = %q, want cursor", query.Cursor)
+ }
if query.Filters != "[]" {
t.Fatalf("filters = %q, want []", query.Filters)
}
+ if query.Sort != models.SortNewest {
+ t.Fatalf("sort = %q, want %q", query.Sort, models.SortNewest)
+ }
}
func TestBuildConversationQueryPrioritizesExactReference(t *testing.T) {
manager := &Manager{filterLocation: func() string { return "UTC" }}
+ orderBy, err := conversationResultOrder(models.SortNewest)
+ if err != nil {
+ t.Fatalf("building result order: %v", err)
+ }
query, args, err := manager.buildQuery(
"SELECT 1 FROM conversations WHERE $3",
- models.Query{Term: "108", Page: 2, PageSize: 500},
+ normalizeQuery(models.Query{Term: "108", PageSize: 500}, maxPageSize),
models.ReadScope{},
- conversationResultOrder,
+ orderBy,
nil,
- maxPageSize,
+ conversationCursorCondition,
)
if err != nil {
t.Fatalf("building query: %v", err)
@@ -42,11 +49,11 @@ func TestBuildConversationQueryPrioritizesExactReference(t *testing.T) {
if !strings.Contains(query, "ORDER BY (conversations.reference_number = $1) DESC, conversations.last_message_at DESC NULLS LAST") {
t.Fatalf("query does not prioritize exact references: %s", query)
}
- if got := args[len(args)-2]; got != maxPageSize {
- t.Fatalf("limit = %v, want %d", got, maxPageSize)
+ if strings.Contains(query, "COUNT(*) OVER()") || strings.Contains(query, " OFFSET ") {
+ t.Fatalf("query performs unbounded pagination work: %s", query)
}
- if got := args[len(args)-1]; got != maxPageSize {
- t.Fatalf("offset = %v, want %d", got, maxPageSize)
+ if got := args[len(args)-1]; got != maxPageSize+1 {
+ t.Fatalf("limit = %v, want %d", got, maxPageSize+1)
}
}
@@ -92,21 +99,29 @@ func TestConversationSearchFieldsAndRanking(t *testing.T) {
oldest.Add(time.Duration(i+1)*time.Hour),
)
}
+ if _, err := db.Exec(`UPDATE conversations SET created_at = last_message_at WHERE reference_number = '108' OR reference_number BETWEEN '200' AND '209'`); err != nil {
+ t.Fatalf("setting conversation creation times: %v", err)
+ }
+ if _, err := db.Exec(`UPDATE conversations SET last_message_at = NULL WHERE reference_number IN ('208', '209')`); err != nil {
+ t.Fatalf("clearing conversation timestamps: %v", err)
+ }
conversationID, senderID := insertSearchConversation(t, db, "999", "other@example.com", "Needle", "Name", "Needle subject", oldest)
- if _, err := db.Exec(`
- INSERT INTO conversation_messages (type, status, conversation_id, text_content, sender_id, sender_type)
- VALUES ('incoming', 'received', $1, 'ordinary message', $2, 'contact')
- `, conversationID, senderID); err != nil {
- t.Fatalf("inserting message: %v", err)
+ for i := range 11 {
+ if _, err := db.Exec(`
+ INSERT INTO conversation_messages (type, status, conversation_id, text_content, sender_id, sender_type, created_at)
+ VALUES ('incoming', 'received', $1, $2, $3, 'contact', $4)
+ `, conversationID, fmt.Sprintf("ordinary message %d", i), senderID, oldest.Add(time.Duration(i)*time.Minute)); err != nil {
+ t.Fatalf("inserting message: %v", err)
+ }
}
scope := models.ReadScope{Read: true, ReadAll: true}
- results, total, err := manager.Conversations(models.Query{Term: "108", Page: 1, PageSize: 10}, scope)
+ results, hasMore, cursor, err := manager.Conversations(models.Query{Term: "108", PageSize: 10}, scope)
if err != nil {
t.Fatalf("searching conversations: %v", err)
}
- if total != 11 {
- t.Fatalf("total = %d, want 11", total)
+ if !hasMore || cursor == "" {
+ t.Fatalf("first page has_more = %t, cursor = %q", hasMore, cursor)
}
if len(results) != 10 {
t.Fatalf("result count = %d, want 10", len(results))
@@ -114,38 +129,132 @@ func TestConversationSearchFieldsAndRanking(t *testing.T) {
if results[0].ReferenceNumber != "108" {
t.Fatalf("first reference = %q, want 108", results[0].ReferenceNumber)
}
+ newestCursor := cursor
- results, total, err = manager.Conversations(models.Query{Term: "Needle", Page: 1, PageSize: 10}, scope)
+ results, hasMore, nextCursor, err := manager.Conversations(models.Query{Term: "108", Cursor: cursor, PageSize: 10}, scope)
+ if err != nil {
+ t.Fatalf("searching next conversation page: %v", err)
+ }
+ if len(results) != 1 || hasMore || nextCursor != "" {
+ t.Fatalf("next page returned %d results, has_more %t, cursor %q", len(results), hasMore, nextCursor)
+ }
+
+ results, hasMore, cursor, err = manager.Conversations(models.Query{Term: "108", Sort: models.SortOldest, PageSize: 4}, scope)
+ if err != nil {
+ t.Fatalf("searching oldest conversations: %v", err)
+ }
+ if len(results) != 4 || !hasMore || cursor == "" {
+ t.Fatalf("oldest conversation search returned %d results, has_more %t, cursor %q", len(results), hasMore, cursor)
+ }
+ if results[0].ReferenceNumber != "108" || results[1].ReferenceNumber != "200" {
+ t.Fatalf("oldest conversation references = %q, %q, want 108, 200", results[0].ReferenceNumber, results[1].ReferenceNumber)
+ }
+ results, hasMore, nextCursor, err = manager.Conversations(models.Query{Term: "108", Sort: models.SortOldest, Cursor: cursor, PageSize: 4}, scope)
+ if err != nil {
+ t.Fatalf("searching next oldest conversation page: %v", err)
+ }
+ if len(results) != 4 || !hasMore || nextCursor == "" || results[0].ReferenceNumber != "203" {
+ t.Fatalf("next oldest conversation page returned results %+v, has_more %t, cursor %q", results, hasMore, nextCursor)
+ }
+ results, hasMore, cursor, err = manager.Conversations(models.Query{Term: "108", Sort: models.SortOldest, Cursor: nextCursor, PageSize: 4}, scope)
+ if err != nil {
+ t.Fatalf("searching final oldest conversation page: %v", err)
+ }
+ if len(results) != 3 || hasMore || cursor != "" || results[1].ReferenceNumber != "208" || results[2].ReferenceNumber != "209" {
+ t.Fatalf("final oldest conversation page returned results %+v, has_more %t, cursor %q", results, hasMore, cursor)
+ }
+ if _, _, _, err := manager.Conversations(models.Query{Term: "108", Sort: models.SortOldest, Cursor: newestCursor, PageSize: 4}, scope); err == nil {
+ t.Fatal("conversation cursor was accepted with a different sort")
+ }
+
+ results, hasMore, cursor, err = manager.Conversations(models.Query{Term: "108", Sort: models.SortStartedFirst, PageSize: 4}, scope)
+ if err != nil {
+ t.Fatalf("searching conversations started first: %v", err)
+ }
+ if len(results) != 4 || !hasMore || cursor == "" || results[0].ReferenceNumber != "108" || results[1].ReferenceNumber != "200" {
+ t.Fatalf("started-first conversation search returned results %+v, has_more %t, cursor %q", results, hasMore, cursor)
+ }
+ results, _, _, err = manager.Conversations(models.Query{Term: "108", Sort: models.SortStartedFirst, Cursor: cursor, PageSize: 4}, scope)
+ if err != nil {
+ t.Fatalf("searching next started-first conversation page: %v", err)
+ }
+ if len(results) != 4 || results[0].ReferenceNumber != "203" {
+ t.Fatalf("next started-first conversation page returned results %+v", results)
+ }
+
+ results, hasMore, cursor, err = manager.Conversations(models.Query{Term: "108", Sort: models.SortStartedLast, PageSize: 4}, scope)
+ if err != nil {
+ t.Fatalf("searching conversations started last: %v", err)
+ }
+ if len(results) != 4 || !hasMore || cursor == "" || results[0].ReferenceNumber != "108" || results[1].ReferenceNumber != "209" {
+ t.Fatalf("started-last conversation search returned results %+v, has_more %t, cursor %q", results, hasMore, cursor)
+ }
+ results, _, _, err = manager.Conversations(models.Query{Term: "108", Sort: models.SortStartedLast, Cursor: cursor, PageSize: 4}, scope)
+ if err != nil {
+ t.Fatalf("searching next started-last conversation page: %v", err)
+ }
+ if len(results) != 4 || results[0].ReferenceNumber != "206" {
+ t.Fatalf("next started-last conversation page returned results %+v", results)
+ }
+
+ results, hasMore, cursor, err = manager.Conversations(models.Query{Term: "Needle", PageSize: 10}, scope)
if err != nil {
t.Fatalf("searching unsupported fields: %v", err)
}
- if total != 0 || len(results) != 0 {
- t.Fatalf("subject or name matched: total %d, results %d", total, len(results))
+ if len(results) != 0 || hasMore || cursor != "" {
+ t.Fatalf("subject or name matched: results %d, has_more %t, cursor %q", len(results), hasMore, cursor)
}
- messages, messageTotal, err := manager.Messages(models.Query{Term: "ordinary", Page: 1, PageSize: 10}, scope)
+ messages, hasMore, cursor, err := manager.Messages(models.Query{Term: "ordinary", PageSize: 10}, scope)
if err != nil {
t.Fatalf("searching messages: %v", err)
}
- if messageTotal != 1 || len(messages) != 1 || messages[0].TextContent != "ordinary message" {
- t.Fatalf("message search returned total %d, results %+v", messageTotal, messages)
+ if len(messages) != 10 || !hasMore || cursor == "" {
+ t.Fatalf("message search returned results %+v, has_more %t, cursor %q", messages, hasMore, cursor)
+ }
+ messages, hasMore, nextCursor, err = manager.Messages(models.Query{Term: "ordinary", Cursor: cursor, PageSize: 10}, scope)
+ if err != nil {
+ t.Fatalf("searching next message page: %v", err)
+ }
+ if len(messages) != 1 || hasMore || nextCursor != "" {
+ t.Fatalf("next message page returned %d results, has_more %t, cursor %q", len(messages), hasMore, nextCursor)
+ }
+ messages, hasMore, cursor, err = manager.Messages(models.Query{Term: "ordinary", Sort: models.SortOldest, PageSize: 10}, scope)
+ if err != nil {
+ t.Fatalf("searching oldest messages: %v", err)
+ }
+ if len(messages) != 10 || !hasMore || cursor == "" || messages[0].TextContent != "ordinary message 0" {
+ t.Fatalf("oldest message search returned results %+v, has_more %t, cursor %q", messages, hasMore, cursor)
+ }
+ messages, hasMore, nextCursor, err = manager.Messages(models.Query{Term: "ordinary", Sort: models.SortOldest, Cursor: cursor, PageSize: 10}, scope)
+ if err != nil {
+ t.Fatalf("searching next oldest message page: %v", err)
+ }
+ if len(messages) != 1 || hasMore || nextCursor != "" || messages[0].TextContent != "ordinary message 10" {
+ t.Fatalf("next oldest message page returned results %+v, has_more %t, cursor %q", messages, hasMore, nextCursor)
+ }
+ if _, _, _, err := manager.Messages(models.Query{Term: "ordinary", Cursor: "invalid", PageSize: 10}, scope); err == nil {
+ t.Fatal("invalid message cursor was accepted")
+ }
+ if _, _, _, err := manager.Messages(models.Query{Term: "ordinary", Sort: models.SortStartedFirst, PageSize: 10}, scope); err == nil {
+ t.Fatal("conversation-only sort was accepted for messages")
}
for _, term := range []string{"%%%", "___", "%_%"} {
- results, total, err = manager.Conversations(models.Query{Term: term, Page: 1, PageSize: 10}, scope)
+ results, hasMore, cursor, err = manager.Conversations(models.Query{Term: term, PageSize: 10}, scope)
if err != nil {
t.Fatalf("searching conversations for %q: %v", term, err)
}
- if total != 0 || len(results) != 0 {
- t.Fatalf("conversation search for %q returned total %d, results %d", term, total, len(results))
+ if len(results) != 0 || hasMore || cursor != "" {
+ t.Fatalf("conversation search for %q returned %d results, has_more %t, cursor %q", term, len(results), hasMore, cursor)
}
- messages, messageTotal, err = manager.Messages(models.Query{Term: term, Page: 1, PageSize: 10}, scope)
+ messages, hasMore, cursor, err = manager.Messages(models.Query{Term: term, PageSize: 10}, scope)
if err != nil {
t.Fatalf("searching messages for %q: %v", term, err)
}
- if messageTotal != 0 || len(messages) != 0 {
- t.Fatalf("message search for %q returned total %d, results %d", term, messageTotal, len(messages))
+ if len(messages) != 0 || hasMore || cursor != "" {
+ t.Fatalf("message search for %q returned %d results, has_more %t, cursor %q", term, len(messages), hasMore, cursor)
}
contacts, err := manager.Contacts(term, 10)