feat(conversations): queue bulk actions
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user