feat(search): scope inbox search access

This commit is contained in:
2026-06-07 07:13:25 +08:00
parent bd20e6d80f
commit 4bde740c80
10 changed files with 338 additions and 15 deletions
+7 -4
View File
@@ -49,11 +49,11 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-07 P3.76 conversation filter query message-type parity, prepared as `feat(conversations): align filter query message types`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align filter query message types`.
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot conversation filter query parity for incoming/outgoing message searches. No route artifacts change.
- Current tracking checkpoint: 2026-06-07 P3.77 search inbox access parity, prepared as `feat(search): scope inbox search access`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): scope inbox search access`.
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot SearchService inbox visibility parity for global/entity conversation and message search. No route artifacts change.
- Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker.
- Worktree status at this implementation checkpoint: conversation filter `q` searches now preserve Chatwoot `ConversationFinder#filter_by_query` semantics while using GoChat's persisted message type strings: query matches only incoming/outgoing messages and excludes activity/template rows. Existing P3.75 automation message filter parity, P3.74 contact type default parity, P3.73 conversation filter permission parity, P3.72 account enabled-feature serialization parity, P3.71 contact referer filter parity, P5.8c contact import search indexing, P3.70 global contact search resolved-scope parity, and prior checkpoints remain implemented. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Worktree status at this implementation checkpoint: reused SearchAPI conversation/message search now carries the current user into the search filter, resolves Chatwoot-style accessible inboxes from account membership and inbox assignments, scopes DB and Meilisearch conversation/message results to those inboxes unless the user is an administrator or assigned to every account inbox, and ignores inaccessible explicit `inbox_id` params by falling back to the accessible base relation. Existing P3.76 conversation filter query message-type parity, P3.75 automation message filter parity, P3.74 contact type default parity, P3.73 conversation filter permission parity, P3.72 account enabled-feature serialization parity, P3.71 contact referer filter parity, P5.8c contact import search indexing, P3.70 global contact search resolved-scope parity, and prior checkpoints remain implemented. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Next executable implementation checkpoint: continue Phase 2/3 drift audit for the next reused-frontend mismatch, or run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes.
- `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full.
- Route dump succeeds with `967` registered routes after profile MFA route tracking.
@@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- |
| 0 | P3.77 search inbox access parity | Implemented for reused global/entity search: GoChat now mirrors Chatwoot `SearchService` by scoping conversation and message search to the current user's assigned inboxes unless the account user is an administrator or assigned to every inbox. The same access filter is applied to DB fallback and Meilisearch filters; inaccessible explicit `inbox_id` params are ignored so the base accessible relation is preserved like the reference. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for custom-role search visibility, exact `advanced_search` feature-gated filters, or 90-day message base-query drift. | Focused search handler, search service, Meilisearch engine, and SearchRepo access/filter tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.76 conversation filter query message-type parity | Implemented for reused conversation filter/list query flows: `q` message search now filters against persisted GoChat string message types (`incoming`, `outgoing`) while preserving the Chatwoot reference restriction to incoming/outgoing messages and excluding activity/template matches. | Keep in Review; reopen from B12 conversation search/filter smoke or fresh reference evidence for duplicate join count behavior, `processed_message_content` query drift, or exact PostgreSQL ILIKE collation differences. | Focused conversation service query filter test, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.75 automation message filter parity | Implemented for reused automation `message_created` rules: GoChat now evaluates `message_type`, `private_note`, and `content` against the triggering message payload, including processed-content fallback and Chatwoot numeric message-type values. | Keep in Review; reopen from B12 automation smoke or fresh reference evidence for SQL-relation based message filtering beyond the triggering message, additional processed-content sources, or exact cross-group query precedence. | Focused automation condition validator/filter/listener tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.74 contact type default parity | Implemented for direct create and CSV import paths: blank GoChat contacts now default to Chatwoot `visitor`, and visitor contacts with email, phone, or social details upgrade to `lead`. | Keep in Review; reopen from B12 CRM/import smoke or fresh reference evidence for channel-specific social detail keys or bulk-import merge edge cases. | Focused contact model/import tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
@@ -240,6 +241,7 @@ These rows are the executable development plan from this point forward. A checkp
| ID | Owner files | Reference files | Work to land | Exit gate |
| --- | --- | --- | --- | --- |
| P3.77 search inbox access parity | `internal/handler/api/v1/search_handler.go`, `internal/search/search_filter.go`, `internal/search/search_service.go`, `internal/search/engine_meili.go`, `internal/repository/search_repo.go`, search handler/service/engine/repository tests | `reference/chatwoot/app/controllers/api/v1/accounts/search_controller.rb`, `reference/chatwoot/app/services/search_service.rb`, `reference/chatwoot/app/javascript/dashboard/api/search.js`, `reference/chatwoot/app/javascript/dashboard/api/specs/search.spec.js` | Global/entity search now follows Chatwoot `SearchService#message_base_query` and `#filter_conversations` inbox visibility boundaries for reused SearchAPI callers: current user id is captured from request context, account search access is resolved from `account_users`, `inbox_members`, and account inbox count, agents are restricted to assigned inboxes, administrators and agents assigned to all inboxes skip inbox filtering, DB and Meilisearch conversation/message searches apply the same accessible inbox set, and unauthorized explicit `inbox_id` filters are ignored rather than emptying the accessible base relation. | Review by `feat(search): scope inbox search access`; focused tests cover handler current-user propagation, service access injection, Meilisearch inbox filters and unauthorized inbox param fallback, SearchRepo access resolution for agents/admins/all-inbox agents, DB message search restriction, and inaccessible inbox param fallback; full `go test ./...` and `git diff --check` must pass. No route artifacts change. |
| P3.76 conversation filter query message-type parity | `internal/service/conversation_service.go`, `internal/service/conversation_service_test.go` | `reference/chatwoot/app/finders/conversation_finder.rb#filter_by_query`, `reference/chatwoot/app/models/message.rb`, `internal/model/enums.go`, `internal/repository/conversation_repo.go` | Conversation service `Filter` query search now follows the same incoming/outgoing restriction as Chatwoot `ConversationFinder#filter_by_query`, but uses GoChat's persisted message type strings instead of raw Chatwoot enum integers. The query also uses a dialect-aware content predicate so SQLite focused tests exercise the same matching path while PostgreSQL keeps `ILIKE`. | Review by `feat(conversations): align filter query message types`; focused service test proves `q` matches incoming message content, excludes activity-only conversations, and ignores non-matching content; full `go test ./...` and `git diff --check` must pass. No route artifacts change. |
| P3.75 automation message filter parity | `internal/automation/condition_filter.go`, `internal/automation/condition_validator.go`, `internal/automation/listener.go`, `internal/automation/model.go`, `internal/automation/condition_filter_test.go`, `internal/automation/condition_validator_test.go`, `internal/automation/listener_test.go` | `reference/chatwoot/app/services/automation_rules/conditions_filter_service.rb`, `reference/chatwoot/app/services/automation_rules/condition_validation_service.rb`, `reference/chatwoot/lib/filters/filter_keys.yml`, `reference/chatwoot/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js`, `reference/chatwoot/app/javascript/dashboard/helper/automationHelper.js` | Automation `message_created` conditions now evaluate the triggering message payload like the inspected reference message filters: `content` uses `processed_message_content` from content attributes when available and falls back to `messages.content`; `private_note` maps to `messages.private`; `message_type` accepts Chatwoot enum integers (`incoming=0`, `outgoing=1`, `activity=2`, `template=3`) and local strings. Validation now restricts message filter operators to the Chatwoot filter key contract. | Review by `feat(automation): align message filters`; focused matcher tests cover content/private/message-type matches, non-matches, missing message data, and OR message groups; validator tests cover supported/unsupported message operators; listener test proves `message_created` rules execute or skip from message filters. Full `go test ./...` and `git diff --check` must pass. No route artifacts change. |
| P3.74 contact type default parity | `internal/model/contact.go`, `internal/model/model_test.go`, `internal/service/contact_service_g3_test.go` | `reference/chatwoot/app/models/contact.rb`, `reference/chatwoot/app/services/contacts/sync_attributes.rb`, `reference/chatwoot/spec/models/contact_spec.rb`, `reference/chatwoot/spec/services/contacts/sync_attributes_spec.rb`, `reference/chatwoot/app/services/data_import/contact_manager.rb` | Contact persistence now mirrors Chatwoot enum/default behavior before CRM scopes see imported records: blank `contact_type` is normalized to `visitor`, and `visitor` contacts with email, phone number, or social additional attributes become `lead`. CSV imports without a `contact_type` column therefore create CRM-v2-visible leads when identity details are present and true visitors when they are absent. | Review by `feat(contacts): default contact type`; focused model/import tests assert default visitor, detail-based lead upgrade, and CSV import defaults; full `go test ./...` and `git diff --check`. No route artifacts change. |
@@ -2688,3 +2690,4 @@ Verification milestone gates:
- 2026-06-07: Documentation checkpoint prepared as `docs: finalize parity tracking plan`; confirms `792786d feat(contacts): default contact type` is already committed, keeps `docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md` as the single active tracker, and lands P3.75 automation message filter parity as the next executable implementation slice. The row records inspected Chatwoot automation filter/validation/frontend files, Go owner files, message attribute contracts, and close gates. Verification for this docs-only checkpoint: `git diff --check`.
- 2026-06-07: P3.75 automation message filter checkpoint prepared as `feat(automation): align message filters`; audited Chatwoot automation `ConditionsFilterService`, `ConditionValidationService`, `filter_keys.yml`, and reused automation builder constants/helper. GoChat `message_created` automation rules now evaluate `message_type`, `private_note`, and `content` from the triggering message payload, including Chatwoot numeric message type values, processed-content fallback, boolean private-note matching, content equality/containment operators, and validator rejection for unsupported message operators. Focused automation matcher/validator/listener tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.76 conversation filter query message-type checkpoint prepared as `feat(conversations): align filter query message types`; audited Chatwoot `ConversationFinder#filter_by_query`, message enum behavior, and GoChat persisted message type storage. GoChat conversation filter `q` searches now keep the Chatwoot incoming/outgoing-only restriction while comparing against local string message types instead of numeric enum values, so activity/template messages remain excluded and valid incoming/outgoing content is found. Focused conversation service query-filter test passed; full `go test ./...` and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.77 search inbox access checkpoint prepared as `feat(search): scope inbox search access`; audited Chatwoot `SearchController`, `SearchService`, and reused dashboard SearchAPI callers. GoChat global/entity conversation and message search now propagates the current user, resolves accessible inboxes from account membership and inbox assignments, scopes DB fallback and Meilisearch filters for non-admin agents, skips the scope for administrators and all-inbox agents, and ignores inaccessible explicit `inbox_id` params by preserving the accessible base relation. Focused search handler/service/engine/repository tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
@@ -289,6 +289,9 @@ func serializeSearchPayload(results []search.SearchResult) gin.H {
func parseChatwootSearchFilter(c *gin.Context) search.SearchFilter {
filter := search.ParseSearchFilter(c)
if userID := getUserID(c); userID != 0 {
filter.CurrentUserID = &userID
}
if c.Query("per_page") == "" {
filter.PerPage = chatwootSearchPerPage
}
@@ -253,6 +253,27 @@ func TestSearchHandler_SearchMessages_Success(t *testing.T) {
assert.Equal(t, 15, repo.msgFilter.PerPage)
}
func TestSearchHandler_SearchMessages_AddsCurrentUserToFilter(t *testing.T) {
repo := &mockSearchRepo{
messages: []model.Message{makeMessage(1, 1, "hello world")},
msgTotal: 1,
}
svc := search.NewSearchService(repo)
handler := NewSearchHandler(svc)
router := setupSearchHandlerRouter(handler)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/messages?q=hello&page=1", nil)
req.Header.Set("X-User-ID", "42")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.NotNil(t, repo.msgFilter)
if assert.NotNil(t, repo.msgFilter.CurrentUserID) {
assert.Equal(t, uint(42), *repo.msgFilter.CurrentUserID)
}
}
func TestSearchHandler_SearchMessages_MeiliHitPayloadShape(t *testing.T) {
svc := search.NewSearchServiceWithEngine(&stubSearchEngine{
resp: &search.SearchResponse{
+60 -2
View File
@@ -31,6 +31,8 @@ type RepoSearchFilter struct {
SenderID *uint
ContentType string
Private *bool
EnforceInboxAccess bool
AccessibleInboxIDs []uint
DateFrom *time.Time
DateTo *time.Time
SortBy string
@@ -103,6 +105,8 @@ func searchFilterToRepo(f *search.SearchFilter) *RepoSearchFilter {
SenderID: f.SenderID,
ContentType: f.ContentType,
Private: f.Private,
EnforceInboxAccess: f.EnforceInboxAccess,
AccessibleInboxIDs: append([]uint(nil), f.AccessibleInboxIDs...),
DateFrom: f.DateFrom,
DateTo: f.DateTo,
SortBy: f.SortBy,
@@ -164,6 +168,7 @@ func (r *SearchRepo) searchConversationsInternal(ctx context.Context, accountID
}
// Apply conversation-specific filters
q = applySearchInboxAccess(q, filter)
q = applyConversationFilters(q, filter)
// Apply date range filter
@@ -216,6 +221,7 @@ func (r *SearchRepo) searchMessagesInternal(ctx context.Context, accountID uint,
}
// Apply message-specific filters
q = applySearchInboxAccess(q, filter)
q = applyMessageFilters(q, filter)
// Apply date range filter
@@ -250,6 +256,36 @@ func (r *SearchRepo) AccountFeatureEnabled(ctx context.Context, accountID uint,
return contactFeatureFlagEnabled(account.FeatureFlags, flag)
}
func (r *SearchRepo) SearchAccessForAccount(ctx context.Context, accountID, userID uint) (search.SearchAccess, error) {
var accountUser model.AccountUser
if err := r.db.WithContext(ctx).
Where("account_id = ? AND user_id = ?", accountID, userID).
First(&accountUser).Error; err != nil {
return search.SearchAccess{}, err
}
if accountUser.IsAdministrator() {
return search.SearchAccess{SkipInboxFiltering: true}, nil
}
var totalInboxes int64
if err := r.db.WithContext(ctx).Model(&model.Inbox{}).Where("account_id = ?", accountID).Count(&totalInboxes).Error; err != nil {
return search.SearchAccess{}, err
}
var inboxIDs []uint
if err := r.db.WithContext(ctx).Model(&model.InboxMember{}).
Joins("JOIN inboxes ON inboxes.id = inbox_members.inbox_id").
Where("inboxes.account_id = ? AND inbox_members.user_id = ?", accountID, userID).
Order("inbox_members.inbox_id ASC").
Pluck("inbox_members.inbox_id", &inboxIDs).Error; err != nil {
return search.SearchAccess{}, err
}
if totalInboxes > 0 && int64(len(inboxIDs)) == totalInboxes {
return search.SearchAccess{SkipInboxFiltering: true, InboxIDs: inboxIDs}, nil
}
return search.SearchAccess{InboxIDs: inboxIDs}, nil
}
// searchContactsInternal is the internal implementation using *RepoSearchFilter.
func (r *SearchRepo) searchContactsInternal(ctx context.Context, accountID uint, query string, filter *RepoSearchFilter) ([]model.Contact, int64, error) {
var contacts []model.Contact
@@ -415,7 +451,7 @@ func applyConversationFilters(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
}
// Inbox ID filter
if filter.InboxID != nil {
if filter.InboxID != nil && searchInboxIDAllowed(filter, *filter.InboxID) {
q = q.Where("inbox_id = ?", *filter.InboxID)
}
@@ -462,13 +498,35 @@ func applyMessageFilters(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
}
// Inbox ID filter (messages also belong to an inbox)
if filter.InboxID != nil {
if filter.InboxID != nil && searchInboxIDAllowed(filter, *filter.InboxID) {
q = q.Where("inbox_id = ?", *filter.InboxID)
}
return q
}
func applySearchInboxAccess(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
if filter == nil || !filter.EnforceInboxAccess {
return q
}
if len(filter.AccessibleInboxIDs) == 0 {
return q.Where("1 = 0")
}
return q.Where("inbox_id IN ?", filter.AccessibleInboxIDs)
}
func searchInboxIDAllowed(filter *RepoSearchFilter, inboxID uint) bool {
if filter == nil || !filter.EnforceInboxAccess {
return true
}
for _, id := range filter.AccessibleInboxIDs {
if id == inboxID {
return true
}
}
return false
}
// applyContactFilters applies contact-specific filter conditions.
func applyContactFilters(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
if filter == nil {
+105
View File
@@ -45,6 +45,20 @@ func createTestMessageForSearch(t *testing.T, db *gorm.DB, accountID, conversati
return msg
}
func createTestInboxForSearch(t *testing.T, db *gorm.DB, accountID uint, name string) *model.Inbox {
t.Helper()
inbox := &model.Inbox{AccountID: accountID, Name: name, ChannelType: "web_widget", ChannelID: 1}
require.NoError(t, db.Create(inbox).Error)
return inbox
}
func createTestUserForSearch(t *testing.T, db *gorm.DB, accountID uint, email string) *model.User {
t.Helper()
user := &model.User{AccountID: accountID, Name: email, Email: email, Password: "password", Active: true}
require.NoError(t, db.Create(user).Error)
return user
}
func createTestContactForSearch(t *testing.T, db *gorm.DB, accountID uint, name, email, phone string) *model.Contact {
t.Helper()
contact := &model.Contact{
@@ -140,6 +154,97 @@ func TestSearchFilterToRepo_PreservesFields(t *testing.T) {
assert.Equal(t, "incoming", result.MessageType)
}
func TestSearchRepo_SearchAccessForAccountScopesAssignedInboxes(t *testing.T) {
db := setupTestDB(t, &model.InboxMember{})
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
user := createTestUserForSearch(t, db, account.ID, "agent-search@example.com")
assigned := createTestInboxForSearch(t, db, account.ID, "Assigned")
_ = createTestInboxForSearch(t, db, account.ID, "Hidden")
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent"}).Error)
require.NoError(t, db.Create(&model.InboxMember{InboxID: assigned.ID, UserID: user.ID}).Error)
access, err := repo.SearchAccessForAccount(context.Background(), account.ID, user.ID)
require.NoError(t, err)
assert.False(t, access.SkipInboxFiltering)
assert.Equal(t, []uint{assigned.ID}, access.InboxIDs)
}
func TestSearchRepo_SearchAccessForAccountSkipsForAdministratorAndAllInboxAgents(t *testing.T) {
db := setupTestDB(t, &model.InboxMember{})
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
admin := createTestUserForSearch(t, db, account.ID, "admin-search@example.com")
agent := createTestUserForSearch(t, db, account.ID, "all-inbox-search@example.com")
inbox1 := createTestInboxForSearch(t, db, account.ID, "Inbox 1")
inbox2 := createTestInboxForSearch(t, db, account.ID, "Inbox 2")
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: admin.ID, Role: "administrator"}).Error)
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: agent.ID, Role: "agent"}).Error)
require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox1.ID, UserID: agent.ID}).Error)
require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox2.ID, UserID: agent.ID}).Error)
adminAccess, err := repo.SearchAccessForAccount(context.Background(), account.ID, admin.ID)
require.NoError(t, err)
assert.True(t, adminAccess.SkipInboxFiltering)
agentAccess, err := repo.SearchAccessForAccount(context.Background(), account.ID, agent.ID)
require.NoError(t, err)
assert.True(t, agentAccess.SkipInboxFiltering)
}
func TestSearchRepo_SearchMessages_InboxAccessRestriction(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
visibleInbox := createTestInboxForSearch(t, db, account.ID, "Visible")
hiddenInbox := createTestInboxForSearch(t, db, account.ID, "Hidden")
visibleConv := createTestConversationForSearch(t, db, account.ID, "open", "")
hiddenConv := createTestConversationForSearch(t, db, account.ID, "open", "")
require.NoError(t, db.Model(visibleConv).Update("inbox_id", visibleInbox.ID).Error)
require.NoError(t, db.Model(hiddenConv).Update("inbox_id", hiddenInbox.ID).Error)
visibleMsg := createTestMessageForSearch(t, db, account.ID, visibleConv.ID, "visible message", "incoming")
require.NoError(t, db.Model(visibleMsg).Update("inbox_id", visibleInbox.ID).Error)
hiddenMsg := createTestMessageForSearch(t, db, account.ID, hiddenConv.ID, "hidden message", "incoming")
require.NoError(t, db.Model(hiddenMsg).Update("inbox_id", hiddenInbox.ID).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25, EnforceInboxAccess: true, AccessibleInboxIDs: []uint{visibleInbox.ID}}
results, total, err := repo.SearchMessages(context.Background(), account.ID, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, visibleMsg.ID, results[0].ID)
}
func TestSearchRepo_SearchMessages_InaccessibleInboxParamFallsBackToAccessibleBase(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
visibleInbox := createTestInboxForSearch(t, db, account.ID, "Visible")
hiddenInbox := createTestInboxForSearch(t, db, account.ID, "Hidden")
visibleConv := createTestConversationForSearch(t, db, account.ID, "open", "")
hiddenConv := createTestConversationForSearch(t, db, account.ID, "open", "")
require.NoError(t, db.Model(visibleConv).Update("inbox_id", visibleInbox.ID).Error)
require.NoError(t, db.Model(hiddenConv).Update("inbox_id", hiddenInbox.ID).Error)
visibleMsg := createTestMessageForSearch(t, db, account.ID, visibleConv.ID, "visible message", "incoming")
require.NoError(t, db.Model(visibleMsg).Update("inbox_id", visibleInbox.ID).Error)
hiddenMsg := createTestMessageForSearch(t, db, account.ID, hiddenConv.ID, "hidden message", "incoming")
require.NoError(t, db.Model(hiddenMsg).Update("inbox_id", hiddenInbox.ID).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25, InboxID: &hiddenInbox.ID, EnforceInboxAccess: true, AccessibleInboxIDs: []uint{visibleInbox.ID}}
results, total, err := repo.SearchMessages(context.Background(), account.ID, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, visibleMsg.ID, results[0].ID)
}
// ========== SearchConversations DB tests (PG only for ILIKE) ==========
func TestSearchRepo_SearchConversations_EmptyQuery(t *testing.T) {
+30 -1
View File
@@ -200,7 +200,10 @@ func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResul
}
parts = appendListFilter(parts, "labels", filter.Labels)
}
if filter.InboxID != nil {
if docType == ResultTypeConversation || docType == ResultTypeMessage {
parts = appendInboxAccessFilter(parts, filter)
}
if filter.InboxID != nil && searchFilterInboxAllowed(filter, *filter.InboxID) {
parts = append(parts, fmt.Sprintf("inbox_id = %d", *filter.InboxID))
}
if docType == ResultTypeMessage {
@@ -273,6 +276,32 @@ func appendListFilter(parts []string, field string, values []string) []string {
return parts
}
func appendInboxAccessFilter(parts []string, filter *SearchFilter) []string {
if filter == nil || !filter.EnforceInboxAccess {
return parts
}
if len(filter.AccessibleInboxIDs) == 0 {
return append(parts, "inbox_id = 0")
}
ids := make([]string, 0, len(filter.AccessibleInboxIDs))
for _, id := range filter.AccessibleInboxIDs {
ids = append(ids, fmt.Sprintf("inbox_id = %d", id))
}
return append(parts, "("+strings.Join(ids, " OR ")+")")
}
func searchFilterInboxAllowed(filter *SearchFilter, inboxID uint) bool {
if filter == nil || !filter.EnforceInboxAccess {
return true
}
for _, id := range filter.AccessibleInboxIDs {
if id == inboxID {
return true
}
}
return false
}
func sortExpression(filter *SearchFilter) string {
if filter == nil {
return ""
+35
View File
@@ -147,6 +147,41 @@ func TestMeiliSearchEngine_SearchSendsMessageSenderIDFilter(t *testing.T) {
assert.Equal(t, "account_id = 42 AND sender_type = \"contact\" AND sender_id = 77", requestBody["filter"])
}
func TestMeiliSearchEngine_SearchSendsInboxAccessFilter(t *testing.T) {
var requestBody map[string]interface{}
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
require.Equal(t, "/indexes/gochat_messages/search", r.URL.Path)
require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody))
return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil
})
engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"})
engine.client.SetTransport(transport)
filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeMessage}, EnforceInboxAccess: true, AccessibleInboxIDs: []uint{3, 5}}
_, err := engine.Search(context.Background(), 42, "hello", filter)
require.NoError(t, err)
assert.Equal(t, "account_id = 42 AND (inbox_id = 3 OR inbox_id = 5)", requestBody["filter"])
}
func TestMeiliSearchEngine_SearchIgnoresInaccessibleInboxParam(t *testing.T) {
var requestBody map[string]interface{}
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
require.Equal(t, "/indexes/gochat_messages/search", r.URL.Path)
require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody))
return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil
})
engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"})
engine.client.SetTransport(transport)
inboxID := uint(9)
filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeMessage}, InboxID: &inboxID, EnforceInboxAccess: true, AccessibleInboxIDs: []uint{3, 5}}
_, err := engine.Search(context.Background(), 42, "hello", filter)
require.NoError(t, err)
assert.Equal(t, "account_id = 42 AND (inbox_id = 3 OR inbox_id = 5)", requestBody["filter"])
}
func TestMeiliSearchEngine_IndexAndDeleteDocument(t *testing.T) {
seen := []string{}
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
+6
View File
@@ -49,6 +49,12 @@ type SearchFilter struct {
ContentType string `form:"content_type"` // text, file, image, etc
Private *bool `form:"private"` // true = private notes only
// Chatwoot SearchService scopes conversation/message results to the current
// user's assigned inboxes unless the user can access every inbox.
CurrentUserID *uint `form:"-"`
EnforceInboxAccess bool `form:"-"`
AccessibleInboxIDs []uint `form:"-"`
// Article-specific filters (Knowledge Base)
PortalID *uint `form:"portal_id"` // portal to search within
ArticleStatus string `form:"article_status"` // draft, published, archived
+41 -4
View File
@@ -16,21 +16,31 @@ type SearchService struct {
searchRepo SearchRepoInterface
engine SearchEngine
featureResolver accountFeatureResolver
accessResolver accountSearchAccessResolver
}
type accountFeatureResolver interface {
AccountFeatureEnabled(ctx context.Context, accountID uint, flag string) bool
}
type SearchAccess struct {
SkipInboxFiltering bool
InboxIDs []uint
}
type accountSearchAccessResolver interface {
SearchAccessForAccount(ctx context.Context, accountID, userID uint) (SearchAccess, error)
}
// NewSearchService creates a new SearchService.
func NewSearchService(searchRepo SearchRepoInterface) *SearchService {
return &SearchService{searchRepo: searchRepo, featureResolver: searchFeatureResolver(searchRepo)}
return &SearchService{searchRepo: searchRepo, featureResolver: searchFeatureResolver(searchRepo), accessResolver: searchAccessResolver(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, featureResolver: searchFeatureResolver(fallbackRepo)}
return &SearchService{searchRepo: fallbackRepo, engine: engine, featureResolver: searchFeatureResolver(fallbackRepo), accessResolver: searchAccessResolver(fallbackRepo)}
}
func searchFeatureResolver(repo SearchRepoInterface) accountFeatureResolver {
@@ -40,6 +50,13 @@ func searchFeatureResolver(repo SearchRepoInterface) accountFeatureResolver {
return nil
}
func searchAccessResolver(repo SearchRepoInterface) accountSearchAccessResolver {
if resolver, ok := repo.(accountSearchAccessResolver); ok {
return resolver
}
return nil
}
// 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.
@@ -68,10 +85,10 @@ func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query
}
if s.engine != nil {
filter = s.withContactResolvedScope(ctx, accountID, filter)
filter = s.withSearchAccess(ctx, accountID, s.withContactResolvedScope(ctx, accountID, filter))
return s.engine.Search(ctx, accountID, query, filter)
}
filter = s.withContactResolvedScope(ctx, accountID, filter)
filter = s.withSearchAccess(ctx, accountID, s.withContactResolvedScope(ctx, accountID, filter))
var allResults []SearchResult
byType := map[string]int64{}
@@ -198,6 +215,7 @@ 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) {
filter = s.withSearchAccess(ctx, accountID, filter)
if s.engine != nil {
return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeConversation)
}
@@ -223,6 +241,7 @@ 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) {
filter = s.withSearchAccess(ctx, accountID, filter)
if s.engine != nil {
return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeMessage)
}
@@ -344,6 +363,7 @@ func cloneSearchFilter(filter *SearchFilter) *SearchFilter {
clone.Status = append([]string(nil), filter.Status...)
clone.Priority = append([]string(nil), filter.Priority...)
clone.Labels = append([]string(nil), filter.Labels...)
clone.AccessibleInboxIDs = append([]uint(nil), filter.AccessibleInboxIDs...)
return &clone
}
@@ -356,6 +376,23 @@ func (s *SearchService) withContactResolvedScope(ctx context.Context, accountID
return clone
}
func (s *SearchService) withSearchAccess(ctx context.Context, accountID uint, filter *SearchFilter) *SearchFilter {
clone := cloneSearchFilter(filter)
if s == nil || s.accessResolver == nil || clone.CurrentUserID == nil || *clone.CurrentUserID == 0 {
return clone
}
access, err := s.accessResolver.SearchAccessForAccount(ctx, accountID, *clone.CurrentUserID)
if err != nil {
applogger.L().Warnf("search access resolution error: %v", err)
clone.EnforceInboxAccess = true
clone.AccessibleInboxIDs = nil
return clone
}
clone.EnforceInboxAccess = !access.SkipInboxFiltering
clone.AccessibleInboxIDs = append([]uint(nil), access.InboxIDs...)
return clone
}
func (s *SearchService) IndexDocument(ctx context.Context, doc SearchDocument) error {
if s.engine == nil {
return nil
+30 -4
View File
@@ -18,9 +18,10 @@ type mockSearchRepo struct {
convCount int64
convErr error
messages []model.Message
msgCount int64
msgErr error
messages []model.Message
msgCount int64
msgErr error
msgFilter *SearchFilter
contacts []model.Contact
contactCount int64
@@ -35,7 +36,9 @@ type mockSearchRepo struct {
articleCount int64
articleErr error
features map[string]bool
features map[string]bool
access SearchAccess
accessErr error
}
func (m *mockSearchRepo) SearchConversations(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Conversation, int64, error) {
@@ -43,6 +46,7 @@ func (m *mockSearchRepo) SearchConversations(ctx context.Context, accountID uint
}
func (m *mockSearchRepo) SearchMessages(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Message, int64, error) {
m.msgFilter = filter
return m.messages, m.msgCount, m.msgErr
}
@@ -55,6 +59,10 @@ func (m *mockSearchRepo) AccountFeatureEnabled(ctx context.Context, accountID ui
return m.features[flag]
}
func (m *mockSearchRepo) SearchAccessForAccount(ctx context.Context, accountID, userID uint) (SearchAccess, error) {
return m.access, m.accessErr
}
func (m *mockSearchRepo) SearchCompanies(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Company, int64, error) {
return m.companies, m.companyCount, m.companyErr
}
@@ -317,6 +325,24 @@ func TestSearchMessages_ConvenienceMethod(t *testing.T) {
assert.Equal(t, ResultTypeMessage, results[0].Type)
}
func TestSearchMessages_AppliesCurrentUserInboxAccess(t *testing.T) {
userID := uint(7)
repo := &mockSearchRepo{
messages: []model.Message{makeMessage(10, 1, "Hello test message", "incoming")},
msgCount: 1,
access: SearchAccess{InboxIDs: []uint{3, 5}},
}
svc := NewSearchService(repo)
filter := &SearchFilter{Page: 1, PerPage: 25, CurrentUserID: &userID}
_, _, err := svc.SearchMessages(context.Background(), 1, "test", filter)
require.NoError(t, err)
require.NotNil(t, repo.msgFilter)
assert.True(t, repo.msgFilter.EnforceInboxAccess)
assert.Equal(t, []uint{3, 5}, repo.msgFilter.AccessibleInboxIDs)
}
func TestSearchContacts_ConvenienceMethod(t *testing.T) {
repo := &mockSearchRepo{}
repo.contacts = []model.Contact{makeContact(5, 1, "Alice", "alice@test.com", "")}