feat(conversations): align meta counts

This commit is contained in:
2026-06-07 02:54:41 +08:00
parent 8591fe1d33
commit cdc0978476
7 changed files with 273 additions and 95 deletions
@@ -635,13 +635,25 @@ func (h *ConversationHandler) Meta(c *gin.Context) {
return
}
meta, svcErr := h.conversationSvc.GetMeta(c.Request.Context(), accountID)
var params service.FilterParams
if err := c.ShouldBindQuery(&params); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
return
}
if params.Labels == "" {
labels := append(c.QueryArray("labels"), c.QueryArray("labels[]")...)
if len(labels) > 0 {
params.Labels = strings.Join(labels, ",")
}
}
meta, svcErr := h.conversationSvc.GetMeta(c.Request.Context(), accountID, currentUserID(c), params)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, meta)
c.JSON(http.StatusOK, gin.H{"meta": meta})
}
// Unread marks a conversation as unread by resetting agent_last_seen_at.
@@ -158,17 +158,20 @@ func (s *ConversationHandlerTestSuite) TearDownTest() {
s.db.Exec("DELETE FROM contact_inboxes")
s.db.Exec("DELETE FROM contacts")
s.db.Exec("DELETE FROM inbox_members")
s.db.Exec("DELETE FROM account_users")
s.db.Exec("DELETE FROM inboxes")
s.db.Exec("DELETE FROM messages")
s.db.Exec("DELETE FROM accounts")
// Re-seed base data
account := &model.Account{Name: "ConvHandlerTestOrg", Locale: "en", Active: true}
account := &model.Account{Name: "ConvHandlerTestOrg", Locale: "en", Active: true, FeatureFlags: `{"conversation_unread_counts":true}`}
s.Require().NoError(s.db.Create(account).Error)
s.testAccount = account
s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: account.ID, UserID: s.testUser.ID, Role: "administrator"}).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "ConvHandlerTestInbox", ChannelType: "web_widget", ChannelID: 1}
s.Require().NoError(s.db.Create(inbox).Error)
s.Require().NoError(s.db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: s.testUser.ID}).Error)
contact := &model.Contact{AccountID: account.ID, Name: "ConvHandlerTestContact"}
s.Require().NoError(s.db.Create(contact).Error)
@@ -193,17 +196,44 @@ func (s *ConversationHandlerTestSuite) TestMeta_Success() {
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp struct {
Success bool `json:"success"`
Data struct {
TotalCount int64 `json:"total_count"`
StatusCounts map[string]int64 `json:"status_counts"`
LabelCounts map[string]int64 `json:"label_counts"`
} `json:"data"`
Meta struct {
MineCount int64 `json:"mine_count"`
AssignedCount int64 `json:"assigned_count"`
UnassignedCount int64 `json:"unassigned_count"`
AllCount int64 `json:"all_count"`
} `json:"meta"`
}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(s.T(), err)
assert.True(s.T(), resp.Success)
assert.Equal(s.T(), int64(1), resp.Data.TotalCount)
assert.NotContains(s.T(), w.Body.String(), "success")
assert.Equal(s.T(), int64(1), resp.Meta.AllCount)
assert.Equal(s.T(), int64(1), resp.Meta.UnassignedCount)
}
func (s *ConversationHandlerTestSuite) TestMeta_FiltersStatusAndIgnoresAssigneeTypeForCounts() {
assigned := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, AssigneeID: &s.testUser.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
s.Require().NoError(s.db.Create(assigned).Error)
resolved := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, AssigneeID: &s.testUser.ID, Status: "resolved", ChannelType: "web_widget", Channel: "web_widget"}
s.Require().NoError(s.db.Create(resolved).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", s.accountURL()+"/conversations/meta?assignee_type=assigned", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp struct {
Meta struct {
MineCount int64 `json:"mine_count"`
AssignedCount int64 `json:"assigned_count"`
UnassignedCount int64 `json:"unassigned_count"`
AllCount int64 `json:"all_count"`
} `json:"meta"`
}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(s.T(), int64(1), resp.Meta.MineCount)
assert.Equal(s.T(), int64(1), resp.Meta.AssignedCount)
assert.Equal(s.T(), int64(1), resp.Meta.UnassignedCount)
assert.Equal(s.T(), int64(2), resp.Meta.AllCount)
}
func (s *ConversationHandlerTestSuite) TestMeta_InvalidAccountID() {
+84 -45
View File
@@ -359,65 +359,104 @@ func (r *ConversationRepo) BulkUpdateStatus(ctx context.Context, accountID uint,
Update("status", status).Error
}
// ConversationMeta holds aggregated statistics for conversations.
// ConversationMeta holds Chatwoot conversation sidebar counts.
type ConversationMeta struct {
StatusCounts map[string]int64 `json:"status_counts"`
LabelCounts map[string]int64 `json:"label_counts"`
TotalCount int64 `json:"total_count"`
MineCount int64 `json:"mine_count"`
AssignedCount int64 `json:"assigned_count"`
UnassignedCount int64 `json:"unassigned_count"`
AllCount int64 `json:"all_count"`
}
// GetMeta retrieves aggregated conversation metadata for an account.
// Reference: Chatwoot conversations_controller#meta
func (r *ConversationRepo) GetMeta(ctx context.Context, accountID uint) (*ConversationMeta, error) {
meta := &ConversationMeta{
StatusCounts: make(map[string]int64),
LabelCounts: make(map[string]int64),
}
// Reference: Chatwoot ConversationFinder#perform_meta_only.
func (r *ConversationRepo) GetMeta(ctx context.Context, accountID, userID uint, params ConversationMetaParams) (*ConversationMeta, error) {
base := r.conversationMetaBaseQuery(ctx, accountID, userID, params)
// Total count
var total int64
if err := r.db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID).Count(&total).Error; err != nil {
var mineCount, unassignedCount, allCount int64
if err := base.Session(&gorm.Session{}).Where("conversations.assignee_id = ?", userID).Count(&mineCount).Error; err != nil {
return nil, err
}
meta.TotalCount = total
// Status distribution
type statusRow struct {
Status string
Count int64
}
var statusRows []statusRow
if err := r.db.WithContext(ctx).Model(&model.Conversation{}).
Select("status, count(*) as count").
Where("account_id = ?", accountID).
Group("status").Find(&statusRows).Error; err != nil {
if err := base.Session(&gorm.Session{}).Where("conversations.assignee_id IS NULL").Count(&unassignedCount).Error; err != nil {
return nil, err
}
for _, row := range statusRows {
meta.StatusCounts[row.Status] = row.Count
}
// Label distribution — parse comma-separated labels field
type labelRow struct {
Labels string
}
var labelRows []labelRow
if err := r.db.WithContext(ctx).Model(&model.Conversation{}).
Select("labels").
Where("account_id = ? AND labels != '' AND labels IS NOT NULL", accountID).
Find(&labelRows).Error; err != nil {
if err := base.Session(&gorm.Session{}).Count(&allCount).Error; err != nil {
return nil, err
}
for _, row := range labelRows {
for _, label := range strings.Split(row.Labels, ",") {
label = strings.TrimSpace(label)
if label != "" {
meta.LabelCounts[label]++
}
return &ConversationMeta{
MineCount: mineCount,
AssignedCount: allCount - unassignedCount,
UnassignedCount: unassignedCount,
AllCount: allCount,
}, nil
}
type ConversationMetaParams struct {
Status string
InboxID *uint
TeamID *uint
Labels []string
ConversationType string
RestrictedInboxIDs []uint
RestrictToInboxes bool
}
func (r *ConversationRepo) conversationMetaBaseQuery(ctx context.Context, accountID, userID uint, params ConversationMetaParams) *gorm.DB {
query := r.db.WithContext(ctx).Model(&model.Conversation{}).Where("conversations.account_id = ?", accountID)
if params.RestrictToInboxes {
if len(params.RestrictedInboxIDs) == 0 {
query = query.Where("1 = 0")
} else {
query = query.Where("conversations.inbox_id IN ?", params.RestrictedInboxIDs)
}
}
return meta, nil
if params.Status == "all" {
// Chatwoot skips status filtering for status=all.
} else if params.Status == "" {
query = query.Where("conversations.status = ?", model.ConversationStatusOpen)
} else {
query = query.Where("conversations.status = ?", params.Status)
}
if params.InboxID != nil {
query = query.Where("conversations.inbox_id = ?", *params.InboxID)
}
if params.TeamID != nil {
query = query.Where("conversations.team_id = ?", *params.TeamID)
}
if len(params.Labels) > 0 {
conditions := make([]string, 0, len(params.Labels))
args := make([]any, 0, len(params.Labels))
for _, label := range params.Labels {
label = strings.TrimSpace(label)
if label == "" {
continue
}
conditions = append(conditions, "conversations.labels LIKE ?")
args = append(args, "%"+label+"%")
}
if len(conditions) > 0 {
tagSubquery := r.db.WithContext(ctx).
Table("conversation_labels").
Select("conversation_labels.conversation_id").
Joins("JOIN tags ON tags.id = conversation_labels.tag_id").
Where("conversation_labels.account_id = ? AND tags.name IN ?", accountID, params.Labels)
query = query.Where(r.db.Where(strings.Join(conditions, " OR "), args...).Or("conversations.id IN (?)", tagSubquery))
}
}
switch params.ConversationType {
case "mention":
query = query.Where("conversations.id IN (SELECT conversation_id FROM mentions WHERE account_id = ? AND user_id = ?)", accountID, userID)
case "participating":
query = query.Where("conversations.id IN (SELECT conversation_id FROM conversation_participants WHERE user_id = ?)", userID)
case "unattended":
query = query.Where("conversations.first_reply_created_at IS NULL OR conversations.waiting_since IS NOT NULL")
}
return query
}
// MarkUnread marks a conversation as unread by resetting agent_last_seen_at to nil.
+42 -20
View File
@@ -435,6 +435,7 @@ func TestConversationRepo_CountByAccount(t *testing.T) {
func TestConversationRepo_GetMeta(t *testing.T) {
db := setupTestDB(t)
repo := NewConversationRepo(db)
userID := uint(42)
account := &model.Account{Name: "MetaTestOrg", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
@@ -445,24 +446,20 @@ func TestConversationRepo_GetMeta(t *testing.T) {
contact := &model.Contact{AccountID: account.ID, Name: "MetaTestContact"}
require.NoError(t, db.Create(contact).Error)
// Create conversations with different statuses and labels
// Chatwoot meta defaults to open conversations and returns assignee buckets.
createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
assigned := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
require.NoError(t, db.Model(assigned).Update("assignee_id", userID).Error)
conv2 := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "resolved")
// Update labels on conv2
require.NoError(t, db.Model(conv2).Update("labels", "bug,critical").Error)
createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "pending")
conv4 := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
require.NoError(t, db.Model(conv4).Update("labels", "bug,feature").Error)
meta, err := repo.GetMeta(context.Background(), account.ID)
meta, err := repo.GetMeta(context.Background(), account.ID, userID, ConversationMetaParams{})
assert.NoError(t, err)
assert.Equal(t, int64(4), meta.TotalCount)
assert.Equal(t, int64(2), meta.StatusCounts["open"])
assert.Equal(t, int64(1), meta.StatusCounts["resolved"])
assert.Equal(t, int64(1), meta.StatusCounts["pending"])
assert.Equal(t, int64(2), meta.LabelCounts["bug"])
assert.Equal(t, int64(1), meta.LabelCounts["critical"])
assert.Equal(t, int64(1), meta.LabelCounts["feature"])
assert.Equal(t, int64(2), meta.AllCount)
assert.Equal(t, int64(1), meta.MineCount)
assert.Equal(t, int64(1), meta.AssignedCount)
assert.Equal(t, int64(1), meta.UnassignedCount)
}
func TestConversationRepo_GetMeta_EmptyAccount(t *testing.T) {
@@ -472,11 +469,10 @@ func TestConversationRepo_GetMeta_EmptyAccount(t *testing.T) {
account := &model.Account{Name: "MetaEmptyOrg", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
meta, err := repo.GetMeta(context.Background(), account.ID)
meta, err := repo.GetMeta(context.Background(), account.ID, 0, ConversationMetaParams{})
assert.NoError(t, err)
assert.Equal(t, int64(0), meta.TotalCount)
assert.Empty(t, meta.StatusCounts)
assert.Empty(t, meta.LabelCounts)
assert.Equal(t, int64(0), meta.AllCount)
assert.Equal(t, int64(0), meta.UnassignedCount)
}
func TestConversationRepo_GetMeta_WrongAccount(t *testing.T) {
@@ -495,10 +491,36 @@ func TestConversationRepo_GetMeta_WrongAccount(t *testing.T) {
createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
// Query a non-existent account ID — should return empty meta
meta, err := repo.GetMeta(context.Background(), 9999)
meta, err := repo.GetMeta(context.Background(), 9999, 0, ConversationMetaParams{})
assert.NoError(t, err)
assert.Equal(t, int64(0), meta.TotalCount)
assert.Empty(t, meta.StatusCounts)
assert.Equal(t, int64(0), meta.AllCount)
}
func TestConversationRepo_GetMeta_FiltersStatusLabelsAndConversationType(t *testing.T) {
db := setupTestDB(t)
repo := NewConversationRepo(db)
userID := uint(77)
account := &model.Account{Name: "MetaFilterOrg", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "MetaFilterInbox", ChannelType: "web_widget", ChannelID: 1}
require.NoError(t, db.Create(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "MetaFilterContact"}
require.NoError(t, db.Create(contact).Error)
match := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
firstReply := int64(1699999999)
require.NoError(t, db.Model(match).Updates(map[string]any{"labels": "vip", "assignee_id": userID, "first_reply_created_at": firstReply}).Error)
waiting := int64(1700000000)
unattended := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
require.NoError(t, db.Model(unattended).Updates(map[string]any{"labels": "vip", "waiting_since": waiting}).Error)
resolved := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "resolved")
require.NoError(t, db.Model(resolved).Update("labels", "vip").Error)
meta, err := repo.GetMeta(context.Background(), account.ID, userID, ConversationMetaParams{Labels: []string{"vip"}, ConversationType: "unattended"})
require.NoError(t, err)
assert.Equal(t, int64(1), meta.AllCount)
assert.Equal(t, int64(1), meta.UnassignedCount)
}
// ========== MarkUnread Tests ==========
@@ -642,4 +664,4 @@ func TestConversationRepo_UpdateCustomAttributes_EmptyJSON(t *testing.T) {
updated, err := repo.FindByID(context.Background(), conv.ID)
assert.NoError(t, err)
assert.NotNil(t, updated.CustomAttributes)
}
}
+47 -4
View File
@@ -921,10 +921,53 @@ func (s *ConversationService) UpdatePriority(ctx context.Context, accountID, id
return conversation, nil
}
// GetMeta retrieves aggregated conversation metadata (status counts, label counts) for an account.
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb#meta
func (s *ConversationService) GetMeta(ctx context.Context, accountID uint) (*repository.ConversationMeta, error) {
return s.repo.GetMeta(ctx, accountID)
// GetMeta retrieves Chatwoot conversation sidebar counts for an account.
// Reference: ConversationFinder#perform_meta_only and conversations/meta.json.jbuilder.
func (s *ConversationService) GetMeta(ctx context.Context, accountID, userID uint, params FilterParams) (*repository.ConversationMeta, error) {
metaParams := repository.ConversationMetaParams{
Status: params.Status,
InboxID: params.InboxID,
TeamID: params.TeamID,
Labels: splitConversationMetaLabels(params.Labels),
ConversationType: params.ConversationType,
}
if userID != 0 {
var accountUser model.AccountUser
err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
metaParams.RestrictToInboxes = true
return s.repo.GetMeta(ctx, accountID, userID, metaParams)
}
if err != nil {
return nil, err
}
if !accountUser.IsAdministrator() {
ids, err := s.visibleUnreadCountInboxIDs(ctx, accountID, userID)
if err != nil {
return nil, err
}
metaParams.RestrictToInboxes = true
metaParams.RestrictedInboxIDs = ids
}
}
return s.repo.GetMeta(ctx, accountID, userID, metaParams)
}
func splitConversationMetaLabels(labels string) []string {
if strings.TrimSpace(labels) == "" {
return nil
}
parts := strings.Split(labels, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
// MarkUnread marks a conversation as unread by setting agent_last_seen_at to
+39 -11
View File
@@ -277,20 +277,25 @@ func TestConversationService_GetMeta(t *testing.T) {
account := createConversationServiceTestAccount(t, db)
inbox := createConversationServiceTestInbox(t, db, account.ID)
contact := createConversationServiceTestContact(t, db, account.ID)
user := &model.User{Name: "Meta Agent", Email: "meta-agent@example.com"}
require.NoError(t, db.Create(user).Error)
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error)
// Create conversations with different statuses
createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
// Chatwoot meta defaults to open conversations and reports assignee buckets.
openUnassigned := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
assert.NotZero(t, openUnassigned.ID)
openAssigned := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
require.NoError(t, db.Model(openAssigned).Update("assignee_id", user.ID).Error)
conv2 := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "resolved")
require.NoError(t, db.Model(conv2).Update("labels", "support").Error)
createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "pending")
meta, err := svc.GetMeta(context.Background(), account.ID)
meta, err := svc.GetMeta(context.Background(), account.ID, user.ID, FilterParams{})
assert.NoError(t, err)
assert.Equal(t, int64(3), meta.TotalCount)
assert.Equal(t, int64(1), meta.StatusCounts["open"])
assert.Equal(t, int64(1), meta.StatusCounts["resolved"])
assert.Equal(t, int64(1), meta.StatusCounts["pending"])
assert.Equal(t, int64(1), meta.LabelCounts["support"])
assert.Equal(t, int64(2), meta.AllCount)
assert.Equal(t, int64(1), meta.MineCount)
assert.Equal(t, int64(1), meta.AssignedCount)
assert.Equal(t, int64(1), meta.UnassignedCount)
}
func TestConversationService_GetMeta_EmptyAccount(t *testing.T) {
@@ -298,10 +303,33 @@ func TestConversationService_GetMeta_EmptyAccount(t *testing.T) {
account := createConversationServiceTestAccount(t, db)
meta, err := svc.GetMeta(context.Background(), account.ID)
meta, err := svc.GetMeta(context.Background(), account.ID, 0, FilterParams{})
assert.NoError(t, err)
assert.Equal(t, int64(0), meta.TotalCount)
assert.Empty(t, meta.StatusCounts)
assert.Equal(t, int64(0), meta.AllCount)
assert.Equal(t, int64(0), meta.UnassignedCount)
}
func TestConversationService_GetMeta_FiltersByLabelAndVisibility(t *testing.T) {
svc, db := setupConversationService(t)
account := createConversationServiceTestAccount(t, db)
inbox := createConversationServiceTestInbox(t, db, account.ID)
hiddenInbox := createConversationServiceTestInbox(t, db, account.ID)
contact := createConversationServiceTestContact(t, db, account.ID)
user := &model.User{Name: "Scoped Meta Agent", Email: "scoped-meta@example.com"}
require.NoError(t, db.Create(user).Error)
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent"}).Error)
require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: user.ID}).Error)
visible := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
require.NoError(t, db.Model(visible).Updates(map[string]any{"labels": "vip", "assignee_id": user.ID}).Error)
hidden := createConversationServiceTestConversation(t, db, account.ID, hiddenInbox.ID, contact.ID, "open")
require.NoError(t, db.Model(hidden).Update("labels", "vip").Error)
meta, err := svc.GetMeta(context.Background(), account.ID, user.ID, FilterParams{Labels: "vip"})
require.NoError(t, err)
assert.Equal(t, int64(1), meta.AllCount)
assert.Equal(t, int64(1), meta.MineCount)
}
// ========== MarkUnread Tests ==========