feat(crm): queue contact bulk actions

This commit is contained in:
2026-06-07 14:03:48 +08:00
parent 64f36ad642
commit 74249d5bf5
5 changed files with 327 additions and 33 deletions
+77 -26
View File
@@ -2,6 +2,7 @@ package v1
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
@@ -32,9 +33,9 @@ func (h *BulkActionHandler) WithWorkerPool(wp *worker.WorkerPool) *BulkActionHan
// 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,omitempty"` // legacy local action names
IDs []uint `json:"ids" binding:"required,min=1"` // Chatwoot sends conversation display IDs
Type string `json:"type"` // "Conversation" or "Contact"
ActionName string `json:"action_name,omitempty"` // legacy local action names
IDs []uint `json:"ids"` // 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
@@ -58,6 +59,7 @@ func (h *BulkActionHandler) Create(c *gin.Context) {
return
}
req.Type = normalizeBulkActionType(req.Type)
switch req.Type {
case "Conversation":
h.handleConversationBulk(c, accountID, req)
@@ -141,31 +143,80 @@ func (h *BulkActionHandler) handleConversationBulk(c *gin.Context, accountID uin
// handleContactBulk processes bulk actions on contacts.
// Reference: Chatwoot only supports "delete" and label operations for contacts in bulk_actions.
func (h *BulkActionHandler) handleContactBulk(c *gin.Context, accountID uint, req BulkActionRequest) {
successCount := 0
failCount := 0
for _, contactID := range req.IDs {
var svcErr error
switch req.ActionName {
case "delete":
svcErr = h.contactSvc.Delete(c.Request.Context(), accountID, contactID)
default:
// Chatwoot: other contact actions (labels) require separate async jobs
// For now, return error for unsupported actions
failCount++
continue
if h.worker != nil {
params := service.ContactBulkActionParams{
Type: req.Type,
ActionName: req.ActionName,
IDs: req.IDs,
Labels: req.Labels,
}
if svcErr != nil {
failCount++
} else {
successCount++
if _, err := service.EnqueueContactBulkAction(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
}
response.OK(c, gin.H{
"success_count": successCount,
"fail_count": failCount,
})
if err := h.performContactBulkSync(c, accountID, req); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to process bulk action")
return
}
c.Status(http.StatusOK)
}
func (h *BulkActionHandler) performContactBulkSync(c *gin.Context, accountID uint, req BulkActionRequest) error {
if h.contactSvc == nil {
return nil
}
switch {
case req.ActionName == "delete":
for _, contactID := range req.IDs {
if err := h.contactSvc.Delete(c.Request.Context(), accountID, contactID); err != nil {
return err
}
}
case len(req.Labels.Add) > 0:
for _, contactID := range req.IDs {
current, err := h.contactSvc.GetLabels(c.Request.Context(), accountID, contactID)
if err != nil {
return err
}
if _, err := h.contactSvc.UpdateLabels(c.Request.Context(), accountID, contactID, append(current, req.Labels.Add...)); err != nil {
return err
}
}
case len(req.Labels.Remove) > 0:
remove := map[string]struct{}{}
for _, label := range req.Labels.Remove {
remove[strings.TrimSpace(label)] = struct{}{}
}
for _, contactID := range req.IDs {
current, err := h.contactSvc.GetLabels(c.Request.Context(), accountID, contactID)
if err != nil {
return err
}
kept := current[:0]
for _, label := range current {
if _, ok := remove[label]; !ok {
kept = append(kept, label)
}
}
if _, err := h.contactSvc.UpdateLabels(c.Request.Context(), accountID, contactID, kept); err != nil {
return err
}
}
}
return nil
}
func normalizeBulkActionType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "conversation":
return "Conversation"
case "contact":
return "Contact"
default:
return strings.TrimSpace(value)
}
}
@@ -47,3 +47,50 @@ func TestBulkActionHandler_ConversationEnqueuesChatwootPayload(t *testing.T) {
require.Contains(t, string(job.Payload), `"status":"resolved"`)
require.Contains(t, string(job.Payload), `"add":["vip"]`)
}
func TestBulkActionHandler_ContactEnqueuesChatwootPayload(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file:bulk-action-contact-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":"contact","ids":[11,12],"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.TaskTypeContactBulkAction, "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), `"type":"Contact"`)
require.Contains(t, string(job.Payload), `"ids":[11,12]`)
require.Contains(t, string(job.Payload), `"add":["vip"]`)
}
func TestBulkActionHandler_InvalidTypeMatchesChatwootPayload(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewBulkActionHandler(nil, nil)
router := gin.New()
router.POST("/api/v1/accounts/:account_id/bulk_actions", handler.Create)
request := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/7/bulk_actions", bytes.NewReader([]byte(`{"type":"Ticket"}`)))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
require.Equal(t, http.StatusUnprocessableEntity, response.Code)
require.JSONEq(t, `{"success":false}`, response.Body.String())
}