* 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>
724 lines
28 KiB
Go
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
|
|
}
|