mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-25 22:09:36 +00:00
add cursor pagination and search sorting
This commit is contained in:
+24
-16
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user