Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
312 lines
8.9 KiB
Plaintext
312 lines
8.9 KiB
Plaintext
package repository
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
)
|
|
|
|
// 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 {
|
|
Status []string
|
|
Priority []string
|
|
AssigneeID *uint
|
|
TeamID *uint
|
|
InboxID *uint
|
|
Labels []string
|
|
ContactSource string
|
|
MessageType string
|
|
SenderType string
|
|
ContentType string
|
|
Private *bool
|
|
DateFrom *time.Time
|
|
DateTo *time.Time
|
|
SortBy string
|
|
SortOrder string
|
|
Page int
|
|
PerPage int
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 on labels, and related content)
|
|
if query != "" {
|
|
likeQuery := "%" + query + "%"
|
|
// Search in conversation labels + match messages content via subquery
|
|
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()
|
|
// Override default sort for conversations to use last_activity_at when appropriate
|
|
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
|
|
if query != "" {
|
|
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.
|
|
// Reference: Chatwoot contacts#search — name, email, phone, identifier
|
|
func (r *SearchRepo) SearchContacts(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
|
|
if query != "" {
|
|
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
|
|
}
|
|
|
|
// --- 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
} |