add cursor pagination and search sorting

This commit is contained in:
Abhinav Raut
2026-09-18 02:21:05 +05:30
parent 6a6e449c8d
commit ab44d64068
9 changed files with 597 additions and 119 deletions
+24 -16
View File
@@ -21,6 +21,13 @@ const (
maxContactSearchLimit = 15
)
type searchPageResults struct {
Results any `json:"results"`
PerPage int `json:"per_page"`
HasMore bool `json:"has_more"`
NextCursor string `json:"next_cursor"`
}
func handleSearchConversations(r *fastglue.Request) error {
app, user, term, err := searchTerm(r)
if err != nil {
@@ -74,11 +81,16 @@ func handlePaginatedSearchConversations(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
results, total, err := app.search.Conversations(q, scope)
results, hasMore, nextCursor, err := app.search.Conversations(q, scope)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(pageResults(results, total, q))
return r.SendEnvelope(searchPageResults{
Results: results,
PerPage: q.PageSize,
HasMore: hasMore,
NextCursor: nextCursor,
})
}
func handlePaginatedSearchMessages(r *fastglue.Request) error {
@@ -90,11 +102,16 @@ func handlePaginatedSearchMessages(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
results, total, err := app.search.Messages(q, scope)
results, hasMore, nextCursor, err := app.search.Messages(q, scope)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(pageResults(results, total, q))
return r.SendEnvelope(searchPageResults{
Results: results,
PerPage: q.PageSize,
HasMore: hasMore,
NextCursor: nextCursor,
})
}
func searchInputs(r *fastglue.Request) (*App, amodels.User, smodels.Query, error) {
@@ -102,11 +119,12 @@ func searchInputs(r *fastglue.Request) (*App, amodels.User, smodels.Query, error
if err != nil {
return app, user, smodels.Query{}, err
}
page, pageSize := getPagination(r)
_, pageSize := getPagination(r)
query := searchmanager.NormalizeQuery(smodels.Query{
Term: term,
Filters: string(r.RequestCtx.QueryArgs().Peek("filters")),
Page: page,
Cursor: string(r.RequestCtx.QueryArgs().Peek("cursor")),
Sort: smodels.Sort(r.RequestCtx.QueryArgs().Peek("sort")),
PageSize: pageSize,
})
return app, user, query, nil
@@ -130,16 +148,6 @@ func searchLimit(r *fastglue.Request, max int) int {
return limit
}
func pageResults(results any, total int, q smodels.Query) envelope.PageResults {
return envelope.PageResults{
Results: results,
Total: total,
PerPage: q.PageSize,
TotalPages: (total + q.PageSize - 1) / q.PageSize,
Page: q.Page,
}
}
func readScope(app *App, agentID int) (smodels.ReadScope, error) {
agent, err := app.user.GetAgentCachedOrLoad(agentID)
if err != nil {
@@ -0,0 +1,81 @@
<template>
<div class="sticky bottom-0 mt-auto bg-background pb-4 pt-3">
<div
class="flex items-center justify-between gap-4 rounded-md border border-border px-4 py-3 shadow-sm"
>
<Select :model-value="perPage" @update:model-value="handlePerPageChange">
<SelectTrigger
class="h-10 w-[70px] sm:h-8"
:aria-label="t('globals.messages.resultsPerPage')"
>
<SelectValue :placeholder="String(perPage)" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in perPageOptions" :key="option" :value="option">
{{ option }}
</SelectItem>
</SelectContent>
</Select>
<div class="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
:class="STEP_BUTTON_CLASS"
:aria-label="t('globals.messages.previousPage')"
:disabled="page <= 1"
@click="goToPage(page - 1)"
>
<ChevronLeft class="h-4 w-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
:class="STEP_BUTTON_CLASS"
:aria-label="t('globals.messages.nextPage')"
:disabled="!hasMore"
@click="goToPage(page + 1)"
>
<ChevronRight class="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
</div>
</template>
<script setup>
const STEP_BUTTON_CLASS = 'h-10 w-10 p-0 sm:h-8 sm:w-8'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import { Button } from '@shared-ui/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
const props = defineProps({
page: { type: Number, required: true },
perPage: { type: Number, required: true },
hasMore: { type: Boolean, required: true },
perPageOptions: { type: Array, default: () => [15, 30, 50, 100] }
})
const emit = defineEmits(['change'])
const { t } = useI18n()
function goToPage(page) {
if (page < 1 || (page > props.page && !props.hasMore)) return
emit('change', { page, perPage: props.perPage })
}
function handlePerPageChange(perPage) {
emit('change', { page: 1, perPage })
}
</script>
@@ -1,13 +1,13 @@
<template>
<div>
<Tabs :model-value="activeTab" @update:model-value="emit('update:activeTab', $event)">
<div class="mb-3 flex items-center gap-2">
<div class="mb-3 flex flex-wrap items-center gap-2">
<TabsList>
<TabsTrigger v-for="type in TYPES" :key="type" :value="type" class="gap-1.5">
{{ t(tabLabelKeys[type], 2) }}
<span class="text-xs tabular-nums text-muted-foreground">{{
results[type].total
}}</span>
<span class="text-xs tabular-nums text-muted-foreground">
{{ knownResultCount(results[type]) }}
</span>
</TabsTrigger>
</TabsList>
<Button
@@ -21,6 +21,28 @@
<X class="h-4 w-4" aria-hidden="true" />
{{ t('globals.messages.clearFilters') }}
</Button>
<div class="ml-auto flex items-center gap-2">
<label for="search-sort" class="text-sm text-muted-foreground">
{{ t('globals.messages.sortBy') }}
</label>
<Select
:model-value="sorts[activeTab]"
@update:model-value="emit('changeSort', { type: activeTab, sort: $event })"
>
<SelectTrigger id="search-sort" class="h-9 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem
v-for="option in SORT_OPTIONS[activeTab]"
:key="option.value"
:value="option.value"
>
{{ t(option.label) }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<TabsContent v-for="type in TYPES" :key="type" :value="type" class="mt-0">
@@ -44,11 +66,11 @@
/>
</div>
</div>
<PaginationBar
v-if="results[type].total_pages > 1"
<SearchPagination
v-if="results[type].page > 1 || results[type].has_more"
:page="results[type].page"
:per-page="results[type].per_page"
:total-pages="results[type].total_pages"
:has-more="results[type].has_more"
@change="emit('changePage', { type, ...$event })"
/>
</TabsContent>
@@ -60,11 +82,30 @@
import { X } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import { Button } from '@shared-ui/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
import PaginationBar from '@main/components/pagination/PaginationBar.vue'
import SearchPagination from './SearchPagination.vue'
import SearchResultItem from './SearchResultItem.vue'
const TYPES = ['conversations', 'messages']
const SORT_OPTIONS = {
conversations: [
{ value: 'newest', label: 'conversation.sort.newestActivity' },
{ value: 'oldest', label: 'conversation.sort.oldestActivity' },
{ value: 'started_first', label: 'conversation.sort.startedFirst' },
{ value: 'started_last', label: 'conversation.sort.startedLast' }
],
messages: [
{ value: 'newest', label: 'conversation.sort.newestActivity' },
{ value: 'oldest', label: 'conversation.sort.oldestActivity' }
]
}
const tabLabelKeys = {
conversations: 'globals.terms.conversation',
messages: 'globals.terms.message'
@@ -74,9 +115,15 @@ defineProps({
results: { type: Object, required: true },
term: { type: String, default: '' },
activeTab: { type: String, required: true },
sorts: { type: Object, required: true },
showClearFilters: { type: Boolean, default: false }
})
const emit = defineEmits(['update:activeTab', 'changePage', 'clearFilters'])
const emit = defineEmits(['update:activeTab', 'changeSort', 'changePage', 'clearFilters'])
const { t } = useI18n()
const knownResultCount = (page) => {
const count = (page.page - 1) * page.per_page + page.results.length
return page.has_more ? `${count}+` : count
}
</script>
@@ -36,7 +36,7 @@
<SearchFilters :filters="filters" @update:filters="filters = $event" />
<div v-if="loading && totalResults === 0" class="flex justify-center items-center h-64">
<div v-if="loading && resultCount === 0" class="flex justify-center items-center h-64">
<Spinner :absolute="false" />
</div>
<div v-else-if="error" class="py-16 text-center space-y-4">
@@ -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
+1 -1
View File
@@ -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",
+14 -4
View File
@@ -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"`
+3 -2
View File
@@ -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,
+227 -42
View File
@@ -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,
+140 -31
View File
@@ -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)