From 3fc4859e2072a3c817575f880f77dd2bb5cbd65c Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 4 Jun 2026 19:56:29 +0800 Subject: [PATCH] feat(search): add meilisearch engine foundation --- cmd/reindex_search/main.go | 266 ++++++++++++++++++ configs/config.yaml | 10 +- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 34 ++- internal/app/bootstrap.go | 236 ++++++++-------- internal/config/config.go | 330 ++++++++++++---------- internal/config/config_test.go | 56 +++- internal/config/validator.go | 27 +- internal/search/engine.go | 248 +++++++++++++++++ internal/search/engine_db.go | 43 +++ internal/search/engine_meili.go | 332 +++++++++++++++++++++++ internal/search/engine_test.go | 123 +++++++++ internal/search/search_result.go | 28 +- internal/search/search_service.go | 71 ++++- 13 files changed, 1515 insertions(+), 289 deletions(-) create mode 100644 cmd/reindex_search/main.go create mode 100644 internal/search/engine.go create mode 100644 internal/search/engine_db.go create mode 100644 internal/search/engine_meili.go create mode 100644 internal/search/engine_test.go diff --git a/cmd/reindex_search/main.go b/cmd/reindex_search/main.go new file mode 100644 index 00000000..0bdbf87b --- /dev/null +++ b/cmd/reindex_search/main.go @@ -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) + } +} diff --git a/configs/config.yaml b/configs/config.yaml index 22d71d88..6929973f 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -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 \ No newline at end of file + last_name: "lastName" # SAML attribute → last name component diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index f4cc50e7..ae40aa4f 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -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`. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index f01482f8..1ab7ac61 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 6d34a08e..28badfde 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } -} \ No newline at end of file +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b41a3d20..bd351221 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) -} \ No newline at end of file +} diff --git a/internal/config/validator.go b/internal/config/validator.go index 90ba06c6..2d5d75f1 100644 --- a/internal/config/validator.go +++ b/internal/config/validator.go @@ -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 { diff --git a/internal/search/engine.go b/internal/search/engine.go new file mode 100644 index 00000000..e6c3568f --- /dev/null +++ b/internal/search/engine.go @@ -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 +} diff --git a/internal/search/engine_db.go b/internal/search/engine_db.go new file mode 100644 index 00000000..96152703 --- /dev/null +++ b/internal/search/engine_db.go @@ -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 +} diff --git a/internal/search/engine_meili.go b/internal/search/engine_meili.go new file mode 100644 index 00000000..be8ee014 --- /dev/null +++ b/internal/search/engine_meili.go @@ -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 +} diff --git a/internal/search/engine_test.go b/internal/search/engine_test.go new file mode 100644 index 00000000..b032f4c4 --- /dev/null +++ b/internal/search/engine_test.go @@ -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) +} diff --git a/internal/search/search_result.go b/internal/search/search_result.go index c270edd9..d4509127 100644 --- a/internal/search/search_result.go +++ b/internal/search/search_result.go @@ -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"` -} \ No newline at end of file + 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"` +} diff --git a/internal/search/search_service.go b/internal/search/search_service.go index 2801975f..562be615 100644 --- a/internal/search/search_service.go +++ b/internal/search/search_service.go @@ -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-- } } -} \ No newline at end of file +}