feat(search): add meilisearch engine foundation
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gochat/gochat/internal/app"
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/search"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var accountID uint
|
||||
var batchSize int
|
||||
var types string
|
||||
var bootstrap bool
|
||||
flag.UintVar(&accountID, "account", 0, "account ID to reindex; 0 means all accounts")
|
||||
flag.IntVar(&batchSize, "batch", 500, "documents per indexing batch")
|
||||
flag.StringVar(&types, "types", "all", "comma-separated types: conversation,message,contact,company,article,help_center,all")
|
||||
flag.BoolVar(&bootstrap, "bootstrap", true, "create/update Meilisearch indexes and settings before indexing")
|
||||
flag.Parse()
|
||||
|
||||
if batchSize < 1 {
|
||||
fmt.Fprintln(os.Stderr, "batch must be >= 1")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config load: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
_ = applogger.Init(applogger.Config{Level: cfg.Log.Level, Format: cfg.Log.Format, Output: "stdout", ErrorOutput: "stderr"})
|
||||
|
||||
db, err := app.NewDatabase(&cfg.Database, cfg.Log.Level)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
searchRepo := repository.NewSearchRepo(db)
|
||||
engine, err := search.NewSearchEngine(search.EngineConfig{
|
||||
Engine: cfg.Search.Engine,
|
||||
Host: cfg.Search.Host,
|
||||
APIKey: cfg.Search.APIKey,
|
||||
IndexPrefix: cfg.Search.IndexPrefix,
|
||||
TimeoutSeconds: cfg.Search.TimeoutSeconds,
|
||||
}, searchRepo)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "search engine: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if bootstrap {
|
||||
if err := engine.Bootstrap(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "bootstrap search indexes: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
selected := parseTypes(types)
|
||||
stats := map[search.SearchResultType]int{}
|
||||
if selected[search.ResultTypeConversation] {
|
||||
stats[search.ResultTypeConversation], err = reindexConversations(ctx, engine, db, accountID, batchSize)
|
||||
fatalIf(err)
|
||||
}
|
||||
if selected[search.ResultTypeMessage] {
|
||||
stats[search.ResultTypeMessage], err = reindexMessages(ctx, engine, db, accountID, batchSize)
|
||||
fatalIf(err)
|
||||
}
|
||||
if selected[search.ResultTypeContact] {
|
||||
stats[search.ResultTypeContact], err = reindexContacts(ctx, engine, db, accountID, batchSize)
|
||||
fatalIf(err)
|
||||
}
|
||||
if selected[search.ResultTypeCompany] {
|
||||
stats[search.ResultTypeCompany], err = reindexCompanies(ctx, engine, db, accountID, batchSize)
|
||||
fatalIf(err)
|
||||
}
|
||||
if selected[search.ResultTypeArticle] || selected[search.ResultTypeHelpCenter] {
|
||||
stats[search.ResultTypeArticle], err = reindexArticles(ctx, engine, db, accountID, batchSize)
|
||||
fatalIf(err)
|
||||
}
|
||||
|
||||
fmt.Println("Search reindex complete")
|
||||
for docType, count := range stats {
|
||||
fmt.Printf("%s: %d\n", docType, count)
|
||||
}
|
||||
}
|
||||
|
||||
func parseTypes(raw string) map[search.SearchResultType]bool {
|
||||
selected := map[search.SearchResultType]bool{}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
switch search.SearchResultType(strings.TrimSpace(part)) {
|
||||
case "", "all":
|
||||
selected[search.ResultTypeConversation] = true
|
||||
selected[search.ResultTypeMessage] = true
|
||||
selected[search.ResultTypeContact] = true
|
||||
selected[search.ResultTypeCompany] = true
|
||||
selected[search.ResultTypeArticle] = true
|
||||
selected[search.ResultTypeHelpCenter] = true
|
||||
case search.ResultTypeConversation:
|
||||
selected[search.ResultTypeConversation] = true
|
||||
case search.ResultTypeMessage:
|
||||
selected[search.ResultTypeMessage] = true
|
||||
case search.ResultTypeContact:
|
||||
selected[search.ResultTypeContact] = true
|
||||
case search.ResultTypeCompany:
|
||||
selected[search.ResultTypeCompany] = true
|
||||
case search.ResultTypeArticle:
|
||||
selected[search.ResultTypeArticle] = true
|
||||
case search.ResultTypeHelpCenter:
|
||||
selected[search.ResultTypeHelpCenter] = true
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func reindexConversations(ctx context.Context, engine search.SearchEngine, db *gorm.DB, accountID uint, batchSize int) (int, error) {
|
||||
var count int
|
||||
var lastID uint
|
||||
for {
|
||||
var rows []model.Conversation
|
||||
q := db.WithContext(ctx).Where("id > ?", lastID).Order("id ASC").Limit(batchSize)
|
||||
if accountID != 0 {
|
||||
q = q.Where("account_id = ?", accountID)
|
||||
}
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return count, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return count, nil
|
||||
}
|
||||
docs := make([]search.SearchDocument, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
docs = append(docs, search.ConversationDocument(row))
|
||||
lastID = row.ID
|
||||
}
|
||||
if err := engine.IndexBatch(ctx, docs); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count += len(rows)
|
||||
}
|
||||
}
|
||||
|
||||
func reindexMessages(ctx context.Context, engine search.SearchEngine, db *gorm.DB, accountID uint, batchSize int) (int, error) {
|
||||
var count int
|
||||
var lastID uint
|
||||
for {
|
||||
var rows []model.Message
|
||||
q := db.WithContext(ctx).Where("id > ?", lastID).Order("id ASC").Limit(batchSize)
|
||||
if accountID != 0 {
|
||||
q = q.Where("account_id = ?", accountID)
|
||||
}
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return count, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return count, nil
|
||||
}
|
||||
docs := make([]search.SearchDocument, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
docs = append(docs, search.MessageDocument(row))
|
||||
lastID = row.ID
|
||||
}
|
||||
if err := engine.IndexBatch(ctx, docs); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count += len(rows)
|
||||
}
|
||||
}
|
||||
|
||||
func reindexContacts(ctx context.Context, engine search.SearchEngine, db *gorm.DB, accountID uint, batchSize int) (int, error) {
|
||||
var count int
|
||||
var lastID uint
|
||||
for {
|
||||
var rows []model.Contact
|
||||
q := db.WithContext(ctx).Where("id > ?", lastID).Order("id ASC").Limit(batchSize)
|
||||
if accountID != 0 {
|
||||
q = q.Where("account_id = ?", accountID)
|
||||
}
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return count, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return count, nil
|
||||
}
|
||||
docs := make([]search.SearchDocument, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
docs = append(docs, search.ContactDocument(row))
|
||||
lastID = row.ID
|
||||
}
|
||||
if err := engine.IndexBatch(ctx, docs); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count += len(rows)
|
||||
}
|
||||
}
|
||||
|
||||
func reindexCompanies(ctx context.Context, engine search.SearchEngine, db *gorm.DB, accountID uint, batchSize int) (int, error) {
|
||||
var count int
|
||||
var lastID uint
|
||||
for {
|
||||
var rows []model.Company
|
||||
q := db.WithContext(ctx).Where("id > ?", lastID).Order("id ASC").Limit(batchSize)
|
||||
if accountID != 0 {
|
||||
q = q.Where("account_id = ?", accountID)
|
||||
}
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return count, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return count, nil
|
||||
}
|
||||
docs := make([]search.SearchDocument, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
docs = append(docs, search.CompanyDocument(row))
|
||||
lastID = row.ID
|
||||
}
|
||||
if err := engine.IndexBatch(ctx, docs); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count += len(rows)
|
||||
}
|
||||
}
|
||||
|
||||
func reindexArticles(ctx context.Context, engine search.SearchEngine, db *gorm.DB, accountID uint, batchSize int) (int, error) {
|
||||
var count int
|
||||
var lastID uint
|
||||
for {
|
||||
var rows []model.Article
|
||||
q := db.WithContext(ctx).Where("id > ?", lastID).Order("id ASC").Limit(batchSize)
|
||||
if accountID != 0 {
|
||||
q = q.Where("account_id = ?", accountID)
|
||||
}
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return count, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return count, nil
|
||||
}
|
||||
docs := make([]search.SearchDocument, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
docs = append(docs, search.ArticleDocument(row))
|
||||
lastID = row.ID
|
||||
}
|
||||
if err := engine.IndexBatch(ctx, docs); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count += len(rows)
|
||||
}
|
||||
}
|
||||
|
||||
func fatalIf(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -45,6 +45,14 @@ rate_limit:
|
||||
requests_per_minute: 100 # max requests per client IP per window
|
||||
window_seconds: 60 # sliding window duration in seconds
|
||||
|
||||
search:
|
||||
# Chatwoot parity target. Use "db" only for explicit local fallback.
|
||||
engine: "meilisearch"
|
||||
host: "http://localhost:7700"
|
||||
api_key: ""
|
||||
index_prefix: "gochat_"
|
||||
timeout_seconds: 5
|
||||
|
||||
saml:
|
||||
enabled: false # SAML 2.0 SSO — enable for enterprise IdP integration
|
||||
# IdP metadata: provide URL or inline XML (URL preferred for auto-refresh)
|
||||
@@ -60,4 +68,4 @@ saml:
|
||||
email: "email" # SAML attribute → GoChat email field
|
||||
display_name: "displayName" # SAML attribute → GoChat name field
|
||||
first_name: "firstName" # SAML attribute → first name component
|
||||
last_name: "lastName" # SAML attribute → last name component
|
||||
last_name: "lastName" # SAML attribute → last name component
|
||||
|
||||
@@ -75,15 +75,32 @@ Tracking table:
|
||||
|
||||
| ID | Task | Target files | Reference source | Status |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| P1.1 | Add `SearchConfig` with engine, host, API key, index prefix, env binding, and defaults. | `internal/config/config.go`, config docs | `.hermes/plans/2025-05-24-global-search-meilisearch.md` | Todo |
|
||||
| P1.2 | Add stable engine contract for search, indexing, deletes, batch indexing, and close. | `internal/search/engine.go` | Chatwoot global/entity search behavior | Todo |
|
||||
| P1.3 | Implement Meilisearch engine wrapper, index naming, bootstrap, sortable/filterable/searchable settings. | `internal/search/engine_meili.go` | Chatwoot search models/services | Todo |
|
||||
| P1.4 | Keep existing DB search as explicit dev fallback only. Production config must prefer Meilisearch. | `internal/search/engine_db.go`, `internal/search/search_service.go` | User decision on Meilisearch | Todo |
|
||||
| P1.5 | Define documents and serializers for conversations, messages, contacts, companies, articles, and help-center content. | `internal/search/documents.go` | `reference/chatwoot` models/serializers | Todo |
|
||||
| P1.1 | Add `SearchConfig` with engine, host, API key, index prefix, env binding, and defaults. | `internal/config/config.go`, `configs/config.yaml`, config tests | `.hermes/plans/2025-05-24-global-search-meilisearch.md` | Done |
|
||||
| P1.2 | Add stable engine contract for search, indexing, deletes, batch indexing, and close. | `internal/search/engine.go` | Chatwoot global/entity search behavior | Done |
|
||||
| P1.3 | Implement Meilisearch engine wrapper, index naming, bootstrap, sortable/filterable/searchable settings. | `internal/search/engine_meili.go` | Chatwoot search models/services | Review |
|
||||
| P1.4 | Keep existing DB search as explicit dev fallback only. Production config must prefer Meilisearch. | `internal/search/engine_db.go`, `internal/search/search_service.go` | User decision on Meilisearch | Done |
|
||||
| P1.5 | Define documents and serializers for conversations, messages, contacts, companies, articles, and help-center content. | `internal/search/engine.go` | `reference/chatwoot` models/serializers | Review |
|
||||
| P1.6 | Wire create/update/delete hooks from entity services into async or synchronous indexing boundary. | `internal/service/*`, `internal/repository/*`, `internal/worker/*` | Chatwoot callbacks/jobs | Todo |
|
||||
| P1.7 | Add batch reindex command and account/entity filters. | `cmd/reindex_search` or equivalent | Chatwoot reindex/search tasks | Todo |
|
||||
| P1.8 | Add mocked engine tests and service integration tests without requiring live Meilisearch. | `internal/search/*_test.go`, touched service tests | Existing test style | Todo |
|
||||
| P1.9 | Document Meilisearch env vars and local startup flow. | this doc, ops docs if needed | Hermes plan | Todo |
|
||||
| P1.7 | Add batch reindex command and account/entity filters. | `cmd/reindex_search` | Chatwoot reindex/search tasks | Done |
|
||||
| P1.8 | Add mocked engine tests and service integration tests without requiring live Meilisearch. | `internal/search/engine_test.go`, `internal/config/config_test.go` | Existing test style | Done |
|
||||
| P1.9 | Document Meilisearch env vars and local startup flow. | this doc, ops docs if needed | Hermes plan | Done |
|
||||
|
||||
Meilisearch local flow:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 7700:7700 -e MEILI_MASTER_KEY=gochat_dev getmeili/meilisearch:latest
|
||||
GOCHAT_SEARCH_ENGINE=meilisearch GOCHAT_SEARCH_HOST=http://localhost:7700 GOCHAT_SEARCH_API_KEY=gochat_dev go run ./cmd/reindex_search -types all
|
||||
```
|
||||
|
||||
Search environment variables:
|
||||
|
||||
| Variable | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `GOCHAT_SEARCH_ENGINE` | `meilisearch` | Use `db` only for explicit local fallback. |
|
||||
| `GOCHAT_SEARCH_HOST` | `http://localhost:7700` | Meilisearch endpoint. |
|
||||
| `GOCHAT_SEARCH_API_KEY` | empty | Set to Meilisearch master/search key when enabled. |
|
||||
| `GOCHAT_SEARCH_INDEX_PREFIX` | `gochat_` | Prefixes indexes such as `gochat_conversations`. |
|
||||
| `GOCHAT_SEARCH_TIMEOUT_SECONDS` | `5` | HTTP timeout for search/index requests. |
|
||||
|
||||
## Phase 2: Route And Controller Parity Audit
|
||||
|
||||
@@ -295,3 +312,4 @@ env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go run ./cmd/d
|
||||
## Progress Log
|
||||
|
||||
- 2026-06-04: Baseline stabilized and committed as `42cdab8 chore: stabilize chatwoot parity baseline`; `go test ./...` passed and route dump reported `TOTAL: 704`.
|
||||
- 2026-06-04: Phase 1 search foundation added: Meilisearch config/env defaults, `SearchEngine` contract, Meilisearch HTTP wrapper with bootstrap/settings, DB fallback adapter, document builders, reindex command, and no-live-Meilisearch tests. Verified `go test ./...` in unsandboxed mode because miniredis/httptest need local sockets; route dump still reports `TOTAL: 704`.
|
||||
|
||||
+123
-113
@@ -14,17 +14,17 @@ import (
|
||||
"github.com/gochat/gochat/internal/campaign"
|
||||
"github.com/gochat/gochat/internal/canned"
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
emailchannel "github.com/gochat/gochat/internal/channel/email"
|
||||
facebookchannel "github.com/gochat/gochat/internal/channel/facebook"
|
||||
googlechannel "github.com/gochat/gochat/internal/channel/google"
|
||||
linechannel "github.com/gochat/gochat/internal/channel/line"
|
||||
microsoftchannel "github.com/gochat/gochat/internal/channel/microsoft"
|
||||
channelprovider "github.com/gochat/gochat/internal/channel/provider"
|
||||
telegramchannel "github.com/gochat/gochat/internal/channel/telegram"
|
||||
tiktokchannel "github.com/gochat/gochat/internal/channel/tiktok"
|
||||
twiliochannel "github.com/gochat/gochat/internal/channel/twilio"
|
||||
twitterchannel "github.com/gochat/gochat/internal/channel/twitter"
|
||||
whatsappchannel "github.com/gochat/gochat/internal/channel/whatsapp"
|
||||
tiktokchannel "github.com/gochat/gochat/internal/channel/tiktok"
|
||||
linechannel "github.com/gochat/gochat/internal/channel/line"
|
||||
twiliochannel "github.com/gochat/gochat/internal/channel/twilio"
|
||||
emailchannel "github.com/gochat/gochat/internal/channel/email"
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
"github.com/gochat/gochat/internal/database"
|
||||
v1 "github.com/gochat/gochat/internal/handler/api/v1"
|
||||
@@ -621,7 +621,17 @@ func Bootstrap(env string) (*App, error) {
|
||||
assignmentPolicyV2Service := service.NewAssignmentPolicyV2Service(assignmentPolicyV2Repo, assignmentPolicyInboxRepo)
|
||||
|
||||
// Search service (M14 — Global Search + Advanced Filter)
|
||||
searchService := search.NewSearchService(searchRepo)
|
||||
searchEngine, err := search.NewSearchEngine(search.EngineConfig{
|
||||
Engine: cfg.Search.Engine,
|
||||
Host: cfg.Search.Host,
|
||||
APIKey: cfg.Search.APIKey,
|
||||
IndexPrefix: cfg.Search.IndexPrefix,
|
||||
TimeoutSeconds: cfg.Search.TimeoutSeconds,
|
||||
}, searchRepo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search engine init failed: %w", err)
|
||||
}
|
||||
searchService := search.NewSearchServiceWithEngine(searchEngine, searchRepo)
|
||||
|
||||
// Custom attribute definition + custom filter + custom attribute value services
|
||||
customAttributeDefinitionService := service.NewCustomAttributeDefinitionService(customAttributeDefinitionRepo)
|
||||
@@ -667,133 +677,133 @@ func Bootstrap(env string) (*App, error) {
|
||||
contactMergeRepo := repository.NewContactMergeRepo(db)
|
||||
contactMergeService := service.NewContactMergeService(contactMergeRepo, db)
|
||||
handlers := &router.Handlers{
|
||||
Auth: v1.NewAuthHandler(authService, oauthService),
|
||||
MFA: v1.NewMFAHandler(mfaService),
|
||||
SAML: v1.NewSAMLHandler(samlService, jwtService, refreshStore, ssoSessionStore, &cfg.SAML),
|
||||
Account: v1.NewAccountHandler(accountService),
|
||||
Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService),
|
||||
Conversation: v1.NewConversationHandler(conversationService, messageService),
|
||||
Inbox: v1.NewInboxHandler(inboxService),
|
||||
InboxMember: v1.NewInboxMemberHandler(inboxMemberService),
|
||||
WebWidget: v1.NewWebWidgetHandler(inboxService),
|
||||
WebWidgetTheme: v1.NewWebWidgetThemeHandler(widgetService, inboxService),
|
||||
WebWidgetPreChat: v1.NewWebWidgetPreChatHandler(widgetService, inboxService),
|
||||
WebWidgetOffline: v1.NewWebWidgetOfflineHandler(widgetService, inboxService),
|
||||
InstagramChannel: v1.NewInstagramChannelHandler(igService, igProvider, inboxService, igRepo),
|
||||
FacebookChannel: v1.NewFacebookChannelHandler(fbChannelService, fbProvider, inboxService, fbChannelRepo),
|
||||
TwitterChannel: v1.NewTwitterChannelHandler(twService, twProvider, inboxService, twRepo),
|
||||
MicrosoftChannel: v1.NewMicrosoftChannelHandler(msService, msProvider, inboxService, msRepo),
|
||||
GoogleChannel: v1.NewGoogleChannelHandler(goService, goProvider, inboxService, goRepo),
|
||||
TikTokChannel: v1.NewTikTokChannelHandler(ttChannelSvc, ttProvider, inboxService, ttChannelRepo),
|
||||
LINEChannel: v1.NewLINEChannelHandler(lineChannelSvc, lineProvider, inboxService, lineChannelRepo),
|
||||
TwilioSMSChannel: v1.NewTwilioChannelHandler(twilioSMSSvc, inboxService, twilioSMSRepo),
|
||||
EmailChannel: v1.NewEmailChannelHandler(emailChannelSvc, inboxService, emailChannelRepo),
|
||||
EmailWebhook: emailWebhookHandler,
|
||||
Message: v1.NewMessageHandler(messageService),
|
||||
Profile: v1.NewProfileHandler(profileService),
|
||||
Notification: v1.NewNotificationHandler(notificationService),
|
||||
PlatformApp: v1.NewPlatformAppHandler(platformAppService),
|
||||
Team: v1.NewTeamHandler(teamService),
|
||||
CaptainAssistant: v1.NewCaptainAssistantHandler(captainAssistantService),
|
||||
CaptainDocument: v1.NewCaptainDocumentHandler(captainDocumentService),
|
||||
CaptainScenario: v1.NewCaptainScenarioHandler(captainScenarioService),
|
||||
CaptainCustomTool: v1.NewCaptainCustomToolHandler(captainCustomToolService),
|
||||
Auth: v1.NewAuthHandler(authService, oauthService),
|
||||
MFA: v1.NewMFAHandler(mfaService),
|
||||
SAML: v1.NewSAMLHandler(samlService, jwtService, refreshStore, ssoSessionStore, &cfg.SAML),
|
||||
Account: v1.NewAccountHandler(accountService),
|
||||
Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService),
|
||||
Conversation: v1.NewConversationHandler(conversationService, messageService),
|
||||
Inbox: v1.NewInboxHandler(inboxService),
|
||||
InboxMember: v1.NewInboxMemberHandler(inboxMemberService),
|
||||
WebWidget: v1.NewWebWidgetHandler(inboxService),
|
||||
WebWidgetTheme: v1.NewWebWidgetThemeHandler(widgetService, inboxService),
|
||||
WebWidgetPreChat: v1.NewWebWidgetPreChatHandler(widgetService, inboxService),
|
||||
WebWidgetOffline: v1.NewWebWidgetOfflineHandler(widgetService, inboxService),
|
||||
InstagramChannel: v1.NewInstagramChannelHandler(igService, igProvider, inboxService, igRepo),
|
||||
FacebookChannel: v1.NewFacebookChannelHandler(fbChannelService, fbProvider, inboxService, fbChannelRepo),
|
||||
TwitterChannel: v1.NewTwitterChannelHandler(twService, twProvider, inboxService, twRepo),
|
||||
MicrosoftChannel: v1.NewMicrosoftChannelHandler(msService, msProvider, inboxService, msRepo),
|
||||
GoogleChannel: v1.NewGoogleChannelHandler(goService, goProvider, inboxService, goRepo),
|
||||
TikTokChannel: v1.NewTikTokChannelHandler(ttChannelSvc, ttProvider, inboxService, ttChannelRepo),
|
||||
LINEChannel: v1.NewLINEChannelHandler(lineChannelSvc, lineProvider, inboxService, lineChannelRepo),
|
||||
TwilioSMSChannel: v1.NewTwilioChannelHandler(twilioSMSSvc, inboxService, twilioSMSRepo),
|
||||
EmailChannel: v1.NewEmailChannelHandler(emailChannelSvc, inboxService, emailChannelRepo),
|
||||
EmailWebhook: emailWebhookHandler,
|
||||
Message: v1.NewMessageHandler(messageService),
|
||||
Profile: v1.NewProfileHandler(profileService),
|
||||
Notification: v1.NewNotificationHandler(notificationService),
|
||||
PlatformApp: v1.NewPlatformAppHandler(platformAppService),
|
||||
Team: v1.NewTeamHandler(teamService),
|
||||
CaptainAssistant: v1.NewCaptainAssistantHandler(captainAssistantService),
|
||||
CaptainDocument: v1.NewCaptainDocumentHandler(captainDocumentService),
|
||||
CaptainScenario: v1.NewCaptainScenarioHandler(captainScenarioService),
|
||||
CaptainCustomTool: v1.NewCaptainCustomToolHandler(captainCustomToolService),
|
||||
CaptainTask: v1.NewCaptainTaskHandler(captainTaskService),
|
||||
CaptainPreference: v1.NewCaptainPreferenceHandler(captainPreferenceService),
|
||||
CaptainTaskExtended: v1.NewCaptainTaskExtendedHandler(captainTaskExtendedService),
|
||||
CaptainAssistantResponse: v1.NewCaptainAssistantResponseHandler(captainAssistantResponseService),
|
||||
CaptainBulkAction: v1.NewCaptainBulkActionHandler(captainBulkActionService),
|
||||
Copilot: v1.NewCopilotHandler(copilotService),
|
||||
Analytics: v1.NewAnalyticsHandler(analyticsService),
|
||||
LiveReport: v1.NewLiveReportHandler(analyticsService),
|
||||
DashboardApp: v1.NewDashboardAppHandler(dashboardAppService),
|
||||
Portal: v1.NewPortalHandler(portalService),
|
||||
Category: v1.NewCategoryHandler(categoryService),
|
||||
Article: v1.NewArticleHandler(articleService),
|
||||
Folder: v1.NewFolderHandler(folderService),
|
||||
PortalMember: v1.NewPortalMemberHandler(portalMemberService),
|
||||
AutomationRule: v1.NewAutomationRuleHandler(automationRuleService),
|
||||
Macro: v1.NewMacroHandler(macroService),
|
||||
CsatSurvey: v1.NewCsatSurveyHandler(csatSurveyService),
|
||||
CannedResponse: v1.NewCannedResponseHandler(cannedResponseService),
|
||||
PushSubscription: v1.NewPushSubscriptionHandler(pushSubscriptionService),
|
||||
Copilot: v1.NewCopilotHandler(copilotService),
|
||||
Analytics: v1.NewAnalyticsHandler(analyticsService),
|
||||
LiveReport: v1.NewLiveReportHandler(analyticsService),
|
||||
DashboardApp: v1.NewDashboardAppHandler(dashboardAppService),
|
||||
Portal: v1.NewPortalHandler(portalService),
|
||||
Category: v1.NewCategoryHandler(categoryService),
|
||||
Article: v1.NewArticleHandler(articleService),
|
||||
Folder: v1.NewFolderHandler(folderService),
|
||||
PortalMember: v1.NewPortalMemberHandler(portalMemberService),
|
||||
AutomationRule: v1.NewAutomationRuleHandler(automationRuleService),
|
||||
Macro: v1.NewMacroHandler(macroService),
|
||||
CsatSurvey: v1.NewCsatSurveyHandler(csatSurveyService),
|
||||
CannedResponse: v1.NewCannedResponseHandler(cannedResponseService),
|
||||
PushSubscription: v1.NewPushSubscriptionHandler(pushSubscriptionService),
|
||||
NotificationSubscription: v1.NewNotificationSubscriptionHandler(notificationSubscriptionService),
|
||||
WebhookSubscription: v1.NewWebhookSubscriptionHandler(webhookSubscriptionService),
|
||||
TelegramWebhook: telegramWebhookHandler,
|
||||
FacebookWebhook: facebookWebhookHandler,
|
||||
WhatsAppWebhook: whatsappWebhookHandler,
|
||||
TikTokWebhook: tiktokWebhookHandler,
|
||||
LineWebhook: lineWebhookHandler,
|
||||
TwilioWebhook: twilioWebhookHandler,
|
||||
Label: v1.NewLabelHandler(tagService, labelService),
|
||||
Campaign: v1.NewCampaignHandler(campaignService),
|
||||
AssignmentPolicy: v1.NewAssignmentPolicyHandler(assignmentPolicyService),
|
||||
SlaPolicy: v1.NewSlaPolicyHandler(slaPolicyService),
|
||||
AssignmentPolicyV2: v1.NewAssignmentPolicyV2Handler(assignmentPolicyV2Service),
|
||||
WebhookSubscription: v1.NewWebhookSubscriptionHandler(webhookSubscriptionService),
|
||||
TelegramWebhook: telegramWebhookHandler,
|
||||
FacebookWebhook: facebookWebhookHandler,
|
||||
WhatsAppWebhook: whatsappWebhookHandler,
|
||||
TikTokWebhook: tiktokWebhookHandler,
|
||||
LineWebhook: lineWebhookHandler,
|
||||
TwilioWebhook: twilioWebhookHandler,
|
||||
Label: v1.NewLabelHandler(tagService, labelService),
|
||||
Campaign: v1.NewCampaignHandler(campaignService),
|
||||
AssignmentPolicy: v1.NewAssignmentPolicyHandler(assignmentPolicyService),
|
||||
SlaPolicy: v1.NewSlaPolicyHandler(slaPolicyService),
|
||||
AssignmentPolicyV2: v1.NewAssignmentPolicyV2Handler(assignmentPolicyV2Service),
|
||||
// Enterprise: AuditLog, CustomRole, AgentCapacityPolicy, CsatMetrics handlers
|
||||
Audit: v1.NewAuditHandler(auditService),
|
||||
CustomRole: v1.NewCustomRoleHandler(customRoleService),
|
||||
AgentCapacity: v1.NewAgentCapacityHandler(agentCapacityPolicyService),
|
||||
CsatMetrics: v1.NewCsatMetricsHandler(csatMetricsService),
|
||||
Search: v1.NewSearchHandler(searchService),
|
||||
Widget: widgetHandler,
|
||||
Audit: v1.NewAuditHandler(auditService),
|
||||
CustomRole: v1.NewCustomRoleHandler(customRoleService),
|
||||
AgentCapacity: v1.NewAgentCapacityHandler(agentCapacityPolicyService),
|
||||
CsatMetrics: v1.NewCsatMetricsHandler(csatMetricsService),
|
||||
Search: v1.NewSearchHandler(searchService),
|
||||
Widget: widgetHandler,
|
||||
// M13: SSO/SAML enterprise authentication handlers
|
||||
AccountSamlSettings: v1.NewAccountSamlSettingsHandler(accountSamlSettingsRepo),
|
||||
SSOSession: v1.NewSSOSessionHandler(ssoSessionStore),
|
||||
// M13: LDAP/OIDC enterprise authentication handlers
|
||||
LDAP: v1.NewLDAPHandler(ldapService, ssoMiddleware, jwtService, refreshStore, ssoSessionStore, &cfg.LDAP, db),
|
||||
OIDC: v1.NewOIDCHandler(oidcService, ssoMiddleware, jwtService, refreshStore, &cfg.OIDC),
|
||||
SSOMiddleware: ssoMiddleware,
|
||||
LDAP: v1.NewLDAPHandler(ldapService, ssoMiddleware, jwtService, refreshStore, ssoSessionStore, &cfg.LDAP, db),
|
||||
OIDC: v1.NewOIDCHandler(oidcService, ssoMiddleware, jwtService, refreshStore, &cfg.OIDC),
|
||||
SSOMiddleware: ssoMiddleware,
|
||||
// M12: AgentBot handlers (platform-level + account-level bots)
|
||||
AgentBot: v1.NewAgentBotHandler(agentBotService),
|
||||
InstallationConfig: v1.NewInstallationConfigHandler(installationConfigService),
|
||||
WidgetTest: v1.NewWidgetTestHandler(widgetTestService),
|
||||
AgentBotInbox: v1.NewAgentBotInboxHandler(agentBotInboxService),
|
||||
AgentBot: v1.NewAgentBotHandler(agentBotService),
|
||||
InstallationConfig: v1.NewInstallationConfigHandler(installationConfigService),
|
||||
WidgetTest: v1.NewWidgetTestHandler(widgetTestService),
|
||||
AgentBotInbox: v1.NewAgentBotInboxHandler(agentBotInboxService),
|
||||
// P9: AgentBot rule engine + trigger config handlers
|
||||
BotRule: v1.NewBotRuleHandler(botRuleService),
|
||||
BotTriggerConfig: v1.NewBotTriggerConfigHandler(botTriggerConfigService),
|
||||
BotRule: v1.NewBotRuleHandler(botRuleService),
|
||||
BotTriggerConfig: v1.NewBotTriggerConfigHandler(botTriggerConfigService),
|
||||
// M12: SSE streaming + conversation insight handlers
|
||||
SSEStream: v1.NewSSEStreamHandler(copilotService, llmProvider),
|
||||
SSEStream: v1.NewSSEStreamHandler(copilotService, llmProvider),
|
||||
ConversationInsight: v1.NewConversationInsightHandler(conversationInsightService),
|
||||
// M4 G3: ContactInbox filter handler (account-scope)
|
||||
ContactInboxFilter: v1.NewContactInboxHandler(contactInboxService),
|
||||
ContactInboxFilter: v1.NewContactInboxHandler(contactInboxService),
|
||||
// Conversation participant + draft message handlers
|
||||
ConversationParticipant: v1.NewConversationParticipantHandler(conversationParticipantService),
|
||||
DraftMessage: v1.NewDraftMessageHandler(draftMessageService),
|
||||
// G4: Companies module (CRUD + search + nested contacts/conversations/notes)
|
||||
Company: v1.NewCompanyHandler(companyService),
|
||||
Company: v1.NewCompanyHandler(companyService),
|
||||
// Custom attributes + custom filters
|
||||
CustomAttributeDefinition: v1.NewCustomAttributeDefinitionHandler(customAttributeDefinitionService),
|
||||
CustomAttributeValue: v1.NewCustomAttributeValueHandler(customAttributeValueService),
|
||||
CustomFilter: v1.NewCustomFilterHandler(customFilterService),
|
||||
// G16: Third-party integration handlers (IntegrationHook CRUD + Slack/Shopify/Linear/Notion)
|
||||
IntegrationHook: v1.NewIntegrationHookHandler(integrationHookService),
|
||||
SlackIntegration: v1.NewSlackIntegrationHandler(slackIntegrationService),
|
||||
ShopifyIntegration: v1.NewShopifyIntegrationHandler(shopifyIntegrationService),
|
||||
LinearIntegration: v1.NewLinearIntegrationHandler(linearIntegrationService),
|
||||
NotionIntegration: v1.NewNotionIntegrationHandler(notionIntegrationService),
|
||||
PlatformUserSSO: v1.NewPlatformUserSSOHandler(),
|
||||
IntegrationHook: v1.NewIntegrationHookHandler(integrationHookService),
|
||||
SlackIntegration: v1.NewSlackIntegrationHandler(slackIntegrationService),
|
||||
ShopifyIntegration: v1.NewShopifyIntegrationHandler(shopifyIntegrationService),
|
||||
LinearIntegration: v1.NewLinearIntegrationHandler(linearIntegrationService),
|
||||
NotionIntegration: v1.NewNotionIntegrationHandler(notionIntegrationService),
|
||||
PlatformUserSSO: v1.NewPlatformUserSSOHandler(),
|
||||
// Platform API AccessToken-authenticated handlers
|
||||
PlatformUser: v1.NewPlatformUserHandler(platformUserService),
|
||||
PlatformAccount: v1.NewPlatformAccountHandler(accountRepo, permissibleRepo, accountService),
|
||||
PlatformAgentBot: v1.NewPlatformAgentBotHandler(agentBotRepo, permissibleRepo),
|
||||
PlatformAccountUser: v1.NewPlatformAccountUserHandler(accountRepo, userRepo, permissibleRepo),
|
||||
Upload: uploadHandler,
|
||||
PlatformUser: v1.NewPlatformUserHandler(platformUserService),
|
||||
PlatformAccount: v1.NewPlatformAccountHandler(accountRepo, permissibleRepo, accountService),
|
||||
PlatformAgentBot: v1.NewPlatformAgentBotHandler(agentBotRepo, permissibleRepo),
|
||||
PlatformAccountUser: v1.NewPlatformAccountUserHandler(accountRepo, userRepo, permissibleRepo),
|
||||
Upload: uploadHandler,
|
||||
// Lane B: AssignableAgent handler (find agents available for assignment)
|
||||
AssignableAgent: v1.NewAssignableAgentHandler(assignableAgentService),
|
||||
AgentBulk: v1.NewAgentBulkHandler(conversationService),
|
||||
BulkAction: v1.NewBulkActionHandler(conversationService, contactService),
|
||||
AssignableAgent: v1.NewAssignableAgentHandler(assignableAgentService),
|
||||
AgentBulk: v1.NewAgentBulkHandler(conversationService),
|
||||
BulkAction: v1.NewBulkActionHandler(conversationService, contactService),
|
||||
// Lane C: CSAT template (singular per inbox) + Inbox limits
|
||||
InboxCsatTemplate: v1.NewInboxCsatTemplateHandler(csatTemplateService),
|
||||
InboxLimit: v1.NewInboxLimitHandler(inboxLimitService),
|
||||
WorkingHour: v1.NewWorkingHourHandler(workingHourService),
|
||||
InboxCsatTemplate: v1.NewInboxCsatTemplateHandler(csatTemplateService),
|
||||
InboxLimit: v1.NewInboxLimitHandler(inboxLimitService),
|
||||
WorkingHour: v1.NewWorkingHourHandler(workingHourService),
|
||||
// Banner handler (Platform CRUD + account read-only)
|
||||
Banner: v1.NewBannerHandler(bannerService),
|
||||
Banner: v1.NewBannerHandler(bannerService),
|
||||
// EmailChannelMigration handler (account-scoped create-only)
|
||||
EmailChannelMigration: v1.NewEmailChannelMigrationHandler(emailChannelMigrationService),
|
||||
// SummaryReport handler (read-only reporting resource — agent/team/inbox/label summaries)
|
||||
SummaryReport: v1.NewSummaryReportHandler(summaryReportService),
|
||||
}
|
||||
}
|
||||
_ = agentBotListener // M12: subscribed to message events via service calls
|
||||
|
||||
// Step 10: Setup Gin router + middleware chain
|
||||
@@ -849,12 +859,12 @@ func Bootstrap(env string) (*App, error) {
|
||||
applogger.L().Info("All dependencies wired successfully")
|
||||
|
||||
return &App{
|
||||
config: cfg,
|
||||
reloader: reloader,
|
||||
db: db,
|
||||
pubsub: ps,
|
||||
engine: engine,
|
||||
wsHub: wsHub,
|
||||
config: cfg,
|
||||
reloader: reloader,
|
||||
db: db,
|
||||
pubsub: ps,
|
||||
engine: engine,
|
||||
wsHub: wsHub,
|
||||
notificationDeliverySvc: notificationDeliverySvc,
|
||||
}, nil
|
||||
}
|
||||
@@ -868,10 +878,10 @@ type hubTypingAdapter struct {
|
||||
|
||||
func (a *hubTypingAdapter) SetTypingOn(ctx context.Context, accountID, conversationID uint, performer *wspkg.Performer) error {
|
||||
msg := &wspkg.WSMessage{
|
||||
Event: wspkg.EventConversationTypingOn,
|
||||
Data: map[string]any{"conversation_id": conversationID},
|
||||
AccountID: accountID,
|
||||
Performer: performer,
|
||||
Event: wspkg.EventConversationTypingOn,
|
||||
Data: map[string]any{"conversation_id": conversationID},
|
||||
AccountID: accountID,
|
||||
Performer: performer,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
a.hub.SendToAccount(accountID, data)
|
||||
@@ -880,10 +890,10 @@ func (a *hubTypingAdapter) SetTypingOn(ctx context.Context, accountID, conversat
|
||||
|
||||
func (a *hubTypingAdapter) SetTypingOff(ctx context.Context, accountID, conversationID uint, performer *wspkg.Performer) error {
|
||||
msg := &wspkg.WSMessage{
|
||||
Event: wspkg.EventConversationTypingOff,
|
||||
Data: map[string]any{"conversation_id": conversationID},
|
||||
AccountID: accountID,
|
||||
Performer: performer,
|
||||
Event: wspkg.EventConversationTypingOff,
|
||||
Data: map[string]any{"conversation_id": conversationID},
|
||||
AccountID: accountID,
|
||||
Performer: performer,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
a.hub.SendToAccount(accountID, data)
|
||||
|
||||
+190
-140
@@ -17,57 +17,68 @@ import (
|
||||
// Build metadata injected via -ldflags at build time.
|
||||
// Usage: go build -ldflags="-X github.com/gochat/gochat/internal/config.Version=v1.0.0 ..."
|
||||
var (
|
||||
Version = "dev" // semantic version, e.g. v1.2.3
|
||||
CommitSHA = "unknown" // git commit short hash
|
||||
BuildDate = "unknown" // UTC timestamp of build
|
||||
Version = "dev" // semantic version, e.g. v1.2.3
|
||||
CommitSHA = "unknown" // git commit short hash
|
||||
BuildDate = "unknown" // UTC timestamp of build
|
||||
)
|
||||
|
||||
// Config holds all application configuration.
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
Captain CaptainConfig `mapstructure:"captain"`
|
||||
Worker WorkerConfig `mapstructure:"worker"`
|
||||
OAuth OAuthConfig `mapstructure:"oauth"`
|
||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||
SAML SAMLConfig `mapstructure:"saml"`
|
||||
LDAP LDAPConfig `mapstructure:"ldap"`
|
||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
||||
Push PushConfig `mapstructure:"push"`
|
||||
Notification NotificationConfig `mapstructure:"notification"`
|
||||
Webhook WebhookConfig `mapstructure:"webhook"`
|
||||
CSRF CSRFConfig `mapstructure:"csrf"`
|
||||
Session SessionConfig `mapstructure:"session"`
|
||||
Storage StorageConfig `mapstructure:"storage"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
Captain CaptainConfig `mapstructure:"captain"`
|
||||
Worker WorkerConfig `mapstructure:"worker"`
|
||||
OAuth OAuthConfig `mapstructure:"oauth"`
|
||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||
SAML SAMLConfig `mapstructure:"saml"`
|
||||
LDAP LDAPConfig `mapstructure:"ldap"`
|
||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
||||
Push PushConfig `mapstructure:"push"`
|
||||
Notification NotificationConfig `mapstructure:"notification"`
|
||||
Webhook WebhookConfig `mapstructure:"webhook"`
|
||||
Search SearchConfig `mapstructure:"search"`
|
||||
CSRF CSRFConfig `mapstructure:"csrf"`
|
||||
Session SessionConfig `mapstructure:"session"`
|
||||
Storage StorageConfig `mapstructure:"storage"`
|
||||
}
|
||||
|
||||
type WorkerConfig struct {
|
||||
Concurrency int `mapstructure:"concurrency"`
|
||||
}
|
||||
|
||||
// SearchConfig controls the full-text search backend. Meilisearch is the
|
||||
// production target for Chatwoot parity; db is only a local development fallback.
|
||||
type SearchConfig struct {
|
||||
Engine string `mapstructure:"engine"` // meilisearch or db
|
||||
Host string `mapstructure:"host"` // e.g. http://localhost:7700
|
||||
APIKey string `mapstructure:"api_key"` // Meilisearch master/search key
|
||||
IndexPrefix string `mapstructure:"index_prefix"` // index name prefix, e.g. gochat_
|
||||
TimeoutSeconds int `mapstructure:"timeout_seconds"` // HTTP timeout for Meilisearch calls
|
||||
}
|
||||
|
||||
type OAuthProviderConfig struct {
|
||||
ClientID string `mapstructure:"client_id"`
|
||||
ClientSecret string `mapstructure:"client_secret"`
|
||||
RedirectURL string `mapstructure:"redirect_url"`
|
||||
TenantID string `mapstructure:"tenant_id"` // Azure AD tenant (Microsoft-specific)
|
||||
Scopes string `mapstructure:"scopes"` // comma-separated OAuth scopes
|
||||
TenantID string `mapstructure:"tenant_id"` // Azure AD tenant (Microsoft-specific)
|
||||
Scopes string `mapstructure:"scopes"` // comma-separated OAuth scopes
|
||||
}
|
||||
|
||||
type OAuthConfig struct {
|
||||
Google OAuthProviderConfig `mapstructure:"google"`
|
||||
GitHub OAuthProviderConfig `mapstructure:"github"`
|
||||
Twitter OAuthProviderConfig `mapstructure:"twitter"`
|
||||
Microsoft OAuthProviderConfig `mapstructure:"microsoft"`
|
||||
Facebook OAuthProviderConfig `mapstructure:"facebook"`
|
||||
Google OAuthProviderConfig `mapstructure:"google"`
|
||||
GitHub OAuthProviderConfig `mapstructure:"github"`
|
||||
Twitter OAuthProviderConfig `mapstructure:"twitter"`
|
||||
Microsoft OAuthProviderConfig `mapstructure:"microsoft"`
|
||||
Facebook OAuthProviderConfig `mapstructure:"facebook"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"` // debug, release, test
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"` // debug, release, test
|
||||
CORS CORSConfig `mapstructure:"cors"`
|
||||
}
|
||||
|
||||
@@ -95,8 +106,8 @@ type DatabaseConfig struct {
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"` // seconds
|
||||
RunMigrations bool `mapstructure:"run_migrations"` // run golang-migrate on startup
|
||||
MigrationsPath string `mapstructure:"migrations_path"` // path to migration files (default: "migrations")
|
||||
RunMigrations bool `mapstructure:"run_migrations"` // run golang-migrate on startup
|
||||
MigrationsPath string `mapstructure:"migrations_path"` // path to migration files (default: "migrations")
|
||||
}
|
||||
|
||||
func (d DatabaseConfig) DSN() string {
|
||||
@@ -136,12 +147,12 @@ type RedisConfig struct {
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string `mapstructure:"secret"`
|
||||
ExpiryHours int `mapstructure:"expiry_hours"`
|
||||
RefreshExpiryHours int `mapstructure:"refresh_expiry_hours"`
|
||||
AccessExpiryMinutes int `mapstructure:"access_expiry_minutes"`
|
||||
Audience string `mapstructure:"audience"`
|
||||
Issuer string `mapstructure:"issuer"`
|
||||
Secret string `mapstructure:"secret"`
|
||||
ExpiryHours int `mapstructure:"expiry_hours"`
|
||||
RefreshExpiryHours int `mapstructure:"refresh_expiry_hours"`
|
||||
AccessExpiryMinutes int `mapstructure:"access_expiry_minutes"`
|
||||
Audience string `mapstructure:"audience"`
|
||||
Issuer string `mapstructure:"issuer"`
|
||||
}
|
||||
|
||||
func (j JWTConfig) ExpiryDuration() time.Duration {
|
||||
@@ -157,17 +168,17 @@ type LogConfig struct {
|
||||
// Uses Redis sliding window counter for production, with in-memory fallback.
|
||||
// Reference: Chatwoot's Rack::Attack throttle configuration.
|
||||
type RateLimitConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"` // enable/disable rate limiting
|
||||
RequestsPerMinute int `mapstructure:"requests_per_minute"` // max requests per client per window
|
||||
WindowSeconds int `mapstructure:"window_seconds"` // sliding window duration in seconds
|
||||
Enabled bool `mapstructure:"enabled"` // enable/disable rate limiting
|
||||
RequestsPerMinute int `mapstructure:"requests_per_minute"` // max requests per client per window
|
||||
WindowSeconds int `mapstructure:"window_seconds"` // sliding window duration in seconds
|
||||
}
|
||||
|
||||
// CaptainConfig holds Captain AI and Copilot feature configuration.
|
||||
// Reference: Chatwoot config/features.yml + ENV variables for Captain
|
||||
type CaptainConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
LLMProvider string `mapstructure:"llm_provider"` // openai, azure, custom
|
||||
LLMModel string `mapstructure:"llm_model"` // gpt-4o, gpt-3.5-turbo, etc.
|
||||
LLMProvider string `mapstructure:"llm_provider"` // openai, azure, custom
|
||||
LLMModel string `mapstructure:"llm_model"` // gpt-4o, gpt-3.5-turbo, etc.
|
||||
LLMAPIKey string `mapstructure:"llm_api_key"`
|
||||
LLMBaseURL string `mapstructure:"llm_base_url"` // custom endpoint
|
||||
EmbeddingModel string `mapstructure:"embedding_model"` // text-embedding-3-small
|
||||
@@ -192,8 +203,8 @@ func (c CaptainConfig) LLMConfig() LLMConfig {
|
||||
|
||||
// LLMConfig holds LLM provider configuration in a provider-friendly format.
|
||||
type LLMConfig struct {
|
||||
Provider string `yaml:"provider"` // openai, azure, custom
|
||||
BaseURL string `yaml:"base_url"` // https://api.openai.com/v1 or custom
|
||||
Provider string `yaml:"provider"` // openai, azure, custom
|
||||
BaseURL string `yaml:"base_url"` // https://api.openai.com/v1 or custom
|
||||
APIKey string `yaml:"api_key"`
|
||||
Model string `yaml:"model"` // gpt-4, gpt-3.5-turbo, etc.
|
||||
EmbedModel string `yaml:"embed_model"` // text-embedding-3-small
|
||||
@@ -204,23 +215,23 @@ type LLMConfig struct {
|
||||
// SAMLConfig holds SAML 2.0 Service Provider configuration.
|
||||
// Reference: P2E §1.6 — SAML SP integration for enterprise SSO.
|
||||
type SAMLConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
IdPMetadataURL string `mapstructure:"idp_metadata_url"` // URL to fetch IdP metadata XML
|
||||
IdPMetadataXML string `mapstructure:"idp_metadata_xml"` // Inline IdP metadata XML (alternative to URL)
|
||||
SPEntityID string `mapstructure:"sp_entity_id"` // Our SP entity ID
|
||||
ACSURL string `mapstructure:"acs_url"` // Assertion Consumer Service URL
|
||||
SPPrivateKey string `mapstructure:"sp_private_key"` // PEM-encoded SP private key
|
||||
SPCertificate string `mapstructure:"sp_certificate"` // PEM-encoded SP certificate
|
||||
AttributeMap SAMLAttributeMap `mapstructure:"attribute_map"` // SAML attribute → GoChat field mapping
|
||||
ClockDriftTolerance int `mapstructure:"clock_drift_tolerance"` // seconds of allowed clock drift
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
IdPMetadataURL string `mapstructure:"idp_metadata_url"` // URL to fetch IdP metadata XML
|
||||
IdPMetadataXML string `mapstructure:"idp_metadata_xml"` // Inline IdP metadata XML (alternative to URL)
|
||||
SPEntityID string `mapstructure:"sp_entity_id"` // Our SP entity ID
|
||||
ACSURL string `mapstructure:"acs_url"` // Assertion Consumer Service URL
|
||||
SPPrivateKey string `mapstructure:"sp_private_key"` // PEM-encoded SP private key
|
||||
SPCertificate string `mapstructure:"sp_certificate"` // PEM-encoded SP certificate
|
||||
AttributeMap SAMLAttributeMap `mapstructure:"attribute_map"` // SAML attribute → GoChat field mapping
|
||||
ClockDriftTolerance int `mapstructure:"clock_drift_tolerance"` // seconds of allowed clock drift
|
||||
}
|
||||
|
||||
// SAMLAttributeMap maps SAML assertion attributes to GoChat user fields.
|
||||
type SAMLAttributeMap struct {
|
||||
Email string `mapstructure:"email"` // SAML attribute name for email
|
||||
Email string `mapstructure:"email"` // SAML attribute name for email
|
||||
DisplayName string `mapstructure:"display_name"` // SAML attribute name for display name
|
||||
FirstName string `mapstructure:"first_name"` // SAML attribute name for first name
|
||||
LastName string `mapstructure:"last_name"` // SAML attribute name for last name
|
||||
FirstName string `mapstructure:"first_name"` // SAML attribute name for first name
|
||||
LastName string `mapstructure:"last_name"` // SAML attribute name for last name
|
||||
}
|
||||
|
||||
// ClockDriftDuration returns clock drift tolerance as a time.Duration.
|
||||
@@ -235,18 +246,18 @@ func (c SAMLConfig) ClockDriftDuration() time.Duration {
|
||||
// Reference: M13 §4.4 — LDAP/Active Directory integration for enterprise authentication.
|
||||
// Per-account LDAP settings override these defaults (stored in DB).
|
||||
type LDAPConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
DefaultHost string `mapstructure:"default_host"` // default LDAP server host (e.g. ldap.example.com)
|
||||
DefaultPort int `mapstructure:"default_port"` // default port (389 for LDAP, 636 for LDAPS)
|
||||
DefaultUseTLS bool `mapstructure:"default_use_tls"` // use StartTLS on LDAP connection
|
||||
DefaultBaseDN string `mapstructure:"default_base_dn"` // default search base DN (e.g. dc=example,dc=com)
|
||||
DefaultBindDN string `mapstructure:"default_bind_dn"` // default bind DN for service account
|
||||
DefaultBindPassword string `mapstructure:"default_bind_password"` // default bind password
|
||||
DefaultUserFilter string `mapstructure:"default_user_filter"` // default LDAP user search filter
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
DefaultHost string `mapstructure:"default_host"` // default LDAP server host (e.g. ldap.example.com)
|
||||
DefaultPort int `mapstructure:"default_port"` // default port (389 for LDAP, 636 for LDAPS)
|
||||
DefaultUseTLS bool `mapstructure:"default_use_tls"` // use StartTLS on LDAP connection
|
||||
DefaultBaseDN string `mapstructure:"default_base_dn"` // default search base DN (e.g. dc=example,dc=com)
|
||||
DefaultBindDN string `mapstructure:"default_bind_dn"` // default bind DN for service account
|
||||
DefaultBindPassword string `mapstructure:"default_bind_password"` // default bind password
|
||||
DefaultUserFilter string `mapstructure:"default_user_filter"` // default LDAP user search filter
|
||||
DefaultEmailAttribute string `mapstructure:"default_email_attribute"` // default email attribute (mail)
|
||||
DefaultNameAttribute string `mapstructure:"default_name_attribute"` // default name attribute (cn)
|
||||
DefaultGroupAttribute string `mapstructure:"default_group_attribute"` // default group attribute (memberOf)
|
||||
SyncInterval int `mapstructure:"sync_interval"` // group sync interval in seconds (default: 3600)
|
||||
SyncInterval int `mapstructure:"sync_interval"` // group sync interval in seconds (default: 3600)
|
||||
}
|
||||
|
||||
// OIDCConfig holds OIDC/OAuth2 enterprise authentication configuration.
|
||||
@@ -254,43 +265,43 @@ type LDAPConfig struct {
|
||||
// Supports Google Workspace, Auth0, Keycloak, Azure AD and any OIDC-compliant IdP.
|
||||
// Per-account OIDC settings override these defaults (stored in DB).
|
||||
type OIDCConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
DefaultClientID string `mapstructure:"default_client_id"` // default OIDC client ID
|
||||
DefaultClientSecret string `mapstructure:"default_client_secret"` // default OIDC client secret
|
||||
DefaultRedirectURL string `mapstructure:"default_redirect_url"` // default redirect URL for callback
|
||||
DefaultIssuerURL string `mapstructure:"default_issuer_url"` // default IdP issuer URL (e.g. https://accounts.google.com)
|
||||
DefaultAuthorizationURL string `mapstructure:"default_authorization_url"` // default authorization endpoint
|
||||
DefaultTokenURL string `mapstructure:"default_token_url"` // default token endpoint
|
||||
DefaultUserInfoURL string `mapstructure:"default_user_info_url"` // default userinfo endpoint (for non-JWT claims)
|
||||
DefaultJWKSURL string `mapstructure:"default_jwks_url"` // default JWKS endpoint for id_token verification
|
||||
DefaultScopes []string `mapstructure:"default_scopes"` // default scopes (openid, profile, email)
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
DefaultClientID string `mapstructure:"default_client_id"` // default OIDC client ID
|
||||
DefaultClientSecret string `mapstructure:"default_client_secret"` // default OIDC client secret
|
||||
DefaultRedirectURL string `mapstructure:"default_redirect_url"` // default redirect URL for callback
|
||||
DefaultIssuerURL string `mapstructure:"default_issuer_url"` // default IdP issuer URL (e.g. https://accounts.google.com)
|
||||
DefaultAuthorizationURL string `mapstructure:"default_authorization_url"` // default authorization endpoint
|
||||
DefaultTokenURL string `mapstructure:"default_token_url"` // default token endpoint
|
||||
DefaultUserInfoURL string `mapstructure:"default_user_info_url"` // default userinfo endpoint (for non-JWT claims)
|
||||
DefaultJWKSURL string `mapstructure:"default_jwks_url"` // default JWKS endpoint for id_token verification
|
||||
DefaultScopes []string `mapstructure:"default_scopes"` // default scopes (openid, profile, email)
|
||||
}
|
||||
|
||||
// PushConfig holds push notification (VAPID/web push) configuration.
|
||||
// Reference: Chatwoot vapid configuration for web push notifications.
|
||||
type PushConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
VapidPublicKey string `mapstructure:"vapid_public_key"`
|
||||
VapidPrivateKey string `mapstructure:"vapid_private_key"`
|
||||
VapidSubject string `mapstructure:"vapid_subject"` // e.g. mailto:admin@example.com
|
||||
VapidSubject string `mapstructure:"vapid_subject"` // e.g. mailto:admin@example.com
|
||||
}
|
||||
|
||||
// NotificationConfig holds notification delivery pipeline configuration.
|
||||
type NotificationConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
DeliveryWorkers int `mapstructure:"delivery_workers"` // concurrent delivery goroutines
|
||||
RetryMaxAttempts int `mapstructure:"retry_max_attempts"`
|
||||
RetryDelaySeconds int `mapstructure:"retry_delay_seconds"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
DeliveryWorkers int `mapstructure:"delivery_workers"` // concurrent delivery goroutines
|
||||
RetryMaxAttempts int `mapstructure:"retry_max_attempts"`
|
||||
RetryDelaySeconds int `mapstructure:"retry_delay_seconds"`
|
||||
}
|
||||
|
||||
// WebhookConfig holds outgoing webhook delivery configuration.
|
||||
// Reference: Chatwoot webhook_config for account-level webhook integrations.
|
||||
type WebhookConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
SigningSecret string `mapstructure:"signing_secret"` // HMAC-SHA256 secret for webhook payloads
|
||||
TimeoutSeconds int `mapstructure:"timeout_seconds"`
|
||||
RetryMaxAttempts int `mapstructure:"retry_max_attempts"`
|
||||
RetryDelaySeconds int `mapstructure:"retry_delay_seconds"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
SigningSecret string `mapstructure:"signing_secret"` // HMAC-SHA256 secret for webhook payloads
|
||||
TimeoutSeconds int `mapstructure:"timeout_seconds"`
|
||||
RetryMaxAttempts int `mapstructure:"retry_max_attempts"`
|
||||
RetryDelaySeconds int `mapstructure:"retry_delay_seconds"`
|
||||
}
|
||||
|
||||
// CSRFConfig holds CSRF protection configuration.
|
||||
@@ -298,35 +309,35 @@ type WebhookConfig struct {
|
||||
// adapted for API-first architecture (no server-side session required).
|
||||
type CSRFConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Secret string `mapstructure:"secret"` // 32-byte hex secret for token generation
|
||||
CookieName string `mapstructure:"cookie_name"` // default: _gochat_csrf
|
||||
HeaderName string `mapstructure:"header_name"` // default: X-CSRF-Token
|
||||
TokenLength int `mapstructure:"token_length"` // default: 32 bytes
|
||||
SafeMethods []string `mapstructure:"safe_methods"` // default: GET, HEAD, OPTIONS
|
||||
SkipPaths []string `mapstructure:"skip_paths"` // paths that skip CSRF validation (e.g., /api/v1/auth/login)
|
||||
CookieSecure bool `mapstructure:"cookie_secure"` // set Secure flag (prod: true)
|
||||
Secret string `mapstructure:"secret"` // 32-byte hex secret for token generation
|
||||
CookieName string `mapstructure:"cookie_name"` // default: _gochat_csrf
|
||||
HeaderName string `mapstructure:"header_name"` // default: X-CSRF-Token
|
||||
TokenLength int `mapstructure:"token_length"` // default: 32 bytes
|
||||
SafeMethods []string `mapstructure:"safe_methods"` // default: GET, HEAD, OPTIONS
|
||||
SkipPaths []string `mapstructure:"skip_paths"` // paths that skip CSRF validation (e.g., /api/v1/auth/login)
|
||||
CookieSecure bool `mapstructure:"cookie_secure"` // set Secure flag (prod: true)
|
||||
CookieHTTPOnly bool `mapstructure:"cookie_http_only"` // set HttpOnly flag (default: false)
|
||||
CookieSameSite string `mapstructure:"cookie_same_site"` // Strict, Lax, or None (default: Strict)
|
||||
CookieDomain string `mapstructure:"cookie_domain"` // optional domain restriction
|
||||
CookiePath string `mapstructure:"cookie_path"` // default: /
|
||||
ExpirySeconds int `mapstructure:"expiry_seconds"` // token rotation period (default: 3600)
|
||||
CookieDomain string `mapstructure:"cookie_domain"` // optional domain restriction
|
||||
CookiePath string `mapstructure:"cookie_path"` // default: /
|
||||
ExpirySeconds int `mapstructure:"expiry_seconds"` // token rotation period (default: 3600)
|
||||
}
|
||||
|
||||
// SessionConfig holds session management configuration.
|
||||
// Reference: Chatwoot Devise sessions — replaced with JWT + session store.
|
||||
type SessionConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
ExpirySeconds int `mapstructure:"expiry_seconds"` // session lifetime (default: 86400 = 24h)
|
||||
TokenLength int `mapstructure:"token_length"` // session ID length in bytes (default: 32)
|
||||
HeaderName string `mapstructure:"header_name"` // header name for session ID (default: X-Session-ID)
|
||||
SkipPaths []string `mapstructure:"skip_paths"` // paths that skip session validation
|
||||
CleanupInterval int `mapstructure:"cleanup_interval"` // expired session cleanup interval in seconds (default: 300)
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
ExpirySeconds int `mapstructure:"expiry_seconds"` // session lifetime (default: 86400 = 24h)
|
||||
TokenLength int `mapstructure:"token_length"` // session ID length in bytes (default: 32)
|
||||
HeaderName string `mapstructure:"header_name"` // header name for session ID (default: X-Session-ID)
|
||||
SkipPaths []string `mapstructure:"skip_paths"` // paths that skip session validation
|
||||
CleanupInterval int `mapstructure:"cleanup_interval"` // expired session cleanup interval in seconds (default: 300)
|
||||
}
|
||||
|
||||
// StorageConfig holds file storage configuration.
|
||||
type StorageConfig struct {
|
||||
Provider string `mapstructure:"provider"` // "local" (default), "s3" (future)
|
||||
LocalPath string `mapstructure:"local_path"` // Directory for local file storage
|
||||
Provider string `mapstructure:"provider"` // "local" (default), "s3" (future)
|
||||
LocalPath string `mapstructure:"local_path"` // Directory for local file storage
|
||||
MaxFileSize int64 `mapstructure:"max_file_size"` // Maximum file size in bytes (default 20MB)
|
||||
}
|
||||
|
||||
@@ -339,6 +350,7 @@ func Load() (*Config, error) {
|
||||
viper.AddConfigPath("/etc/gochat/")
|
||||
|
||||
viper.SetEnvPrefix("GOCHAT")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
viper.AutomaticEnv()
|
||||
|
||||
// Set defaults for rate limiting
|
||||
@@ -387,6 +399,14 @@ func Load() (*Config, error) {
|
||||
viper.SetDefault("webhook.retry_max_attempts", 3)
|
||||
viper.SetDefault("webhook.retry_delay_seconds", 60)
|
||||
|
||||
// Set defaults for search. Meilisearch is the Chatwoot parity target; db is
|
||||
// reserved for explicit local development fallback.
|
||||
viper.SetDefault("search.engine", "meilisearch")
|
||||
viper.SetDefault("search.host", "http://localhost:7700")
|
||||
viper.SetDefault("search.api_key", "")
|
||||
viper.SetDefault("search.index_prefix", "gochat_")
|
||||
viper.SetDefault("search.timeout_seconds", 5)
|
||||
|
||||
// Set defaults for CSRF protection
|
||||
viper.SetDefault("csrf.enabled", true)
|
||||
viper.SetDefault("csrf.cookie_name", "_gochat_csrf")
|
||||
@@ -422,10 +442,26 @@ func Load() (*Config, error) {
|
||||
if cfg.RateLimit.WindowSeconds == 0 {
|
||||
cfg.RateLimit.WindowSeconds = 60
|
||||
}
|
||||
applySearchDefaults(&cfg.Search)
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func applySearchDefaults(search *SearchConfig) {
|
||||
if search.Engine == "" {
|
||||
search.Engine = "meilisearch"
|
||||
}
|
||||
if search.Host == "" {
|
||||
search.Host = "http://localhost:7700"
|
||||
}
|
||||
if search.IndexPrefix == "" {
|
||||
search.IndexPrefix = "gochat_"
|
||||
}
|
||||
if search.TimeoutSeconds == 0 {
|
||||
search.TimeoutSeconds = 5
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigReloader manages hot-reloading of configuration files.
|
||||
// It watches for changes and applies safe, runtime-updatable config fields
|
||||
// without requiring a full application restart.
|
||||
@@ -494,6 +530,7 @@ func (r *ConfigReloader) handleConfigChange(e fsnotify.Event) {
|
||||
if newCfg.RateLimit.WindowSeconds == 0 {
|
||||
newCfg.RateLimit.WindowSeconds = 60
|
||||
}
|
||||
applySearchDefaults(&newCfg.Search)
|
||||
|
||||
// Validate the entire new config — if invalid, skip the reload
|
||||
if err := Validate(&newCfg); err != nil {
|
||||
@@ -567,7 +604,8 @@ func (r *ConfigReloader) Stop() {
|
||||
// LoadWithEnv loads config with environment overlay support.
|
||||
// Base config.yaml is loaded first, then config.{env}.yaml merges on top.
|
||||
// This follows Chatwoot's Rails-style environment-specific config pattern:
|
||||
// config/environments/development.rb overrides config/application.rb defaults.
|
||||
//
|
||||
// config/environments/development.rb overrides config/application.rb defaults.
|
||||
func LoadWithEnv(env string) (*Config, error) {
|
||||
v := viper.New()
|
||||
|
||||
@@ -579,44 +617,49 @@ func LoadWithEnv(env string) (*Config, error) {
|
||||
// Bind specific env keys that viper can't auto-infer for nested structs
|
||||
// These are common overrides that users set via environment variables
|
||||
envBindings := map[string]string{
|
||||
"GOCHAT_SERVER_HOST": "server.host",
|
||||
"GOCHAT_SERVER_PORT": "server.port",
|
||||
"GOCHAT_SERVER_MODE": "server.mode",
|
||||
"GOCHAT_DATABASE_HOST": "database.host",
|
||||
"GOCHAT_DATABASE_PORT": "database.port",
|
||||
"GOCHAT_DATABASE_USER": "database.user",
|
||||
"GOCHAT_DATABASE_PASSWORD": "database.password",
|
||||
"GOCHAT_DATABASE_NAME": "database.name",
|
||||
"GOCHAT_DATABASE_DBNAME": "database.dbname",
|
||||
"GOCHAT_DATABASE_SSLMODE": "database.sslmode",
|
||||
"GOCHAT_REDIS_URL": "redis.url",
|
||||
"GOCHAT_REDIS_HOST": "redis.host",
|
||||
"GOCHAT_REDIS_PORT": "redis.port",
|
||||
"GOCHAT_REDIS_PASSWORD": "redis.password",
|
||||
"GOCHAT_JWT_SECRET": "jwt.secret",
|
||||
"JWT_SECRET": "jwt.secret", // Alias for compatibility (no prefix)
|
||||
"GOCHAT_JWT_EXPIRY_HOURS": "jwt.expiry_hours",
|
||||
"GOCHAT_LOG_LEVEL": "log.level",
|
||||
"GOCHAT_LOG_FORMAT": "log.format",
|
||||
"GOCHAT_CAPTAIN_ENABLED": "captain.enabled",
|
||||
"GOCHAT_CAPTAIN_LLM_PROVIDER": "captain.llm_provider",
|
||||
"GOCHAT_CAPTAIN_LLM_MODEL": "captain.llm_model",
|
||||
"GOCHAT_CAPTAIN_LLM_API_KEY": "captain.llm_api_key",
|
||||
"GOCHAT_WORKER_CONCURRENCY": "worker.concurrency",
|
||||
"GOCHAT_SERVER_HOST": "server.host",
|
||||
"GOCHAT_SERVER_PORT": "server.port",
|
||||
"GOCHAT_SERVER_MODE": "server.mode",
|
||||
"GOCHAT_DATABASE_HOST": "database.host",
|
||||
"GOCHAT_DATABASE_PORT": "database.port",
|
||||
"GOCHAT_DATABASE_USER": "database.user",
|
||||
"GOCHAT_DATABASE_PASSWORD": "database.password",
|
||||
"GOCHAT_DATABASE_NAME": "database.name",
|
||||
"GOCHAT_DATABASE_DBNAME": "database.dbname",
|
||||
"GOCHAT_DATABASE_SSLMODE": "database.sslmode",
|
||||
"GOCHAT_REDIS_URL": "redis.url",
|
||||
"GOCHAT_REDIS_HOST": "redis.host",
|
||||
"GOCHAT_REDIS_PORT": "redis.port",
|
||||
"GOCHAT_REDIS_PASSWORD": "redis.password",
|
||||
"GOCHAT_JWT_SECRET": "jwt.secret",
|
||||
"JWT_SECRET": "jwt.secret", // Alias for compatibility (no prefix)
|
||||
"GOCHAT_JWT_EXPIRY_HOURS": "jwt.expiry_hours",
|
||||
"GOCHAT_LOG_LEVEL": "log.level",
|
||||
"GOCHAT_LOG_FORMAT": "log.format",
|
||||
"GOCHAT_CAPTAIN_ENABLED": "captain.enabled",
|
||||
"GOCHAT_CAPTAIN_LLM_PROVIDER": "captain.llm_provider",
|
||||
"GOCHAT_CAPTAIN_LLM_MODEL": "captain.llm_model",
|
||||
"GOCHAT_CAPTAIN_LLM_API_KEY": "captain.llm_api_key",
|
||||
"GOCHAT_WORKER_CONCURRENCY": "worker.concurrency",
|
||||
"GOCHAT_SEARCH_ENGINE": "search.engine",
|
||||
"GOCHAT_SEARCH_HOST": "search.host",
|
||||
"GOCHAT_SEARCH_API_KEY": "search.api_key",
|
||||
"GOCHAT_SEARCH_INDEX_PREFIX": "search.index_prefix",
|
||||
"GOCHAT_SEARCH_TIMEOUT_SECONDS": "search.timeout_seconds",
|
||||
// G10: OAuth config for new channel integrations (Twitter, Microsoft, Google)
|
||||
"GOCHAT_OAUTH_TWITTER_CLIENT_ID": "oauth.twitter.client_id",
|
||||
"GOCHAT_OAUTH_TWITTER_CLIENT_SECRET": "oauth.twitter.client_secret",
|
||||
"GOCHAT_OAUTH_TWITTER_REDIRECT_URL": "oauth.twitter.redirect_url",
|
||||
"GOCHAT_OAUTH_TWITTER_SCOPES": "oauth.twitter.scopes",
|
||||
"GOCHAT_OAUTH_TWITTER_CLIENT_ID": "oauth.twitter.client_id",
|
||||
"GOCHAT_OAUTH_TWITTER_CLIENT_SECRET": "oauth.twitter.client_secret",
|
||||
"GOCHAT_OAUTH_TWITTER_REDIRECT_URL": "oauth.twitter.redirect_url",
|
||||
"GOCHAT_OAUTH_TWITTER_SCOPES": "oauth.twitter.scopes",
|
||||
"GOCHAT_OAUTH_MICROSOFT_CLIENT_ID": "oauth.microsoft.client_id",
|
||||
"GOCHAT_OAUTH_MICROSOFT_CLIENT_SECRET": "oauth.microsoft.client_secret",
|
||||
"GOCHAT_OAUTH_MICROSOFT_TENANT_ID": "oauth.microsoft.tenant_id",
|
||||
"GOCHAT_OAUTH_MICROSOFT_REDIRECT_URL": "oauth.microsoft.redirect_url",
|
||||
"GOCHAT_OAUTH_MICROSOFT_SCOPES": "oauth.microsoft.scopes",
|
||||
"GOCHAT_OAUTH_GOOGLE_CLIENT_ID": "oauth.google.client_id",
|
||||
"GOCHAT_OAUTH_GOOGLE_CLIENT_SECRET": "oauth.google.client_secret",
|
||||
"GOCHAT_OAUTH_GOOGLE_REDIRECT_URL": "oauth.google.redirect_url",
|
||||
"GOCHAT_OAUTH_GOOGLE_SCOPES": "oauth.google.scopes",
|
||||
"GOCHAT_OAUTH_GOOGLE_CLIENT_ID": "oauth.google.client_id",
|
||||
"GOCHAT_OAUTH_GOOGLE_CLIENT_SECRET": "oauth.google.client_secret",
|
||||
"GOCHAT_OAUTH_GOOGLE_REDIRECT_URL": "oauth.google.redirect_url",
|
||||
"GOCHAT_OAUTH_GOOGLE_SCOPES": "oauth.google.scopes",
|
||||
}
|
||||
for envKey, configKey := range envBindings {
|
||||
if err := v.BindEnv(configKey, envKey); err != nil {
|
||||
@@ -735,6 +778,12 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("rate_limit.requests_per_minute", 100)
|
||||
v.SetDefault("rate_limit.window_seconds", 60)
|
||||
|
||||
v.SetDefault("search.engine", "meilisearch")
|
||||
v.SetDefault("search.host", "http://localhost:7700")
|
||||
v.SetDefault("search.api_key", "")
|
||||
v.SetDefault("search.index_prefix", "gochat_")
|
||||
v.SetDefault("search.timeout_seconds", 5)
|
||||
|
||||
v.SetDefault("worker.concurrency", 4)
|
||||
|
||||
// CORS production defaults
|
||||
@@ -787,6 +836,7 @@ func applyZeroDefaults(cfg *Config) {
|
||||
if cfg.Worker.Concurrency == 0 {
|
||||
cfg.Worker.Concurrency = 4
|
||||
}
|
||||
applySearchDefaults(&cfg.Search)
|
||||
// CSRF defaults
|
||||
if cfg.CSRF.CookieName == "" {
|
||||
cfg.CSRF.CookieName = "_gochat_csrf"
|
||||
@@ -832,4 +882,4 @@ func applyZeroDefaults(cfg *Config) {
|
||||
if cfg.Storage.MaxFileSize == 0 {
|
||||
cfg.Storage.MaxFileSize = 20 * 1024 * 1024 // 20MB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,12 @@ func TestValidate_ValidConfig(t *testing.T) {
|
||||
ExpiryHours: 24,
|
||||
RefreshExpiryHours: 168,
|
||||
},
|
||||
Log: LogConfig{Level: "info", Format: "json"},
|
||||
Captain: CaptainConfig{Enabled: false},
|
||||
Worker: WorkerConfig{Concurrency: 4},
|
||||
OAuth: OAuthConfig{},
|
||||
Log: LogConfig{Level: "info", Format: "json"},
|
||||
Captain: CaptainConfig{Enabled: false},
|
||||
Worker: WorkerConfig{Concurrency: 4},
|
||||
OAuth: OAuthConfig{},
|
||||
RateLimit: RateLimitConfig{Enabled: true, RequestsPerMinute: 100, WindowSeconds: 60},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "http://localhost:7700", IndexPrefix: "gochat_", TimeoutSeconds: 5},
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
@@ -39,10 +40,10 @@ func TestValidate_ValidConfig(t *testing.T) {
|
||||
|
||||
func TestValidate_InvalidPort(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 0, Mode: "debug"},
|
||||
Server: ServerConfig{Host: "localhost", Port: 0, Mode: "debug"},
|
||||
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
@@ -52,10 +53,10 @@ func TestValidate_InvalidPort(t *testing.T) {
|
||||
|
||||
func TestValidate_InvalidMode(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "invalid"},
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "invalid"},
|
||||
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
@@ -156,6 +157,39 @@ func TestValidate_InvalidWorkerConcurrency(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "worker concurrency")
|
||||
}
|
||||
|
||||
func TestValidate_SearchMeilisearchRequiresValidHost(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1},
|
||||
RateLimit: RateLimitConfig{RequestsPerMinute: 100, WindowSeconds: 60},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "not a url", TimeoutSeconds: 5},
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid search.host")
|
||||
}
|
||||
|
||||
func TestValidate_SearchDBFallbackAllowed(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1},
|
||||
RateLimit: RateLimitConfig{RequestsPerMinute: 100, WindowSeconds: 60},
|
||||
Search: SearchConfig{Engine: "db"},
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDatabaseConfig_DSN(t *testing.T) {
|
||||
cfg := DatabaseConfig{
|
||||
Host: "localhost",
|
||||
@@ -184,4 +218,4 @@ func TestServerConfig_Address(t *testing.T) {
|
||||
cfg := ServerConfig{Host: "0.0.0.0", Port: 3000}
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
assert.Equal(t, "0.0.0.0:3000", addr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -70,6 +71,28 @@ func Validate(cfg *Config) error {
|
||||
return fmt.Errorf("rate_limit.window_seconds must be >= 1")
|
||||
}
|
||||
|
||||
// Search validation. Meilisearch is the production parity engine; db remains
|
||||
// available only as an explicit development fallback.
|
||||
engine := strings.ToLower(cfg.Search.Engine)
|
||||
if engine == "" {
|
||||
engine = "meilisearch"
|
||||
}
|
||||
if engine != "meilisearch" && engine != "db" {
|
||||
return fmt.Errorf("invalid search engine: %s (must be meilisearch or db)", cfg.Search.Engine)
|
||||
}
|
||||
if engine == "meilisearch" {
|
||||
if cfg.Search.Host == "" {
|
||||
return fmt.Errorf("search.host is required when search.engine=meilisearch")
|
||||
}
|
||||
searchURL, err := url.Parse(cfg.Search.Host)
|
||||
if err != nil || searchURL.Scheme == "" || searchURL.Host == "" {
|
||||
return fmt.Errorf("invalid search.host: %s", cfg.Search.Host)
|
||||
}
|
||||
}
|
||||
if cfg.Search.TimeoutSeconds < 0 {
|
||||
return fmt.Errorf("search.timeout_seconds must be >= 0")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -78,12 +101,12 @@ func ParseStatementTimeout(timeout string) (time.Duration, error) {
|
||||
if timeout == "" {
|
||||
return 14 * time.Second, nil
|
||||
}
|
||||
|
||||
|
||||
// Handle plain seconds (e.g., "14s")
|
||||
if secs, err := strconv.Atoi(timeout); err == nil {
|
||||
return time.Duration(secs) * time.Second, nil
|
||||
}
|
||||
|
||||
|
||||
// Handle Go duration format (e.g., "14s", "500ms")
|
||||
d, err := time.ParseDuration(timeout)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
EngineMeilisearch = "meilisearch"
|
||||
EngineDB = "db"
|
||||
)
|
||||
|
||||
// EngineConfig is the search package's stable configuration boundary. The app
|
||||
// layer maps internal/config.SearchConfig into this struct.
|
||||
type EngineConfig struct {
|
||||
Engine string
|
||||
Host string
|
||||
APIKey string
|
||||
IndexPrefix string
|
||||
TimeoutSeconds int
|
||||
}
|
||||
|
||||
// SearchEngine hides the full-text backend behind one contract. Meilisearch is
|
||||
// the production parity engine; DB implementations are local development only.
|
||||
type SearchEngine interface {
|
||||
Search(ctx context.Context, accountID uint, query string, filter *SearchFilter) (*SearchResponse, error)
|
||||
IndexDocument(ctx context.Context, doc SearchDocument) error
|
||||
IndexBatch(ctx context.Context, docs []SearchDocument) error
|
||||
DeleteDocument(ctx context.Context, docType SearchResultType, accountID uint, id uint) error
|
||||
Bootstrap(ctx context.Context) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// SearchDocument is the normalized payload stored in Meilisearch. Data keeps the
|
||||
// entity payload available for Chatwoot-compatible frontend responses.
|
||||
type SearchDocument struct {
|
||||
UID string `json:"uid"`
|
||||
ID uint `json:"id"`
|
||||
Type SearchResultType `json:"type"`
|
||||
AccountID uint `json:"account_id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
MessageType string `json:"message_type,omitempty"`
|
||||
SenderType string `json:"sender_type,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
Private bool `json:"private"`
|
||||
ContactSource string `json:"contact_source,omitempty"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
AssigneeID *uint `json:"assignee_id,omitempty"`
|
||||
TeamID *uint `json:"team_id,omitempty"`
|
||||
InboxID *uint `json:"inbox_id,omitempty"`
|
||||
ContactID *uint `json:"contact_id,omitempty"`
|
||||
ConversationID *uint `json:"conversation_id,omitempty"`
|
||||
PortalID *uint `json:"portal_id,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
CreatedAtTS int64 `json:"created_at_ts"`
|
||||
UpdatedAtTS int64 `json:"updated_at_ts"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (d *SearchDocument) ensureUID() {
|
||||
if d.UID == "" {
|
||||
d.UID = documentUID(d.Type, d.AccountID, d.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func documentUID(docType SearchResultType, accountID uint, id uint) string {
|
||||
return fmt.Sprintf("%d:%s:%d", accountID, docType, id)
|
||||
}
|
||||
|
||||
func normalizeEngineConfig(cfg EngineConfig) EngineConfig {
|
||||
cfg.Engine = strings.ToLower(strings.TrimSpace(cfg.Engine))
|
||||
if cfg.Engine == "" {
|
||||
cfg.Engine = EngineMeilisearch
|
||||
}
|
||||
if cfg.Host == "" {
|
||||
cfg.Host = "http://localhost:7700"
|
||||
}
|
||||
if cfg.IndexPrefix == "" {
|
||||
cfg.IndexPrefix = "gochat_"
|
||||
}
|
||||
if cfg.TimeoutSeconds == 0 {
|
||||
cfg.TimeoutSeconds = 5
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// NewSearchEngine builds the configured search backend.
|
||||
func NewSearchEngine(cfg EngineConfig, fallbackRepo SearchRepoInterface) (SearchEngine, error) {
|
||||
cfg = normalizeEngineConfig(cfg)
|
||||
switch cfg.Engine {
|
||||
case EngineMeilisearch:
|
||||
return NewMeiliSearchEngine(cfg), nil
|
||||
case EngineDB:
|
||||
if fallbackRepo == nil {
|
||||
return nil, fmt.Errorf("db search engine requires a fallback repository")
|
||||
}
|
||||
return NewSearchEngineDB(fallbackRepo), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported search engine %q", cfg.Engine)
|
||||
}
|
||||
}
|
||||
|
||||
func searchableTypes(filter *SearchFilter) []SearchResultType {
|
||||
if filter != nil && len(filter.Types) > 0 {
|
||||
return filter.Types
|
||||
}
|
||||
return []SearchResultType{
|
||||
ResultTypeConversation,
|
||||
ResultTypeMessage,
|
||||
ResultTypeContact,
|
||||
ResultTypeCompany,
|
||||
ResultTypeArticle,
|
||||
ResultTypeHelpCenter,
|
||||
}
|
||||
}
|
||||
|
||||
func timestamp(t time.Time) int64 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
func ConversationDocument(conv model.Conversation) SearchDocument {
|
||||
inboxID := conv.InboxID
|
||||
doc := SearchDocument{
|
||||
ID: conv.ID,
|
||||
Type: ResultTypeConversation,
|
||||
AccountID: conv.AccountID,
|
||||
Title: fmt.Sprintf("Conversation #%d", conv.ID),
|
||||
Content: conv.Labels,
|
||||
Status: conv.Status,
|
||||
Priority: conv.Priority,
|
||||
Labels: splitLabels(conv.Labels),
|
||||
AssigneeID: conv.AssigneeID,
|
||||
TeamID: conv.TeamID,
|
||||
InboxID: &inboxID,
|
||||
ContactID: &conv.ContactID,
|
||||
CreatedAtTS: timestamp(conv.CreatedAt),
|
||||
UpdatedAtTS: timestamp(conv.UpdatedAt),
|
||||
Data: map[string]interface{}{"conversation": conv},
|
||||
}
|
||||
doc.Snippet = conversationSnippet(&conv, "")
|
||||
doc.ensureUID()
|
||||
return doc
|
||||
}
|
||||
|
||||
func MessageDocument(msg model.Message) SearchDocument {
|
||||
inboxID := msg.InboxID
|
||||
conversationID := msg.ConversationID
|
||||
doc := SearchDocument{
|
||||
ID: msg.ID,
|
||||
Type: ResultTypeMessage,
|
||||
AccountID: msg.AccountID,
|
||||
Title: fmt.Sprintf("Message #%d", msg.ID),
|
||||
Content: msg.Content,
|
||||
Snippet: messageSnippet(&msg, ""),
|
||||
Status: msg.Status,
|
||||
MessageType: msg.MessageType,
|
||||
SenderType: msg.SenderType,
|
||||
ContentType: msg.ContentType,
|
||||
Private: msg.Private,
|
||||
InboxID: &inboxID,
|
||||
ConversationID: &conversationID,
|
||||
CreatedAtTS: timestamp(msg.CreatedAt),
|
||||
UpdatedAtTS: timestamp(msg.UpdatedAt),
|
||||
Data: map[string]interface{}{"message": msg},
|
||||
}
|
||||
doc.ensureUID()
|
||||
return doc
|
||||
}
|
||||
|
||||
func ContactDocument(contact model.Contact) SearchDocument {
|
||||
doc := SearchDocument{
|
||||
ID: contact.ID,
|
||||
Type: ResultTypeContact,
|
||||
AccountID: contact.AccountID,
|
||||
Title: contact.Name,
|
||||
Content: strings.TrimSpace(strings.Join([]string{contact.Email, contact.PhoneNumber, contact.Identifier}, " ")),
|
||||
Snippet: contactSnippet(&contact, ""),
|
||||
ContactSource: contact.ContactType,
|
||||
CreatedAtTS: timestamp(contact.CreatedAt),
|
||||
UpdatedAtTS: timestamp(contact.UpdatedAt),
|
||||
Data: map[string]interface{}{"contact": contact},
|
||||
}
|
||||
doc.ensureUID()
|
||||
return doc
|
||||
}
|
||||
|
||||
func CompanyDocument(company model.Company) SearchDocument {
|
||||
doc := SearchDocument{
|
||||
ID: company.ID,
|
||||
Type: ResultTypeCompany,
|
||||
AccountID: company.AccountID,
|
||||
Title: company.Name,
|
||||
Content: strings.TrimSpace(strings.Join([]string{company.Description, company.Domain, company.WebsiteURL}, " ")),
|
||||
Snippet: company.Name,
|
||||
CreatedAtTS: timestamp(company.CreatedAt),
|
||||
UpdatedAtTS: timestamp(company.UpdatedAt),
|
||||
Data: map[string]interface{}{"company": company},
|
||||
}
|
||||
doc.ensureUID()
|
||||
return doc
|
||||
}
|
||||
|
||||
func ArticleDocument(article model.Article) SearchDocument {
|
||||
portalID := article.PortalID
|
||||
doc := SearchDocument{
|
||||
ID: article.ID,
|
||||
Type: ResultTypeArticle,
|
||||
AccountID: article.AccountID,
|
||||
Title: article.Title,
|
||||
Content: strings.TrimSpace(strings.Join([]string{article.Description, article.Content}, " ")),
|
||||
Snippet: articleSnippet(&article, ""),
|
||||
Status: article.Status,
|
||||
PortalID: &portalID,
|
||||
Locale: article.Locale,
|
||||
CreatedAtTS: timestamp(article.CreatedAt),
|
||||
UpdatedAtTS: timestamp(article.UpdatedAt),
|
||||
Data: map[string]interface{}{"article": article},
|
||||
}
|
||||
doc.ensureUID()
|
||||
return doc
|
||||
}
|
||||
|
||||
func splitLabels(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
raw = strings.Trim(raw, "[]")
|
||||
parts := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '"' || r == '\'' })
|
||||
labels := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
labels = append(labels, part)
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
)
|
||||
|
||||
// SearchEngineDB adapts the existing repository-backed search into the engine
|
||||
// contract. It is intentionally a development fallback; production parity uses
|
||||
// Meilisearch.
|
||||
type SearchEngineDB struct {
|
||||
repo SearchRepoInterface
|
||||
}
|
||||
|
||||
func NewSearchEngineDB(repo SearchRepoInterface) *SearchEngineDB {
|
||||
applogger.L().Warn("Search engine: db fallback enabled; Meilisearch is required for production Chatwoot parity")
|
||||
return &SearchEngineDB{repo: repo}
|
||||
}
|
||||
|
||||
func (e *SearchEngineDB) Search(ctx context.Context, accountID uint, query string, filter *SearchFilter) (*SearchResponse, error) {
|
||||
return (&SearchService{searchRepo: e.repo}).GlobalSearch(ctx, accountID, query, filter)
|
||||
}
|
||||
|
||||
func (e *SearchEngineDB) IndexDocument(ctx context.Context, doc SearchDocument) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *SearchEngineDB) IndexBatch(ctx context.Context, docs []SearchDocument) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *SearchEngineDB) DeleteDocument(ctx context.Context, docType SearchResultType, accountID uint, id uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *SearchEngineDB) Bootstrap(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *SearchEngineDB) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type MeiliSearchEngine struct {
|
||||
client *resty.Client
|
||||
indexPrefix string
|
||||
}
|
||||
|
||||
type meiliSearchResponse struct {
|
||||
Hits []map[string]interface{} `json:"hits"`
|
||||
EstimatedTotalHits int64 `json:"estimatedTotalHits"`
|
||||
TotalHits int64 `json:"totalHits"`
|
||||
}
|
||||
|
||||
func NewMeiliSearchEngine(cfg EngineConfig) *MeiliSearchEngine {
|
||||
cfg = normalizeEngineConfig(cfg)
|
||||
client := resty.New().SetBaseURL(strings.TrimRight(cfg.Host, "/"))
|
||||
if cfg.APIKey != "" {
|
||||
client.SetAuthToken(cfg.APIKey)
|
||||
}
|
||||
if cfg.TimeoutSeconds > 0 {
|
||||
client.SetTimeout(time.Duration(cfg.TimeoutSeconds) * time.Second)
|
||||
}
|
||||
return &MeiliSearchEngine{client: client, indexPrefix: cfg.IndexPrefix}
|
||||
}
|
||||
|
||||
func (e *MeiliSearchEngine) Search(ctx context.Context, accountID uint, query string, filter *SearchFilter) (*SearchResponse, error) {
|
||||
if filter == nil {
|
||||
filter = &SearchFilter{Page: 1, PerPage: DefaultPerPage, SortBy: DefaultSortBy, SortOrder: DefaultSortOrder}
|
||||
}
|
||||
if filter.Page < 1 {
|
||||
filter.Page = 1
|
||||
}
|
||||
if filter.PerPage < 1 {
|
||||
filter.PerPage = DefaultPerPage
|
||||
}
|
||||
|
||||
results := make([]SearchResult, 0)
|
||||
byType := map[string]int64{}
|
||||
var total int64
|
||||
for _, docType := range searchableTypes(filter) {
|
||||
resp, err := e.searchIndex(ctx, docType, accountID, query, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := resp.EstimatedTotalHits
|
||||
if count == 0 && resp.TotalHits > 0 {
|
||||
count = resp.TotalHits
|
||||
}
|
||||
byType[string(docType)] = count
|
||||
total += count
|
||||
for _, hit := range resp.Hits {
|
||||
results = append(results, hitToSearchResult(docType, hit))
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(results, func(i, j int) bool {
|
||||
if results[i].Score == results[j].Score {
|
||||
return results[i].ID > results[j].ID
|
||||
}
|
||||
return results[i].Score > results[j].Score
|
||||
})
|
||||
|
||||
return &SearchResponse{
|
||||
Results: results,
|
||||
TotalCount: total,
|
||||
ByType: byType,
|
||||
Page: filter.Page,
|
||||
PerPage: filter.PerPage,
|
||||
Query: strings.TrimSpace(query),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *MeiliSearchEngine) IndexDocument(ctx context.Context, doc SearchDocument) error {
|
||||
doc.ensureUID()
|
||||
resp, err := e.client.R().
|
||||
SetContext(ctx).
|
||||
SetBody([]SearchDocument{doc}).
|
||||
Post(fmt.Sprintf("/indexes/%s/documents", e.indexName(doc.Type)))
|
||||
return meiliError(resp, err, "index document")
|
||||
}
|
||||
|
||||
func (e *MeiliSearchEngine) IndexBatch(ctx context.Context, docs []SearchDocument) error {
|
||||
grouped := map[SearchResultType][]SearchDocument{}
|
||||
for _, doc := range docs {
|
||||
doc.ensureUID()
|
||||
grouped[doc.Type] = append(grouped[doc.Type], doc)
|
||||
}
|
||||
for docType, batch := range grouped {
|
||||
resp, err := e.client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(batch).
|
||||
Post(fmt.Sprintf("/indexes/%s/documents", e.indexName(docType)))
|
||||
if err := meiliError(resp, err, "index batch"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *MeiliSearchEngine) DeleteDocument(ctx context.Context, docType SearchResultType, accountID uint, id uint) error {
|
||||
resp, err := e.client.R().
|
||||
SetContext(ctx).
|
||||
Delete(fmt.Sprintf("/indexes/%s/documents/%s", e.indexName(docType), documentUID(docType, accountID, id)))
|
||||
return meiliError(resp, err, "delete document")
|
||||
}
|
||||
|
||||
func (e *MeiliSearchEngine) Bootstrap(ctx context.Context) error {
|
||||
for _, docType := range searchableTypes(nil) {
|
||||
if err := e.ensureIndex(ctx, docType); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.applySettings(ctx, docType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *MeiliSearchEngine) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *MeiliSearchEngine) searchIndex(ctx context.Context, docType SearchResultType, accountID uint, query string, filter *SearchFilter) (*meiliSearchResponse, error) {
|
||||
body := map[string]interface{}{
|
||||
"q": strings.TrimSpace(query),
|
||||
"offset": filter.Offset(),
|
||||
"limit": filter.PerPage,
|
||||
"filter": e.filterExpression(accountID, docType, filter),
|
||||
"showRankingScore": true,
|
||||
}
|
||||
if sortExpr := sortExpression(filter); sortExpr != "" {
|
||||
body["sort"] = []string{sortExpr}
|
||||
}
|
||||
|
||||
var out meiliSearchResponse
|
||||
resp, err := e.client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(body).
|
||||
SetResult(&out).
|
||||
Post(fmt.Sprintf("/indexes/%s/search", e.indexName(docType)))
|
||||
if err := meiliError(resp, err, "search"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (e *MeiliSearchEngine) ensureIndex(ctx context.Context, docType SearchResultType) error {
|
||||
uid := e.indexName(docType)
|
||||
resp, err := e.client.R().SetContext(ctx).Get(fmt.Sprintf("/indexes/%s", uid))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode() != http.StatusNotFound {
|
||||
return meiliError(resp, nil, "check index")
|
||||
}
|
||||
resp, err = e.client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]interface{}{"uid": uid, "primaryKey": "uid"}).
|
||||
Post("/indexes")
|
||||
return meiliError(resp, err, "create index")
|
||||
}
|
||||
|
||||
func (e *MeiliSearchEngine) applySettings(ctx context.Context, docType SearchResultType) error {
|
||||
settings := map[string]interface{}{
|
||||
"searchableAttributes": []string{"title", "content", "snippet", "status", "priority", "labels", "locale"},
|
||||
"filterableAttributes": []string{"account_id", "type", "status", "priority", "message_type", "sender_type", "content_type", "private", "contact_source", "labels", "assignee_id", "team_id", "inbox_id", "contact_id", "conversation_id", "portal_id", "locale", "created_at_ts", "updated_at_ts"},
|
||||
"sortableAttributes": []string{"created_at_ts", "updated_at_ts", "id"},
|
||||
}
|
||||
resp, err := e.client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(settings).
|
||||
Patch(fmt.Sprintf("/indexes/%s/settings", e.indexName(docType)))
|
||||
return meiliError(resp, err, "apply settings")
|
||||
}
|
||||
|
||||
func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResultType, filter *SearchFilter) string {
|
||||
parts := []string{fmt.Sprintf("account_id = %d", accountID)}
|
||||
if filter == nil {
|
||||
return strings.Join(parts, " AND ")
|
||||
}
|
||||
if docType == ResultTypeConversation {
|
||||
parts = appendListFilter(parts, "status", filter.Status)
|
||||
parts = appendListFilter(parts, "priority", filter.Priority)
|
||||
if filter.AssigneeID != nil {
|
||||
parts = append(parts, fmt.Sprintf("assignee_id = %d", *filter.AssigneeID))
|
||||
}
|
||||
if filter.TeamID != nil {
|
||||
parts = append(parts, fmt.Sprintf("team_id = %d", *filter.TeamID))
|
||||
}
|
||||
parts = appendListFilter(parts, "labels", filter.Labels)
|
||||
}
|
||||
if filter.InboxID != nil {
|
||||
parts = append(parts, fmt.Sprintf("inbox_id = %d", *filter.InboxID))
|
||||
}
|
||||
if docType == ResultTypeMessage {
|
||||
if filter.MessageType != "" {
|
||||
parts = append(parts, fmt.Sprintf("message_type = %q", filter.MessageType))
|
||||
}
|
||||
if filter.SenderType != "" {
|
||||
parts = append(parts, fmt.Sprintf("sender_type = %q", filter.SenderType))
|
||||
}
|
||||
if filter.ContentType != "" {
|
||||
parts = append(parts, fmt.Sprintf("content_type = %q", filter.ContentType))
|
||||
}
|
||||
if filter.Private != nil {
|
||||
parts = append(parts, fmt.Sprintf("private = %t", *filter.Private))
|
||||
}
|
||||
}
|
||||
if docType == ResultTypeContact && filter.ContactSource != "" {
|
||||
parts = append(parts, fmt.Sprintf("contact_source = %q", filter.ContactSource))
|
||||
}
|
||||
if docType == ResultTypeArticle || docType == ResultTypeHelpCenter {
|
||||
if filter.PortalID != nil {
|
||||
parts = append(parts, fmt.Sprintf("portal_id = %d", *filter.PortalID))
|
||||
}
|
||||
if filter.ArticleStatus != "" {
|
||||
parts = append(parts, fmt.Sprintf("status = %q", filter.ArticleStatus))
|
||||
}
|
||||
if filter.ArticleLocale != "" {
|
||||
parts = append(parts, fmt.Sprintf("locale = %q", filter.ArticleLocale))
|
||||
}
|
||||
}
|
||||
if filter.DateFrom != nil {
|
||||
parts = append(parts, fmt.Sprintf("created_at_ts >= %d", filter.DateFrom.Unix()))
|
||||
}
|
||||
if filter.DateTo != nil {
|
||||
parts = append(parts, fmt.Sprintf("created_at_ts <= %d", filter.DateTo.Unix()))
|
||||
}
|
||||
return strings.Join(parts, " AND ")
|
||||
}
|
||||
|
||||
func (e *MeiliSearchEngine) indexName(docType SearchResultType) string {
|
||||
return e.indexPrefix + strings.ReplaceAll(string(docType), "_", "_") + "s"
|
||||
}
|
||||
|
||||
func appendListFilter(parts []string, field string, values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return parts
|
||||
}
|
||||
quoted := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
quoted = append(quoted, fmt.Sprintf("%s = %q", field, value))
|
||||
}
|
||||
}
|
||||
if len(quoted) > 0 {
|
||||
parts = append(parts, "("+strings.Join(quoted, " OR ")+")")
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func sortExpression(filter *SearchFilter) string {
|
||||
if filter == nil {
|
||||
return ""
|
||||
}
|
||||
field := filter.SortBy
|
||||
switch field {
|
||||
case "", "created", "created_at":
|
||||
field = "created_at_ts"
|
||||
case "updated", "updated_at":
|
||||
field = "updated_at_ts"
|
||||
case "id":
|
||||
field = "id"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
dir := filter.SortOrder
|
||||
if dir != "asc" {
|
||||
dir = "desc"
|
||||
}
|
||||
return field + ":" + dir
|
||||
}
|
||||
|
||||
func hitToSearchResult(docType SearchResultType, hit map[string]interface{}) SearchResult {
|
||||
result := SearchResult{Type: docType, Data: hit}
|
||||
if v, ok := hit["type"].(string); ok && v != "" {
|
||||
result.Type = SearchResultType(v)
|
||||
}
|
||||
result.ID = uintFromHit(hit["id"])
|
||||
result.AccountID = uintFromHit(hit["account_id"])
|
||||
if snippet, ok := hit["snippet"].(string); ok {
|
||||
result.Snippet = snippet
|
||||
} else if title, ok := hit["title"].(string); ok {
|
||||
result.Snippet = title
|
||||
}
|
||||
if score, ok := hit["_rankingScore"].(float64); ok {
|
||||
result.Score = score
|
||||
} else {
|
||||
result.Score = 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func uintFromHit(v interface{}) uint {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return uint(n)
|
||||
case int:
|
||||
return uint(n)
|
||||
case int64:
|
||||
return uint(n)
|
||||
case uint:
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func meiliError(resp *resty.Response, err error, action string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("meilisearch %s: %w", action, err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
if resp.StatusCode() >= 400 {
|
||||
return fmt.Errorf("meilisearch %s: status=%d body=%s", action, resp.StatusCode(), string(resp.Body()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func jsonResponse(status int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSearchEngine_DefaultsToMeilisearch(t *testing.T) {
|
||||
engine, err := NewSearchEngine(EngineConfig{}, nil)
|
||||
require.NoError(t, err)
|
||||
_, ok := engine.(*MeiliSearchEngine)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestNewSearchEngine_DBFallbackRequiresRepo(t *testing.T) {
|
||||
engine, err := NewSearchEngine(EngineConfig{Engine: EngineDB}, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, engine)
|
||||
}
|
||||
|
||||
func TestDocumentBuildersSetStableUIDAndType(t *testing.T) {
|
||||
conv := makeConversation(12, 3, "open", "billing,urgent")
|
||||
conv.InboxID = 7
|
||||
conv.ContactID = 9
|
||||
doc := ConversationDocument(conv)
|
||||
|
||||
assert.Equal(t, "3:conversation:12", doc.UID)
|
||||
assert.Equal(t, ResultTypeConversation, doc.Type)
|
||||
assert.Equal(t, uint(3), doc.AccountID)
|
||||
assert.Equal(t, []string{"billing", "urgent"}, doc.Labels)
|
||||
}
|
||||
|
||||
func TestMeiliSearchEngine_SearchSendsScopedFilter(t *testing.T) {
|
||||
var requestBody map[string]interface{}
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
require.Equal(t, "/indexes/gochat_contacts/search", r.URL.Path)
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody))
|
||||
return jsonResponse(http.StatusOK, `{
|
||||
"hits":[{"uid":"42:contact:9","id":9,"type":"contact","account_id":42,"snippet":"Ada Lovelace","_rankingScore":0.98}],
|
||||
"estimatedTotalHits":1
|
||||
}`), nil
|
||||
})
|
||||
|
||||
engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"})
|
||||
engine.client.SetTransport(transport)
|
||||
filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeContact}}
|
||||
resp, err := engine.Search(context.Background(), 42, "ada", filter)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), resp.TotalCount)
|
||||
assert.Len(t, resp.Results, 1)
|
||||
assert.Equal(t, uint(9), resp.Results[0].ID)
|
||||
assert.Equal(t, "account_id = 42", requestBody["filter"])
|
||||
assert.Equal(t, "ada", requestBody["q"])
|
||||
}
|
||||
|
||||
func TestMeiliSearchEngine_IndexAndDeleteDocument(t *testing.T) {
|
||||
seen := []string{}
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
seen = append(seen, r.Method+" "+r.URL.Path)
|
||||
return jsonResponse(http.StatusAccepted, `{"taskUid":1}`), nil
|
||||
})
|
||||
|
||||
engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"})
|
||||
engine.client.SetTransport(transport)
|
||||
doc := ContactDocument(model.Contact{Base: model.Base{ID: 5}, AccountID: 2, Name: "Grace"})
|
||||
require.NoError(t, engine.IndexDocument(context.Background(), doc))
|
||||
require.NoError(t, engine.DeleteDocument(context.Background(), ResultTypeContact, 2, 5))
|
||||
|
||||
assert.Equal(t, []string{
|
||||
"POST /indexes/gochat_contacts/documents",
|
||||
"DELETE /indexes/gochat_contacts/documents/2:contact:5",
|
||||
}, seen)
|
||||
}
|
||||
|
||||
func TestMeiliSearchEngine_BootstrapCreatesMissingIndexesAndSettings(t *testing.T) {
|
||||
created := 0
|
||||
settings := 0
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/indexes/"):
|
||||
return jsonResponse(http.StatusNotFound, `{"message":"not found"}`), nil
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/indexes":
|
||||
created++
|
||||
return jsonResponse(http.StatusAccepted, `{"taskUid":1}`), nil
|
||||
case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/settings"):
|
||||
settings++
|
||||
return jsonResponse(http.StatusAccepted, `{"taskUid":2}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"})
|
||||
engine.client.SetTransport(transport)
|
||||
require.NoError(t, engine.Bootstrap(context.Background()))
|
||||
assert.Equal(t, len(searchableTypes(nil)), created)
|
||||
assert.Equal(t, len(searchableTypes(nil)), settings)
|
||||
}
|
||||
@@ -8,28 +8,30 @@ const (
|
||||
ResultTypeConversation SearchResultType = "conversation"
|
||||
ResultTypeMessage SearchResultType = "message"
|
||||
ResultTypeContact SearchResultType = "contact"
|
||||
ResultTypeCompany SearchResultType = "company"
|
||||
ResultTypeArticle SearchResultType = "article"
|
||||
ResultTypeHelpCenter SearchResultType = "help_center"
|
||||
)
|
||||
|
||||
// SearchResult is a unified hit from any searchable entity.
|
||||
// Each result carries the entity type, its primary ID, a relevance score,
|
||||
// and a snippet (short text excerpt) for display in the search UI.
|
||||
type SearchResult struct {
|
||||
Type SearchResultType `json:"type"`
|
||||
ID uint `json:"id"`
|
||||
AccountID uint `json:"account_id"`
|
||||
Snippet string `json:"snippet"` // short excerpt for display
|
||||
Score float64 `json:"score"` // relevance score (higher = more relevant)
|
||||
Data interface{} `json:"data"` // full entity payload (Conversation, Message, Contact)
|
||||
Type SearchResultType `json:"type"`
|
||||
ID uint `json:"id"`
|
||||
AccountID uint `json:"account_id"`
|
||||
Snippet string `json:"snippet"` // short excerpt for display
|
||||
Score float64 `json:"score"` // relevance score (higher = more relevant)
|
||||
Data interface{} `json:"data"` // full entity payload (Conversation, Message, Contact)
|
||||
}
|
||||
|
||||
// SearchResponse is the top-level response structure for a global search query.
|
||||
// Reference: Chatwoot API returns grouped results by type with pagination metadata.
|
||||
type SearchResponse struct {
|
||||
Results []SearchResult `json:"results"`
|
||||
TotalCount int64 `json:"total_count"` // total hits across all types
|
||||
ByType map[string]int64 `json:"by_type"` // count per type: {"conversation":5,"message":12,...}
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
Results []SearchResult `json:"results"`
|
||||
TotalCount int64 `json:"total_count"` // total hits across all types
|
||||
ByType map[string]int64 `json:"by_type"` // count per type: {"conversation":5,"message":12,...}
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
// Reference: Chatwoot GlobalSearchService — cross-entity search with filter params.
|
||||
type SearchService struct {
|
||||
searchRepo SearchRepoInterface
|
||||
engine SearchEngine
|
||||
}
|
||||
|
||||
// NewSearchService creates a new SearchService.
|
||||
@@ -21,12 +22,21 @@ func NewSearchService(searchRepo SearchRepoInterface) *SearchService {
|
||||
return &SearchService{searchRepo: searchRepo}
|
||||
}
|
||||
|
||||
// NewSearchServiceWithEngine creates a SearchService backed by an explicit
|
||||
// SearchEngine. The repository remains available for db fallback and legacy tests.
|
||||
func NewSearchServiceWithEngine(engine SearchEngine, fallbackRepo SearchRepoInterface) *SearchService {
|
||||
return &SearchService{searchRepo: fallbackRepo, engine: engine}
|
||||
}
|
||||
|
||||
// GlobalSearch performs a unified search across all searchable entity types
|
||||
// (conversations, messages, contacts) based on the provided query and filters.
|
||||
// Returns a SearchResponse with results grouped by type and pagination metadata.
|
||||
// Reference: Chatwoot GlobalSearchService — searches across conversations, messages, contacts
|
||||
func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query string, filter *SearchFilter) (*SearchResponse, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if filter == nil {
|
||||
filter = &SearchFilter{Page: 1, PerPage: DefaultPerPage, SortBy: DefaultSortBy, SortOrder: DefaultSortOrder}
|
||||
}
|
||||
|
||||
if query == "" && len(filter.Status) == 0 && len(filter.Priority) == 0 &&
|
||||
filter.AssigneeID == nil && filter.TeamID == nil && filter.InboxID == nil &&
|
||||
@@ -45,6 +55,10 @@ func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query
|
||||
}, nil
|
||||
}
|
||||
|
||||
if s.engine != nil {
|
||||
return s.engine.Search(ctx, accountID, query, filter)
|
||||
}
|
||||
|
||||
var allResults []SearchResult
|
||||
byType := map[string]int64{}
|
||||
var totalCount int64
|
||||
@@ -149,6 +163,9 @@ func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query
|
||||
// SearchConversations performs a filtered conversation search.
|
||||
// Convenience method for conversation-only search with full filter support.
|
||||
func (s *SearchService) SearchConversations(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]SearchResult, int64, error) {
|
||||
if s.engine != nil {
|
||||
return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeConversation)
|
||||
}
|
||||
conversations, total, err := s.searchRepo.SearchConversations(ctx, accountID, query, filter)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("search conversations: %w", err)
|
||||
@@ -171,6 +188,9 @@ func (s *SearchService) SearchConversations(ctx context.Context, accountID uint,
|
||||
// SearchMessages performs a filtered message search.
|
||||
// Convenience method for message-only search with full filter support.
|
||||
func (s *SearchService) SearchMessages(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]SearchResult, int64, error) {
|
||||
if s.engine != nil {
|
||||
return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeMessage)
|
||||
}
|
||||
messages, total, err := s.searchRepo.SearchMessages(ctx, accountID, query, filter)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("search messages: %w", err)
|
||||
@@ -193,6 +213,9 @@ func (s *SearchService) SearchMessages(ctx context.Context, accountID uint, quer
|
||||
// SearchContacts performs a filtered contact search.
|
||||
// Convenience method for contact-only search with full filter support.
|
||||
func (s *SearchService) SearchContacts(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]SearchResult, int64, error) {
|
||||
if s.engine != nil {
|
||||
return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeContact)
|
||||
}
|
||||
contacts, total, err := s.searchRepo.SearchContacts(ctx, accountID, query, filter)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("search contacts: %w", err)
|
||||
@@ -215,6 +238,9 @@ func (s *SearchService) SearchContacts(ctx context.Context, accountID uint, quer
|
||||
// SearchArticles performs a filtered knowledge base article search.
|
||||
// Convenience method for article-only search with full filter support.
|
||||
func (s *SearchService) SearchArticles(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]SearchResult, int64, error) {
|
||||
if s.engine != nil {
|
||||
return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeArticle)
|
||||
}
|
||||
articles, total, err := s.searchRepo.SearchArticles(ctx, accountID, query, filter)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("search articles: %w", err)
|
||||
@@ -234,6 +260,49 @@ func (s *SearchService) SearchArticles(ctx context.Context, accountID uint, quer
|
||||
return results, total, nil
|
||||
}
|
||||
|
||||
func (s *SearchService) searchWithEngineForType(ctx context.Context, accountID uint, query string, filter *SearchFilter, resultType SearchResultType) ([]SearchResult, int64, error) {
|
||||
engineFilter := cloneSearchFilter(filter)
|
||||
engineFilter.Types = []SearchResultType{resultType}
|
||||
resp, err := s.engine.Search(ctx, accountID, query, engineFilter)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return resp.Results, resp.ByType[string(resultType)], nil
|
||||
}
|
||||
|
||||
func cloneSearchFilter(filter *SearchFilter) *SearchFilter {
|
||||
if filter == nil {
|
||||
return &SearchFilter{Page: 1, PerPage: DefaultPerPage, SortBy: DefaultSortBy, SortOrder: DefaultSortOrder}
|
||||
}
|
||||
clone := *filter
|
||||
clone.Types = append([]SearchResultType(nil), filter.Types...)
|
||||
clone.Status = append([]string(nil), filter.Status...)
|
||||
clone.Priority = append([]string(nil), filter.Priority...)
|
||||
clone.Labels = append([]string(nil), filter.Labels...)
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (s *SearchService) IndexDocument(ctx context.Context, doc SearchDocument) error {
|
||||
if s.engine == nil {
|
||||
return nil
|
||||
}
|
||||
return s.engine.IndexDocument(ctx, doc)
|
||||
}
|
||||
|
||||
func (s *SearchService) IndexBatch(ctx context.Context, docs []SearchDocument) error {
|
||||
if s.engine == nil {
|
||||
return nil
|
||||
}
|
||||
return s.engine.IndexBatch(ctx, docs)
|
||||
}
|
||||
|
||||
func (s *SearchService) DeleteDocument(ctx context.Context, docType SearchResultType, accountID uint, id uint) error {
|
||||
if s.engine == nil {
|
||||
return nil
|
||||
}
|
||||
return s.engine.DeleteDocument(ctx, docType, accountID, id)
|
||||
}
|
||||
|
||||
// --- Snippet and scoring helpers ---
|
||||
|
||||
// conversationSnippet generates a short display snippet for a conversation result.
|
||||
@@ -421,4 +490,4 @@ func sortResultsByScore(results []SearchResult) {
|
||||
j--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user