Files
gochat/internal/repository/search_repo.go
T

511 lines
16 KiB
Go

package repository
import (
"context"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/search"
)
// RepoSearchFilter holds filter parameters for search queries at the repository level.
// This is a repository-level mirror of the search.SearchFilter struct to avoid
// an import cycle between internal/search and internal/repository.
type RepoSearchFilter struct {
SearchMode search.SearchMode // ilike or trigram
Status []string
Priority []string
AssigneeID *uint
TeamID *uint
InboxID *uint
Labels []string
ContactSource string
MessageType string
SenderType string
SenderID *uint
ContentType string
Private *bool
DateFrom *time.Time
DateTo *time.Time
SortBy string
SortOrder string
Page int
PerPage int
// Article-specific filters (Knowledge Base)
PortalID *uint
ArticleStatus string
ArticleLocale string
}
// IsTrigram returns true if the search mode uses pg_trgm fuzzy matching.
func (f *RepoSearchFilter) IsTrigram() bool {
return f.SearchMode == search.SearchModeTrigram
}
// Offset returns the calculated offset from Page and PerPage.
func (f *RepoSearchFilter) Offset() int {
return (f.Page - 1) * f.PerPage
}
// OrderClause returns the SQL ORDER BY clause string based on SortBy and SortOrder.
func (f *RepoSearchFilter) OrderClause() string {
col := f.SortBy
if col == "" {
col = "created_at"
}
dir := f.SortOrder
if dir != "asc" {
dir = "desc"
}
// Map common aliases to actual column names
switch col {
case "last_activity":
col = "last_activity_at"
case "created":
col = "created_at"
case "updated":
col = "updated_at"
}
return col + " " + dir
}
// searchFilterToRepo converts a search.SearchFilter to a RepoSearchFilter.
// This is used by the adapter methods that satisfy search.SearchRepoInterface.
func searchFilterToRepo(f *search.SearchFilter) *RepoSearchFilter {
if f == nil {
return &RepoSearchFilter{SearchMode: search.SearchModeILike, Page: 1, PerPage: 25, SortBy: "created_at", SortOrder: "desc"}
}
// Default SearchMode to ILIKE if not explicitly set
mode := f.SearchMode
if mode == "" {
mode = search.SearchModeILike
}
return &RepoSearchFilter{
SearchMode: mode,
Status: f.Status,
Priority: f.Priority,
AssigneeID: f.AssigneeID,
TeamID: f.TeamID,
InboxID: f.InboxID,
Labels: f.Labels,
ContactSource: f.ContactSource,
MessageType: f.MessageType,
SenderType: f.SenderType,
SenderID: f.SenderID,
ContentType: f.ContentType,
Private: f.Private,
DateFrom: f.DateFrom,
DateTo: f.DateTo,
SortBy: f.SortBy,
SortOrder: f.SortOrder,
Page: f.Page,
PerPage: f.PerPage,
PortalID: f.PortalID,
ArticleStatus: f.ArticleStatus,
ArticleLocale: f.ArticleLocale,
}
}
// SearchRepo provides unified search across conversations, messages, and contacts
// with advanced filtering and sorting support.
// Reference: Chatwoot GlobalSearchService + ConversationFilterService
type SearchRepo struct {
db *gorm.DB
}
// NewSearchRepo creates a new SearchRepo.
func NewSearchRepo(db *gorm.DB) *SearchRepo {
return &SearchRepo{db: db}
}
// SearchConversations searches conversations with advanced filters.
// This method satisfies search.SearchRepoInterface by accepting *search.SearchFilter
// and converting it to *RepoSearchFilter internally.
// Reference: Chatwoot conversations#index with filter params (status, assignee_id, labels, etc.)
func (r *SearchRepo) SearchConversations(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Conversation, int64, error) {
repoFilter := searchFilterToRepo(filter)
return r.searchConversationsInternal(ctx, accountID, query, repoFilter)
}
// searchConversationsInternal is the internal implementation using *RepoSearchFilter.
func (r *SearchRepo) searchConversationsInternal(ctx context.Context, accountID uint, query string, filter *RepoSearchFilter) ([]model.Conversation, int64, error) {
var conversations []model.Conversation
var total int64
q := r.db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID)
// Apply text search — ILIKE (default) or pg_trgm fuzzy match
if query != "" {
if filter.IsTrigram() {
// pg_trgm fuzzy match: uses % operator with similarity threshold
// Requires pg_trgm extension and GIN indexes on searchable columns.
// Default similarity threshold is 0.3 (set_show_trgm).
q = q.Where(
"labels % ? OR EXISTS (SELECT 1 FROM messages WHERE messages.conversation_id = conversations.id AND messages.content % ? AND messages.account_id = ?)",
query, query, accountID,
)
} else {
// ILIKE substring match (default, works without pg_trgm)
likeQuery := "%" + query + "%"
q = q.Where(
"labels ILIKE ? OR EXISTS (SELECT 1 FROM messages WHERE messages.conversation_id = conversations.id AND messages.content ILIKE ? AND messages.account_id = ?)",
likeQuery, likeQuery, accountID,
)
}
}
// Apply conversation-specific filters
q = applyConversationFilters(q, filter)
// Apply date range filter
q = applyDateRangeFilter(q, filter)
// Count
if err := q.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("search conversations count: %w", err)
}
// Apply sort + pagination
orderClause := filter.OrderClause()
// Conversations default to last_activity_at DESC NULLS LAST
if filter.SortBy == "created_at" || filter.SortBy == "" {
orderClause = "last_activity_at DESC NULLS LAST, id DESC"
}
err := q.Offset(filter.Offset()).Limit(filter.PerPage).
Order(orderClause).
Find(&conversations).Error
return conversations, total, err
}
// SearchMessages searches messages with advanced filters.
// This method satisfies search.SearchRepoInterface by accepting *search.SearchFilter
// and converting it to *RepoSearchFilter internally.
// Reference: Chatwoot messages search — full text search on message content
func (r *SearchRepo) SearchMessages(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Message, int64, error) {
repoFilter := searchFilterToRepo(filter)
return r.searchMessagesInternal(ctx, accountID, query, repoFilter)
}
// searchMessagesInternal is the internal implementation using *RepoSearchFilter.
func (r *SearchRepo) searchMessagesInternal(ctx context.Context, accountID uint, query string, filter *RepoSearchFilter) ([]model.Message, int64, error) {
var messages []model.Message
var total int64
q := r.db.WithContext(ctx).Model(&model.Message{}).Where("account_id = ?", accountID)
// Text search on message content — ILIKE or pg_trgm fuzzy match
if query != "" {
if filter.IsTrigram() {
// pg_trgm fuzzy match: uses % operator (similarity threshold default 0.3)
q = q.Where("content % ?", query)
} else {
// ILIKE substring match
likeQuery := "%" + query + "%"
q = q.Where("content ILIKE ?", likeQuery)
}
}
// Apply message-specific filters
q = applyMessageFilters(q, filter)
// Apply date range filter
q = applyDateRangeFilter(q, filter)
// Count
if err := q.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("search messages count: %w", err)
}
// Apply sort + pagination
err := q.Offset(filter.Offset()).Limit(filter.PerPage).
Order(filter.OrderClause() + ", id DESC").
Find(&messages).Error
return messages, total, err
}
// SearchContacts searches contacts with advanced filters.
// This method satisfies search.SearchRepoInterface by accepting *search.SearchFilter
// and converting it to *RepoSearchFilter internally.
// Reference: Chatwoot contacts#search — name, email, phone, identifier.
func (r *SearchRepo) SearchContacts(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Contact, int64, error) {
repoFilter := searchFilterToRepo(filter)
return r.searchContactsInternal(ctx, accountID, query, repoFilter)
}
// searchContactsInternal is the internal implementation using *RepoSearchFilter.
func (r *SearchRepo) searchContactsInternal(ctx context.Context, accountID uint, query string, filter *RepoSearchFilter) ([]model.Contact, int64, error) {
var contacts []model.Contact
var total int64
q := r.db.WithContext(ctx).Model(&model.Contact{}).Where("account_id = ?", accountID)
// Text search on contact fields — ILIKE or pg_trgm fuzzy match
if query != "" {
if filter.IsTrigram() {
// pg_trgm fuzzy match: uses % operator on name, email, phone_number, identifier
q = q.Where("name % ? OR email % ? OR phone_number % ? OR identifier % ?",
query, query, query, query)
} else {
// ILIKE substring match
likeQuery := "%" + query + "%"
q = q.Where("name ILIKE ? OR email ILIKE ? OR phone_number ILIKE ? OR identifier ILIKE ?",
likeQuery, likeQuery, likeQuery, likeQuery)
}
}
// Apply contact-specific filters
q = applyContactFilters(q, filter)
// Apply date range filter
q = applyDateRangeFilter(q, filter)
// Count
if err := q.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("search contacts count: %w", err)
}
// Apply sort + pagination
orderClause := filter.OrderClause()
if filter.SortBy == "created_at" || filter.SortBy == "" {
orderClause = "last_activity_at DESC NULLS LAST, id DESC"
}
err := q.Offset(filter.Offset()).Limit(filter.PerPage).
Order(orderClause).
Find(&contacts).Error
return contacts, total, err
}
// SearchCompanies searches companies with advanced filters.
func (r *SearchRepo) SearchCompanies(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Company, int64, error) {
repoFilter := searchFilterToRepo(filter)
return r.searchCompaniesInternal(ctx, accountID, query, repoFilter)
}
func (r *SearchRepo) searchCompaniesInternal(ctx context.Context, accountID uint, query string, filter *RepoSearchFilter) ([]model.Company, int64, error) {
var companies []model.Company
var total int64
q := r.db.WithContext(ctx).Model(&model.Company{}).Where("account_id = ?", accountID)
if query != "" {
if filter.IsTrigram() {
q = q.Where("name % ? OR description % ? OR domain % ? OR website_url % ?", query, query, query, query)
} else {
likeQuery := "%" + query + "%"
q = q.Where("LOWER(name) LIKE LOWER(?) OR LOWER(description) LIKE LOWER(?) OR LOWER(domain) LIKE LOWER(?) OR LOWER(website_url) LIKE LOWER(?)",
likeQuery, likeQuery, likeQuery, likeQuery)
}
}
q = applyDateRangeFilter(q, filter)
if err := q.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("search companies count: %w", err)
}
err := q.Offset(filter.Offset()).Limit(filter.PerPage).
Order(filter.OrderClause() + ", id DESC").
Find(&companies).Error
return companies, total, err
}
// SearchArticles searches knowledge base articles with advanced filters.
// This method satisfies search.SearchRepoInterface by accepting *search.SearchFilter
// and converting it to *RepoSearchFilter internally.
// Reference: Chatwoot ArticlesController#search — full text search on title, description, content.
func (r *SearchRepo) SearchArticles(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Article, int64, error) {
repoFilter := searchFilterToRepo(filter)
return r.searchArticlesInternal(ctx, accountID, query, repoFilter)
}
// searchArticlesInternal is the internal implementation using *RepoSearchFilter.
func (r *SearchRepo) searchArticlesInternal(ctx context.Context, accountID uint, query string, filter *RepoSearchFilter) ([]model.Article, int64, error) {
var articles []model.Article
var total int64
q := r.db.WithContext(ctx).Model(&model.Article{}).Where("account_id = ?", accountID)
// Text search on article title, description, and content — ILIKE or pg_trgm fuzzy match
if query != "" {
if filter.IsTrigram() {
// pg_trgm fuzzy match: uses % operator on title, description, content
q = q.Where("title % ? OR description % ? OR content % ?",
query, query, query)
} else {
// ILIKE substring match
likeQuery := "%" + query + "%"
q = q.Where("title ILIKE ? OR description ILIKE ? OR content ILIKE ?",
likeQuery, likeQuery, likeQuery)
}
}
// Apply article-specific filters
q = applyArticleFilters(q, filter)
// Apply date range filter
q = applyDateRangeFilter(q, filter)
// Count
if err := q.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("search articles count: %w", err)
}
// Sorting: articles default to updated_at DESC
orderClause := filter.OrderClause()
if filter.SortBy == "created_at" || filter.SortBy == "" {
orderClause = "updated_at DESC"
}
err := q.Offset(filter.Offset()).Limit(filter.PerPage).
Order(orderClause + ", id DESC").
Find(&articles).Error
return articles, total, err
}
// --- Filter helper functions ---
// applyConversationFilters applies conversation-specific filter conditions.
func applyConversationFilters(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
if filter == nil {
return q
}
// Status filter
if len(filter.Status) > 0 {
q = q.Where("status IN ?", filter.Status)
}
// Priority filter
if len(filter.Priority) > 0 {
q = q.Where("priority IN ?", filter.Priority)
}
// Assignee ID filter
if filter.AssigneeID != nil {
q = q.Where("assignee_id = ?", *filter.AssigneeID)
}
// Team ID filter
if filter.TeamID != nil {
q = q.Where("team_id = ?", *filter.TeamID)
}
// Inbox ID filter
if filter.InboxID != nil {
q = q.Where("inbox_id = ?", *filter.InboxID)
}
// Labels filter — uses text containment for PostgreSQL
// Reference: Chatwoot filters by labels array overlap
if len(filter.Labels) > 0 {
for _, label := range filter.Labels {
// Labels column is text (JSON array stored as string)
// Use ILIKE to match label within the text representation
q = q.Where("labels ILIKE ?", fmt.Sprintf(`%%%s%%`, strings.ToLower(label)))
}
}
return q
}
// applyMessageFilters applies message-specific filter conditions.
func applyMessageFilters(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
if filter == nil {
return q
}
// Message type filter
if filter.MessageType != "" {
q = q.Where("message_type = ?", filter.MessageType)
}
// Sender type filter
if filter.SenderType != "" {
q = q.Where("sender_type = ?", filter.SenderType)
}
if filter.SenderID != nil {
q = q.Where("sender_id = ?", *filter.SenderID)
}
// Content type filter
if filter.ContentType != "" {
q = q.Where("content_type = ?", filter.ContentType)
}
// Private flag filter
if filter.Private != nil {
q = q.Where("private = ?", *filter.Private)
}
// Inbox ID filter (messages also belong to an inbox)
if filter.InboxID != nil {
q = q.Where("inbox_id = ?", *filter.InboxID)
}
return q
}
// applyContactFilters applies contact-specific filter conditions.
func applyContactFilters(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
if filter == nil {
return q
}
// Contact source filter
if filter.ContactSource != "" {
q = q.Where("source = ?", filter.ContactSource)
}
return q
}
// applyArticleFilters applies article-specific filter conditions.
func applyArticleFilters(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
if filter == nil {
return q
}
// Portal ID filter
if filter.PortalID != nil {
q = q.Where("portal_id = ?", *filter.PortalID)
}
// Article status filter
if filter.ArticleStatus != "" {
q = q.Where("status = ?", filter.ArticleStatus)
}
// Locale filter
if filter.ArticleLocale != "" {
q = q.Where("locale = ?", filter.ArticleLocale)
}
// Author filter (reuse AssigneeID field for author_id in article context)
if filter.AssigneeID != nil {
q = q.Where("author_id = ?", *filter.AssigneeID)
}
return q
}
// applyDateRangeFilter applies a date range filter on created_at.
func applyDateRangeFilter(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
if filter == nil {
return q
}
if filter.DateFrom != nil {
q = q.Where("created_at >= ?", filter.DateFrom)
}
if filter.DateTo != nil {
q = q.Where("created_at <= ?", filter.DateTo)
}
return q
}