feat(search): align time filters
This commit is contained in:
@@ -49,11 +49,11 @@ Hermes task landing checklist:
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Current tracking checkpoint: 2026-06-07 P3.79 search advanced-filter gate parity, prepared as `feat(search): gate advanced filters`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): gate advanced filters`.
|
||||
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot SearchService `advanced_search` feature-gated filter parity for global/entity search. No route artifacts change.
|
||||
- Current tracking checkpoint: 2026-06-07 P3.80 search time-bound parity, prepared as `feat(search): align time filters`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): align time filters`.
|
||||
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot SearchService entity-specific time filter columns and 90-day cap parity for global/entity 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: reused SearchAPI advanced filters now follow Chatwoot `SearchService` feature gates: `since/until` date filters for conversations, messages, contacts, and articles apply only when account `advanced_search` is enabled; message `from` and explicit `inbox_id` filters also require `advanced_search`, while always-on message recency and current-user inbox access remain enforced. Existing P3.78 search message recency parity, P3.77 search inbox access parity, 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.
|
||||
- Worktree status at this implementation checkpoint: reused SearchAPI advanced time filters now follow Chatwoot `SearchService`: `since` is capped to `90.days.ago`, `until` is capped to `90.days.from_now`, conversations and contacts filter by `last_activity_at`, articles filter by `updated_at`, and messages filter by `created_at`. Meilisearch documents/settings now expose `last_activity_at_ts` for conversation/contact filtering while keeping P3.79 advanced-search feature gates, P3.78 message recency, P3.77 current-user inbox access, and prior checkpoints 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.80 search time-bound parity | Implemented for reused SearchAPI advanced time filters: GoChat now caps `since`/`until` like Chatwoot `SearchService#cap_since_time`/`#cap_until_time`, applies conversation/contact DB filters to Unix `last_activity_at`, article DB filters to `updated_at`, message filters to `created_at`, and emits matching Meilisearch filter fields (`last_activity_at_ts`, `updated_at_ts`, `created_at_ts`). | Keep in Review; reopen from B12 search smoke or fresh reference evidence for Searchkick-specific time filters, timezone edge cases beyond Unix seconds, or additional entity types exposed by reused frontend global search. | Focused search service, Meilisearch engine, document builder, and SearchRepo time-filter tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
|
||||
| 0 | P3.79 search advanced-filter gate parity | Implemented for reused SearchAPI filters: GoChat now mirrors Chatwoot `SearchService` by applying `since/until`, message `from`, and message explicit `inbox_id` filters only when account `advanced_search` is enabled. Always-on message recency and user inbox access scopes remain active regardless of the feature gate. DB fallback and Meilisearch filters use the same gate. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for global `ChatwootApp.advanced_search_allowed?` gating, Searchkick advanced message filters, exact `cap_until_time` future bounds, or custom-role search visibility. | Focused search service, Meilisearch engine, and SearchRepo advanced-gate tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
|
||||
| 0 | P3.78 search message recency parity | Implemented for reused global/entity message search: GoChat now applies Chatwoot `SearchService#message_base_query`'s recent-message base relation (`created_at >= 3.months.ago`) before message content search and filters. The DB fallback uses `messages.created_at`; Meilisearch message searches add the same `created_at_ts` lower bound, while contact/conversation/article search remains unaffected. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for exact Rails `3.months.ago` calendar edge cases, `cap_until_time` future bounds, or advanced-search feature-gated date filter drift. | Focused search service, Meilisearch engine, and SearchRepo message-recency tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
|
||||
| 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. |
|
||||
@@ -243,6 +244,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.80 search time-bound parity | `internal/search/search_service.go`, `internal/search/engine.go`, `internal/search/engine_meili.go`, `internal/repository/search_repo.go`, search service/engine/repository tests | `reference/chatwoot/app/services/search_service.rb#apply_time_filter`, `reference/chatwoot/app/services/search_service.rb#cap_since_time`, `reference/chatwoot/app/services/search_service.rb#cap_until_time`, `reference/chatwoot/app/services/search_service.rb#filter_conversations`, `reference/chatwoot/app/services/search_service.rb#apply_message_filters`, `reference/chatwoot/app/services/search_service.rb#filter_contacts`, `reference/chatwoot/app/services/search_service.rb#filter_articles` | Advanced search time filters now use the same entity columns and caps as Chatwoot: `DateFrom` is capped to no earlier than `90.days.ago`, `DateTo` is capped to no later than `90.days.from_now`; DB fallback uses `conversations.last_activity_at` and `contacts.last_activity_at` Unix seconds, `messages.created_at`, and `articles.updated_at`; Meilisearch documents/settings carry `last_activity_at_ts`, and Meili filters pick `last_activity_at_ts`, `created_at_ts`, or `updated_at_ts` by entity. | Review by `feat(search): align time filters`; focused tests cover service caps, document `last_activity_at_ts`, Meilisearch field selection, and DB conversation/contact/article field selection; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
|
||||
| P3.79 search advanced-filter gate parity | `internal/search/search_filter.go`, `internal/search/search_service.go`, `internal/search/engine_meili.go`, `internal/repository/search_repo.go`, search service/engine/repository tests | `reference/chatwoot/app/services/search_service.rb#filter_conversations`, `reference/chatwoot/app/services/search_service.rb#apply_message_filters`, `reference/chatwoot/app/services/search_service.rb#apply_time_filter`, `reference/chatwoot/app/services/search_service.rb#apply_sender_filter`, `reference/chatwoot/app/services/search_service.rb#apply_inbox_id_filter`, `reference/chatwoot/app/javascript/dashboard/api/search.js`, `reference/chatwoot/app/javascript/dashboard/api/specs/search.spec.js` | Search filters now honor the same `current_account.feature_enabled?('advanced_search')` gates as Chatwoot: the search service resolves the account feature into the filter; DB fallback skips `since/until` date filters unless advanced search is enabled; message sender (`from`) and explicit `inbox_id` filters are ignored unless advanced search is enabled; Meilisearch omits those advanced filter expressions under the same gate. Message `created_at >= 3.months.ago` and user inbox access scopes remain always-on. | Review by `feat(search): gate advanced filters`; focused tests cover service feature propagation, DB sender filter ignored/enforced by feature state, and Meilisearch omission/emission of sender/date/inbox advanced filters; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
|
||||
| P3.78 search message recency parity | `internal/search/search_filter.go`, `internal/search/search_service.go`, `internal/search/engine_meili.go`, `internal/repository/search_repo.go`, search service/engine/repository tests | `reference/chatwoot/app/services/search_service.rb#message_base_query`, `reference/chatwoot/app/services/search_service.rb#filter_messages_with_like`, `reference/chatwoot/app/services/search_service.rb#filter_messages_with_gin`, `reference/chatwoot/app/javascript/dashboard/api/search.js`, `reference/chatwoot/app/javascript/dashboard/api/specs/search.spec.js` | Global/entity message search now follows Chatwoot's recent-message base relation: the search service stamps message search filters with a `3.months.ago` lower bound, DB fallback applies it to `messages.created_at` before content/filter/date predicates, Meilisearch message searches apply `created_at_ts >= cutoff`, and older explicit `since` params cannot expand the message result set beyond the reference base query. | Review by `feat(search): scope message recency`; focused tests cover service cutoff stamping, DB exclusion of older messages, and Meilisearch message filter emission; full `go test ./...` and `git diff --check` must pass. No route artifacts change. |
|
||||
| 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. |
|
||||
@@ -348,6 +350,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
|
||||
| Commit | Scope | Verification summary | Follow-up state |
|
||||
| --- | --- | --- | --- |
|
||||
| `feat(search): align time filters` | Advances P3.80 with Chatwoot `SearchService` advanced time-filter parity. GoChat now caps `since` to `90.days.ago` and `until` to `90.days.from_now`, applies conversation/contact search time filters to `last_activity_at`, message filters to `created_at`, and article filters to `updated_at`; Meilisearch documents/settings now include `last_activity_at_ts` and emit entity-specific time filter fields. | `go test ./internal/search ./internal/repository -run 'TestSearchMessages_CapsAdvancedSearchDateRange\|TestMeiliSearchEngine_SearchUsesReferenceTimeFilterFields\|TestDocumentBuildersSetStableUIDAndType\|TestSearchRepo_Search(Conversations\|Contacts\|Articles)_DateFilterUses' -count=1`; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.80 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
| `feat(contacts): default contact type` | Advances P3.74 with Chatwoot contact default/sync parity. Contact persistence now normalizes blank `contact_type` to `visitor`, then upgrades visitor contacts with email, phone number, or social additional attributes to `lead`, so CSV imports without an explicit contact type no longer create empty-type records that drift from CRM v2 lead-only behavior. | `go test ./internal/model ./internal/service -run 'TestContact_Create|TestContactService_ImportCSV_DefaultsContactTypeLikeChatwoot' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.74 to Review; continue Phase 2/3 drift audit for message filters, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
| `feat(conversations): scope filter permissions` | Advances P3.73 with Chatwoot `Conversations::PermissionFilterService` parity for `POST /conversations/filter`. Advanced filters now apply assigned-inbox visibility before filter payloads and meta counts, so non-admin agents cannot see conversations from inboxes they do not belong to while administrators retain account-wide visibility. | `go test ./internal/service -run 'TestConversationService_Filter_(RestrictsAgentToAssignedInboxes|AllowsAdministratorAllInboxes)' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.73 to Review; continue Phase 2/3 drift audit for message filters, import/contact-type drift, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
| `feat(accounts): serialize enabled features` | Advances P3.72 with Chatwoot account `enabled_features` serialization parity. GoChat account payloads now seed default enabled features from the local reference `config/features.yml`, include explicit true flags such as `crm_v2`, and omit false flags instead of exposing raw `feature_flags` to reused dashboard feature gates. | `go test ./internal/handler/api/v1 -run 'AccountHandlerSuite/TestGet_(FeaturesUseChatwootEnabledFeatures|DefaultFeaturesMatchChatwootDefaults)' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.72 to Review; continue Phase 2/3 drift audit for message filters, conversation permission-filter depth, import/contact-type drift, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
@@ -2697,3 +2700,4 @@ Verification milestone gates:
|
||||
- 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.
|
||||
- 2026-06-07: P3.78 search message recency checkpoint prepared as `feat(search): scope message recency`; audited Chatwoot `SearchService#message_base_query`, message search SQL/GIN branches, and reused dashboard SearchAPI callers. GoChat global/entity message search now stamps message filters with the Chatwoot `3.months.ago` lower bound, applies it to DB fallback `messages.created_at`, emits the same `created_at_ts` lower bound for Meilisearch message searches, and keeps the recent base relation even when older `since` params are provided. Focused search service/engine/repository tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
|
||||
- 2026-06-07: P3.79 search advanced-filter gate checkpoint prepared as `feat(search): gate advanced filters`; audited Chatwoot `SearchService` feature gates around conversation/contact/article time filters and message time/sender/inbox filters, plus reused dashboard SearchAPI callers. GoChat search now resolves account `advanced_search` into the search filter, skips `since/until` date predicates unless that feature is enabled, and ignores message `from` plus explicit `inbox_id` filters unless advanced search is enabled; message recency and user inbox access remain always-on. Focused search service/engine/repository tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
|
||||
- 2026-06-07: P3.80 search time-bound checkpoint prepared as `feat(search): align time filters`; audited Chatwoot `SearchService#apply_time_filter`, `#cap_since_time`, `#cap_until_time`, and the conversation/message/contact/article filter callers. GoChat advanced search time filters now cap `since`/`until` to the same +/-90-day bounds, use `last_activity_at` for conversation/contact DB fallback, `updated_at` for articles, and `created_at` for messages; Meilisearch documents/settings now expose `last_activity_at_ts` and choose the same entity-specific timestamp fields. Focused search service/engine/repository tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
|
||||
|
||||
@@ -176,7 +176,7 @@ func (r *SearchRepo) searchConversationsInternal(ctx context.Context, accountID
|
||||
q = applyConversationFilters(q, filter)
|
||||
|
||||
// Apply date range filter
|
||||
q = applyDateRangeFilter(q, filter)
|
||||
q = applyDateRangeFilter(q, filter, "last_activity_at", true)
|
||||
|
||||
// Count
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
@@ -230,7 +230,7 @@ func (r *SearchRepo) searchMessagesInternal(ctx context.Context, accountID uint,
|
||||
q = applyMessageFilters(q, filter)
|
||||
|
||||
// Apply date range filter
|
||||
q = applyDateRangeFilter(q, filter)
|
||||
q = applyDateRangeFilter(q, filter, "created_at", false)
|
||||
|
||||
// Count
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
@@ -323,7 +323,7 @@ func (r *SearchRepo) searchContactsInternal(ctx context.Context, accountID uint,
|
||||
q = applyContactFilters(q, filter)
|
||||
|
||||
// Apply date range filter
|
||||
q = applyDateRangeFilter(q, filter)
|
||||
q = applyDateRangeFilter(q, filter, "last_activity_at", true)
|
||||
|
||||
// Count
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
@@ -363,7 +363,7 @@ func (r *SearchRepo) searchCompaniesInternal(ctx context.Context, accountID uint
|
||||
}
|
||||
}
|
||||
|
||||
q = applyDateRangeFilter(q, filter)
|
||||
q = applyDateRangeFilter(q, filter, "created_at", false)
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("search companies count: %w", err)
|
||||
}
|
||||
@@ -408,7 +408,7 @@ func (r *SearchRepo) searchArticlesInternal(ctx context.Context, accountID uint,
|
||||
q = applyArticleFilters(q, filter)
|
||||
|
||||
// Apply date range filter
|
||||
q = applyDateRangeFilter(q, filter)
|
||||
q = applyDateRangeFilter(q, filter, "updated_at", false)
|
||||
|
||||
// Count
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
@@ -583,18 +583,32 @@ func applyArticleFilters(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
|
||||
return q
|
||||
}
|
||||
|
||||
// applyDateRangeFilter applies a date range filter on created_at.
|
||||
func applyDateRangeFilter(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
|
||||
// applyDateRangeFilter applies Chatwoot SearchService time filters using the
|
||||
// entity-specific column selected by the caller.
|
||||
func applyDateRangeFilter(q *gorm.DB, filter *RepoSearchFilter, columnName string, unixSeconds bool) *gorm.DB {
|
||||
if filter == nil || !filter.AdvancedSearchEnabled {
|
||||
return q
|
||||
}
|
||||
if columnName == "" {
|
||||
columnName = "created_at"
|
||||
}
|
||||
|
||||
if filter.DateFrom != nil {
|
||||
q = q.Where("created_at >= ?", filter.DateFrom)
|
||||
q = q.Where(columnName+" >= ?", dateRangeValue(filter.DateFrom, unixSeconds))
|
||||
}
|
||||
if filter.DateTo != nil {
|
||||
q = q.Where("created_at <= ?", filter.DateTo)
|
||||
q = q.Where(columnName+" <= ?", dateRangeValue(filter.DateTo, unixSeconds))
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
func dateRangeValue(value *time.Time, unixSeconds bool) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if unixSeconds {
|
||||
return value.Unix()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -350,6 +350,28 @@ func TestSearchRepo_SearchConversations_WithStatusFilter(t *testing.T) {
|
||||
assert.Equal(t, "open", results[0].Status)
|
||||
}
|
||||
|
||||
func TestSearchRepo_SearchConversations_DateFilterUsesLastActivityAt(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
repo := NewSearchRepo(db)
|
||||
|
||||
account := createTestAccountForSearch(t, db)
|
||||
cutoff := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
|
||||
oldActivity := cutoff.Add(-time.Hour).Unix()
|
||||
recentActivity := cutoff.Add(time.Hour).Unix()
|
||||
oldConv := createTestConversationForSearch(t, db, account.ID, "open", "date-filter")
|
||||
recentConv := createTestConversationForSearch(t, db, account.ID, "open", "date-filter")
|
||||
require.NoError(t, db.Model(oldConv).Update("last_activity_at", oldActivity).Error)
|
||||
require.NoError(t, db.Model(recentConv).Update("last_activity_at", recentActivity).Error)
|
||||
|
||||
filter := &search.SearchFilter{Page: 1, PerPage: 25, AdvancedSearchEnabled: true, DateFrom: &cutoff}
|
||||
results, total, err := repo.SearchConversations(context.Background(), account.ID, "", filter)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, recentConv.ID, results[0].ID)
|
||||
}
|
||||
|
||||
// ========== SearchMessages DB tests (PG only for ILIKE) ==========
|
||||
|
||||
func TestSearchRepo_SearchMessages_ContentMatch(t *testing.T) {
|
||||
@@ -463,11 +485,33 @@ func TestSearchRepo_SearchContacts_ResolvedScopeCRMV2(t *testing.T) {
|
||||
assert.Equal(t, lead.ID, results[0].ID)
|
||||
}
|
||||
|
||||
func TestSearchRepo_SearchContacts_DateFilterUsesLastActivityAt(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
repo := NewSearchRepo(db)
|
||||
|
||||
account := createTestAccountForSearch(t, db)
|
||||
cutoff := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
|
||||
oldActivity := cutoff.Add(-time.Hour).Unix()
|
||||
recentActivity := cutoff.Add(time.Hour).Unix()
|
||||
oldContact := createTestContactForSearch(t, db, account.ID, "Old Activity", "old@example.com", "")
|
||||
recentContact := createTestContactForSearch(t, db, account.ID, "Recent Activity", "recent@example.com", "")
|
||||
require.NoError(t, db.Model(oldContact).Update("last_activity_at", oldActivity).Error)
|
||||
require.NoError(t, db.Model(recentContact).Update("last_activity_at", recentActivity).Error)
|
||||
|
||||
filter := &search.SearchFilter{Page: 1, PerPage: 25, AdvancedSearchEnabled: true, DateFrom: &cutoff}
|
||||
results, total, err := repo.SearchContacts(context.Background(), account.ID, "", filter)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, recentContact.ID, results[0].ID)
|
||||
}
|
||||
|
||||
// ========== SearchArticles DB tests (PG only for ILIKE) ==========
|
||||
|
||||
func TestSearchRepo_SearchArticles_TitleMatch(t *testing.T) {
|
||||
skipIfSQLite(t)
|
||||
db := setupTestDB(t)
|
||||
db := setupTestDB(t, &model.Article{})
|
||||
repo := NewSearchRepo(db)
|
||||
|
||||
account := createTestAccountForSearch(t, db)
|
||||
@@ -483,7 +527,7 @@ func TestSearchRepo_SearchArticles_TitleMatch(t *testing.T) {
|
||||
|
||||
func TestSearchRepo_SearchArticles_WithStatusFilter(t *testing.T) {
|
||||
skipIfSQLite(t)
|
||||
db := setupTestDB(t)
|
||||
db := setupTestDB(t, &model.Article{})
|
||||
repo := NewSearchRepo(db)
|
||||
|
||||
account := createTestAccountForSearch(t, db)
|
||||
@@ -499,6 +543,26 @@ func TestSearchRepo_SearchArticles_WithStatusFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchRepo_SearchArticles_DateFilterUsesUpdatedAt(t *testing.T) {
|
||||
db := setupTestDB(t, &model.Article{})
|
||||
repo := NewSearchRepo(db)
|
||||
|
||||
account := createTestAccountForSearch(t, db)
|
||||
cutoff := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
|
||||
oldArticle := createTestArticleForSearch(t, db, account.ID, 1, "Old Updated Article", "desc", "content", "published")
|
||||
recentArticle := createTestArticleForSearch(t, db, account.ID, 1, "Recent Updated Article", "desc", "content", "published")
|
||||
require.NoError(t, db.Model(oldArticle).UpdateColumn("updated_at", cutoff.Add(-time.Hour)).Error)
|
||||
require.NoError(t, db.Model(recentArticle).UpdateColumn("updated_at", cutoff.Add(time.Hour)).Error)
|
||||
|
||||
filter := &search.SearchFilter{Page: 1, PerPage: 25, AdvancedSearchEnabled: true, DateFrom: &cutoff}
|
||||
results, total, err := repo.SearchArticles(context.Background(), account.ID, "", filter)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, recentArticle.ID, results[0].ID)
|
||||
}
|
||||
|
||||
// ========== Interface satisfaction test ==========
|
||||
|
||||
func TestSearchRepo_SatisfiesSearchRepoInterface(t *testing.T) {
|
||||
|
||||
+25
-15
@@ -65,6 +65,7 @@ type SearchDocument struct {
|
||||
Locale string `json:"locale,omitempty"`
|
||||
CreatedAtTS int64 `json:"created_at_ts"`
|
||||
UpdatedAtTS int64 `json:"updated_at_ts"`
|
||||
LastActivityAtTS int64 `json:"last_activity_at_ts,omitempty"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
@@ -132,24 +133,32 @@ func timestamp(t time.Time) int64 {
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
func timestampPtr(value *int64) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
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},
|
||||
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),
|
||||
LastActivityAtTS: timestampPtr(conv.LastActivityAt),
|
||||
Data: map[string]interface{}{"conversation": conv},
|
||||
}
|
||||
doc.Snippet = conversationSnippet(&conv, "")
|
||||
doc.ensureUID()
|
||||
@@ -196,6 +205,7 @@ func ContactDocument(contact model.Contact) SearchDocument {
|
||||
ContactHasDetails: content != "",
|
||||
CreatedAtTS: timestamp(contact.CreatedAt),
|
||||
UpdatedAtTS: timestamp(contact.UpdatedAt),
|
||||
LastActivityAtTS: timestampPtr(contact.LastActivityAt),
|
||||
Data: map[string]interface{}{"contact": contact},
|
||||
}
|
||||
doc.ensureUID()
|
||||
|
||||
@@ -174,8 +174,8 @@ func (e *MeiliSearchEngine) ensureIndex(ctx context.Context, docType SearchResul
|
||||
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", "sender_id", "content_type", "private", "contact_source", "contact_type", "contact_has_details", "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"},
|
||||
"filterableAttributes": []string{"account_id", "type", "status", "priority", "message_type", "sender_type", "sender_id", "content_type", "private", "contact_source", "contact_type", "contact_has_details", "labels", "assignee_id", "team_id", "inbox_id", "contact_id", "conversation_id", "portal_id", "locale", "created_at_ts", "updated_at_ts", "last_activity_at_ts"},
|
||||
"sortableAttributes": []string{"created_at_ts", "updated_at_ts", "last_activity_at_ts", "id"},
|
||||
}
|
||||
resp, err := e.client.R().
|
||||
SetContext(ctx).
|
||||
@@ -249,15 +249,27 @@ func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResul
|
||||
parts = append(parts, fmt.Sprintf("locale = %q", filter.ArticleLocale))
|
||||
}
|
||||
}
|
||||
dateField := dateFilterField(docType)
|
||||
if filter.AdvancedSearchEnabled && filter.DateFrom != nil {
|
||||
parts = append(parts, fmt.Sprintf("created_at_ts >= %d", filter.DateFrom.Unix()))
|
||||
parts = append(parts, fmt.Sprintf("%s >= %d", dateField, filter.DateFrom.Unix()))
|
||||
}
|
||||
if filter.AdvancedSearchEnabled && filter.DateTo != nil {
|
||||
parts = append(parts, fmt.Sprintf("created_at_ts <= %d", filter.DateTo.Unix()))
|
||||
parts = append(parts, fmt.Sprintf("%s <= %d", dateField, filter.DateTo.Unix()))
|
||||
}
|
||||
return strings.Join(parts, " AND ")
|
||||
}
|
||||
|
||||
func dateFilterField(docType SearchResultType) string {
|
||||
switch docType {
|
||||
case ResultTypeConversation, ResultTypeContact:
|
||||
return "last_activity_at_ts"
|
||||
case ResultTypeArticle, ResultTypeHelpCenter:
|
||||
return "updated_at_ts"
|
||||
default:
|
||||
return "created_at_ts"
|
||||
}
|
||||
}
|
||||
|
||||
func (e *MeiliSearchEngine) indexName(docType SearchResultType) string {
|
||||
return e.indexPrefix + strings.ReplaceAll(string(docType), "_", "_") + "s"
|
||||
}
|
||||
|
||||
@@ -43,15 +43,18 @@ func TestNewSearchEngine_DBFallbackRequiresRepo(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDocumentBuildersSetStableUIDAndType(t *testing.T) {
|
||||
lastActivity := int64(1772884800)
|
||||
conv := makeConversation(12, 3, "open", "billing,urgent")
|
||||
conv.InboxID = 7
|
||||
conv.ContactID = 9
|
||||
conv.LastActivityAt = &lastActivity
|
||||
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)
|
||||
assert.Equal(t, lastActivity, doc.LastActivityAtTS)
|
||||
}
|
||||
|
||||
func TestContactDocumentSetsResolvedScopeFields(t *testing.T) {
|
||||
@@ -203,6 +206,28 @@ func TestMeiliSearchEngine_SearchSendsMessageRecentBaseFilter(t *testing.T) {
|
||||
assert.Equal(t, "account_id = 42 AND created_at_ts >= 1772884800", requestBody["filter"])
|
||||
}
|
||||
|
||||
func TestMeiliSearchEngine_SearchUsesReferenceTimeFilterFields(t *testing.T) {
|
||||
seen := map[string]string{}
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
var requestBody map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody))
|
||||
seen[r.URL.Path] = requestBody["filter"].(string)
|
||||
return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil
|
||||
})
|
||||
|
||||
engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"})
|
||||
engine.client.SetTransport(transport)
|
||||
from := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC)
|
||||
filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeConversation, ResultTypeContact, ResultTypeArticle, ResultTypeMessage}, AdvancedSearchEnabled: true, DateFrom: &from}
|
||||
_, err := engine.Search(context.Background(), 42, "hello", filter)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "account_id = 42 AND last_activity_at_ts >= 1772884800", seen["/indexes/gochat_conversations/search"])
|
||||
assert.Equal(t, "account_id = 42 AND last_activity_at_ts >= 1772884800", seen["/indexes/gochat_contacts/search"])
|
||||
assert.Equal(t, "account_id = 42 AND updated_at_ts >= 1772884800", seen["/indexes/gochat_articles/search"])
|
||||
assert.Equal(t, "account_id = 42 AND created_at_ts >= 1772884800", seen["/indexes/gochat_messages/search"])
|
||||
}
|
||||
|
||||
func TestMeiliSearchEngine_SearchIgnoresInaccessibleInboxParam(t *testing.T) {
|
||||
var requestBody map[string]interface{}
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
|
||||
@@ -387,9 +387,26 @@ func (s *SearchService) withAdvancedSearchFeature(ctx context.Context, accountID
|
||||
if s != nil && s.featureResolver != nil {
|
||||
clone.AdvancedSearchEnabled = s.featureResolver.AccountFeatureEnabled(ctx, accountID, "advanced_search")
|
||||
}
|
||||
if clone.AdvancedSearchEnabled {
|
||||
capAdvancedSearchDateRange(clone)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func capAdvancedSearchDateRange(filter *SearchFilter) {
|
||||
now := searchNow()
|
||||
maxLookback := now.Add(-90 * 24 * time.Hour)
|
||||
maxFuture := now.Add(90 * 24 * time.Hour)
|
||||
if filter.DateFrom != nil && filter.DateFrom.Before(maxLookback) {
|
||||
capped := maxLookback
|
||||
filter.DateFrom = &capped
|
||||
}
|
||||
if filter.DateTo != nil && filter.DateTo.After(maxFuture) {
|
||||
capped := maxFuture
|
||||
filter.DateTo = &capped
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SearchService) withContactResolvedScope(ctx context.Context, accountID uint, filter *SearchFilter) *SearchFilter {
|
||||
clone := cloneSearchFilter(filter)
|
||||
clone.ContactResolvedScope = true
|
||||
|
||||
@@ -18,6 +18,7 @@ type mockSearchRepo struct {
|
||||
conversations []model.Conversation
|
||||
convCount int64
|
||||
convErr error
|
||||
convFilter *SearchFilter
|
||||
|
||||
messages []model.Message
|
||||
msgCount int64
|
||||
@@ -33,9 +34,10 @@ type mockSearchRepo struct {
|
||||
companyCount int64
|
||||
companyErr error
|
||||
|
||||
articles []model.Article
|
||||
articleCount int64
|
||||
articleErr error
|
||||
articles []model.Article
|
||||
articleCount int64
|
||||
articleErr error
|
||||
articleFilter *SearchFilter
|
||||
|
||||
features map[string]bool
|
||||
access SearchAccess
|
||||
@@ -43,6 +45,7 @@ type mockSearchRepo struct {
|
||||
}
|
||||
|
||||
func (m *mockSearchRepo) SearchConversations(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Conversation, int64, error) {
|
||||
m.convFilter = filter
|
||||
return m.conversations, m.convCount, m.convErr
|
||||
}
|
||||
|
||||
@@ -69,6 +72,7 @@ func (m *mockSearchRepo) SearchCompanies(ctx context.Context, accountID uint, qu
|
||||
}
|
||||
|
||||
func (m *mockSearchRepo) SearchArticles(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Article, int64, error) {
|
||||
m.articleFilter = filter
|
||||
return m.articles, m.articleCount, m.articleErr
|
||||
}
|
||||
|
||||
@@ -378,6 +382,32 @@ func TestSearchMessages_AppliesAdvancedSearchFeatureFlag(t *testing.T) {
|
||||
assert.True(t, repo.msgFilter.AdvancedSearchEnabled)
|
||||
}
|
||||
|
||||
func TestSearchMessages_CapsAdvancedSearchDateRange(t *testing.T) {
|
||||
fixedNow := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
|
||||
originalNow := searchNow
|
||||
searchNow = func() time.Time { return fixedNow }
|
||||
t.Cleanup(func() { searchNow = originalNow })
|
||||
repo := &mockSearchRepo{
|
||||
messages: []model.Message{makeMessage(10, 1, "Hello test message", "incoming")},
|
||||
msgCount: 1,
|
||||
features: map[string]bool{"advanced_search": true},
|
||||
}
|
||||
svc := NewSearchService(repo)
|
||||
since := fixedNow.Add(-120 * 24 * time.Hour)
|
||||
until := fixedNow.Add(120 * 24 * time.Hour)
|
||||
|
||||
_, _, err := svc.SearchMessages(context.Background(), 1, "test", &SearchFilter{Page: 1, PerPage: 25, DateFrom: &since, DateTo: &until})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, repo.msgFilter)
|
||||
if assert.NotNil(t, repo.msgFilter.DateFrom) {
|
||||
assert.Equal(t, fixedNow.Add(-90*24*time.Hour), *repo.msgFilter.DateFrom)
|
||||
}
|
||||
if assert.NotNil(t, repo.msgFilter.DateTo) {
|
||||
assert.Equal(t, fixedNow.Add(90*24*time.Hour), *repo.msgFilter.DateTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchContacts_ConvenienceMethod(t *testing.T) {
|
||||
repo := &mockSearchRepo{}
|
||||
repo.contacts = []model.Contact{makeContact(5, 1, "Alice", "alice@test.com", "")}
|
||||
|
||||
Reference in New Issue
Block a user