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
+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)
}
}