From cda7d0266e44c0fdf35d48aecf479ca89d41bda7 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 18:16:56 +0800 Subject: [PATCH] feat(conversations): queue bulk actions --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 16 +- internal/app/bootstrap.go | 2 +- .../handler/api/v1/bulk_action_handler.go | 51 ++++-- .../api/v1/bulk_action_handler_test.go | 49 ++++++ .../conversation_maintenance_worker.go | 152 ++++++++++++++++++ .../conversation_maintenance_worker_test.go | 81 ++++++++++ 6 files changed, 335 insertions(+), 16 deletions(-) create mode 100644 internal/handler/api/v1/bulk_action_handler_test.go diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 386e5172..55c7823a 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(conversations): queue message status updates`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): queue bulk actions`. - 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, 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. +- 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, widget/public message status updates, and account conversation bulk actions through durable jobs. Next active implementation slice is remaining Captain/Copilot jobs, provider webhook/outbound job fan-out, inbound webhook job deferral, 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, conversation maintenance, and message status update cores are in Review; remaining Captain/Copilot/provider/outbound/bulk-action 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, message status update, and account bulk-action cores are in Review; remaining Captain/Copilot/provider/outbound 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 bulk actions` | Advances P5.12 with a durable Chatwoot `BulkActionsJob` equivalent for account conversation bulk actions. `POST /bulk_actions` now accepts frontend Chatwoot payloads without `action_name`, including `fields`, nested `labels.add/remove`, `snoozed_until`, and display-ID `ids`; when a WorkerPool is configured it enqueues `conversation:bulk_action` on the `medium` queue and returns empty `200 OK`. Worker replay scopes by account and display IDs, merges labels, applies status/team/assignee/snooze updates, and leaves same-display conversations in other accounts untouched. | `go test ./internal/service -run 'ConversationMaintenanceJobs.*BulkAction\|ConversationMaintenanceJobs' -count=1`; `go test ./internal/handler/api/v1 -run 'BulkActionHandler' -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.12 section. | Move P5.12 account bulk actions to Review; continue remaining Captain/Copilot jobs, provider webhook/outbound delivery, inbound webhook job deferral, and analytics aggregation. | | `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. | @@ -1624,7 +1625,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, 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.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. | Review by `feat(conversations): queue maintenance jobs`, `feat(conversations): queue message status updates`, and `feat(conversations): queue bulk actions` | | 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: @@ -1777,13 +1778,17 @@ P5.12 current checkpoint: - 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`. - `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. +- `conversation:bulk_action` now mirrors Chatwoot `BulkActionsJob` on the `medium` queue for account conversation bulk actions. The account `POST /bulk_actions` handler accepts the reused frontend payload shape with `fields`, nested `labels.add/remove`, `snoozed_until`, and display-ID `ids`, enqueues the job when a WorkerPool is configured, and returns empty `200 OK` like Chatwoot. +- Bulk-action replay scopes records by account and conversation display IDs, merges label adds/removals without duplicating labels, applies status/team/assignee/snooze fields, and leaves same-display conversations in other accounts untouched. +- Remaining P5.12 work: none known for the named maintenance jobs in this row; follow-up provider/outbound jobs remain P5.9/P5.10. 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 -run 'ConversationMaintenanceJobs.*BulkAction\|ConversationMaintenanceJobs' -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'BulkActionHandler' -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 @@ -1990,3 +1995,4 @@ Verification milestone gates: - 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. +- 2026-06-05: P5.12 durable account bulk action checkpoint prepared as `feat(conversations): queue bulk actions`; account conversation bulk actions now accept Chatwoot frontend payloads without `action_name`, enqueue `conversation:bulk_action` on the `medium` queue, and worker replay applies display-ID-scoped status/team/assignee/snooze/label updates without crossing account boundaries. Focused service/API/worker/app tests passed. Next slice is remaining Captain/Copilot jobs, provider webhook/outbound delivery, inbound webhook job deferral, and analytics aggregation. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index d9298e5e..6968f7a4 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -822,7 +822,7 @@ func Bootstrap(env string) (*App, error) { // Lane B: AssignableAgent handler (find agents available for assignment) AssignableAgent: v1.NewAssignableAgentHandler(assignableAgentService), AgentBulk: v1.NewAgentBulkHandler(conversationService), - BulkAction: v1.NewBulkActionHandler(conversationService, contactService), + BulkAction: v1.NewBulkActionHandler(conversationService, contactService).WithWorkerPool(workerPool), // Lane C: CSAT template (singular per inbox) + Inbox limits InboxCsatTemplate: v1.NewInboxCsatTemplateHandler(csatTemplateService), InboxLimit: v1.NewInboxLimitHandler(inboxLimitService), diff --git a/internal/handler/api/v1/bulk_action_handler.go b/internal/handler/api/v1/bulk_action_handler.go index 54d8ab29..7cc1c815 100644 --- a/internal/handler/api/v1/bulk_action_handler.go +++ b/internal/handler/api/v1/bulk_action_handler.go @@ -7,6 +7,7 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/internal/worker" "github.com/gochat/gochat/pkg/response" ) @@ -15,6 +16,7 @@ import ( type BulkActionHandler struct { conversationSvc *service.ConversationService contactSvc *service.ContactService + worker *worker.WorkerPool } // NewBulkActionHandler creates a new BulkActionHandler. @@ -22,16 +24,22 @@ func NewBulkActionHandler(conversationSvc *service.ConversationService, contactS return &BulkActionHandler{conversationSvc: conversationSvc, contactSvc: contactSvc} } +func (h *BulkActionHandler) WithWorkerPool(wp *worker.WorkerPool) *BulkActionHandler { + h.worker = wp + return h +} + // BulkActionRequest is the DTO for generic bulk actions. // Reference: Chatwoot bulk_actions_controller#create — params[:type], params[:action_name], params[:ids] type BulkActionRequest struct { - Type string `json:"type" binding:"required"` // "Conversation" or "Contact" - ActionName string `json:"action_name" binding:"required"` // "resolve", "open", "snooze", "delete", "label_add", "label_remove" - IDs []uint `json:"ids" binding:"required,min=1"` // target object IDs - AssigneeID *uint `json:"assignee_id,omitempty"` // for conversation assign - TeamID *uint `json:"team_id,omitempty"` // for conversation team assign - SnoozedUntil string `json:"snoozed_until,omitempty"` // for conversation snooze - Labels []string `json:"labels,omitempty"` // labels to add/update + Type string `json:"type" binding:"required"` // "Conversation" or "Contact" + ActionName string `json:"action_name,omitempty"` // legacy local action names + IDs []uint `json:"ids" binding:"required,min=1"` // Chatwoot sends conversation display IDs + Fields service.ConversationBulkActionFields `json:"fields,omitempty"` + Labels service.ConversationBulkActionLabels `json:"labels,omitempty"` + AssigneeID *uint `json:"assignee_id,omitempty"` // legacy local assign field + TeamID *uint `json:"team_id,omitempty"` // legacy local team field + SnoozedUntil string `json:"snoozed_until,omitempty"` // for conversation snooze } // Create processes a bulk action request. @@ -62,6 +70,29 @@ func (h *BulkActionHandler) Create(c *gin.Context) { // handleConversationBulk processes bulk actions on conversations. func (h *BulkActionHandler) handleConversationBulk(c *gin.Context, accountID uint, req BulkActionRequest) { + if h.worker != nil { + params := service.ConversationBulkActionParams{ + Type: req.Type, + ActionName: req.ActionName, + IDs: req.IDs, + Fields: req.Fields, + Labels: req.Labels, + SnoozedUntil: req.SnoozedUntil, + } + if req.AssigneeID != nil && params.Fields.AssigneeID == nil { + params.Fields.AssigneeID = req.AssigneeID + } + if req.TeamID != nil && params.Fields.TeamID == nil { + params.Fields.TeamID = req.TeamID + } + if _, err := service.EnqueueConversationBulkAction(c.Request.Context(), h.worker, accountID, getUserID(c), params); err != nil { + response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to enqueue bulk action") + return + } + c.Status(http.StatusOK) + return + } + successCount := 0 failCount := 0 @@ -86,8 +117,8 @@ func (h *BulkActionHandler) handleConversationBulk(c *gin.Context, accountID uin case "delete": svcErr = h.conversationSvc.Delete(c.Request.Context(), accountID, convID) case "label_add": - if len(req.Labels) > 0 { - _, svcErr = h.conversationSvc.UpdateLabels(c.Request.Context(), accountID, convID, req.Labels) + if len(req.Labels.Add) > 0 { + _, svcErr = h.conversationSvc.UpdateLabels(c.Request.Context(), accountID, convID, req.Labels.Add) } default: failCount++ @@ -137,4 +168,4 @@ func (h *BulkActionHandler) handleContactBulk(c *gin.Context, accountID uint, re "success_count": successCount, "fail_count": failCount, }) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/bulk_action_handler_test.go b/internal/handler/api/v1/bulk_action_handler_test.go new file mode 100644 index 00000000..2698228b --- /dev/null +++ b/internal/handler/api/v1/bulk_action_handler_test.go @@ -0,0 +1,49 @@ +package v1 + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/internal/worker" +) + +func TestBulkActionHandler_ConversationEnqueuesChatwootPayload(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open("file:bulk-action-handler?mode=memory&cache=private"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.BackgroundJob{})) + sqlDB, _ := db.DB() + t.Cleanup(func() { _ = sqlDB.Close() }) + + wp := worker.NewWorkerPool(db) + handler := NewBulkActionHandler(nil, nil).WithWorkerPool(wp) + router := gin.New() + router.POST("/api/v1/accounts/:account_id/bulk_actions", handler.Create) + + body := []byte(`{"type":"Conversation","ids":[101,102],"fields":{"status":"resolved"},"labels":{"add":["vip"],"remove":["old"]}}`) + request := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/7/bulk_actions", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-User-ID", "42") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + require.Equal(t, http.StatusOK, response.Code) + require.Empty(t, response.Body.String()) + + var job model.BackgroundJob + require.NoError(t, db.Where("job_type = ? AND queue = ?", service.TaskTypeConversationBulkAction, "medium").First(&job).Error) + require.Contains(t, string(job.Payload), `"account_id":7`) + require.Contains(t, string(job.Payload), `"user_id":42`) + require.Contains(t, string(job.Payload), `"ids":[101,102]`) + require.Contains(t, string(job.Payload), `"status":"resolved"`) + require.Contains(t, string(job.Payload), `"add":["vip"]`) +} diff --git a/internal/service/conversation_maintenance_worker.go b/internal/service/conversation_maintenance_worker.go index cfa2c057..27d71ecb 100644 --- a/internal/service/conversation_maintenance_worker.go +++ b/internal/service/conversation_maintenance_worker.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "sync" "time" @@ -20,6 +21,7 @@ const ( TaskTypeConversationResolutionScheduler = "account:conversations_resolution_scheduler" TaskTypeConversationResolutionForAccount = "conversation:resolution" TaskTypeConversationUpdateMessageStatus = "conversation:update_message_status" + TaskTypeConversationBulkAction = "conversation:bulk_action" ) const ( @@ -42,6 +44,32 @@ type conversationUpdateMessageStatusJob struct { Status string `json:"status"` } +type ConversationBulkActionParams struct { + Type string `json:"type"` + ActionName string `json:"action_name,omitempty"` + IDs []uint `json:"ids"` + Fields ConversationBulkActionFields `json:"fields,omitempty"` + Labels ConversationBulkActionLabels `json:"labels,omitempty"` + SnoozedUntil string `json:"snoozed_until,omitempty"` +} + +type ConversationBulkActionFields struct { + Status *string `json:"status,omitempty"` + AssigneeID *uint `json:"assignee_id,omitempty"` + TeamID *uint `json:"team_id,omitempty"` +} + +type ConversationBulkActionLabels struct { + Add []string `json:"add,omitempty"` + Remove []string `json:"remove,omitempty"` +} + +type conversationBulkActionJob struct { + AccountID uint `json:"account_id"` + UserID uint `json:"user_id,omitempty"` + Params ConversationBulkActionParams `json:"params"` +} + var conversationMaintenanceRegistrations sync.Map // RegisterConversationMaintenanceJobs wires Chatwoot scheduled maintenance jobs @@ -65,6 +93,7 @@ func registerConversationMaintenanceJobsWithNow(wp *worker.WorkerPool, db *gorm. wp.Register(TaskTypeConversationResolutionScheduler, runner.performResolutionScheduler) wp.Register(TaskTypeConversationResolutionForAccount, runner.performResolutionForAccount) wp.Register(TaskTypeConversationUpdateMessageStatus, runner.performUpdateMessageStatus) + wp.Register(TaskTypeConversationBulkAction, runner.performConversationBulkAction) } func EnqueueScheduledItemsTrigger(ctx context.Context, wp *worker.WorkerPool, scheduledAt time.Time) (*model.BackgroundJob, error) { @@ -94,6 +123,19 @@ func EnqueueConversationMessageStatusUpdate(ctx context.Context, wp *worker.Work ) } +func EnqueueConversationBulkAction(ctx context.Context, wp *worker.WorkerPool, accountID, userID uint, params ConversationBulkActionParams) (*model.BackgroundJob, error) { + if wp == nil { + return nil, nil + } + if accountID == 0 || len(params.IDs) == 0 { + return nil, fmt.Errorf("invalid conversation bulk action payload: account_id=%d ids=%v", accountID, params.IDs) + } + return wp.Enqueue(ctx, TaskTypeConversationBulkAction, conversationBulkActionJob{AccountID: accountID, UserID: userID, Params: params}, + worker.WithQueue("medium"), + worker.WithMaxAttempts(3), + ) +} + func scheduledItemsIdempotencyKey(scheduledAt time.Time) string { bucket := scheduledAt.UTC().Truncate(scheduledItemsInterval).Unix() return fmt.Sprintf("scheduled:trigger_items:%d", bucket) @@ -260,3 +302,113 @@ func validConversationMessageStatus(status string) bool { return false } } + +func (r *conversationMaintenanceRunner) performConversationBulkAction(ctx context.Context, job *model.BackgroundJob) error { + var payload conversationBulkActionJob + if err := json.Unmarshal(job.Payload, &payload); err != nil { + return fmt.Errorf("unmarshal conversation bulk action job: %w", err) + } + if payload.AccountID == 0 || len(payload.Params.IDs) == 0 { + return fmt.Errorf("invalid conversation bulk action job payload: %#v", payload) + } + if payload.Params.Type != "" && payload.Params.Type != "Conversation" { + return nil + } + + var conversations []model.Conversation + if err := r.db.WithContext(ctx). + Where("account_id = ? AND display_id IN ?", payload.AccountID, payload.Params.IDs). + Find(&conversations).Error; err != nil { + return fmt.Errorf("load bulk action conversations: %w", err) + } + for i := range conversations { + conversation := conversations[i] + updates := map[string]any{} + if payload.Params.Fields.Status != nil && *payload.Params.Fields.Status != "" { + updates["status"] = *payload.Params.Fields.Status + now := r.now() + switch *payload.Params.Fields.Status { + case string(model.ConversationStatusResolved): + updates["resolved_at"] = now + case string(model.ConversationStatusOpen): + updates["resumed_at"] = now + updates["snoozed_until"] = nil + } + } + if payload.Params.Fields.AssigneeID != nil { + updates["assignee_id"] = payload.Params.Fields.AssigneeID + } + if payload.Params.Fields.TeamID != nil { + updates["team_id"] = payload.Params.Fields.TeamID + } + if payload.Params.SnoozedUntil != "" { + if snoozedUntil, ok := parseBulkActionTime(payload.Params.SnoozedUntil); ok { + updates["snoozed_until"] = snoozedUntil.Unix() + } + } + if len(payload.Params.Labels.Add) > 0 || len(payload.Params.Labels.Remove) > 0 { + updates["labels"] = mergeConversationLabels(conversation.Labels, payload.Params.Labels.Add, payload.Params.Labels.Remove) + } + if len(updates) == 0 { + continue + } + if err := r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", conversation.ID).Updates(updates).Error; err != nil { + return fmt.Errorf("bulk update conversation %d: %w", conversation.ID, err) + } + } + return nil +} + +func parseBulkActionTime(value string) (time.Time, bool) { + if value == "" { + return time.Time{}, false + } + if ts, err := time.Parse(time.RFC3339, value); err == nil { + return ts.UTC(), true + } + if ts, err := time.Parse("2006-01-02T15:04:05.000Z", value); err == nil { + return ts.UTC(), true + } + return time.Time{}, false +} + +func mergeConversationLabels(existing string, add, remove []string) string { + labels := map[string]bool{} + order := []string{} + for _, label := range splitConversationLabels(existing) { + if !labels[label] { + labels[label] = true + order = append(order, label) + } + } + for _, label := range add { + label = strings.TrimSpace(label) + if label == "" || labels[label] { + continue + } + labels[label] = true + order = append(order, label) + } + for _, label := range remove { + delete(labels, strings.TrimSpace(label)) + } + merged := make([]string, 0, len(order)) + for _, label := range order { + if labels[label] { + merged = append(merged, label) + } + } + return strings.Join(merged, ",") +} + +func splitConversationLabels(existing string) []string { + parts := strings.Split(existing, ",") + labels := make([]string, 0, len(parts)) + for _, part := range parts { + label := strings.TrimSpace(part) + if label != "" { + labels = append(labels, label) + } + } + return labels +} diff --git a/internal/service/conversation_maintenance_worker_test.go b/internal/service/conversation_maintenance_worker_test.go index 523c84c0..e7ba4e20 100644 --- a/internal/service/conversation_maintenance_worker_test.go +++ b/internal/service/conversation_maintenance_worker_test.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "strings" "testing" "time" @@ -237,6 +238,86 @@ func TestConversationMaintenanceJobsIgnoreInvalidMessageStatus(t *testing.T) { assertMessageStatus(t, db, message.ID, string(model.MessageStatusSent)) } +func TestConversationMaintenanceJobsConversationBulkAction(t *testing.T) { + now := time.Date(2026, 6, 5, 23, 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) + otherAccount := createTestAccount(t, db) + inbox := createTestInbox(t, db, account.ID, "web_widget") + contact := createTestContact(t, db, account.ID) + convA := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) + convB := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) + otherConv := createTestConversation(t, db, otherAccount.ID, inbox.ID, contact.ID) + displayA := uint(101) + displayB := uint(102) + sharedOtherDisplay := displayA + if err := db.Model(convA).Updates(map[string]any{"display_id": displayA, "labels": "vip,old"}).Error; err != nil { + t.Fatalf("set display A: %v", err) + } + if err := db.Model(convB).Updates(map[string]any{"display_id": displayB, "labels": "old"}).Error; err != nil { + t.Fatalf("set display B: %v", err) + } + if err := db.Model(otherConv).Updates(map[string]any{"display_id": sharedOtherDisplay, "labels": "other"}).Error; err != nil { + t.Fatalf("set display other: %v", err) + } + status := string(model.ConversationStatusSnoozed) + teamID := uint(77) + assigneeID := uint(88) + snoozedUntil := now.Add(time.Hour).Format(time.RFC3339) + + _, err := EnqueueConversationBulkAction(context.Background(), wp, account.ID, 42, ConversationBulkActionParams{ + Type: "Conversation", + IDs: []uint{displayA, displayB}, + Fields: ConversationBulkActionFields{ + Status: &status, + TeamID: &teamID, + AssigneeID: &assigneeID, + }, + Labels: ConversationBulkActionLabels{Add: []string{"urgent", "vip"}, Remove: []string{"old"}}, + SnoozedUntil: snoozedUntil, + }) + if err != nil { + t.Fatalf("enqueue bulk action: %v", err) + } + assertJobCount(t, db, TaskTypeConversationBulkAction, 1) + var queued model.BackgroundJob + if err := db.Where("job_type = ?", TaskTypeConversationBulkAction).First(&queued).Error; err != nil { + t.Fatalf("load bulk action job: %v", err) + } + if queued.Queue != "medium" { + t.Fatalf("expected medium queue, got %s", queued.Queue) + } + + processRequiredJob(t, wp, "conversation bulk action") + + for _, id := range []uint{convA.ID, convB.ID} { + var conversation model.Conversation + if err := db.First(&conversation, id).Error; err != nil { + t.Fatalf("load conversation %d: %v", id, err) + } + if conversation.Status != status || conversation.TeamID == nil || *conversation.TeamID != teamID || conversation.AssigneeID == nil || *conversation.AssigneeID != assigneeID { + t.Fatalf("conversation %d not bulk updated: status=%s team=%v assignee=%v", id, conversation.Status, conversation.TeamID, conversation.AssigneeID) + } + if conversation.SnoozedUntil == nil || *conversation.SnoozedUntil != now.Add(time.Hour).Unix() { + t.Fatalf("conversation %d snoozed_until not updated: %v", id, conversation.SnoozedUntil) + } + if strings.Contains(conversation.Labels, "old") || !strings.Contains(conversation.Labels, "urgent") || !strings.Contains(conversation.Labels, "vip") { + t.Fatalf("conversation %d labels not merged, got %q", id, conversation.Labels) + } + } + + var untouched model.Conversation + if err := db.First(&untouched, otherConv.ID).Error; err != nil { + t.Fatalf("load other conversation: %v", err) + } + if untouched.Labels != "other" || untouched.Status != string(model.ConversationStatusOpen) { + t.Fatalf("other account conversation should not change: status=%s labels=%s", untouched.Status, untouched.Labels) + } +} + func createTestOneoffCampaign(t *testing.T, db *gorm.DB, accountID, inboxID, contactID uint, scheduledAt time.Time) *campaign.Campaign { t.Helper() c := &campaign.Campaign{