diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 592a9ea6..386e5172 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -17,9 +17,9 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline - Plan freeze checkpoint: 2026-06-05 docs-only tracker landing after `13cb750 feat(captain): align task payload persistence`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(captain): queue document syncs`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): queue message status updates`. - Latest documentation checkpoint before this freeze: `3263ed9 docs: land copilot task execution plan`; this document now carries the active follow-up plan directly. -- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.11 queues Captain document sync requests through durable `captain:document_sync` jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, and account auto-resolution through durable jobs. Next active implementation slice is remaining Captain/Copilot jobs, provider webhook/outbound job fan-out, update-message-status/bulk-action jobs, and analytics aggregation. +- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.11 queues Captain document sync requests through durable `captain:document_sync` jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, account auto-resolution, and widget/public message status updates through durable jobs. Next active implementation slice is remaining Captain/Copilot jobs, provider webhook/outbound job fan-out, bulk-action jobs, and analytics aggregation. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 830` after adding the Chatwoot-compatible applied-SLA index route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -78,7 +78,7 @@ Open work after the current checkpoint: | Phase 2 | Route and controller parity audit | Doing | Ruby/Bundler unavailable, so Chatwoot route extraction currently uses static `routes.rb` fallback | | Phase 3 | Data and serializer parity | Doing | JSON fixture coverage is partial and still endpoint-family based | | Phase 4 | Enterprise feature completion | Doing | B7, B8, B9, B10, and B11 are in Review; B12 reused frontend smoke is the next broad verification gate | -| Phase 5 | Background jobs and integrations | Doing | P5.1/P5.2/P5.3/P5.4/P5.5/P5.6/P5.7/P5.8/P5.11/P5.12 durable worker, event dispatch, search indexing, automation delivery, macro, CSAT survey/template, SLA scan, contact export, Captain document sync, and conversation maintenance cores are in Review; remaining Captain/Copilot/provider/outbound job integration remains open | +| Phase 5 | Background jobs and integrations | Doing | P5.1/P5.2/P5.3/P5.4/P5.5/P5.6/P5.7/P5.8/P5.11/P5.12 durable worker, event dispatch, search indexing, automation delivery, macro, CSAT survey/template, SLA scan, contact export, Captain document sync, conversation maintenance, and message status update cores are in Review; remaining Captain/Copilot/provider/outbound/bulk-action job integration remains open | | Phase 6 | Core placeholder burn-down | Doing | account/contact/conversation/message/inbox placeholder groups remain broad | | Phase 7 | Verification harness | Review | B12.1 boot/readiness, B12.2a API assertions, B12.2b browser smoke harness, B12.3a enterprise API assertions, and B12.3b enterprise browser route navigation exist; optional live Meilisearch/full-browser runs remain environment-dependent | @@ -101,6 +101,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(conversations): queue message status updates` | Advances P5.12 with a durable Chatwoot `Conversations::UpdateMessageStatusJob` equivalent. Widget and public inbox `update_last_seen` paths now enqueue `conversation:update_message_status` on the `deferred` queue when a WorkerPool is configured. Worker replay validates `read`/`delivered`, skips missing conversations, and updates only non-incoming messages created at or before the viewing timestamp while preserving already-read and post-cutoff messages. | `go test ./internal/service -run 'ConversationMaintenanceJobs\|WidgetService_UpdateLastSeenQueuesMessageStatusJob' -count=1`; `go test ./internal/service ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.12 section. | Move P5.12 update-message-status depth to Review; continue durable bulk-action fan-out, remaining Captain/Copilot jobs, provider webhook/outbound delivery, and analytics aggregation. | | `feat(captain): queue document syncs` | Advances P5.11 with a durable Chatwoot `Captain::Documents::PerformSyncJob` equivalent. `POST /captain/documents/:id/sync` still returns `202` after marking the account-scoped document as syncing, but now enqueues `captain:document_sync` on the `low` queue when a WorkerPool is configured. Worker replay calls the existing fakeable `CaptainDocumentSyncBackend`, persists synced/failed/disabled states through `SyncDocumentByAccount`, and lets missing documents or malformed payloads retry through `background_jobs`. | `go test ./internal/service -run 'CaptainDocumentService' -count=1`; `go test ./internal/handler/api/v1 -run 'CaptainDocument\|CaptainResource' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.11 section. | Move P5.11 document sync core to Review; continue Captain crawl/response-building/embedding/Copilot response jobs, provider webhook/outbound delivery, update-message-status/bulk-action jobs, and analytics aggregation. | | `feat(csat): queue channel templates` | Advances P5.6 with durable WhatsApp/Twilio CSAT template creation. `POST /inboxes/:inbox_id/csat_template` accepts Chatwoot nested `template` params, persists the singular template as pending, and enqueues `csat:template_create` when a WorkerPool is configured. Worker replay calls a fakeable provider boundary, records WhatsApp template metadata or Twilio content/approval SIDs into `inbox.csat_config`, updates template status, and retries provider failures through `background_jobs`. | `go test ./internal/service -run 'CsatTemplate' -count=1`; `go test ./internal/handler/api/v1 -run 'InboxCsatTemplate' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.6 section. | Move P5.6 channel templates to Review; continue Captain/Copilot jobs, provider webhook/outbound delivery, update-message-status/bulk-action jobs, and analytics aggregation. | | `feat(conversations): queue maintenance jobs` | Advances P5.12 and the scheduled-items portion of P5.5. `scheduled:trigger_items` now mirrors Chatwoot `TriggerScheduledItemsJob` by queueing due one-off campaigns, snoozed conversation reopening, account auto-resolution scheduling, and the next hourly root trigger. One-off campaigns claim active rows as `processing` before execution and complete after artifact creation, so duplicate worker replays do not duplicate campaign messages. | `go test ./internal/service -run 'ConversationMaintenanceJobs' -count=1`; `go test ./internal/service ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.12 section. | Move P5.12 conversation maintenance core to Review; continue update-message-status/bulk-action job depth plus CSAT channel templates, Captain/Copilot, provider webhook/outbound delivery, and analytics aggregation. | @@ -1623,7 +1624,7 @@ Tracking table: | P5.9 | Queue inbound provider webhook processing where Chatwoot defers work. | `webhooks/*_events_job.rb`, `webhooks/*_delivery_job.rb` | webhook handlers, provider services | Provider HTTP ack behavior remains Chatwoot-compatible while persistence/dispatch runs through retryable jobs with signature validation preserved. | Todo | | P5.10 | Queue outbound message delivery and delivery-status updates. | `send_reply_job.rb`, provider delivery/status jobs | message send/channel services, delivery status handler | Outgoing message creation and provider delivery are separated; retries update message/delivery status exactly once. | Todo | | P5.11 | Queue Captain document sync, crawl, response building, embeddings, and Copilot responses. | Captain document/crawl/response/embedding/Copilot jobs | `internal/service/captain_document_service.go`, Captain/Copilot services | Existing fakeable disabled/failure gates run under durable jobs; document statuses and Copilot message persistence survive worker restart. | Doing: Captain document sync Review by `feat(captain): queue document syncs`; crawl/response/embedding/Copilot response jobs Todo | -| P5.12 | Queue conversation maintenance jobs. | `trigger_scheduled_items_job.rb`, `campaigns/trigger_oneoff_campaign_job.rb`, `conversations/resolution_job.rb`, `reopen_snoozed_conversations_job.rb`, `update_message_status_job.rb`, `bulk_actions_job.rb` | conversation service/handlers | Auto-resolution, snooze reopen, status updates, and bulk actions are scheduled/retryable with idempotent tests. | Doing: scheduled items, one-off campaigns, snooze reopen, and auto-resolution Review by `feat(conversations): queue maintenance jobs`; update-message-status and bulk-action jobs Todo | +| P5.12 | Queue conversation maintenance jobs. | `trigger_scheduled_items_job.rb`, `campaigns/trigger_oneoff_campaign_job.rb`, `conversations/resolution_job.rb`, `reopen_snoozed_conversations_job.rb`, `update_message_status_job.rb`, `bulk_actions_job.rb` | conversation service/handlers | Auto-resolution, snooze reopen, status updates, and bulk actions are scheduled/retryable with idempotent tests. | Doing: scheduled items, one-off campaigns, snooze reopen, auto-resolution, and update-message-status Review by `feat(conversations): queue maintenance jobs` plus `feat(conversations): queue message status updates`; bulk-action jobs Todo | | P5.13 | Replace placeholder analytics/report builders that need background aggregation. | reporting jobs/services and report controllers | `internal/service/analytics_service.go`, reporting services | Frontend-visible reports no longer use placeholder values; any expensive aggregation is scheduled or cached with freshness rules. | Todo | P5.1 current checkpoint: @@ -1774,12 +1775,15 @@ P5.12 current checkpoint: - One-off campaign replay claims active campaigns as `processing` before execution, creates the campaign conversation/message through the existing campaign builder, marks the campaign completed, and no-ops duplicate replays once the campaign is no longer active. - Root replay also queues `conversation:reopen_snoozed` and `account:conversations_resolution_scheduler`. The snooze job reopens due snoozed conversations and clears `snoozed_until`; the scheduler queues `conversation:resolution` jobs for accounts with auto-resolution enabled. - Account resolution replay resolves old open conversations using Chatwoot-style minute thresholds from `auto_resolve_duration`, stamps `resolved_at`, and retries missing accounts through `background_jobs`. -- Remaining P5.12 work: `conversations/update_message_status_job.rb` depth and durable bulk-action fan-out. +- `conversation:update_message_status` now mirrors Chatwoot `Conversations::UpdateMessageStatusJob` on the `deferred` queue. Widget and public inbox `update_last_seen` producers enqueue the job after persisting `contact_last_seen_at`. +- Message-status replay validates only `read` and `delivered`, no-ops missing conversations, and updates non-incoming `sent`/`delivered` messages created before the viewer timestamp while skipping incoming, already-read, and post-cutoff messages. +- Remaining P5.12 work: durable `BulkActionsJob` fan-out for account conversation bulk actions. P5.12 verification: ```bash env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service -run 'ConversationMaintenanceJobs' -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service -run 'ConversationMaintenanceJobs\|WidgetService_UpdateLastSeenQueuesMessageStatusJob' -count=1 env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service ./internal/worker ./internal/app -count=1 env TMPDIR=/home/rogee/Projects/gochat/.tmp/test-tmp GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./... git diff --check @@ -1985,3 +1989,4 @@ Verification milestone gates: - 2026-06-05: P5.12 durable conversation maintenance checkpoint prepared as `feat(conversations): queue maintenance jobs`; the Chatwoot scheduled-items chain now runs through `scheduled:trigger_items`, due one-off campaigns queue `campaign:trigger_oneoff`, snoozed conversations reopen through `conversation:reopen_snoozed`, and account auto-resolution fans out through `account:conversations_resolution_scheduler` and `conversation:resolution`. Focused service/worker/app tests passed. Next slice is CSAT WhatsApp/Twilio templates, Captain/Copilot jobs, provider webhook/outbound delivery, update-message-status/bulk-action jobs, and analytics aggregation. - 2026-06-05: P5.6 durable CSAT template checkpoint prepared as `feat(csat): queue channel templates`; inbox CSAT template creation now accepts Chatwoot nested template payloads, queues `csat:template_create` jobs when a WorkerPool is configured, and worker replay records WhatsApp template IDs or Twilio content/approval SIDs through a fakeable provider boundary. Focused service/API/worker/app tests passed. Next slice is Captain/Copilot jobs, provider webhook/outbound delivery, update-message-status/bulk-action jobs, and analytics aggregation. - 2026-06-05: P5.11 durable Captain document sync checkpoint prepared as `feat(captain): queue document syncs`; Captain document sync requests now mark the document syncing and enqueue `captain:document_sync` jobs on the `low` queue, while worker replay uses the existing fakeable sync backend to persist disabled, failed, or synced document states. Focused service/API/worker/app tests passed. Next slice is remaining Captain crawl/response/embedding/Copilot response jobs, provider webhook/outbound delivery, update-message-status/bulk-action jobs, and analytics aggregation. +- 2026-06-05: P5.12 durable message status checkpoint prepared as `feat(conversations): queue message status updates`; widget and public inbox last-seen updates now enqueue `conversation:update_message_status` jobs on the `deferred` queue, and worker replay marks eligible non-incoming `sent/delivered` messages as `read` or `delivered` up to the viewer timestamp while ignoring invalid statuses and missing conversations. Focused service/worker/app tests passed. Next slice is durable account bulk actions, remaining Captain/Copilot jobs, provider webhook/outbound delivery, and analytics aggregation. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 69ceb425..d9298e5e 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -694,6 +694,7 @@ func Bootstrap(env string) (*App, error) { widgetFileUploadRepo := repository.NewWidgetFileUploadRepo(db) widgetOfflineMessageRepo := repository.NewWidgetOfflineMessageRepo(db) widgetService := service.NewWidgetService(inboxRepo, contactRepo, contactInboxRepo, conversationRepo, messageRepo, widgetTypingAdapter, widgetThemeConfigRepo, preChatFormRepo, widgetFileUploadRepo, widgetOfflineMessageRepo, inboxMemberRepo, tagRepo, campaignRepo) + widgetService.SetWorkerPool(workerPool) widgetHandler := widget.NewHandler(widgetService) // Upload: DirectUpload repo + service + handler (account-level + widget direct uploads) diff --git a/internal/service/conversation_maintenance_worker.go b/internal/service/conversation_maintenance_worker.go index f65b635f..cfa2c057 100644 --- a/internal/service/conversation_maintenance_worker.go +++ b/internal/service/conversation_maintenance_worker.go @@ -19,6 +19,7 @@ const ( TaskTypeConversationReopenSnoozed = "conversation:reopen_snoozed" TaskTypeConversationResolutionScheduler = "account:conversations_resolution_scheduler" TaskTypeConversationResolutionForAccount = "conversation:resolution" + TaskTypeConversationUpdateMessageStatus = "conversation:update_message_status" ) const ( @@ -35,6 +36,12 @@ type conversationResolutionJob struct { AccountID uint `json:"account_id"` } +type conversationUpdateMessageStatusJob struct { + ConversationID uint `json:"conversation_id"` + Timestamp int64 `json:"timestamp"` + Status string `json:"status"` +} + var conversationMaintenanceRegistrations sync.Map // RegisterConversationMaintenanceJobs wires Chatwoot scheduled maintenance jobs @@ -57,6 +64,7 @@ func registerConversationMaintenanceJobsWithNow(wp *worker.WorkerPool, db *gorm. wp.Register(TaskTypeConversationReopenSnoozed, runner.performReopenSnoozed) wp.Register(TaskTypeConversationResolutionScheduler, runner.performResolutionScheduler) wp.Register(TaskTypeConversationResolutionForAccount, runner.performResolutionForAccount) + wp.Register(TaskTypeConversationUpdateMessageStatus, runner.performUpdateMessageStatus) } func EnqueueScheduledItemsTrigger(ctx context.Context, wp *worker.WorkerPool, scheduledAt time.Time) (*model.BackgroundJob, error) { @@ -71,6 +79,21 @@ func EnqueueScheduledItemsTrigger(ctx context.Context, wp *worker.WorkerPool, sc ) } +func EnqueueConversationMessageStatusUpdate(ctx context.Context, wp *worker.WorkerPool, conversationID uint, timestamp time.Time, status string) (*model.BackgroundJob, error) { + if wp == nil { + return nil, nil + } + if status == "" { + status = string(model.MessageStatusRead) + } + payload := conversationUpdateMessageStatusJob{ConversationID: conversationID, Timestamp: timestamp.UTC().Unix(), Status: status} + return wp.Enqueue(ctx, TaskTypeConversationUpdateMessageStatus, payload, + worker.WithQueue("deferred"), + worker.WithMaxAttempts(3), + worker.WithIdempotencyKey(fmt.Sprintf("conversation:update_message_status:%d:%s:%d", conversationID, status, payload.Timestamp)), + ) +} + func scheduledItemsIdempotencyKey(scheduledAt time.Time) string { bucket := scheduledAt.UTC().Truncate(scheduledItemsInterval).Unix() return fmt.Sprintf("scheduled:trigger_items:%d", bucket) @@ -200,3 +223,40 @@ func (r *conversationMaintenanceRunner) performResolutionForAccount(ctx context. Limit(conversationResolutionLimit). Updates(updates).Error } + +func (r *conversationMaintenanceRunner) performUpdateMessageStatus(ctx context.Context, job *model.BackgroundJob) error { + var payload conversationUpdateMessageStatusJob + if err := json.Unmarshal(job.Payload, &payload); err != nil { + return fmt.Errorf("unmarshal conversation message status job: %w", err) + } + if payload.ConversationID == 0 || payload.Timestamp == 0 { + return fmt.Errorf("invalid conversation message status job payload: %#v", payload) + } + if !validConversationMessageStatus(payload.Status) { + return nil + } + + var conversation model.Conversation + if err := r.db.WithContext(ctx).First(&conversation, payload.ConversationID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } + return fmt.Errorf("load conversation %d for message status update: %w", payload.ConversationID, err) + } + + return 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 +} + +func validConversationMessageStatus(status string) bool { + switch status { + case string(model.MessageStatusRead), string(model.MessageStatusDelivered): + return true + default: + return false + } +} diff --git a/internal/service/conversation_maintenance_worker_test.go b/internal/service/conversation_maintenance_worker_test.go index b82a85fa..523c84c0 100644 --- a/internal/service/conversation_maintenance_worker_test.go +++ b/internal/service/conversation_maintenance_worker_test.go @@ -179,6 +179,64 @@ func TestConversationMaintenanceJobsRetryMissingResolutionAccount(t *testing.T) } } +func TestConversationMaintenanceJobsUpdateMessageStatus(t *testing.T) { + now := time.Date(2026, 6, 5, 22, 0, 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 }) + + 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) + + beforeSent := createConversationMaintenanceMessage(t, db, account.ID, inbox.ID, conversation.ID, "outgoing", string(model.MessageStatusSent), cutoff.Add(-time.Minute)) + beforeDelivered := createConversationMaintenanceMessage(t, db, account.ID, inbox.ID, conversation.ID, "outgoing", string(model.MessageStatusDelivered), cutoff.Add(-30*time.Second)) + incoming := createConversationMaintenanceMessage(t, db, account.ID, inbox.ID, conversation.ID, "incoming", string(model.MessageStatusSent), cutoff.Add(-time.Minute)) + alreadyRead := createConversationMaintenanceMessage(t, db, account.ID, inbox.ID, conversation.ID, "outgoing", string(model.MessageStatusRead), cutoff.Add(-time.Minute)) + afterCutoff := 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) + } + assertJobCount(t, db, TaskTypeConversationUpdateMessageStatus, 1) + var queued model.BackgroundJob + if err := db.Where("job_type = ?", TaskTypeConversationUpdateMessageStatus).First(&queued).Error; err != nil { + t.Fatalf("load queued message status job: %v", err) + } + if queued.Queue != "deferred" { + t.Fatalf("expected deferred queue, got %s", queued.Queue) + } + + processRequiredJob(t, wp, "message status") + + assertMessageStatus(t, db, beforeSent.ID, string(model.MessageStatusRead)) + assertMessageStatus(t, db, beforeDelivered.ID, string(model.MessageStatusRead)) + assertMessageStatus(t, db, incoming.ID, string(model.MessageStatusSent)) + assertMessageStatus(t, db, alreadyRead.ID, string(model.MessageStatusRead)) + assertMessageStatus(t, db, afterCutoff.ID, string(model.MessageStatusSent)) +} + +func TestConversationMaintenanceJobsIgnoreInvalidMessageStatus(t *testing.T) { + now := time.Date(2026, 6, 5, 22, 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 }) + + 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) + message := createConversationMaintenanceMessage(t, db, account.ID, inbox.ID, conversation.ID, "outgoing", string(model.MessageStatusSent), now.Add(-time.Minute)) + + if _, err := wp.Enqueue(context.Background(), TaskTypeConversationUpdateMessageStatus, conversationUpdateMessageStatusJob{ConversationID: conversation.ID, Timestamp: now.Unix(), Status: "failed"}, worker.WithQueue("deferred")); err != nil { + t.Fatalf("enqueue invalid status job: %v", err) + } + processRequiredJob(t, wp, "invalid message status") + assertMessageStatus(t, db, message.ID, string(model.MessageStatusSent)) +} + func createTestOneoffCampaign(t *testing.T, db *gorm.DB, accountID, inboxID, contactID uint, scheduledAt time.Time) *campaign.Campaign { t.Helper() c := &campaign.Campaign{ @@ -201,6 +259,35 @@ func createTestOneoffCampaign(t *testing.T, db *gorm.DB, accountID, inboxID, con return c } +func createConversationMaintenanceMessage(t *testing.T, db *gorm.DB, accountID, inboxID, conversationID uint, messageType, status string, createdAt time.Time) *model.Message { + t.Helper() + message := &model.Message{ + AccountID: accountID, + InboxID: inboxID, + ConversationID: conversationID, + Content: fmt.Sprintf("%s %d", messageType, time.Now().UnixNano()), + MessageType: messageType, + Status: status, + } + message.CreatedAt = createdAt + message.UpdatedAt = createdAt + if err := db.Create(message).Error; err != nil { + t.Fatalf("create message: %v", err) + } + return message +} + +func assertMessageStatus(t *testing.T, db *gorm.DB, messageID uint, want string) { + t.Helper() + var message model.Message + if err := db.First(&message, messageID).Error; err != nil { + t.Fatalf("load message %d: %v", messageID, err) + } + if message.Status != want { + t.Fatalf("expected message %d status %s, got %s", messageID, want, message.Status) + } +} + func assertJobCount(t *testing.T, db *gorm.DB, jobType string, want int64) { t.Helper() var count int64 diff --git a/internal/service/widget_service.go b/internal/service/widget_service.go index a91fcd9f..029783ae 100644 --- a/internal/service/widget_service.go +++ b/internal/service/widget_service.go @@ -17,6 +17,7 @@ import ( channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/search" + "github.com/gochat/gochat/internal/worker" ws "github.com/gochat/gochat/internal/ws" applogger "github.com/gochat/gochat/pkg/logger" "gorm.io/datatypes" @@ -51,6 +52,7 @@ type WidgetService struct { inboxMemberRepo *repository.InboxMemberRepo tagRepo *repository.TagRepo campaignRepo *repository.CampaignRepo + worker *worker.WorkerPool } // NewWidgetService creates a new Widget service. @@ -86,6 +88,10 @@ func NewWidgetService( } } +func (s *WidgetService) SetWorkerPool(wp *worker.WorkerPool) { + s.worker = wp +} + // --- DTOs --- // WidgetInitRequest is the DTO for the /widget/init endpoint. @@ -744,6 +750,9 @@ func (s *WidgetService) PublicUpdateLastSeen(ctx context.Context, inboxIdentifie if err := s.conversationRepo.Update(ctx, conversation); err != nil { return nil, err } + if _, err := EnqueueConversationMessageStatusUpdate(ctx, s.worker, conversation.ID, time.Unix(now, 0), string(model.MessageStatusRead)); err != nil { + return nil, err + } return conversation, nil } @@ -1109,6 +1118,9 @@ func (s *WidgetService) UpdateLastSeen(ctx context.Context, widgetToken string) if err := s.conversationRepo.Update(ctx, conversation); err != nil { return nil, err } + if _, err := EnqueueConversationMessageStatusUpdate(ctx, s.worker, conversation.ID, time.Unix(now, 0), string(model.MessageStatusRead)); err != nil { + return nil, err + } return conversation, nil } diff --git a/internal/service/widget_service_test.go b/internal/service/widget_service_test.go index 217760f6..e8dd7dce 100644 --- a/internal/service/widget_service_test.go +++ b/internal/service/widget_service_test.go @@ -18,6 +18,7 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/worker" ) // ========== Test Helpers ========== @@ -44,6 +45,7 @@ func setupWidgetServiceTest(t *testing.T) (*gorm.DB, *WidgetService) { &model.PreChatForm{}, &model.WidgetFileUpload{}, &model.WidgetOfflineMessage{}, + &model.BackgroundJob{}, ), "failed to auto-migrate models") t.Cleanup(func() { @@ -435,6 +437,41 @@ func TestWidgetService_GetConversations_InvalidToken(t *testing.T) { assert.Error(t, err) } +func TestWidgetService_UpdateLastSeenQueuesMessageStatusJob(t *testing.T) { + db, svc := setupWidgetServiceTest(t) + ctx := context.Background() + + seedWidgetInbox(t, db) + initResp, err := svc.Init(ctx, WidgetInitRequest{WebsiteToken: "test_ws_token_123"}) + require.NoError(t, err) + + incoming, err := svc.SendMessage(ctx, WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, Content: "hello"}) + require.NoError(t, err) + outgoing := &model.Message{ + AccountID: incoming.Message.AccountID, + InboxID: incoming.Message.InboxID, + ConversationID: incoming.ConversationID, + Content: "reply before last seen", + MessageType: "outgoing", + Status: string(model.MessageStatusSent), + } + outgoing.CreatedAt = time.Now().Add(-time.Minute) + outgoing.UpdatedAt = outgoing.CreatedAt + require.NoError(t, db.Create(outgoing).Error) + + wp := worker.NewWorkerPool(db) + RegisterConversationMaintenanceJobs(wp, db) + svc.SetWorkerPool(wp) + + conversation, err := svc.UpdateLastSeen(ctx, initResp.WidgetToken) + require.NoError(t, err) + require.NotNil(t, conversation.ContactLastSeenAt) + + var job model.BackgroundJob + require.NoError(t, db.Where("job_type = ? AND queue = ? AND status = ?", TaskTypeConversationUpdateMessageStatus, "deferred", model.BackgroundJobStatusQueued).First(&job).Error) + assert.Contains(t, string(job.Payload), fmt.Sprintf(`"conversation_id":%d`, outgoing.ConversationID)) +} + // ========== GetMessages Tests ========== func TestWidgetService_GetMessages(t *testing.T) {