Files
gochat/internal/repository/conversation_repo.go
T

521 lines
20 KiB
Go

package repository
import (
"context"
"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, falling back to primary key for
// legacy GoChat data and tests that predate display_id.
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 display_id = ?", accountID, routeID).First(&conversation).Error
if err == nil {
return &conversation, nil
}
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 {
var next uint
if err := r.db.WithContext(ctx).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 r.db.WithContext(ctx).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 {
return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id).
Update("assignee_id", assigneeID).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 aggregated statistics for conversations.
type ConversationMeta struct {
StatusCounts map[string]int64 `json:"status_counts"`
LabelCounts map[string]int64 `json:"label_counts"`
TotalCount int64 `json:"total_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),
}
// Total count
var total int64
if err := r.db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID).Count(&total).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 {
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 {
return nil, err
}
for _, row := range labelRows {
for _, label := range strings.Split(row.Labels, ",") {
label = strings.TrimSpace(label)
if label != "" {
meta.LabelCounts[label]++
}
}
}
return meta, nil
}
// 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 {
Label string `gorm:"column:label"`
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) ([]UnreadCountResult, error) {
var results []UnreadCountResult
err := r.db.WithContext(ctx).Model(&model.Conversation{}).
Select("inbox_id as key, COUNT(*) as count").
Where("account_id = ? AND status = ?", accountID, model.ConversationStatusOpen).
Where("agent_last_seen_at IS NULL OR agent_last_seen_at < last_message_at").
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) ([]UnreadCountResult, error) {
var results []UnreadCountResult
err := 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("agent_last_seen_at IS NULL OR agent_last_seen_at < last_message_at").
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) ([]UnreadCountByLabel, error) {
var results []UnreadCountByLabel
err := r.db.WithContext(ctx).
Table("conversation_labels").
Select("tags.name as label, 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.agent_last_seen_at IS NULL OR conversations.agent_last_seen_at < conversations.last_message_at").
Group("tags.name").
Find(&results).Error
return results, err
}
// 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
}