Files
gochat/backend/internal/repository/conversation_repo.go
T
Rogeeandrogee 74eb006985 H-43: fix WEB Captain takeover E2E flow (#8)
* test(shangwutong): cover CID rename reliability

* H-43: fix WEB Captain takeover flow

* H-48: preserve compatible provider model

* H-49: make Captain takeover atomic

* H-50: prevent duplicate widget initialization

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-13 14:49:04 +08:00

724 lines
28 KiB
Go

package repository
import (
"context"
"errors"
"strings"
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/search"
)
// ConversationRepo implements GORM repository for Conversation.
// Reference: Chatwoot app/models/conversation.rb
type ConversationRepo struct {
db *gorm.DB
}
// DB returns the underlying gorm.DB for advanced query building.
func (r *ConversationRepo) DB() *gorm.DB {
return r.db
}
// NewConversationRepo creates a new Conversation repository.
func NewConversationRepo(db *gorm.DB) *ConversationRepo {
return &ConversationRepo{db: db}
}
// FindByID retrieves a conversation by primary key.
func (r *ConversationRepo) FindByID(ctx context.Context, id uint) (*model.Conversation, error) {
var conversation model.Conversation
err := r.db.WithContext(ctx).First(&conversation, id).Error
if err != nil {
return nil, err
}
return &conversation, nil
}
// FindByAccountAndID retrieves a conversation scoped to an account.
func (r *ConversationRepo) FindByAccountAndID(ctx context.Context, accountID, id uint) (*model.Conversation, error) {
var conversation model.Conversation
err := r.db.WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).First(&conversation).Error
if err != nil {
return nil, err
}
return &conversation, nil
}
// FindByAccountAndDisplayIDOrID retrieves a conversation using Chatwoot's
// account-scoped display_id route semantics, while preserving routes emitted
// for legacy rows that still have a NULL/zero display_id. Legacy primary keys
// must win before display_id lookup because the two identifier spaces can
// collide after newer conversations receive account-scoped display IDs.
func (r *ConversationRepo) FindByAccountAndDisplayIDOrID(ctx context.Context, accountID, routeID uint) (*model.Conversation, error) {
var conversation model.Conversation
err := r.db.WithContext(ctx).
Where("account_id = ? AND id = ? AND (display_id IS NULL OR display_id = 0)", accountID, routeID).
First(&conversation).Error
if err == nil {
return &conversation, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
err = r.db.WithContext(ctx).Where("account_id = ? AND display_id = ?", accountID, routeID).First(&conversation).Error
if err == nil {
return &conversation, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
err = r.db.WithContext(ctx).Where("account_id = ? AND id = ?", accountID, routeID).First(&conversation).Error
if err != nil {
return nil, err
}
return &conversation, nil
}
// FindByAccount retrieves all conversations for an account.
func (r *ConversationRepo) FindByAccount(ctx context.Context, accountID uint, offset, limit int) ([]model.Conversation, int64, error) {
var conversations []model.Conversation
var total int64
countDB := r.db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID)
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
err := r.db.WithContext(ctx).Where("account_id = ?", accountID).
Offset(offset).Limit(limit).Order("id DESC").
Find(&conversations).Error
return conversations, total, err
}
// FindByInbox retrieves all conversations for an inbox.
func (r *ConversationRepo) FindByInbox(ctx context.Context, accountID, inboxID uint, offset, limit int) ([]model.Conversation, int64, error) {
var conversations []model.Conversation
var total int64
countDB := r.db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ? AND inbox_id = ?", accountID, inboxID)
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
err := r.db.WithContext(ctx).
Where("account_id = ? AND inbox_id = ?", accountID, inboxID).
Offset(offset).Limit(limit).Order("id DESC").
Find(&conversations).Error
return conversations, total, err
}
// FindByStatus retrieves conversations by status for an account.
func (r *ConversationRepo) FindByStatus(ctx context.Context, accountID uint, status model.ConversationStatus, offset, limit int) ([]model.Conversation, int64, error) {
var conversations []model.Conversation
var total int64
countDB := r.db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ? AND status = ?", accountID, status)
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
err := r.db.WithContext(ctx).
Where("account_id = ? AND status = ?", accountID, status).
Offset(offset).Limit(limit).Order("id DESC").
Find(&conversations).Error
return conversations, total, err
}
// FindByAssignee retrieves conversations assigned to a specific agent.
func (r *ConversationRepo) FindByAssignee(ctx context.Context, accountID, assigneeID uint, offset, limit int) ([]model.Conversation, int64, error) {
var conversations []model.Conversation
var total int64
countDB := r.db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ? AND assignee_id = ?", accountID, assigneeID)
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
err := r.db.WithContext(ctx).
Where("account_id = ? AND assignee_id = ?", accountID, assigneeID).
Offset(offset).Limit(limit).Order("id DESC").
Find(&conversations).Error
return conversations, total, err
}
// FindUnassigned retrieves conversations with no assignee.
func (r *ConversationRepo) FindUnassigned(ctx context.Context, accountID uint, offset, limit int) ([]model.Conversation, int64, error) {
var conversations []model.Conversation
var total int64
countDB := r.db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ? AND assignee_id IS NULL AND status = ?", accountID, model.ConversationStatusOpen)
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
err := r.db.WithContext(ctx).
Where("account_id = ? AND assignee_id IS NULL AND status = ?", accountID, model.ConversationStatusOpen).
Offset(offset).Limit(limit).Order("id DESC").
Find(&conversations).Error
return conversations, total, err
}
// FindByContact retrieves conversations for a specific contact.
func (r *ConversationRepo) FindByContact(ctx context.Context, accountID, contactID uint, offset, limit int) ([]model.Conversation, int64, error) {
var conversations []model.Conversation
var total int64
countDB := r.db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ? AND contact_id = ?", accountID, contactID)
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
err := r.db.WithContext(ctx).
Where("account_id = ? AND contact_id = ?", accountID, contactID).
Offset(offset).Limit(limit).Order("id DESC").
Find(&conversations).Error
return conversations, total, err
}
// FindRecentByContact retrieves the latest conversations for a contact.
// Reference: Chatwoot contacts/conversations#index limits to the latest 20 conversations ordered by last_activity_at.
func (r *ConversationRepo) FindRecentByContact(ctx context.Context, accountID, contactID uint, inboxID *uint, limit int) ([]model.Conversation, error) {
var conversations []model.Conversation
query := r.db.WithContext(ctx).
Where("account_id = ? AND contact_id = ?", accountID, contactID)
if inboxID != nil && *inboxID != 0 {
query = query.Where("inbox_id = ?", *inboxID)
}
err := query.Limit(limit).Order("last_activity_at DESC").Order("id DESC").Find(&conversations).Error
return conversations, err
}
// FindByContactIDs retrieves conversations for multiple contacts within an account.
func (r *ConversationRepo) FindByContactIDs(ctx context.Context, accountID uint, contactIDs []uint, offset, limit int) ([]model.Conversation, int64, error) {
var conversations []model.Conversation
var total int64
if len(contactIDs) == 0 {
return []model.Conversation{}, 0, nil
}
countDB := r.db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ? AND contact_id IN ?", accountID, contactIDs)
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
err := r.db.WithContext(ctx).
Where("account_id = ? AND contact_id IN ?", accountID, contactIDs).
Offset(offset).Limit(limit).Order("last_activity_at DESC").Order("id DESC").
Find(&conversations).Error
return conversations, total, err
}
// FindByTeam retrieves conversations assigned to a specific team.
func (r *ConversationRepo) FindByTeam(ctx context.Context, accountID, teamID uint, offset, limit int) ([]model.Conversation, int64, error) {
var conversations []model.Conversation
var total int64
countDB := r.db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ? AND team_id = ?", accountID, teamID)
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
err := r.db.WithContext(ctx).
Where("account_id = ? AND team_id = ?", accountID, teamID).
Offset(offset).Limit(limit).Order("id DESC").
Find(&conversations).Error
return conversations, total, err
}
// Search searches conversations by content within an account.
func (r *ConversationRepo) Search(ctx context.Context, accountID uint, query string, offset, limit int, searchMode search.SearchMode) ([]model.Conversation, int64, error) {
var conversations []model.Conversation
var total int64
condition := r.db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ?", accountID)
if query != "" {
if searchMode == search.SearchModeTrigram {
// pg_trgm fuzzy match on conversation labels
condition = condition.Where("labels % ?", query)
} else {
// ILIKE substring match (default)
likeQuery := "%" + query + "%"
condition = condition.Where("labels ILIKE ?", likeQuery)
}
}
if err := condition.Count(&total).Error; err != nil {
return nil, 0, err
}
err := condition.Offset(offset).Limit(limit).Order("id DESC").
Find(&conversations).Error
return conversations, total, err
}
// Create inserts a new conversation.
func (r *ConversationRepo) Create(ctx context.Context, conversation *model.Conversation) error {
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
return r.db.WithContext(ctx).Create(conversation).Error
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// PostgreSQL needs serialization because MAX(display_id)+1 is otherwise
// racy when a channel imports several conversations concurrently.
if tx.Dialector != nil && tx.Dialector.Name() == "postgres" {
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(conversation.AccountID)).Error; err != nil {
return err
}
}
var next uint
if err := tx.Model(&model.Conversation{}).
Select("COALESCE(MAX(display_id), 0) + 1").
Where("account_id = ?", conversation.AccountID).
Scan(&next).Error; err != nil {
return err
}
conversation.DisplayID = &next
return tx.Create(conversation).Error
})
}
// Update modifies an existing conversation.
func (r *ConversationRepo) Update(ctx context.Context, conversation *model.Conversation) error {
return r.db.WithContext(ctx).Save(conversation).Error
}
// UpdateStatus changes the conversation status.
func (r *ConversationRepo) UpdateStatus(ctx context.Context, id uint, status model.ConversationStatus) error {
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Update("status", status).Error
}
// AssignAgent assigns a conversation to an agent.
func (r *ConversationRepo) AssignAgent(ctx context.Context, id, assigneeID uint) error {
var value any
if assigneeID != 0 {
value = assigneeID
}
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Updates(map[string]any{
"assignee_id": value, "assignee_agent_bot_id": nil,
"status": gorm.Expr("CASE WHEN assignee_agent_bot_id IS NOT NULL AND status = ? THEN ? ELSE status END", model.ConversationStatusPending, model.ConversationStatusOpen),
}).Error
}
func (r *ConversationRepo) AssignAgentBot(ctx context.Context, id, agentBotID uint) error {
var value any
status := any(model.ConversationStatusPending)
if agentBotID != 0 {
value = agentBotID
} else {
status = gorm.Expr("CASE WHEN assignee_agent_bot_id IS NOT NULL AND status = ? THEN ? ELSE status END", model.ConversationStatusPending, model.ConversationStatusOpen)
}
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Updates(map[string]any{"assignee_id": nil, "assignee_agent_bot_id": value, "status": status}).Error
}
func (r *ConversationRepo) StartAITakeover(ctx context.Context, tx *gorm.DB, id, agentBotID uint) error {
return tx.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Updates(map[string]any{
"assignee_id": nil, "assignee_agent_bot_id": agentBotID,
"status": model.ConversationStatusPending,
"ai_takeover_version": gorm.Expr("ai_takeover_version + 1"),
}).Error
}
func (r *ConversationRepo) ExitAITakeover(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Model(&model.Conversation{}).
Where("id = ? AND assignee_agent_bot_id IS NOT NULL", id).
Updates(map[string]any{
"assignee_agent_bot_id": nil,
"status": gorm.Expr("CASE WHEN status = ? THEN ? ELSE status END", model.ConversationStatusPending, model.ConversationStatusOpen),
"ai_takeover_version": gorm.Expr("ai_takeover_version + 1"),
}).Error
}
// ToggleStatus toggles conversation between open/resolved.
func (r *ConversationRepo) ToggleStatus(ctx context.Context, id uint, status model.ConversationStatus) error {
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Update("status", status).Error
}
// UpdateLabels updates the labels on a conversation.
func (r *ConversationRepo) UpdateLabels(ctx context.Context, id uint, labels string) error {
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Update("labels", labels).Error
}
// Delete soft-deletes a conversation.
func (r *ConversationRepo) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.Conversation{}, id).Error
}
// CountByAccount returns total number of conversations in an account.
func (r *ConversationRepo) CountByAccount(ctx context.Context, accountID uint) (int64, error) {
var total int64
err := r.db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID).Count(&total).Error
return total, err
}
// Mute sets a conversation as muted.
func (r *ConversationRepo) Mute(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Update("muted", true).Error
}
// Unmute sets a conversation as unmuted.
func (r *ConversationRepo) Unmute(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Update("muted", false).Error
}
// UpdatePriority updates the priority of a conversation.
func (r *ConversationRepo) UpdatePriority(ctx context.Context, id uint, priority string) error {
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Update("priority", priority).Error
}
// UpdateSnoozedUntil updates the snoozed_until timestamp for a conversation.
func (r *ConversationRepo) UpdateSnoozedUntil(ctx context.Context, id uint, snoozedUntil *int64) error {
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Update("snoozed_until", snoozedUntil).Error
}
// UpdateCustomAttributes updates the custom attributes of a conversation.
func (r *ConversationRepo) UpdateCustomAttributes(ctx context.Context, id uint, attrs datatypes.JSON) error {
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Update("custom_attributes", attrs).Error
}
// UpdateAdditionalAttributes updates the additional attributes of a conversation.
func (r *ConversationRepo) UpdateAdditionalAttributes(ctx context.Context, id uint, attrs datatypes.JSON) error {
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Update("additional_attributes", attrs).Error
}
// UpdateTeam assigns or unassigns a team from a conversation.
func (r *ConversationRepo) UpdateTeam(ctx context.Context, id uint, teamID *uint) error {
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Update("team_id", teamID).Error
}
// BulkUpdateStatus updates the status of multiple conversations at once.
func (r *ConversationRepo) BulkUpdateStatus(ctx context.Context, accountID uint, ids []uint, status string) error {
return r.db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ? AND id IN ?", accountID, ids).
Update("status", status).Error
}
// ConversationMeta holds Chatwoot conversation sidebar counts.
type ConversationMeta struct {
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 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)
var mineCount, unassignedCount, allCount int64
if err := base.Session(&gorm.Session{}).Where("conversations.assignee_id = ?", userID).Count(&mineCount).Error; err != nil {
return nil, err
}
if err := base.Session(&gorm.Session{}).Where("conversations.assignee_id IS NULL").Count(&unassignedCount).Error; err != nil {
return nil, err
}
if err := base.Session(&gorm.Session{}).Count(&allCount).Error; err != nil {
return nil, err
}
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
Query string
SourceID string
RestrictedInboxIDs []uint
RestrictToInboxes bool
}
type ConversationFinderListParams struct {
ConversationMetaParams
AssigneeType string
SortBy string
UpdatedWithin *int
}
// ListForFinder retrieves conversations using the same filtered base as ConversationFinder#perform.
func (r *ConversationRepo) ListForFinder(ctx context.Context, accountID, userID uint, params ConversationFinderListParams, offset, limit int) ([]model.Conversation, error) {
query := r.conversationMetaBaseQuery(ctx, accountID, userID, params.ConversationMetaParams)
switch params.AssigneeType {
case "me":
query = query.Where("conversations.assignee_id = ?", userID)
case "unassigned":
query = query.Where("conversations.assignee_id IS NULL")
case "assigned":
query = query.Where("conversations.assignee_id IS NOT NULL")
}
if params.UpdatedWithin != nil {
query = query.Where("conversations.updated_at > ?", time.Now().Add(-time.Duration(*params.UpdatedWithin)*time.Second))
} else {
query = query.Offset(offset).Limit(limit)
}
var conversations []model.Conversation
err := query.Order(conversationFinderOrderClause(params.SortBy)).Find(&conversations).Error
return conversations, err
}
func conversationFinderOrderClause(sortBy string) string {
switch sortBy {
case "last_activity_at_asc":
return "conversations.last_activity_at ASC"
case "created_at_asc", "sort_on_created_at":
return "conversations.created_at ASC"
case "created_at_desc":
return "conversations.created_at DESC"
case "priority_asc":
return "conversations.priority ASC"
case "priority_desc", "sort_on_priority":
return "conversations.priority DESC"
case "waiting_since_asc", "sort_on_waiting_since":
return "conversations.waiting_since ASC"
case "waiting_since_desc":
return "conversations.waiting_since DESC"
case "priority_desc_created_at_asc":
return "conversations.priority DESC, conversations.created_at ASC"
default:
return "conversations.last_activity_at DESC"
}
}
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)
trimmedQuery := strings.TrimSpace(params.Query)
if params.RestrictToInboxes {
if len(params.RestrictedInboxIDs) == 0 {
query = query.Where("1 = 0")
} else {
query = query.Where("conversations.inbox_id IN ?", params.RestrictedInboxIDs)
}
}
if trimmedQuery != "" {
// Chatwoot ConversationFinder#set_up skips filter_by_status whenever q is present.
} else 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))
}
}
if trimmedQuery != "" {
searchTerm := "%" + trimmedQuery + "%"
query = query.Joins("JOIN messages ON messages.conversation_id = conversations.id").
Where("messages.content LIKE ?", searchTerm).
Where("messages.message_type IN ?", []string{string(model.MessageTypeIncoming), string(model.MessageTypeOutgoing)})
}
if strings.TrimSpace(params.SourceID) != "" {
query = query.Joins("JOIN contact_inboxes ON contact_inboxes.id = conversations.contact_inbox_id").
Where("contact_inboxes.source_id = ?", strings.TrimSpace(params.SourceID))
}
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.
// This is used when no incoming messages exist for the conversation.
// Reference: Chatwoot conversations_controller#unread
func (r *ConversationRepo) MarkUnread(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Update("agent_last_seen_at", nil).Error
}
// UpdateAgentLastSeenAt sets agent_last_seen_at to a specific timestamp.
// Used by MarkUnread to set it to (last_incoming_message.CreatedAt - 1 second).
func (r *ConversationRepo) UpdateAgentLastSeenAt(ctx context.Context, id uint, lastSeenAt time.Time) error {
ts := lastSeenAt.Unix()
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Update("agent_last_seen_at", ts).Error
}
// UnreadCountResult holds unread conversation counts grouped by a key.
type UnreadCountResult struct {
Key uint `gorm:"column:key"`
Count int64 `gorm:"column:count"`
}
// UnreadCountByLabel holds unread conversation counts grouped by label name.
type UnreadCountByLabel struct {
Key uint `gorm:"column:key"`
Count int64 `gorm:"column:count"`
}
// GetUnreadCountsByInbox returns the number of unread conversations per inbox
// for a given account. A conversation is unread if agent_last_seen_at < last_message_at
// or agent_last_seen_at is nil and the conversation has messages.
// Reference: Chatwoot app/services/conversations/unread_counts/counter.rb
func (r *ConversationRepo) GetUnreadCountsByInbox(ctx context.Context, accountID uint, inboxIDs []uint, permissionMode string, userID uint) ([]UnreadCountResult, error) {
var results []UnreadCountResult
if len(inboxIDs) == 0 {
return results, nil
}
query := r.db.WithContext(ctx).Model(&model.Conversation{}).
Select("inbox_id as key, COUNT(*) as count").
Where("account_id = ? AND status = ?", accountID, model.ConversationStatusOpen).
Where("inbox_id IN ?", inboxIDs).
Where("agent_last_seen_at IS NULL OR agent_last_seen_at < last_message_at")
query = applyUnreadPermissionScope(query, permissionMode, userID)
err := query.Group("inbox_id").
Find(&results).Error
return results, err
}
// GetUnreadCountsByTeam returns the number of unread conversations per team
// for a given account.
func (r *ConversationRepo) GetUnreadCountsByTeam(ctx context.Context, accountID uint, inboxIDs, teamIDs []uint, permissionMode string, userID uint) ([]UnreadCountResult, error) {
var results []UnreadCountResult
if len(inboxIDs) == 0 || len(teamIDs) == 0 {
return results, nil
}
query := r.db.WithContext(ctx).Model(&model.Conversation{}).
Select("team_id as key, COUNT(*) as count").
Where("account_id = ? AND status = ? AND team_id IS NOT NULL", accountID, model.ConversationStatusOpen).
Where("inbox_id IN ? AND team_id IN ?", inboxIDs, teamIDs).
Where("agent_last_seen_at IS NULL OR agent_last_seen_at < last_message_at")
query = applyUnreadPermissionScope(query, permissionMode, userID)
err := query.Group("team_id").
Find(&results).Error
return results, err
}
// GetUnreadCountsByLabel returns the number of unread conversations per label
// for a given account. Queries conversation_labels join table.
func (r *ConversationRepo) GetUnreadCountsByLabel(ctx context.Context, accountID uint, inboxIDs []uint, permissionMode string, userID uint) ([]UnreadCountByLabel, error) {
var results []UnreadCountByLabel
if len(inboxIDs) == 0 {
return results, nil
}
query := r.db.WithContext(ctx).
Table("conversation_labels").
Select("conversation_labels.tag_id as key, COUNT(*) as count").
Joins("JOIN conversations ON conversations.id = conversation_labels.conversation_id").
Joins("JOIN tags ON tags.id = conversation_labels.tag_id").
Where("conversations.account_id = ? AND conversations.status = ?", accountID, model.ConversationStatusOpen).
Where("conversations.inbox_id IN ?", inboxIDs).
Where("tags.show_on_sidebar = ?", true).
Where("conversations.agent_last_seen_at IS NULL OR conversations.agent_last_seen_at < conversations.last_message_at")
query = applyUnreadPermissionScope(query, permissionMode, userID)
err := query.Group("conversation_labels.tag_id").
Find(&results).Error
return results, err
}
func applyUnreadPermissionScope(query *gorm.DB, permissionMode string, userID uint) *gorm.DB {
switch permissionMode {
case "unassigned_and_mine":
return query.Where("conversations.assignee_id IS NULL OR conversations.assignee_id = ?", userID)
case "mine":
return query.Where("conversations.assignee_id = ?", userID)
case "none":
return query.Where("1 = 0")
default:
return query
}
}
// AssigneeWorkloadResult 表示每个agent的open conversations计数结果
// Reference: Chatwoot assignable_agents workload计算
type AssigneeWorkloadResult struct {
AssigneeID uint `json:"assignee_id"`
Count int64 `json:"count"`
}
// CountOpenConversationsByAssignees 批量统计给定account中指定agent们的open conversations数量。
// 用于计算每个agent的workload,以支持智能分配排序。
// Reference: Chatwoot auto assignment logic — 按open conversations数量排序
func (r *ConversationRepo) CountOpenConversationsByAssignees(ctx context.Context, accountID uint, assigneeIDs []uint) ([]AssigneeWorkloadResult, error) {
if len(assigneeIDs) == 0 {
return []AssigneeWorkloadResult{}, nil
}
var results []AssigneeWorkloadResult
err := r.db.WithContext(ctx).Model(&model.Conversation{}).
Select("assignee_id, COUNT(*) as count").
Where("account_id = ? AND status = ? AND assignee_id IN ?", accountID, model.ConversationStatusOpen, assigneeIDs).
Group("assignee_id").
Find(&results).Error
if err != nil {
return nil, err
}
return results, nil
}