From 140b17ed72ace8b7a4895d76eb8fcd83ef2c2792 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 14:40:13 +0800 Subject: [PATCH] feat(search): index conversation maintenance --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 16 ++- internal/app/bootstrap.go | 2 +- .../conversation_maintenance_worker.go | 112 ++++++++++++++++-- .../conversation_maintenance_worker_test.go | 79 ++++++++++++ .../service/search_indexer_worker_test.go | 4 + 5 files changed, 194 insertions(+), 19 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index ef819cca..e30c2734 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,11 +49,11 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-07 P5.3d conversation bulk-action search indexing, prepared as `feat(search): index conversation bulk actions`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): index conversation bulk actions`. -- Latest documentation/tooling checkpoint: this tracker update records Meilisearch conversation reindex fan-out after account bulk-action status, assignment, snooze, and label mutations. +- Current tracking checkpoint: 2026-06-07 P5.3e conversation maintenance search indexing, prepared as `feat(search): index conversation maintenance`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): index conversation maintenance`. +- Latest documentation/tooling checkpoint: this tracker update records Meilisearch reindex fan-out after scheduled snooze reopen, auto-resolution, and message-status maintenance mutations. - 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: account conversation bulk actions now enqueue durable search reindex follow-ups for updated conversations, so Meilisearch documents stay fresh after bulk status/team/assignee/snooze/label mutations. This retains P5.3c contact label search indexing, P3.103 contact bulk-action parity, P3.102 enterprise account route parity, P6.1 webhook placeholder burn-down, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Worktree status at this implementation checkpoint: conversation maintenance jobs now enqueue durable search reindex follow-ups after scheduled snooze reopen, auto-resolution, and outgoing message status updates, so Meilisearch conversation/message documents stay fresh after background state transitions. This retains P5.3d conversation bulk-action indexing, P5.3c contact label search indexing, P3.103 contact bulk-action parity, and prior checkpoints. 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 `972` registered routes after enterprise account 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 | P5.3e conversation maintenance search indexing | Implemented for Meilisearch maintenance-job parity: scheduled snooze reopen and account auto-resolution reindex updated conversations, message-status maintenance reindexes changed messages plus the parent conversation, and bootstrap registers search-aware maintenance handlers after the durable search indexer is wired. | Keep in Review; reopen from live Meilisearch gate, scheduled maintenance smoke, or a fresh background mutation path that changes searchable conversation/message fields without indexing. | Focused conversation maintenance search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P5.3d conversation bulk-action search indexing | Implemented for Meilisearch conversation bulk-action parity: `conversation:bulk_action` now reindexes updated account conversations after status, assignee, team, snooze, or label mutations, and the app registers a search-aware bulk-action runner after the durable search indexer is wired. | Keep in Review; reopen from live Meilisearch gate, conversation bulk-action search smoke, or a fresh mutation path that changes searchable conversation fields without indexing. | Focused conversation bulk-action search test passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P5.3c contact label search indexing | Implemented for Meilisearch contact-label parity: contact search documents now include `labels`, durable `search:index` contact replay loads current `contact_labels`/`tags`, direct contact label replacement reindexes the contact, and contact bulk-action label/delete workers enqueue search index/delete follow-ups after account-scoped side effects. | Keep in Review; reopen from live Meilisearch gate, contact label search smoke, or a fresh mutation path that changes contact labels without indexing. | Focused service/search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P3.103 contact bulk actions | Implemented for reused CRM contact index actions: `POST /api/v1/accounts/:account_id/bulk_actions` now normalizes Chatwoot `type`, accepts Contact payloads without requiring `action_name`, enqueues durable `contact:bulk_action` jobs on the `medium` queue, returns empty `200 OK`, applies label add/remove account-scoped through `contact_labels`/`tags`, soft-deletes only current-account selected contacts, and treats unknown contact operations as no-op success like `Contacts::BulkActionService`. | Keep in Review; reopen from B12 contacts smoke or fresh reference evidence for exact Pundit authorization, label serializer, deleted-association cleanup, or notification side effects beyond the inspected controller/service/frontend contract. | Focused BulkActionHandler tests passed; focused conversation maintenance contact bulk-action worker tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | @@ -394,6 +395,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(search): index conversation maintenance` | Advances P5.3/B6 for mandatory Meilisearch background-maintenance parity. Scheduled snooze reopen and account auto-resolution now reindex updated conversations, message-status maintenance reindexes changed messages plus the parent conversation, and bootstrap registers search-aware maintenance handlers after durable search indexing is wired. | Focused conversation maintenance search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P5.3e to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(search): index conversation bulk actions` | Advances P5.3/B6 for mandatory Meilisearch conversation bulk-action parity. Account conversation bulk-action worker replay now reindexes every updated conversation after bulk status/team/assignee/snooze/label changes, while the app registers the search-aware runner after durable search indexing is wired. | Focused conversation bulk-action search test passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P5.3d to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(search): index contact labels` | Advances P5.3/B6 for mandatory Meilisearch contact label parity. Contact documents now carry label arrays, durable contact index replay preloads current label assignments, `ContactService.UpdateLabels` reindexes contacts after replacement, and contact bulk-action add/remove/delete jobs enqueue search index/delete follow-up jobs through the durable indexer. | Focused service/search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P5.3c to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(crm): queue contact bulk actions` | Advances P3.103/P5.12 with Chatwoot contact bulk-action parity. GoChat now accepts reused frontend Contact bulk payloads for label add/remove and delete, normalizes `type` like the Rails controller, enqueues `contact:bulk_action` on the `medium` queue when a WorkerPool is configured, returns empty `200 OK`, and replays account-scoped label/delete side effects through durable workers. | Focused BulkActionHandler tests and conversation maintenance worker contact bulk-action tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.103 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | @@ -2119,7 +2121,7 @@ Tracking table: | --- | --- | --- | --- | --- | --- | | P5.1 | Implement durable worker core and job model. | `reference/chatwoot/app/jobs/application_job.rb`, `mutex_application_job.rb` | `internal/worker/worker.go`, `internal/model/background_job.go`, `migrations/000026_add_background_jobs.*.sql` | Job table, worker persistence API, enqueue API, worker loop, retry/backoff, scheduled jobs, mutex/idempotency keys, dead-letter state, and restart pickup tests exist. | Review by `feat(worker): add durable background jobs` | | P5.2 | Route async dispatcher events through durable jobs. | `event_dispatcher_job.rb`, Chatwoot async dispatcher listeners | `internal/dispatch/dispatcher.go`, `internal/channel/dispatcher.go` | Heavy listeners can enqueue durable jobs without changing sync listener behavior; tests cover sync vs async routing and replay. | Review by `feat(dispatch): queue async events durably` | -| P5.3 | Move Meilisearch indexing and reindex fan-out into retryable jobs. | Meilisearch plan plus Chatwoot callbacks/jobs that index searchable records | search services, contact/company/conversation indexing hooks | Create/update/delete/label/bulk-action indexing survives handler success, retries on Meilisearch failure, and optional live Meilisearch gate remains green. | Review by `feat(search): queue index updates durably`, `feat(search): index contact labels`, and `feat(search): index conversation bulk actions` | +| P5.3 | Move Meilisearch indexing and reindex fan-out into retryable jobs. | Meilisearch plan plus Chatwoot callbacks/jobs that index searchable records | search services, contact/company/conversation indexing hooks | Create/update/delete/label/bulk-action/maintenance indexing survives handler success, retries on Meilisearch failure, and optional live Meilisearch gate remains green. | Review by `feat(search): queue index updates durably`, `feat(search): index contact labels`, `feat(search): index conversation bulk actions`, and `feat(search): index conversation maintenance` | | P5.4 | Queue automation webhook and transcript delivery. | `webhook_job.rb`, automation action execution services | `internal/automation/action_delivery.go`, `internal/automation/action_service.go` | Existing timeout/retry fakeable delivery is invoked by durable jobs; logs preserve attempt metadata and idempotency. | Review by `feat(automation): queue external action deliveries` | | P5.5 | Queue delayed automation actions and macro execution. | `trigger_scheduled_items_job.rb`, `macros_execution_job.rb` | automation rule listener, macro service | Delayed actions execute after schedule time if reference params exist; macro execute supports multi-conversation job fan-out, and repeated workers do not duplicate side effects. | Review by `feat(automation): queue macro and csat jobs` and `feat(automation): close delayed action parity`; current reference exposes no delayed automation action params | | P5.6 | Queue CSAT survey sends and channel-specific templates. | CSAT listener/services, WhatsApp/Twilio template services/jobs | `internal/automation/csat_survey_listener.go`, `internal/service/csat_template_service.go`, channel send services | Resolve-triggered CSAT send is durable; WhatsApp/Twilio template delivery and failure states are fakeable and observable. | Review by `feat(automation): queue macro and csat jobs` and `feat(csat): queue channel templates` | @@ -2171,10 +2173,11 @@ P5.3 current checkpoint: - Contact replay now also reloads current `contact_labels`/`tags` so Meilisearch contact documents carry the same label filter field used by reused CRM contact/search flows. - Contact label replacement and contact bulk-action label/delete jobs now reindex or delete contact documents after the label side effect, keeping Meilisearch label filters fresh. - Conversation bulk-action replay now reindexes every updated conversation after bulk status, assignment, team, snooze, or label mutations so conversation search documents do not stale after reused inbox bulk actions. +- Conversation maintenance replay now reindexes updated conversations after snooze reopen and auto-resolution, and reindexes updated messages plus the parent conversation after deferred outgoing message status updates. - Missing records during an index replay are converted into delegate delete calls, which keeps delayed create/update jobs from resurrecting documents after a database delete. - Search read paths remain Meilisearch-first through `searchService`; contact and company services still use the live `SearchService` reader instead of the durable wrapper. - No-worker construction still falls back to synchronous indexing for focused tests and development paths that do not start the durable worker. -- Remaining P5.3 work is limited to explicit reindex fan-out/CLI scheduling and optional live Meilisearch environment gates; normal service-layer writes, contact-label writes, and account bulk-action conversation writes are now durable. +- Remaining P5.3 work is limited to explicit reindex fan-out/CLI scheduling and optional live Meilisearch environment gates; normal service-layer writes, contact-label writes, account bulk-action conversation writes, and scheduled maintenance writes are now durable. P5.3 verification: @@ -2800,3 +2803,4 @@ Verification milestone gates: - 2026-06-07: P3.103 contact bulk-action checkpoint prepared as `feat(crm): queue contact bulk actions`; audited Chatwoot `BulkActionsController`, `Contacts::BulkActionJob`, `Contacts::BulkActionService`, reused dashboard `api/bulkActions.js`, and `ContactsIndex.vue` label/delete callers. GoChat account bulk actions now accept Contact payloads for label add/remove and delete, normalize lower-case type values, enqueue durable `contact:bulk_action` jobs on the `medium` queue, return empty `200 OK`, replay account-scoped contact label add/remove through tags/contact_labels, soft-delete only selected current-account contacts, and preserve no-op success for unknown contact bulk payloads. Focused BulkActionHandler and conversation maintenance worker contact bulk-action tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P5.3c contact label search-index checkpoint prepared as `feat(search): index contact labels`; audited Meilisearch contact document/filter behavior, contact label endpoints, and the new contact bulk-action mutation path. GoChat contact search documents now include `labels`, durable contact index replay preloads current contact labels from `contact_labels`/`tags`, direct contact label replacement reindexes the contact, and contact bulk-action label/delete jobs enqueue search index/delete follow-ups through the durable search indexer. Focused service/search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P5.3d conversation bulk-action search-index checkpoint prepared as `feat(search): index conversation bulk actions`; audited the Chatwoot account `BulkActionsJob` mutation path and the P5.3 Meilisearch durability contract. GoChat conversation bulk-action worker replay now reindexes every updated account-scoped conversation after bulk status, assignee, team, snooze, or label updates, and bootstrap registers the search-aware bulk-action runner after the durable search indexer is wired. Focused conversation bulk-action search test passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. +- 2026-06-07: P5.3e conversation maintenance search-index checkpoint prepared as `feat(search): index conversation maintenance`; audited Chatwoot scheduled conversation maintenance jobs and the P5.3 Meilisearch durability contract. GoChat scheduled snooze reopen and account auto-resolution jobs now reindex updated conversations, deferred outgoing message-status jobs reindex updated messages plus their parent conversation, and bootstrap registers the search-aware maintenance runner after the durable search indexer is wired. Focused conversation maintenance search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 34c0cc38..38821039 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -679,7 +679,7 @@ func Bootstrap(env string) (*App, error) { } searchService := search.NewSearchServiceWithEngine(searchEngine, searchRepo) searchIndexer := service.NewDurableSearchIndexer(db, workerPool, searchService) - service.RegisterBulkActionSearchIndexer(workerPool, db, searchIndexer) + service.RegisterConversationMaintenanceSearchIndexer(workerPool, db, searchIndexer) conversationService.SetSearchIndexer(searchIndexer) messageService.SetSearchIndexer(searchIndexer) contactService.SetSearchIndexer(searchIndexer) diff --git a/internal/service/conversation_maintenance_worker.go b/internal/service/conversation_maintenance_worker.go index fcb2063c..a0a6227f 100644 --- a/internal/service/conversation_maintenance_worker.go +++ b/internal/service/conversation_maintenance_worker.go @@ -94,14 +94,21 @@ func RegisterConversationMaintenanceJobs(wp *worker.WorkerPool, db *gorm.DB) { } func RegisterContactBulkActionSearchIndexer(wp *worker.WorkerPool, db *gorm.DB, indexer SearchIndexer) { - RegisterBulkActionSearchIndexer(wp, db, indexer) + RegisterConversationMaintenanceSearchIndexer(wp, db, indexer) } func RegisterBulkActionSearchIndexer(wp *worker.WorkerPool, db *gorm.DB, indexer SearchIndexer) { + RegisterConversationMaintenanceSearchIndexer(wp, db, indexer) +} + +func RegisterConversationMaintenanceSearchIndexer(wp *worker.WorkerPool, db *gorm.DB, indexer SearchIndexer) { if wp == nil || db == nil { return } runner := &conversationMaintenanceRunner{wp: wp, db: db, now: time.Now, searchIndexer: indexer} + wp.Register(TaskTypeConversationReopenSnoozed, runner.performReopenSnoozed) + wp.Register(TaskTypeConversationResolutionForAccount, runner.performResolutionForAccount) + wp.Register(TaskTypeConversationUpdateMessageStatus, runner.performUpdateMessageStatus) wp.Register(TaskTypeConversationBulkAction, runner.performConversationBulkAction) wp.Register(TaskTypeContactBulkAction, runner.performContactBulkAction) } @@ -254,15 +261,27 @@ func (r *conversationMaintenanceRunner) performReopenSnoozed(ctx context.Context now := r.now() nowUnix := now.Unix() lookbackUnix := now.Add(-scheduledItemsLookback).Unix() + var conversationIDs []uint + if err := r.db.WithContext(ctx).Model(&model.Conversation{}). + Where("status = ?", string(model.ConversationStatusSnoozed)). + Where("snoozed_until BETWEEN ? AND ?", lookbackUnix, nowUnix). + Pluck("id", &conversationIDs).Error; err != nil { + return err + } + if len(conversationIDs) == 0 { + return nil + } updates := map[string]any{ "status": string(model.ConversationStatusOpen), "snoozed_until": nil, "resumed_at": now, } - return r.db.WithContext(ctx).Model(&model.Conversation{}). - Where("status = ?", string(model.ConversationStatusSnoozed)). - Where("snoozed_until BETWEEN ? AND ?", lookbackUnix, nowUnix). - Updates(updates).Error + if err := r.db.WithContext(ctx).Model(&model.Conversation{}). + Where("id IN ?", conversationIDs). + Updates(updates).Error; err != nil { + return err + } + return r.indexConversationSearchDocumentsByIDs(ctx, conversationIDs) } func (r *conversationMaintenanceRunner) performResolutionScheduler(ctx context.Context, job *model.BackgroundJob) error { @@ -297,15 +316,27 @@ func (r *conversationMaintenanceRunner) performResolutionForAccount(ctx context. } cutoff := r.now().Add(-time.Duration(account.AutoResolveDuration) * time.Minute).Unix() now := r.now() + var conversationIDs []uint + if err := r.db.WithContext(ctx).Model(&model.Conversation{}). + Where("account_id = ? AND status = ? AND contact_id <> 0", account.ID, string(model.ConversationStatusOpen)). + Where("last_activity_at IS NOT NULL AND last_activity_at < ?", cutoff). + Limit(conversationResolutionLimit). + Pluck("id", &conversationIDs).Error; err != nil { + return err + } + if len(conversationIDs) == 0 { + return nil + } updates := map[string]any{ "status": string(model.ConversationStatusResolved), "resolved_at": now, } - return r.db.WithContext(ctx).Model(&model.Conversation{}). - Where("account_id = ? AND status = ? AND contact_id <> 0", account.ID, string(model.ConversationStatusOpen)). - Where("last_activity_at IS NOT NULL AND last_activity_at < ?", cutoff). - Limit(conversationResolutionLimit). - Updates(updates).Error + if err := r.db.WithContext(ctx).Model(&model.Conversation{}). + Where("id IN ?", conversationIDs). + Updates(updates).Error; err != nil { + return err + } + return r.indexConversationSearchDocumentsByIDs(ctx, conversationIDs) } func (r *conversationMaintenanceRunner) performUpdateMessageStatus(ctx context.Context, job *model.BackgroundJob) error { @@ -328,12 +359,27 @@ func (r *conversationMaintenanceRunner) performUpdateMessageStatus(ctx context.C return fmt.Errorf("load conversation %d for message status update: %w", payload.ConversationID, err) } - return r.db.WithContext(ctx).Model(&model.Message{}). + var messageIDs []uint + if err := r.db.WithContext(ctx).Model(&model.Message{}). Where("conversation_id = ?", conversation.ID). Where("status IN ?", []string{string(model.MessageStatusSent), string(model.MessageStatusDelivered)}). Where("message_type <> ?", "incoming"). Where("created_at <= ?", time.Unix(payload.Timestamp, 0).UTC()). - Update("status", payload.Status).Error + Pluck("id", &messageIDs).Error; err != nil { + return err + } + if len(messageIDs) == 0 { + return nil + } + if err := r.db.WithContext(ctx).Model(&model.Message{}). + Where("id IN ?", messageIDs). + Update("status", payload.Status).Error; err != nil { + return err + } + if err := r.indexMessageSearchDocuments(ctx, conversation.AccountID, messageIDs); err != nil { + return err + } + return r.indexConversationSearchDocumentsByIDs(ctx, []uint{conversation.ID}) } func validConversationMessageStatus(status string) bool { @@ -428,6 +474,48 @@ func (r *conversationMaintenanceRunner) indexConversationSearchDocuments(ctx con return nil } +func (r *conversationMaintenanceRunner) indexConversationSearchDocumentsByIDs(ctx context.Context, conversationIDs []uint) error { + if r.searchIndexer == nil || len(conversationIDs) == 0 { + return nil + } + var conversations []model.Conversation + if err := r.db.WithContext(ctx). + Preload("Contact"). + Preload("Inbox"). + Preload("Assignee"). + Preload("Messages", func(db *gorm.DB) *gorm.DB { return db.Order("created_at ASC, id ASC") }). + Where("id IN ?", conversationIDs). + Find(&conversations).Error; err != nil { + return err + } + for i := range conversations { + if err := r.searchIndexer.IndexConversation(ctx, &conversations[i]); err != nil { + return err + } + } + return nil +} + +func (r *conversationMaintenanceRunner) indexMessageSearchDocuments(ctx context.Context, accountID uint, messageIDs []uint) error { + if r.searchIndexer == nil || len(messageIDs) == 0 { + return nil + } + var messages []model.Message + if err := r.db.WithContext(ctx). + Preload("Conversation"). + Preload("Attachments", func(db *gorm.DB) *gorm.DB { return db.Order("id ASC") }). + Where("account_id = ? AND id IN ?", accountID, messageIDs). + Find(&messages).Error; err != nil { + return err + } + for i := range messages { + if err := r.searchIndexer.IndexMessage(ctx, &messages[i]); err != nil { + return err + } + } + return nil +} + func (r *conversationMaintenanceRunner) performContactBulkAction(ctx context.Context, job *model.BackgroundJob) error { var payload contactBulkActionJob if err := json.Unmarshal(job.Payload, &payload); err != nil { diff --git a/internal/service/conversation_maintenance_worker_test.go b/internal/service/conversation_maintenance_worker_test.go index 6e078a53..8f99ae5c 100644 --- a/internal/service/conversation_maintenance_worker_test.go +++ b/internal/service/conversation_maintenance_worker_test.go @@ -158,6 +158,46 @@ func TestConversationMaintenanceJobsProcessCampaignSnoozeAndResolution(t *testin } } +func TestConversationMaintenanceJobsStatusMaintenanceQueuesSearchIndex(t *testing.T) { + now := time.Date(2026, 6, 5, 20, 30, 0, 0, time.UTC) + db := setupServiceTestDB(t) + wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now })) + registerConversationMaintenanceJobsWithNow(wp, db, func() time.Time { return now }) + delegate := &recordingDurableSearchIndexer{} + searchIndexer := NewDurableSearchIndexer(db, wp, delegate) + RegisterConversationMaintenanceSearchIndexer(wp, db, searchIndexer) + + account := createTestAccount(t, db) + account.AutoResolveDuration = 30 + if err := db.Save(account).Error; err != nil { + t.Fatalf("save auto resolve account: %v", err) + } + inbox := createTestInbox(t, db, account.ID, "web_widget") + contact := createTestContact(t, db, account.ID) + snoozed := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) + if err := db.Model(snoozed).Updates(map[string]any{"status": string(model.ConversationStatusSnoozed), "snoozed_until": now.Add(-time.Minute).Unix()}).Error; err != nil { + t.Fatalf("snooze conversation: %v", err) + } + oldOpen := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) + if err := db.Model(oldOpen).Update("last_activity_at", now.Add(-45*time.Minute).Unix()).Error; err != nil { + t.Fatalf("set old activity: %v", err) + } + + if _, err := wp.Enqueue(context.Background(), TaskTypeConversationReopenSnoozed, nil, worker.WithQueue("low")); err != nil { + t.Fatalf("enqueue reopen: %v", err) + } + processRequiredJob(t, wp, "reopen with index") + processRequiredJob(t, wp, "reopen search index") + assertIndexedConversationStatus(t, delegate, snoozed.ID, string(model.ConversationStatusOpen)) + + if _, err := wp.Enqueue(context.Background(), TaskTypeConversationResolutionForAccount, conversationResolutionJob{AccountID: account.ID}, worker.WithQueue("low")); err != nil { + t.Fatalf("enqueue resolution: %v", err) + } + processRequiredJob(t, wp, "resolution with index") + processRequiredJob(t, wp, "resolution search index") + assertIndexedConversationStatus(t, delegate, oldOpen.ID, string(model.ConversationStatusResolved)) +} + func TestConversationMaintenanceJobsRetryMissingResolutionAccount(t *testing.T) { now := time.Date(2026, 6, 5, 21, 0, 0, 0, time.UTC) db := setupServiceTestDB(t) @@ -219,6 +259,35 @@ func TestConversationMaintenanceJobsUpdateMessageStatus(t *testing.T) { assertMessageStatus(t, db, afterCutoff.ID, string(model.MessageStatusSent)) } +func TestConversationMaintenanceJobsUpdateMessageStatusQueuesSearchIndex(t *testing.T) { + now := time.Date(2026, 6, 5, 22, 15, 0, 0, time.UTC) + db := setupServiceTestDB(t) + wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now })) + registerConversationMaintenanceJobsWithNow(wp, db, func() time.Time { return now }) + delegate := &recordingDurableSearchIndexer{} + searchIndexer := NewDurableSearchIndexer(db, wp, delegate) + RegisterConversationMaintenanceSearchIndexer(wp, db, searchIndexer) + + account := createTestAccount(t, db) + inbox := createTestInbox(t, db, account.ID, "web_widget") + contact := createTestContact(t, db, account.ID) + conversation := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) + cutoff := now.Add(-time.Minute) + message := createConversationMaintenanceMessage(t, db, account.ID, inbox.ID, conversation.ID, "outgoing", string(model.MessageStatusSent), cutoff.Add(-time.Minute)) + + if _, err := EnqueueConversationMessageStatusUpdate(context.Background(), wp, conversation.ID, cutoff, string(model.MessageStatusRead)); err != nil { + t.Fatalf("enqueue message status update: %v", err) + } + processRequiredJob(t, wp, "message status with index") + processRequiredJob(t, wp, "message search index") + processRequiredJob(t, wp, "conversation search index") + + if len(delegate.indexedMessages) != 1 || delegate.indexedMessages[0].ID != message.ID || delegate.indexedMessages[0].Status != string(model.MessageStatusRead) { + t.Fatalf("expected indexed read message, got %#v", delegate.indexedMessages) + } + assertIndexedConversationStatus(t, delegate, conversation.ID, string(model.ConversationStatusOpen)) +} + func TestConversationMaintenanceJobsIgnoreInvalidMessageStatus(t *testing.T) { now := time.Date(2026, 6, 5, 22, 30, 0, 0, time.UTC) db := setupServiceTestDB(t) @@ -527,6 +596,16 @@ func assertContactHasLabel(t *testing.T, db *gorm.DB, accountID, contactID uint, } } +func assertIndexedConversationStatus(t *testing.T, indexer *recordingDurableSearchIndexer, conversationID uint, status string) { + t.Helper() + for _, conversation := range indexer.indexedConversations { + if conversation.ID == conversationID && conversation.Status == status { + return + } + } + t.Fatalf("expected indexed conversation %d with status %s, got %#v", conversationID, status, indexer.indexedConversations) +} + func assertMessageStatus(t *testing.T, db *gorm.DB, messageID uint, want string) { t.Helper() var message model.Message diff --git a/internal/service/search_indexer_worker_test.go b/internal/service/search_indexer_worker_test.go index 49fb2e15..a4358e74 100644 --- a/internal/service/search_indexer_worker_test.go +++ b/internal/service/search_indexer_worker_test.go @@ -12,6 +12,7 @@ import ( type recordingDurableSearchIndexer struct { indexedConversations []model.Conversation + indexedMessages []model.Message indexedContacts []uint indexedContactLabels map[uint][]string indexedArticles []model.Article @@ -31,6 +32,9 @@ func (r *recordingDurableSearchIndexer) DeleteConversation(ctx context.Context, } func (r *recordingDurableSearchIndexer) IndexMessage(ctx context.Context, message *model.Message) error { + if message != nil { + r.indexedMessages = append(r.indexedMessages, *message) + } return r.err }