feat(conversations): align meta counts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user