feat(search): add meilisearch engine foundation

This commit is contained in:
2026-06-04 19:56:29 +08:00
parent db0bd5b0f6
commit 3fc4859e20
13 changed files with 1515 additions and 289 deletions
+266
View File
@@ -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
View File
@@ -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
+26 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
}
+45 -11
View File
@@ -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)
}
}
+25 -2
View File
@@ -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 {
+248
View File
@@ -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
}
+43
View File
@@ -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
}
+332
View File
@@ -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
}
+123
View File
@@ -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)
}
+15 -13
View File
@@ -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"`
}
+70 -1
View File
@@ -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--
}
}
}
}