Files
gochat/internal/service/conversation_service.go
T

2002 lines
75 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/gochat/gochat/internal/automation"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/search"
"github.com/gochat/gochat/internal/worker"
applogger "github.com/gochat/gochat/pkg/logger"
pkgvalidator "github.com/gochat/gochat/pkg/validator"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// ConversationService implements business logic for Conversation operations.
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb
type ConversationService struct {
repo *repository.ConversationRepo
msgRepo *repository.MessageRepo
dispatcher *channel.Dispatcher
inboxMemberSvc *InboxMemberService
accountUserRepo *repository.AccountUserRepo
teamRepo *repository.TeamRepo
teamMemberRepo *repository.TeamMemberRepo
searchIndexer SearchIndexer
appliedSlaSvc *AppliedSlaService
transcriptMailer automation.AutomationTranscriptDeliverer
worker *worker.WorkerPool
}
// NewConversationService creates a new Conversation service.
func NewConversationService(repo *repository.ConversationRepo, msgRepo *repository.MessageRepo, dispatcher *channel.Dispatcher, inboxMemberSvc *InboxMemberService, accountUserRepo *repository.AccountUserRepo, teamRepo *repository.TeamRepo, teamMemberRepo *repository.TeamMemberRepo) *ConversationService {
return &ConversationService{repo: repo, msgRepo: msgRepo, dispatcher: dispatcher, inboxMemberSvc: inboxMemberSvc, accountUserRepo: accountUserRepo, teamRepo: teamRepo, teamMemberRepo: teamMemberRepo, transcriptMailer: automation.NewEnvAutomationTranscriptDeliverer()}
}
func (s *ConversationService) SetSearchIndexer(indexer SearchIndexer) {
s.searchIndexer = indexer
}
func (s *ConversationService) SetAppliedSlaService(appliedSlaSvc *AppliedSlaService) {
s.appliedSlaSvc = appliedSlaSvc
}
func (s *ConversationService) SetTranscriptDeliverer(deliverer automation.AutomationTranscriptDeliverer) {
s.transcriptMailer = deliverer
}
func (s *ConversationService) SetWorkerPool(wp *worker.WorkerPool) {
s.worker = wp
RegisterConversationDeleteJobs(wp, s)
}
func (s *ConversationService) DB() *gorm.DB {
if s == nil || s.repo == nil {
return nil
}
return s.repo.DB()
}
func (s *ConversationService) indexConversation(ctx context.Context, conversation *model.Conversation) {
if s.searchIndexer != nil {
logSearchIndexError("conversation", conversation.ID, s.searchIndexer.IndexConversation(ctx, conversation))
}
}
func (s *ConversationService) deleteConversationIndex(ctx context.Context, accountID uint, id uint) {
if s.searchIndexer != nil {
logSearchIndexError("conversation", id, s.searchIndexer.DeleteConversation(ctx, accountID, id))
}
}
func (s *ConversationService) indexMessage(ctx context.Context, message *model.Message) {
if s.searchIndexer != nil {
logSearchIndexError("message", message.ID, s.searchIndexer.IndexMessage(ctx, message))
}
}
// dispatchConversationEvent is a helper to build and dispatch a conversation event.
func (s *ConversationService) dispatchConversationEvent(ctx context.Context, eventType channel.EventType, conversation *model.Conversation) {
s.dispatchConversationEventWithData(ctx, eventType, conversation, nil)
}
func (s *ConversationService) dispatchConversationEventWithData(ctx context.Context, eventType channel.EventType, conversation *model.Conversation, data map[string]interface{}) {
event := channel.NewChannelEvent(eventType, channel.ChannelType(conversation.ChannelType), conversation.AccountID, conversation.InboxID)
event.ConversationID = conversation.ID
event.ContactID = conversation.ContactID
if conversation.AssigneeID != nil {
event.UserID = *conversation.AssigneeID
}
for key, value := range data {
event.Data[key] = value
}
event.Data["conversation"] = conversation
applogger.L().Infof("dispatching event %s for conversation %d", eventType, conversation.ID)
if err := s.dispatcher.Dispatch(ctx, event); err != nil {
applogger.L().Errorf("failed to dispatch event %s for conversation %d: %v", eventType, conversation.ID, err)
}
}
func changedAttributes(changes map[string][2]interface{}) map[string]interface{} {
out := make(map[string]interface{}, len(changes))
for attr, values := range changes {
if fmt.Sprintf("%v", values[0]) == fmt.Sprintf("%v", values[1]) {
continue
}
out[attr] = map[string]interface{}{
"from": values[0],
"to": values[1],
}
}
return out
}
func eventDataWithChanges(changes map[string]interface{}) map[string]interface{} {
if len(changes) == 0 {
return nil
}
return map[string]interface{}{"changed_attributes": changes}
}
// ListByAccount retrieves all conversations for an account.
func (s *ConversationService) ListByAccount(ctx context.Context, accountID uint, offset, limit int) ([]model.Conversation, int64, error) {
return s.repo.FindByAccount(ctx, accountID, offset, limit)
}
// ListByInbox retrieves conversations for an inbox within an account.
func (s *ConversationService) ListByInbox(ctx context.Context, accountID, inboxID uint, offset, limit int) ([]model.Conversation, int64, error) {
return s.repo.FindByInbox(ctx, accountID, inboxID, offset, limit)
}
// ListByStatus retrieves conversations filtered by status.
func (s *ConversationService) ListByStatus(ctx context.Context, accountID uint, status string, offset, limit int) ([]model.Conversation, int64, error) {
return s.repo.FindByStatus(ctx, accountID, model.ConversationStatus(status), offset, limit)
}
// ListByAssignee retrieves conversations assigned to a specific agent.
func (s *ConversationService) ListByAssignee(ctx context.Context, accountID, assigneeID uint, offset, limit int) ([]model.Conversation, int64, error) {
return s.repo.FindByAssignee(ctx, accountID, assigneeID, offset, limit)
}
// ListRecentByContact retrieves recent conversations for a contact.
func (s *ConversationService) ListRecentByContact(ctx context.Context, accountID, contactID uint, inboxID *uint, limit int) ([]model.Conversation, error) {
if limit <= 0 {
limit = 20
}
return s.repo.FindRecentByContact(ctx, accountID, contactID, inboxID, limit)
}
// ListUnassigned retrieves unassigned open conversations.
func (s *ConversationService) ListUnassigned(ctx context.Context, accountID uint, offset, limit int) ([]model.Conversation, int64, error) {
return s.repo.FindUnassigned(ctx, accountID, offset, limit)
}
// GetByID retrieves a single conversation.
func (s *ConversationService) GetByID(ctx context.Context, id uint) (*model.Conversation, error) {
return s.repo.FindByID(ctx, id)
}
// GetByAccountAndID retrieves a conversation scoped to an account.
func (s *ConversationService) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.Conversation, error) {
return s.repo.FindByAccountAndID(ctx, accountID, id)
}
// GetByAccountAndDisplayIDOrID resolves Chatwoot conversation route IDs.
func (s *ConversationService) GetByAccountAndDisplayIDOrID(ctx context.Context, accountID, routeID uint) (*model.Conversation, error) {
return s.repo.FindByAccountAndDisplayIDOrID(ctx, accountID, routeID)
}
// GetInboxAssistant returns the Captain assistant connected to the conversation's inbox.
// Reference: Chatwoot enterprise ConversationsController#inbox_assistant.
func (s *ConversationService) GetInboxAssistant(ctx context.Context, accountID, routeID uint) (*model.CaptainAssistant, error) {
conversation, err := s.repo.FindByAccountAndDisplayIDOrID(ctx, accountID, routeID)
if err != nil {
return nil, err
}
var assistant model.CaptainAssistant
err = s.repo.DB().WithContext(ctx).
Joins("JOIN captain_inboxes ON captain_inboxes.captain_assistant_id = captain_assistants.id").
Where("captain_inboxes.account_id = ? AND captain_inboxes.inbox_id = ? AND captain_assistants.account_id = ?", accountID, conversation.InboxID, accountID).
Order("captain_inboxes.id DESC").
First(&assistant).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &assistant, nil
}
// ListReportingEvents returns conversation-scoped reporting events in Chatwoot order.
// Reference: Chatwoot enterprise ConversationsController#reporting_events.
func (s *ConversationService) ListReportingEvents(ctx context.Context, accountID, routeID uint) ([]model.ReportingEvent, error) {
conversation, err := s.repo.FindByAccountAndDisplayIDOrID(ctx, accountID, routeID)
if err != nil {
return nil, err
}
var events []model.ReportingEvent
err = s.repo.DB().WithContext(ctx).
Where("account_id = ? AND conversation_id = ?", accountID, conversation.ID).
Order("created_at ASC").
Find(&events).Error
return events, err
}
// CreateConversationRequest is the DTO for creating a conversation.
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations_controller.rb #create
// Supports creating conversation with an initial message (like Chatwoot's ConversationBuilder).
type CreateConversationRequest struct {
InboxID uint `json:"inbox_id" validate:"required"`
ContactID uint `json:"contact_id" validate:"required"`
Status string `json:"status,omitempty" validate:"omitempty,oneof=open resolved pending snoozed"`
Priority string `json:"priority,omitempty" validate:"omitempty,oneof=urgent high medium low"`
SlaPolicyID *uint `json:"sla_policy_id,omitempty"`
// Initial message fields (Chatwoot: conversation + message created together)
MessageContent string `json:"message_content,omitempty"`
MessageType string `json:"message_type,omitempty" validate:"omitempty,oneof=outgoing incoming"`
}
// Create creates a new conversation.
func (s *ConversationService) Create(ctx context.Context, accountID uint, req CreateConversationRequest) (*model.Conversation, error) {
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, err
}
status := model.ConversationStatusOpen
if req.Status != "" {
status = model.ConversationStatus(req.Status)
}
priority := model.ConversationPriorityLow
if req.Priority != "" {
priority = model.ConversationPriority(req.Priority)
}
if err := s.validateSlaPolicy(ctx, accountID, req.SlaPolicyID); err != nil {
return nil, err
}
conversation := &model.Conversation{
AccountID: accountID,
InboxID: req.InboxID,
ContactID: req.ContactID,
Status: string(status),
Priority: string(priority),
SlaPolicyID: req.SlaPolicyID,
}
if err := s.repo.Create(ctx, conversation); err != nil {
applogger.L().Errorf("Failed to create conversation: %v", err)
return nil, err
}
if err := s.ensureAppliedSla(ctx, accountID, conversation); err != nil {
return nil, err
}
// Dispatch EventConversationCreated
s.dispatchConversationEvent(ctx, channel.EventConversationCreated, conversation)
s.indexConversation(ctx, conversation)
// If status is open, also dispatch EventConversationOpened
if status == model.ConversationStatusOpen {
s.dispatchConversationEvent(ctx, channel.EventConversationOpened, conversation)
}
// Chatwoot: Create initial message if message_content is provided
// Reference: Chatwoot ConversationBuilder builds conversation + first message together
if req.MessageContent != "" {
msgType := req.MessageType
if msgType == "" {
msgType = "outgoing"
}
initialMsg := &model.Message{
AccountID: accountID,
ConversationID: conversation.ID,
InboxID: req.InboxID,
Content: req.MessageContent,
MessageType: msgType,
ContentType: "text",
SenderType: "user",
}
if err := s.msgRepo.Create(ctx, initialMsg); err != nil {
applogger.L().Errorf("Failed to create initial message for conversation %d: %v", conversation.ID, err)
// Non-critical: conversation was created, message creation failure is logged but not fatal
} else {
s.indexMessage(ctx, initialMsg)
event := channel.NewChannelEvent(channel.EventMessageCreated, channel.ChannelAPI, accountID, req.InboxID)
event.ConversationID = conversation.ID
event.ContactID = conversation.ContactID
event.Data["message"] = initialMsg
event.Data["conversation"] = conversation
if err := s.dispatcher.Dispatch(ctx, event); err != nil {
applogger.L().Errorf("failed to dispatch event %s for message %d: %v", channel.EventMessageCreated, initialMsg.ID, err)
}
}
}
return conversation, nil
}
// UpdateConversationRequest is the DTO for updating a conversation.
type UpdateConversationRequest struct {
Status string `json:"status,omitempty" validate:"omitempty,oneof=open resolved pending snoozed"`
Priority string `json:"priority,omitempty" validate:"omitempty,oneof=urgent high medium low"`
SlaPolicyID *uint `json:"sla_policy_id,omitempty"`
}
// Update modifies an existing conversation.
func (s *ConversationService) Update(ctx context.Context, accountID, id uint, req UpdateConversationRequest) (*model.Conversation, error) {
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, err
}
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
if err != nil {
return nil, err
}
oldStatus := conversation.Status
oldPriority := conversation.Priority
var oldSlaPolicyID interface{}
if conversation.SlaPolicyID != nil {
oldSlaPolicyID = *conversation.SlaPolicyID
}
if req.Status != "" {
conversation.Status = req.Status
}
if req.Priority != "" {
conversation.Priority = req.Priority
}
if req.SlaPolicyID != nil {
if *req.SlaPolicyID == 0 {
return nil, errors.New("sla policy cannot be removed from conversation")
}
if conversation.SlaPolicyID != nil && *conversation.SlaPolicyID != *req.SlaPolicyID {
return nil, errors.New("conversation already has a different sla")
}
if err := s.validateSlaPolicy(ctx, accountID, req.SlaPolicyID); err != nil {
return nil, err
}
conversation.SlaPolicyID = req.SlaPolicyID
}
if err := s.repo.Update(ctx, conversation); err != nil {
return nil, err
}
if err := s.ensureAppliedSla(ctx, accountID, conversation); err != nil {
return nil, err
}
var newSlaPolicyID interface{}
if conversation.SlaPolicyID != nil {
newSlaPolicyID = *conversation.SlaPolicyID
}
changes := changedAttributes(map[string][2]interface{}{
"status": {oldStatus, conversation.Status},
"priority": {oldPriority, conversation.Priority},
"sla_policy_id": {oldSlaPolicyID, newSlaPolicyID},
})
changeData := eventDataWithChanges(changes)
// Dispatch EventConversationUpdated
s.dispatchConversationEventWithData(ctx, channel.EventConversationUpdated, conversation, changeData)
s.indexConversation(ctx, conversation)
// If status changed, dispatch appropriate status event
if req.Status != "" && req.Status != oldStatus {
newStatus := model.ConversationStatus(req.Status)
switch newStatus {
case model.ConversationStatusResolved:
s.dispatchConversationEventWithData(ctx, channel.EventConversationResolved, conversation, changeData)
case model.ConversationStatusOpen:
s.dispatchConversationEventWithData(ctx, channel.EventConversationOpened, conversation, changeData)
}
}
return conversation, nil
}
func (s *ConversationService) ensureAppliedSla(ctx context.Context, accountID uint, conversation *model.Conversation) error {
if s.appliedSlaSvc == nil || conversation == nil || conversation.SlaPolicyID == nil || *conversation.SlaPolicyID == 0 {
return nil
}
_, err := s.appliedSlaSvc.CreateFromConversation(ctx, accountID, conversation.ID, *conversation.SlaPolicyID)
return err
}
func (s *ConversationService) validateSlaPolicy(ctx context.Context, accountID uint, slaPolicyID *uint) error {
if s.appliedSlaSvc == nil || slaPolicyID == nil || *slaPolicyID == 0 {
return nil
}
return s.appliedSlaSvc.ValidateSlaPolicy(ctx, accountID, *slaPolicyID)
}
// AssignAgentRequest is the DTO for assigning an agent to a conversation.
type AssignAgentRequest struct {
AssigneeID uint `json:"assignee_id" validate:"required"`
}
// AssignAgent assigns a conversation to an agent.
func (s *ConversationService) AssignAgent(ctx context.Context, accountID, id uint, assigneeID uint) (*model.Conversation, error) {
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
if err != nil {
return nil, err
}
if assigneeID == 0 {
// Unassign: dispatch EventConversationUnassigned
if err := s.repo.AssignAgent(ctx, conversation.ID, 0); err != nil {
return nil, err
}
conversation.AssigneeID = nil
event := channel.NewChannelEvent(channel.EventConversationUnassigned, channel.ChannelType(conversation.ChannelType), conversation.AccountID, conversation.InboxID)
event.ConversationID = conversation.ID
event.ContactID = conversation.ContactID
event.Data["conversation"] = conversation
applogger.L().Infof("dispatching event %s for conversation %d", channel.EventConversationUnassigned, conversation.ID)
if err := s.dispatcher.Dispatch(ctx, event); err != nil {
applogger.L().Errorf("failed to dispatch event %s: %v", channel.EventConversationUnassigned, err)
}
s.indexConversation(ctx, conversation)
return conversation, nil
}
// Authorization: assignee must be a valid agent/admin in this account.
// Reference: Chatwoot Conversations::AssignmentService — validates assignee is account member
if s.accountUserRepo != nil {
isMember, err := s.accountUserRepo.IsAgentOrAdmin(ctx, accountID, assigneeID)
if err != nil {
return nil, fmt.Errorf("failed to check assignee role: %w", err)
}
if !isMember {
return nil, errors.New("assignee is not an agent or administrator in this account")
}
}
// Validate: the assignee must be a member (agent) of the conversation's inbox.
// This enforces seat assignment routing — agents can only be assigned to conversations
// in inboxes they are members of.
if s.inboxMemberSvc != nil && !s.inboxMemberSvc.IsMemberOfInbox(ctx, conversation.InboxID, assigneeID) {
return nil, errors.New("assignee is not a member of the conversation's inbox")
}
if err := s.ensureAssigneeHasInboxCapacity(ctx, accountID, conversation, assigneeID); err != nil {
return nil, err
}
if err := s.repo.AssignAgent(ctx, conversation.ID, assigneeID); err != nil {
return nil, err
}
conversation.AssigneeID = &assigneeID
// Dispatch EventConversationAssigned
event := channel.NewChannelEvent(channel.EventConversationAssigned, channel.ChannelType(conversation.ChannelType), conversation.AccountID, conversation.InboxID)
event.ConversationID = conversation.ID
event.ContactID = conversation.ContactID
event.UserID = assigneeID
event.Data["conversation"] = conversation
applogger.L().Infof("dispatching event %s for conversation %d", channel.EventConversationAssigned, conversation.ID)
if err := s.dispatcher.Dispatch(ctx, event); err != nil {
applogger.L().Errorf("failed to dispatch event %s: %v", channel.EventConversationAssigned, err)
}
s.indexConversation(ctx, conversation)
return conversation, nil
}
// UnassignAgent removes the agent assignment from a conversation.
// Convenience wrapper around AssignAgent(id, 0) — sends EventConversationUnassigned.
// Reference: Chatwoot conversations_controller.rb #unassign
func (s *ConversationService) UnassignAgent(ctx context.Context, accountID, id uint) (*model.Conversation, error) {
return s.AssignAgent(ctx, accountID, id, 0)
}
// ToggleStatusRequest is the DTO for toggling conversation status.
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations_controller.rb #toggle_status
type ToggleStatusRequest struct {
Status string `json:"status" validate:"omitempty,oneof=open resolved pending snoozed"`
AssigneeID *uint `json:"assignee_id,omitempty"` // Chatwoot: auto-assign on reopen
SnoozedUntil *int64 `json:"snoozed_until,omitempty"` // Chatwoot: snooze with wake-up time (unix timestamp)
UserID *uint `json:"user_id,omitempty"` // Chatwoot: should_assign_conversation — auto-assign to agent who opens
IsBot bool `json:"is_bot,omitempty"` // Chatwoot: pending_to_open_by_bot — agent bot triggers handoff
}
// ToggleStatus toggles the conversation status with Chatwoot-compatible logic:
// - Reopening a resolved conversation auto-assigns to previous agent or specified assignee
// - Snoozed conversations require snoozed_until timestamp
func (s *ConversationService) ToggleStatus(ctx context.Context, accountID, id uint, req ToggleStatusRequest) (*model.Conversation, error) {
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, err
}
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
if err != nil {
return nil, err
}
oldStatus := model.ConversationStatus(conversation.Status)
newStatus := model.ConversationStatus(req.Status)
if strings.TrimSpace(req.Status) == "" {
if oldStatus == model.ConversationStatusOpen {
newStatus = model.ConversationStatusResolved
} else {
newStatus = model.ConversationStatusOpen
}
}
// Reference: Chatwoot conversations_controller#toggle_status
// 1. pending_to_open_by_bot: AgentBot moves pending→open triggers bot_handoff!
if oldStatus == model.ConversationStatusPending && newStatus == model.ConversationStatusOpen && req.IsBot {
// Bot handoff: transition from pending to open via agent bot
// Chatwoot: @conversation.bot_handoff! sets status to open and fires handoff event
if err := s.repo.ToggleStatus(ctx, conversation.ID, model.ConversationStatusOpen); err != nil {
return nil, err
}
conversation.Status = string(model.ConversationStatusOpen)
// Fire bot handoff event (Chatwoot dispatches conversation.bot_handoff!)
changes := changedAttributes(map[string][2]interface{}{"status": {string(oldStatus), conversation.Status}})
s.dispatchConversationEventWithData(ctx, channel.EventConversationOpened, conversation, eventDataWithChanges(changes))
s.indexConversation(ctx, conversation)
return conversation, nil
}
// 2. should_assign_conversation: Agent user opens conversation → auto-assign to themselves
// Chatwoot: assign_conversation if should_assign_conversation? (status=open, user is agent)
if newStatus == model.ConversationStatusOpen && req.UserID != nil && !req.IsBot {
// Auto-assign to the agent who opened it
conversation.AssigneeID = req.UserID
}
if err := s.repo.ToggleStatus(ctx, conversation.ID, newStatus); err != nil {
return nil, err
}
// Chatwoot: on reopen, auto-assign to previous agent if no assignee specified
// Reference: Chatwoot Conversations::StatusChangeService auto-assigns on reopen
if newStatus == model.ConversationStatusOpen && conversation.Status == string(model.ConversationStatusResolved) {
if req.AssigneeID != nil {
conversation.AssigneeID = req.AssigneeID
} else if conversation.AssigneeID != nil {
// Keep previous assignee on reopen (Chatwoot behavior)
} else {
// No previous assignee - will need auto-assignment logic
// Production note: Auto-assignment based on inbox round-robin needs InboxMemberSvc
}
}
// Chatwoot: snoozed_until for snoozed conversations
if newStatus == model.ConversationStatusSnoozed && req.SnoozedUntil != nil {
conversation.SnoozedUntil = req.SnoozedUntil
}
conversation.Status = string(newStatus)
// Chatwoot: set resolved_at/resumed_at timestamps on status transitions
// Reference: Chatwoot Conversation model has resolved_at and reopened_at (resumed_at)
now := time.Now()
if newStatus == model.ConversationStatusResolved {
conversation.ResolvedAt = &now
} else if newStatus == model.ConversationStatusOpen && oldStatus == model.ConversationStatusResolved {
conversation.ResumedAt = &now
}
// Persist timestamp changes
if err := s.repo.Update(ctx, conversation); err != nil {
return nil, err
}
// Dispatch appropriate status event based on new status
changes := changedAttributes(map[string][2]interface{}{"status": {string(oldStatus), conversation.Status}})
changeData := eventDataWithChanges(changes)
switch newStatus {
case model.ConversationStatusResolved:
s.dispatchConversationEventWithData(ctx, channel.EventConversationResolved, conversation, changeData)
case model.ConversationStatusOpen:
s.dispatchConversationEventWithData(ctx, channel.EventConversationOpened, conversation, changeData)
default:
// For pending/snoozed, dispatch generic updated event
s.dispatchConversationEventWithData(ctx, channel.EventConversationUpdated, conversation, changeData)
}
s.indexConversation(ctx, conversation)
return conversation, nil
}
// UpdateLabelsRequest is the DTO for updating conversation labels.
type UpdateLabelsRequest struct {
Labels []string `json:"labels" validate:"required"`
}
// UpdateLabels updates the labels on a conversation.
func (s *ConversationService) UpdateLabels(ctx context.Context, accountID, id uint, labels []string) (*model.Conversation, error) {
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
if err != nil {
return nil, err
}
// Convert labels array to JSON string for storage
labelsJSON := ""
for i, label := range labels {
if i > 0 {
labelsJSON += ","
}
labelsJSON += label
}
if err := s.repo.UpdateLabels(ctx, conversation.ID, labelsJSON); err != nil {
return nil, err
}
conversation.Labels = labelsJSON
// Dispatch EventConversationLabelsUpdated
s.dispatchConversationEvent(ctx, channel.EventConversationLabelsUpdated, conversation)
s.indexConversation(ctx, conversation)
return conversation, nil
}
// Delete soft-deletes a conversation.
func (s *ConversationService) Delete(ctx context.Context, accountID, id uint) error {
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
if err != nil {
return err
}
if s.worker != nil {
_, err := s.worker.Enqueue(ctx, TaskTypeConversationDeleteObject, conversationDeleteObjectJob{AccountID: accountID, ConversationID: conversation.ID}, worker.WithQueue("low"), worker.WithMaxAttempts(3), worker.WithIdempotencyKey(fmt.Sprintf("conversation-delete:%d:%d", accountID, conversation.ID)))
return err
}
return s.deleteLoaded(ctx, conversation)
}
func (s *ConversationService) deleteNow(ctx context.Context, accountID, id uint) error {
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
if err != nil {
return err
}
return s.deleteLoaded(ctx, conversation)
}
func (s *ConversationService) deleteLoaded(ctx context.Context, conversation *model.Conversation) error {
if err := s.repo.Delete(ctx, conversation.ID); err != nil {
return err
}
// Dispatch EventConversationDeleted
s.dispatchConversationEvent(ctx, channel.EventConversationDeleted, conversation)
s.deleteConversationIndex(ctx, conversation.AccountID, conversation.ID)
return nil
}
// ListMessages retrieves messages for a conversation.
func (s *ConversationService) ListMessages(ctx context.Context, conversationID uint, offset, limit int) ([]model.Message, int64, error) {
return s.msgRepo.FindByConversation(ctx, conversationID, offset, limit)
}
// Mute mutes a conversation.
func (s *ConversationService) Mute(ctx context.Context, accountID, id uint) (*model.Conversation, error) {
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
if err != nil {
return nil, err
}
db := s.repo.DB().WithContext(ctx)
if err := db.Transaction(func(tx *gorm.DB) error {
if conversation.ContactID != 0 {
if err := tx.Model(&model.Contact{}).
Where("id = ? AND account_id = ?", conversation.ContactID, accountID).
Update("blocked", true).Error; err != nil {
return err
}
}
return tx.Model(&model.Conversation{}).
Where("id = ? AND account_id = ?", conversation.ID, accountID).
Updates(map[string]any{"status": string(model.ConversationStatusResolved), "muted": true}).Error
}); err != nil {
return nil, err
}
conversation.Status = string(model.ConversationStatusResolved)
conversation.Muted = true
// Dispatch EventConversationMuted
s.dispatchConversationEvent(ctx, channel.EventConversationMuted, conversation)
s.indexConversation(ctx, conversation)
return conversation, nil
}
// Unmute unmutes a conversation.
func (s *ConversationService) Unmute(ctx context.Context, accountID, id uint) (*model.Conversation, error) {
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
if err != nil {
return nil, err
}
db := s.repo.DB().WithContext(ctx)
if err := db.Transaction(func(tx *gorm.DB) error {
if conversation.ContactID != 0 {
if err := tx.Model(&model.Contact{}).
Where("id = ? AND account_id = ?", conversation.ContactID, accountID).
Update("blocked", false).Error; err != nil {
return err
}
}
return tx.Model(&model.Conversation{}).
Where("id = ? AND account_id = ?", conversation.ID, accountID).
Update("muted", false).Error
}); err != nil {
return nil, err
}
conversation.Muted = false
// Dispatch EventConversationUnmuted
s.dispatchConversationEvent(ctx, channel.EventConversationUnmuted, conversation)
s.indexConversation(ctx, conversation)
return conversation, nil
}
// FilterParams holds advanced filter criteria for conversations.
// Reference: Chatwoot ConversationFinder — app/finders/conversation_finder.rb
// Supports all 7 Chatwoot filter dimensions: status, assignee_type, inbox_id, team_id,
// labels, conversation_type, and sort_by (with updated_within as an extra filter).
type FilterParams struct {
Status string `json:"status,omitempty" form:"status" validate:"omitempty,oneof=open resolved pending snoozed all"`
Priority string `json:"priority,omitempty" form:"priority" validate:"omitempty,oneof=urgent high medium low none"`
AssigneeType string `json:"assignee_type,omitempty" form:"assignee_type" validate:"omitempty,oneof=me unassigned assigned all"`
AssigneeID *uint `json:"assignee_id,omitempty" form:"assignee_id"`
InboxID *uint `json:"inbox_id,omitempty" form:"inbox_id"`
InboxIDs []uint `json:"inbox_ids,omitempty" form:"inbox_ids"` // multi-inbox filtering (Chatwoot: inbox_id can be array)
TeamID *uint `json:"team_id,omitempty" form:"team_id"`
Labels string `json:"labels,omitempty" form:"labels"`
Tags string `json:"tags,omitempty" form:"tags"` // custom tags (Chatwoot: same as labels via ActsAsTaggableOn)
ConversationType string `json:"conversation_type,omitempty" form:"conversation_type" validate:"omitempty,oneof=mention participating unattended"`
SortBy string `json:"sort_by,omitempty" form:"sort_by" validate:"omitempty,oneof=last_activity_at_asc last_activity_at_desc created_at_asc created_at_desc priority_asc priority_desc waiting_since_asc waiting_since_desc priority_desc_created_at_asc latest sort_on_created_at sort_on_priority sort_on_waiting_since"`
UpdatedWithin *int `json:"updated_within,omitempty" form:"updated_within"` // seconds
Query string `json:"q,omitempty" form:"q"` // 1:1 Chatwoot: filter_by_query — search messages ILIKE
SourceID string `json:"source_id,omitempty" form:"source_id"` // 1:1 Chatwoot: filter_by_source_id — contact_inbox.source_id
Payload []ConversationFilterCondition `json:"payload,omitempty" form:"-"`
// AllowedInboxIDs is populated at runtime — the list of inbox IDs the user can access.
// Not populated from request params; set by the service based on user permissions.
AllowedInboxIDs []uint
}
type ConversationFilterCondition struct {
AttributeKey string `json:"attribute_key"`
FilterOperator string `json:"filter_operator"`
Values []any `json:"values"`
QueryOperator string `json:"query_operator,omitempty"`
CustomAttributeType string `json:"custom_attribute_type,omitempty"`
}
// FilterResult holds the paginated conversations plus meta counts.
// 1:1 Chatwoot ConversationFinder#perform response shape:
// {conversations: [...], count: {mine_count, assigned_count, unassigned_count, all_count}}
type FilterResult struct {
Conversations []model.Conversation `json:"conversations"`
Count FilterCountMeta `json:"count"`
}
type FilterCountMeta struct {
MineCount int64 `json:"mine_count"`
AssignedCount int64 `json:"assigned_count"`
UnassignedCount int64 `json:"unassigned_count"`
AllCount int64 `json:"all_count"`
}
// Filter retrieves conversations matching advanced filter criteria.
// Reference: Chatwoot ConversationFinder#perform — filters by assignee_type, status,
// team_id, labels, conversation_type, and applies sort order with pagination.
func (s *ConversationService) Filter(ctx context.Context, accountID uint, userID uint, params FilterParams, offset, limit int) (*FilterResult, error) {
if err := pkgvalidator.ValidateStruct(params); err != nil {
return nil, err
}
var conversations []model.Conversation
query := s.repo.DB().WithContext(ctx).Model(&model.Conversation{}).Where("conversations.account_id = ?", accountID)
var err error
query, err = s.applyConversationPermissionFilter(ctx, accountID, userID, query)
if err != nil {
return nil, err
}
if len(params.Payload) > 0 {
advancedQuery, err := s.applyConversationFilterPayload(ctx, query, accountID, params.Payload)
if err != nil {
return nil, err
}
query = advancedQuery
}
// 1. Filter by status
if params.Status != "" {
query = query.Where("status = ?", params.Status)
}
// 2. Filter by priority
if params.Priority != "" {
query = query.Where("priority = ?", params.Priority)
}
// 3. Filter by assignee_type (Chatwoot: me/unassigned/assigned/all)
// When AssigneeID is also set, it takes precedence for "me" type.
switch params.AssigneeType {
case "me":
query = query.Where("assignee_id = ?", userID)
case "unassigned":
query = query.Where("assignee_id IS NULL")
case "assigned":
query = query.Where("assignee_id IS NOT NULL")
case "all":
// no additional filter
default:
// If assignee_type is empty but AssigneeID is set, use the ID directly
if params.AssigneeID != nil {
if *params.AssigneeID == 0 {
query = query.Where("assignee_id IS NULL")
} else {
query = query.Where("assignee_id = ?", *params.AssigneeID)
}
}
}
// 4. Filter by inbox_id
if params.InboxID != nil {
query = query.Where("inbox_id = ?", *params.InboxID)
}
// 5. Filter by team_id
if params.TeamID != nil {
query = query.Where("team_id = ?", *params.TeamID)
}
// 6. Filter by labels — 1:1 Chatwoot: tagged_with(labels, any: true) → OR semantics
if params.Labels != "" {
labels := strings.Split(params.Labels, ",")
conditions := make([]string, len(labels))
args := make([]interface{}, len(labels))
for i, label := range labels {
conditions[i] = "labels LIKE ?"
args[i] = "%" + strings.TrimSpace(label) + "%"
}
query = query.Where(strings.Join(conditions, " OR "), args...)
}
// 7. Filter by conversation_type (Chatwoot: mention/participating/unattended)
switch params.ConversationType {
case "mention":
// Mentioned conversations — subquery on mentions table
query = query.Where("id IN (SELECT conversation_id FROM mentions WHERE user_id = ?)", userID)
case "participating":
// Participating conversations — subquery on participants table
query = query.Where("id IN (SELECT conversation_id FROM conversation_participants WHERE user_id = ?)", userID)
case "unattended":
// Unattended conversations — first_reply_at IS NULL AND assignee_id IS NULL
query = query.Where("first_reply_at IS NULL AND assignee_id IS NULL")
}
// 8. Filter by updated_within (seconds) — Chatwoot: updated_at > NOW - interval
if params.UpdatedWithin != nil {
query = query.Where("updated_at > NOW() - INTERVAL '" + fmt.Sprintf("%d", *params.UpdatedWithin) + " seconds'")
}
// 9. Filter by query (q) — 1:1 Chatwoot: filter_by_query
// Search messages.content ILIKE, restricted to incoming/outgoing message types
if params.Query != "" {
searchClause, searchTerm := conversationMessageSearchClause(query, params.Query)
query = query.Joins("JOIN messages ON messages.conversation_id = conversations.id").
Where(searchClause, searchTerm).
Where("messages.message_type IN ?", []string{string(model.MessageTypeIncoming), string(model.MessageTypeOutgoing)})
}
// 10. Filter by source_id — 1:1 Chatwoot: filter_by_source_id
// Joins contact_inbox, filters by source_id
if params.SourceID != "" {
query = query.Joins("JOIN contact_inboxes ON contact_inboxes.id = conversations.contact_inbox_id").
Where("contact_inboxes.source_id = ?", params.SourceID)
}
// 9. Sort order — Chatwoot ConversationFinder::SORT_OPTIONS
orderClause := "last_activity_at DESC" // default: last_activity_at_desc
switch params.SortBy {
case "last_activity_at_asc":
orderClause = "last_activity_at ASC"
case "last_activity_at_desc", "latest":
orderClause = "last_activity_at DESC"
case "created_at_asc", "sort_on_created_at":
orderClause = "created_at ASC"
case "created_at_desc":
orderClause = "created_at DESC"
case "priority_asc":
orderClause = "priority ASC"
case "priority_desc", "sort_on_priority":
orderClause = "priority DESC"
case "waiting_since_asc", "sort_on_waiting_since":
orderClause = "waiting_since ASC"
case "waiting_since_desc":
orderClause = "waiting_since DESC"
}
countQuery := query.Session(&gorm.Session{})
var mineCount, unassignedCount, allCount int64
if err := countQuery.Session(&gorm.Session{}).Where("assignee_id = ?", userID).Count(&mineCount).Error; err != nil {
return nil, err
}
if err := countQuery.Session(&gorm.Session{}).Where("assignee_id IS NULL").Count(&unassignedCount).Error; err != nil {
return nil, err
}
if err := countQuery.Session(&gorm.Session{}).Count(&allCount).Error; err != nil {
return nil, err
}
err = query.Offset(offset).Limit(limit).Order(orderClause).Find(&conversations).Error
if err != nil {
return nil, err
}
return &FilterResult{
Conversations: conversations,
Count: FilterCountMeta{
MineCount: mineCount,
AssignedCount: allCount - unassignedCount,
UnassignedCount: unassignedCount,
AllCount: allCount,
},
}, nil
}
func conversationMessageSearchClause(query *gorm.DB, value string) (string, string) {
term := "%" + value + "%"
if query != nil && query.Dialector != nil && query.Dialector.Name() != "postgres" {
return "LOWER(messages.content) LIKE ?", "%" + strings.ToLower(value) + "%"
}
return "messages.content ILIKE ?", term
}
func (s *ConversationService) applyConversationPermissionFilter(ctx context.Context, accountID, userID uint, query *gorm.DB) (*gorm.DB, error) {
if userID == 0 {
return query, nil
}
var accountUser model.AccountUser
err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return query.Where("1 = 0"), nil
}
if err != nil {
return query, err
}
if accountUser.IsAdministrator() {
return query, nil
}
inboxIDs, err := s.visibleUnreadCountInboxIDs(ctx, accountID, userID)
if err != nil {
return query, err
}
if len(inboxIDs) == 0 {
return query.Where("1 = 0"), nil
}
return query.Where("conversations.inbox_id IN ?", inboxIDs), nil
}
func (s *ConversationService) applyConversationFilterPayload(ctx context.Context, query *gorm.DB, accountID uint, payload []ConversationFilterCondition) (*gorm.DB, error) {
clauses := make([]string, 0, len(payload)*2)
args := make([]any, 0, len(payload))
for _, condition := range payload {
queryOperator := strings.ToUpper(strings.TrimSpace(condition.QueryOperator))
if queryOperator != "" && queryOperator != "AND" && queryOperator != "OR" {
return nil, fmt.Errorf("Query operator must be either \"AND\" or \"OR\".")
}
clause, clauseArgs, err := s.conversationFilterClause(ctx, query, accountID, condition)
if err != nil {
return nil, err
}
if len(clauses) > 0 && !conversationFilterIsQueryOperator(clauses[len(clauses)-1]) {
clauses = append(clauses, "AND")
}
clauses = append(clauses, clause)
args = append(args, clauseArgs...)
if queryOperator != "" {
clauses = append(clauses, queryOperator)
}
}
if len(clauses) == 0 {
return query, nil
}
last := clauses[len(clauses)-1]
if conversationFilterIsQueryOperator(last) {
clauses = clauses[:len(clauses)-1]
}
query = query.Where(strings.Join(clauses, " "), args...)
return query, nil
}
func (s *ConversationService) conversationFilterClause(ctx context.Context, query *gorm.DB, accountID uint, condition ConversationFilterCondition) (string, []any, error) {
attribute := strings.TrimSpace(condition.AttributeKey)
operator := strings.TrimSpace(condition.FilterOperator)
values := conversationFilterStringValues(condition.Values)
if attribute == "labels" {
return conversationFilterLabelsClause(accountID, operator, values)
}
if attribute == "created_at" || attribute == "last_activity_at" {
return conversationFilterDateClause(attribute, operator, values)
}
column, allowedOperators, err := conversationFilterColumn(attribute)
if err == nil {
return conversationFilterBuildClause(attribute, column, nil, operator, values, allowedOperators, false)
}
if additional, ok := conversationFilterAdditionalAttribute(attribute); ok {
expr, exprArgs := conversationFilterJSONExtract(query, "conversations.additional_attributes", attribute)
return conversationFilterBuildClause(attribute, expr, exprArgs, operator, values, additional.allowedOperators, false)
}
def, defErr := s.findConversationFilterCustomAttributeDefinition(ctx, accountID, attribute, condition.CustomAttributeType)
if defErr != nil {
return "", nil, defErr
}
if def == nil {
return "", nil, fmt.Errorf("Invalid attribute key - [%s]", attribute)
}
expr, exprArgs := conversationFilterJSONExtract(query, "conversations.custom_attributes", attribute)
expr = conversationFilterCustomAttributeExpression(expr, def.AttributeType)
return conversationFilterBuildClause(attribute, expr, exprArgs, operator, conversationFilterCustomAttributeValues(values, def.AttributeType), conversationFilterCustomAttributeOperators(def.AttributeType), true)
}
func conversationFilterIsQueryOperator(value string) bool {
return value == "AND" || value == "OR"
}
func conversationFilterColumn(attribute string) (string, []string, error) {
switch attribute {
case "status":
return "conversations.status", []string{"equal_to", "not_equal_to"}, nil
case "priority":
return "conversations.priority", []string{"equal_to", "not_equal_to"}, nil
case "assignee_id":
return "conversations.assignee_id", []string{"equal_to", "not_equal_to", "is_present", "is_not_present"}, nil
case "inbox_id":
return "conversations.inbox_id", []string{"equal_to", "not_equal_to", "is_present", "is_not_present"}, nil
case "team_id":
return "conversations.team_id", []string{"equal_to", "not_equal_to", "is_present", "is_not_present"}, nil
case "display_id":
return "CAST(conversations.display_id AS TEXT)", []string{"equal_to", "not_equal_to", "contains", "does_not_contain"}, nil
case "campaign_id":
return "conversations.campaign_id", []string{"equal_to", "not_equal_to", "is_present", "is_not_present"}, nil
default:
return "", nil, fmt.Errorf("Invalid attribute key - [%s]", attribute)
}
}
func conversationFilterLabelsClause(accountID uint, operator string, values []string) (string, []any, error) {
allowedOperators := []string{"equal_to", "not_equal_to", "is_present", "is_not_present"}
if !conversationFilterOperatorAllowed(operator, allowedOperators) {
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for labels are [%s].", strings.Join(allowedOperators, ","))
}
if operator != "is_present" && operator != "is_not_present" && len(values) == 0 {
return "", nil, fmt.Errorf("Invalid value for labels")
}
base := "SELECT 1 FROM conversation_labels JOIN tags ON tags.id = conversation_labels.tag_id WHERE conversation_labels.conversation_id = conversations.id AND conversation_labels.account_id = ? AND tags.account_id = ?"
args := []any{accountID, accountID}
switch operator {
case "equal_to":
args = append(args, values)
return "EXISTS (" + base + " AND tags.name IN ?)", args, nil
case "not_equal_to":
args = append(args, values)
return "NOT EXISTS (" + base + " AND tags.name IN ?)", args, nil
case "is_present":
return "EXISTS (" + base + ")", args, nil
case "is_not_present":
return "NOT EXISTS (" + base + ")", args, nil
default:
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for labels are [%s].", strings.Join(allowedOperators, ","))
}
}
func conversationFilterDateClause(attribute, operator string, values []string) (string, []any, error) {
allowedOperators := []string{"is_greater_than", "is_less_than", "days_before"}
if !conversationFilterOperatorAllowed(operator, allowedOperators) {
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for %s are [%s].", attribute, strings.Join(allowedOperators, ","))
}
if len(values) == 0 {
return "", nil, fmt.Errorf("Invalid value for %s", attribute)
}
value, err := conversationFilterDateValue(attribute, operator, values[0])
if err != nil {
return "", nil, fmt.Errorf("Invalid value for %s", attribute)
}
comparison := ">"
if operator == "is_less_than" || operator == "days_before" {
comparison = "<"
}
if attribute == "last_activity_at" {
return "conversations.last_activity_at " + comparison + " ?", []any{value.Unix()}, nil
}
return "conversations.created_at " + comparison + " ?", []any{value}, nil
}
func conversationFilterDateValue(attribute, operator, value string) (time.Time, error) {
if operator == "days_before" {
days, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return time.Time{}, err
}
today := time.Now().UTC().Truncate(24 * time.Hour)
return today.AddDate(0, 0, -days), nil
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed, nil
}
if parsed, err := time.Parse("2006-01-02", value); err == nil {
return parsed, nil
}
if attribute == "last_activity_at" {
seconds, err := strconv.ParseInt(value, 10, 64)
if err == nil {
return time.Unix(seconds, 0).UTC(), nil
}
}
return time.Time{}, fmt.Errorf("invalid date")
}
type conversationAdditionalFilter struct {
allowedOperators []string
}
func conversationFilterAdditionalAttribute(attribute string) (conversationAdditionalFilter, bool) {
switch attribute {
case "browser_language", "conversation_language":
return conversationAdditionalFilter{allowedOperators: []string{"equal_to", "not_equal_to"}}, true
case "referer", "mail_subject":
return conversationAdditionalFilter{allowedOperators: []string{"equal_to", "not_equal_to", "contains", "does_not_contain"}}, true
default:
return conversationAdditionalFilter{}, false
}
}
func (s *ConversationService) findConversationFilterCustomAttributeDefinition(ctx context.Context, accountID uint, attribute, customAttributeType string) (*model.CustomAttributeDefinition, error) {
attributeModel := strings.TrimSpace(customAttributeType)
if attributeModel == "" {
attributeModel = "conversation_attribute"
}
if attributeModel == "conversation" {
attributeModel = "conversation_attribute"
}
if attributeModel != "conversation_attribute" {
return nil, fmt.Errorf("Invalid attribute key - [%s]", attribute)
}
var def model.CustomAttributeDefinition
err := s.repo.DB().WithContext(ctx).
Where("account_id = ? AND attribute_name = ? AND attribute_model IN ?", accountID, attribute, []string{"conversation_attribute", "conversation"}).
First(&def).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &def, nil
}
func conversationFilterBuildClause(attribute, expression string, expressionArgs []any, operator string, values []string, allowedOperators []string, includeNullForNotEqual bool) (string, []any, error) {
if !conversationFilterOperatorAllowed(operator, allowedOperators) {
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for %s are [%s].", attribute, strings.Join(allowedOperators, ","))
}
if operator == "is_present" {
return expression + " IS NOT NULL", append([]any{}, expressionArgs...), nil
}
if operator == "is_not_present" {
return expression + " IS NULL", append([]any{}, expressionArgs...), nil
}
if len(values) == 0 {
return "", nil, fmt.Errorf("Invalid value for %s", attribute)
}
args := append([]any{}, expressionArgs...)
switch operator {
case "equal_to":
args = append(args, values)
return expression + " IN ?", args, nil
case "not_equal_to":
args = append(args, values)
if includeNullForNotEqual {
args = append(args, expressionArgs...)
return "(" + expression + " NOT IN ? OR " + expression + " IS NULL)", args, nil
}
return expression + " NOT IN ?", args, nil
case "contains":
clause, likeArgs := conversationFilterLikeClause(expression, expressionArgs, values, false)
return clause, likeArgs, nil
case "does_not_contain":
clause, likeArgs := conversationFilterLikeClause(expression, expressionArgs, values, true)
return clause, likeArgs, nil
default:
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for %s are [%s].", attribute, strings.Join(allowedOperators, ","))
}
}
func conversationFilterLikeClause(expression string, expressionArgs []any, values []string, negate bool) (string, []any) {
clauses := make([]string, 0, len(values))
args := make([]any, 0, len(values)*(len(expressionArgs)+1))
operator := "LIKE"
joiner := " OR "
if negate {
operator = "NOT LIKE"
joiner = " AND "
}
for _, value := range values {
clauses = append(clauses, "LOWER("+expression+") "+operator+" ?")
args = append(args, expressionArgs...)
args = append(args, "%"+strings.ToLower(value)+"%")
}
return "(" + strings.Join(clauses, joiner) + ")", args
}
func conversationFilterJSONExtract(query *gorm.DB, column, key string) (string, []any) {
dialect := ""
if query != nil && query.Dialector != nil {
dialect = query.Dialector.Name()
}
switch dialect {
case "sqlite":
return "json_extract(" + column + ", ?)", []any{"$." + key}
case "mysql":
return "JSON_UNQUOTE(JSON_EXTRACT(" + column + ", ?))", []any{"$." + key}
default:
return column + " ->> ?", []any{key}
}
}
func conversationFilterCustomAttributeOperators(attributeType string) []string {
switch strings.TrimSpace(attributeType) {
case "text", "link", "list", "":
return []string{"equal_to", "not_equal_to", "contains", "does_not_contain"}
default:
return []string{"equal_to", "not_equal_to"}
}
}
func conversationFilterCustomAttributeValues(values []string, attributeType string) []string {
switch strings.TrimSpace(attributeType) {
case "text", "link", "list", "":
lowered := make([]string, 0, len(values))
for _, value := range values {
lowered = append(lowered, strings.ToLower(value))
}
return lowered
default:
return values
}
}
func conversationFilterCustomAttributeExpression(expression, attributeType string) string {
switch strings.TrimSpace(attributeType) {
case "text", "link", "list", "":
return "LOWER(" + expression + ")"
default:
return expression
}
}
func conversationFilterOperatorAllowed(operator string, allowed []string) bool {
for _, value := range allowed {
if operator == value {
return true
}
}
return false
}
func conversationFilterStringValues(values []any) []string {
result := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(fmt.Sprintf("%v", value))
if text != "" {
result = append(result, text)
}
}
return result
}
// ListWithFinder retrieves conversations for the Chatwoot index/search finder contract.
// Reference: Chatwoot ConversationFinder#perform.
func (s *ConversationService) ListWithFinder(ctx context.Context, accountID, userID uint, params FilterParams, offset, limit int) (*FilterResult, error) {
if err := pkgvalidator.ValidateStruct(params); err != nil {
return nil, err
}
metaParams, err := s.conversationMetaParamsForUser(ctx, accountID, userID, params)
if err != nil {
return nil, err
}
meta, err := s.repo.GetMeta(ctx, accountID, userID, metaParams)
if err != nil {
return nil, err
}
conversations, err := s.repo.ListForFinder(ctx, accountID, userID, repository.ConversationFinderListParams{
ConversationMetaParams: metaParams,
AssigneeType: params.AssigneeType,
SortBy: params.SortBy,
UpdatedWithin: params.UpdatedWithin,
}, offset, limit)
if err != nil {
return nil, err
}
return &FilterResult{
Conversations: conversations,
Count: FilterCountMeta{
MineCount: meta.MineCount,
AssignedCount: meta.AssignedCount,
UnassignedCount: meta.UnassignedCount,
AllCount: meta.AllCount,
},
}, nil
}
// Search searches conversations by label or metadata within an account.
func (s *ConversationService) Search(ctx context.Context, accountID uint, query string, offset, limit int, searchMode search.SearchMode) ([]model.Conversation, int64, error) {
return s.repo.Search(ctx, accountID, query, offset, limit, searchMode)
}
// UpdatePriority updates the priority of a conversation.
func (s *ConversationService) UpdatePriority(ctx context.Context, accountID, id uint, priority string) (*model.Conversation, error) {
if priority == "" {
priority = "none"
}
newPriority := model.ConversationPriority(priority)
if newPriority != model.ConversationPriorityUrgent &&
newPriority != model.ConversationPriorityHigh &&
newPriority != model.ConversationPriorityMedium &&
newPriority != model.ConversationPriorityLow &&
priority != "none" {
return nil, errors.New("invalid priority value")
}
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
if err != nil {
return nil, err
}
oldPriority := conversation.Priority
conversation.Priority = string(newPriority)
if err := s.repo.Update(ctx, conversation); err != nil {
return nil, err
}
changes := changedAttributes(map[string][2]interface{}{"priority": {oldPriority, conversation.Priority}})
changeData := eventDataWithChanges(changes)
// Dispatch Chatwoot automation-visible update plus local priority event.
s.dispatchConversationEventWithData(ctx, channel.EventConversationUpdated, conversation, changeData)
s.dispatchConversationEventWithData(ctx, channel.EventConversationPriorityUpdated, conversation, changeData)
s.indexConversation(ctx, conversation)
return conversation, nil
}
// GetMeta retrieves Chatwoot conversation sidebar counts for an account.
// Reference: ConversationFinder#perform_meta_only and conversations/meta.json.jbuilder.
func (s *ConversationService) GetMeta(ctx context.Context, accountID, userID uint, params FilterParams) (*repository.ConversationMeta, error) {
metaParams, err := s.conversationMetaParamsForUser(ctx, accountID, userID, params)
if err != nil {
return nil, err
}
return s.repo.GetMeta(ctx, accountID, userID, metaParams)
}
func (s *ConversationService) conversationMetaParamsForUser(ctx context.Context, accountID, userID uint, params FilterParams) (repository.ConversationMetaParams, error) {
metaParams := repository.ConversationMetaParams{
Status: params.Status,
InboxID: params.InboxID,
TeamID: params.TeamID,
Labels: splitConversationMetaLabels(params.Labels),
ConversationType: params.ConversationType,
Query: params.Query,
SourceID: params.SourceID,
}
if userID != 0 {
var accountUser model.AccountUser
err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
metaParams.RestrictToInboxes = true
return metaParams, nil
}
if err != nil {
return metaParams, err
}
if !accountUser.IsAdministrator() {
ids, err := s.visibleUnreadCountInboxIDs(ctx, accountID, userID)
if err != nil {
return metaParams, err
}
metaParams.RestrictToInboxes = true
metaParams.RestrictedInboxIDs = ids
}
}
return metaParams, nil
}
func splitConversationMetaLabels(labels string) []string {
if strings.TrimSpace(labels) == "" {
return nil
}
parts := strings.Split(labels, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
// MarkUnread marks a conversation as unread by setting agent_last_seen_at to
// (last_incoming_message.created_at - 1 second), matching Chatwoot behavior.
// If no incoming messages exist, agent_last_seen_at is set to nil.
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb#unread
func (s *ConversationService) MarkUnread(ctx context.Context, accountID, id uint) (*model.Conversation, error) {
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
if err != nil {
return nil, err
}
// Find the last incoming message for this conversation
lastIncomingMsg, err := s.msgRepo.FindLastIncomingByConversation(ctx, id)
if err != nil {
// No incoming message found; clear both last-seen columns like Chatwoot's update_last_seen_on_conversation(nil, true).
if err := s.updateLastSeenColumns(ctx, accountID, id, nil, true); err != nil {
return nil, err
}
} else {
// Set both last-seen columns to last_incoming_message.CreatedAt - 1 second.
lastSeenAt := lastIncomingMsg.CreatedAt.Add(-1 * time.Second).Unix()
if err := s.updateLastSeenColumns(ctx, accountID, id, &lastSeenAt, true); err != nil {
return nil, err
}
}
// Re-fetch to get updated state
conversation, err = s.repo.FindByAccountAndID(ctx, accountID, id)
if err != nil {
return nil, err
}
s.indexConversation(ctx, conversation)
return conversation, nil
}
// SendTranscript sends a conversation transcript via email.
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb#transcript
func (s *ConversationService) SendTranscript(ctx context.Context, accountID, conversationID uint, email string) error {
recipient := strings.TrimSpace(email)
if recipient == "" {
return errors.New("email address is required for transcript")
}
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, conversationID)
if err != nil {
return err
}
var account model.Account
if err := s.repo.DB().WithContext(ctx).First(&account, accountID).Error; err != nil {
return err
}
now := time.Now()
if !account.EmailTranscriptEnabled() {
return ErrEmailTranscriptDisabled
}
if limit := account.EmailRateLimit(); limit > 0 && account.EmailsSentToday(now) >= limit {
return ErrEmailRateLimited
}
subject, body, err := s.buildTranscriptEmail(ctx, accountID, conversation)
if err != nil {
return err
}
if s.transcriptMailer != nil {
_, err = s.transcriptMailer.DeliverTranscript(ctx, automation.AutomationTranscriptRequest{
AccountID: accountID,
ConversationID: conversation.ID,
Recipient: recipient,
Subject: subject,
Body: body,
})
if err != nil {
return err
}
}
if err := account.IncrementEmailSentCount(now); err != nil {
return err
}
if err := s.repo.DB().WithContext(ctx).Model(&model.Account{}).Where("id = ?", account.ID).Update("custom_attributes", account.CustomAttributes).Error; err != nil {
return err
}
applogger.L().Infof("Transcript request: conversation=%d, account=%d, email=%s, contact=%d",
conversation.ID, conversation.AccountID, recipient, conversation.ContactID)
return nil
}
func (s *ConversationService) buildTranscriptEmail(ctx context.Context, accountID uint, conversation *model.Conversation) (string, string, error) {
var messages []model.Message
if err := s.repo.DB().WithContext(ctx).
Where("conversation_id = ? AND account_id = ? AND private = ? AND message_type IN ?", conversation.ID, accountID, false, []string{string(model.MessageTypeIncoming), string(model.MessageTypeOutgoing)}).
Order("id ASC").
Find(&messages).Error; err != nil {
return "", "", err
}
displayID := conversation.ID
if conversation.DisplayID != nil && *conversation.DisplayID > 0 {
displayID = *conversation.DisplayID
}
subject := fmt.Sprintf("[#%d] Conversation Transcript", displayID)
var body strings.Builder
body.WriteString(fmt.Sprintf("Conversation #%d transcript\n\n", displayID))
for _, message := range messages {
if strings.TrimSpace(message.Content) == "" {
continue
}
body.WriteString(fmt.Sprintf("[%s] %s\n", message.MessageType, message.Content))
}
return subject, body.String(), nil
}
// UpdateCustomAttributes updates the custom attributes of a conversation.
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb#custom_attributes
func (s *ConversationService) UpdateCustomAttributes(ctx context.Context, accountID, id uint, attrs datatypes.JSON) (*model.Conversation, error) {
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id)
if err != nil {
return nil, err
}
if err := s.repo.UpdateCustomAttributes(ctx, id, attrs); err != nil {
return nil, err
}
// Re-fetch to get updated state
conversation, err = s.repo.FindByAccountAndID(ctx, accountID, id)
if err != nil {
return nil, err
}
s.indexConversation(ctx, conversation)
return conversation, nil
}
// UnreadCountsPayload holds the unread conversation counts payload response.
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb
type UnreadCountsPayload struct {
Inboxes map[uint]int64 `json:"inboxes"`
Labels map[uint]int64 `json:"labels"`
Teams map[uint]int64 `json:"teams"`
}
var ErrConversationUnreadCountsFeatureNotEnabled = errors.New("Conversation unread counts feature not enabled for this account")
var ErrEmailTranscriptDisabled = errors.New("Email transcript is not available on your plan")
var ErrEmailRateLimited = errors.New("email transcript rate limit exceeded")
// GetUnreadCounts returns unread conversation counts grouped by inbox, label, and team.
// Reference: Chatwoot app/services/conversations/unread_counts/counter.rb
func (s *ConversationService) GetUnreadCounts(ctx context.Context, accountID, userID uint) (*UnreadCountsPayload, error) {
if s == nil || s.repo == nil || s.repo.DB() == nil {
return nil, errors.New("conversation service not ready")
}
var account model.Account
if err := s.repo.DB().WithContext(ctx).Select("id", "feature_flags").First(&account, accountID).Error; err != nil {
return nil, err
}
if !featureFlagStringEnabled(account.FeatureFlags, "conversation_unread_counts") {
return nil, ErrConversationUnreadCountsFeatureNotEnabled
}
permissionMode, err := s.unreadCountsPermissionMode(ctx, accountID, userID)
if err != nil {
return nil, err
}
if permissionMode == "none" {
return emptyUnreadCountsPayload(), nil
}
inboxIDs, err := s.visibleUnreadCountInboxIDs(ctx, accountID, userID)
if err != nil {
return nil, err
}
teamIDs, err := s.visibleUnreadCountTeamIDs(ctx, accountID, userID)
if err != nil {
return nil, err
}
inboxCounts, err := s.repo.GetUnreadCountsByInbox(ctx, accountID, inboxIDs, permissionMode, userID)
if err != nil {
return nil, err
}
teamCounts, err := s.repo.GetUnreadCountsByTeam(ctx, accountID, inboxIDs, teamIDs, permissionMode, userID)
if err != nil {
return nil, err
}
labelCounts, err := s.repo.GetUnreadCountsByLabel(ctx, accountID, inboxIDs, permissionMode, userID)
if err != nil {
return nil, err
}
inboxes := make(map[uint]int64)
for _, r := range inboxCounts {
inboxes[r.Key] = r.Count
}
teams := make(map[uint]int64)
for _, r := range teamCounts {
teams[r.Key] = r.Count
}
labels := make(map[uint]int64)
for _, r := range labelCounts {
labels[r.Key] = r.Count
}
return &UnreadCountsPayload{
Inboxes: inboxes,
Labels: labels,
Teams: teams,
}, nil
}
func emptyUnreadCountsPayload() *UnreadCountsPayload {
return &UnreadCountsPayload{Inboxes: map[uint]int64{}, Labels: map[uint]int64{}, Teams: map[uint]int64{}}
}
func (s *ConversationService) unreadCountsPermissionMode(ctx context.Context, accountID, userID uint) (string, error) {
if userID == 0 {
return "base", nil
}
var accountUser model.AccountUser
err := s.repo.DB().WithContext(ctx).Preload("CustomRole").Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "none", nil
}
if err != nil {
return "", err
}
if !accountUser.IsAgent() || !accountUser.HasCustomRole() {
return "base", nil
}
if accountUser.CustomRole == nil {
return "none", nil
}
permissions, err := accountUser.CustomRole.GetPermissionKeys()
if err != nil {
return "", err
}
if hasUnreadCountPermission(permissions, model.DimensionConversationManage) {
return "base", nil
}
if hasUnreadCountPermission(permissions, model.DimensionConversationUnassignedManage) {
return "unassigned_and_mine", nil
}
if hasUnreadCountPermission(permissions, model.DimensionConversationParticipatingManage) {
return "mine", nil
}
return "none", nil
}
func hasUnreadCountPermission(permissions []model.PermissionDimension, permission model.PermissionDimension) bool {
for _, item := range permissions {
if item == permission {
return true
}
}
return false
}
func (s *ConversationService) visibleUnreadCountInboxIDs(ctx context.Context, accountID, userID uint) ([]uint, error) {
var ids []uint
query := s.repo.DB().WithContext(ctx).Model(&model.Inbox{}).Where("account_id = ?", accountID)
if userID != 0 {
var accountUser model.AccountUser
err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return ids, nil
}
if err != nil {
return nil, err
}
if !accountUser.IsAdministrator() {
query = query.Joins("INNER JOIN inbox_members ON inbox_members.inbox_id = inboxes.id").Where("inbox_members.user_id = ?", userID)
}
}
if err := query.Pluck("inboxes.id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
func (s *ConversationService) visibleUnreadCountTeamIDs(ctx context.Context, accountID, userID uint) ([]uint, error) {
var ids []uint
query := s.repo.DB().WithContext(ctx).Model(&model.Team{}).Where("account_id = ?", accountID)
if userID != 0 {
var accountUser model.AccountUser
err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return ids, nil
}
if err != nil {
return nil, err
}
if !accountUser.IsAdministrator() {
query = query.Joins("INNER JOIN team_members ON team_members.team_id = teams.id").Where("team_members.user_id = ?", userID)
}
}
if err := query.Pluck("teams.id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// ToggleTyping broadcasts a typing status event for an agent in a conversation.
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations_controller.rb #toggle_typing_status
func (s *ConversationService) ToggleTyping(ctx context.Context, accountID, conversationID, userID uint, typingStatus string, isPrivate bool) error {
conversation, err := s.GetByAccountAndID(ctx, accountID, conversationID)
if err != nil {
return err
}
eventType, ok := conversationTypingEventType(typingStatus)
if !ok {
return nil
}
event := channel.NewChannelEvent(eventType, channel.ChannelAPI, accountID, conversation.InboxID)
event.ConversationID = conversationID
event.ContactID = conversation.ContactID
event.UserID = userID
event.Data["typing_status"] = typingStatus
event.Data["is_private"] = isPrivate
s.dispatcher.Dispatch(ctx, event)
return nil
}
func conversationTypingEventType(status string) (channel.EventType, bool) {
switch strings.TrimSpace(status) {
case "on", "typing_on":
return channel.EventConversationTypingOn, true
case "off", "typing_off":
return channel.EventConversationTypingOff, true
default:
return "", false
}
}
// UpdateLastSeen marks the current user as having viewed a conversation.
// Reference: Chatwoot conversations_controller.rb #update_last_seen
func (s *ConversationService) UpdateLastSeen(ctx context.Context, accountID, conversationID, userID uint) error {
conversation, err := s.GetByAccountAndID(ctx, accountID, conversationID)
if err != nil {
return err
}
if userID != 0 {
now := time.Now()
if err := s.repo.DB().WithContext(ctx).Model(&model.Notification{}).
Where("user_id = ? AND account_id = ? AND primary_actor_type = ? AND primary_actor_id = ? AND read_at IS NULL", userID, accountID, "Conversation", conversation.ID).
Update("read_at", now).Error; err != nil {
return err
}
}
isAssignee := userID != 0 && conversation.AssigneeID != nil && *conversation.AssigneeID == userID
nowTS := time.Now().Unix()
if isAssignee {
hasUnread, err := s.hasMessagesSince(ctx, conversation, conversation.AssigneeLastSeenAt)
if err != nil {
return err
}
if hasUnread {
return s.updateLastSeenColumns(ctx, accountID, conversation.ID, &nowTS, true)
}
} else {
hasUnread, err := s.hasMessagesSince(ctx, conversation, conversation.AgentLastSeenAt)
if err != nil {
return err
}
if hasUnread {
return s.updateLastSeenColumns(ctx, accountID, conversation.ID, &nowTS, false)
}
}
if !shouldUpdateConversationLastSeen(conversation, isAssignee, nowTS) {
return nil
}
return s.updateLastSeenColumns(ctx, accountID, conversation.ID, &nowTS, isAssignee)
}
func (s *ConversationService) hasMessagesSince(ctx context.Context, conversation *model.Conversation, seenAt *int64) (bool, error) {
query := s.repo.DB().WithContext(ctx).Model(&model.Message{}).
Where("account_id = ? AND conversation_id = ?", conversation.AccountID, conversation.ID)
if seenAt != nil {
query = query.Where("created_at > ?", time.Unix(*seenAt, 0))
}
var count int64
if err := query.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func shouldUpdateConversationLastSeen(conversation *model.Conversation, isAssignee bool, nowTS int64) bool {
agentNeedsUpdate := conversation.AgentLastSeenAt == nil || *conversation.AgentLastSeenAt < nowTS-int64(time.Hour/time.Second)
if !isAssignee {
return agentNeedsUpdate
}
assigneeNeedsUpdate := conversation.AssigneeLastSeenAt == nil || *conversation.AssigneeLastSeenAt < nowTS-int64(time.Hour/time.Second)
return agentNeedsUpdate || assigneeNeedsUpdate
}
func (s *ConversationService) updateLastSeenColumns(ctx context.Context, accountID, conversationID uint, lastSeenAt *int64, updateAssignee bool) error {
updates := map[string]any{"agent_last_seen_at": lastSeenAt}
if updateAssignee {
updates["assignee_last_seen_at"] = lastSeenAt
}
return s.repo.DB().WithContext(ctx).Model(&model.Conversation{}).
Where("id = ? AND account_id = ?", conversationID, accountID).
UpdateColumns(updates).Error
}
// AssignTeam assigns a team (and optionally a specific agent) to a conversation.
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations/assignments_controller.rb
func (s *ConversationService) AssignTeam(ctx context.Context, accountID, conversationID uint, agentID *uint, teamID *uint) (*model.Conversation, error) {
conversation, err := s.GetByAccountAndID(ctx, accountID, conversationID)
if err != nil {
return nil, err
}
// === Team validation ===
// Reference: Chatwoot AssignmentsController#set_team — validates team belongs to account
if teamID != nil && *teamID != 0 {
if s.teamRepo != nil {
team, err := s.teamRepo.FindByIDAndAccount(ctx, *teamID, accountID)
if err != nil || team == nil {
return nil, errors.New("team not found in this account")
}
// Check team auto-assignment eligibility
if !team.AllowAutoAssignment && agentID == nil {
return nil, errors.New("team does not allow auto-assignment")
}
}
conversation.TeamID = teamID
}
// === Agent authorization ===
// Reference: Chatwoot AssignmentService — validates assignee is account member + inbox member
if agentID != nil && *agentID != 0 {
// Role check: must be agent/admin in this account
if s.accountUserRepo != nil {
isMember, err := s.accountUserRepo.IsAgentOrAdmin(ctx, accountID, *agentID)
if err != nil {
return nil, fmt.Errorf("failed to check assignee role: %w", err)
}
if !isMember {
return nil, errors.New("assignee is not an agent or administrator in this account")
}
}
// Inbox membership check
if s.inboxMemberSvc != nil && !s.inboxMemberSvc.IsMemberOfInbox(ctx, conversation.InboxID, *agentID) {
return nil, errors.New("assignee is not a member of the conversation's inbox")
}
if err := s.ensureAssigneeHasInboxCapacity(ctx, accountID, conversation, *agentID); err != nil {
return nil, err
}
conversation.AssigneeID = agentID
}
// === Team overflow logic ===
// Reference: Chatwoot CapacityService — when all team members are offline/busy,
// fall back to account-level online agents
if teamID != nil && *teamID != 0 && (agentID == nil || *agentID == 0) {
if s.teamMemberRepo != nil {
teamMembers, err := s.teamMemberRepo.FindByTeam(ctx, *teamID)
if err != nil {
applogger.L().Errorf("failed to get team members for overflow check: %v", err)
} else {
// Check if any team member is online
onlineMemberFound := false
for _, tm := range teamMembers {
if tm.AvailabilityStatus == "online" {
onlineMemberFound = true
break
}
}
if !onlineMemberFound && len(teamMembers) > 0 {
// Overflow: all team members offline — fall back to account online agents
applogger.L().Infof("team %d has no online members, falling back to account %d online agents", *teamID, accountID)
if s.accountUserRepo != nil {
onlineAgents, err := s.accountUserRepo.FindOnlineAgentsByAccount(ctx, accountID)
if err != nil {
applogger.L().Errorf("failed to find online agents for overflow: %v", err)
} else if len(onlineAgents) > 0 {
// Assign first available online agent as overflow
for _, onlineAgent := range onlineAgents {
fallbackID := onlineAgent.UserID
if err := s.ensureAssigneeHasInboxCapacity(ctx, accountID, conversation, fallbackID); err != nil {
applogger.L().Infof("overflow skipped agent %d due to capacity: %v", fallbackID, err)
continue
}
conversation.AssigneeID = &fallbackID
applogger.L().Infof("overflow assigned agent %d from account %d online pool", fallbackID, accountID)
break
}
}
}
}
}
}
}
if err := s.repo.Update(ctx, conversation); err != nil {
return nil, err
}
s.dispatchConversationEvent(ctx, channel.EventConversationAssigned, conversation)
s.indexConversation(ctx, conversation)
return conversation, nil
}
func (s *ConversationService) ensureAssigneeHasInboxCapacity(ctx context.Context, accountID uint, conversation *model.Conversation, assigneeID uint) error {
if conversation == nil || conversation.AssigneeID != nil && *conversation.AssigneeID == assigneeID {
return nil
}
db := s.DB()
if db == nil {
return nil
}
var accountUser model.AccountUser
if err := db.WithContext(ctx).
Where("account_id = ? AND user_id = ?", accountID, assigneeID).
First(&accountUser).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("assignee is not an agent or administrator in this account")
}
return fmt.Errorf("load assignee capacity policy: %w", err)
}
if accountUser.AgentCapacityPolicyID == nil {
return nil
}
var limit model.InboxCapacityLimit
if err := db.WithContext(ctx).
Where("agent_capacity_policy_id = ? AND inbox_id = ?", *accountUser.AgentCapacityPolicyID, conversation.InboxID).
First(&limit).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return fmt.Errorf("load inbox capacity limit: %w", err)
}
q := db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ? AND inbox_id = ? AND assignee_id = ? AND status = ?", accountID, conversation.InboxID, assigneeID, model.ConversationStatusOpen).
Where("id <> ?", conversation.ID)
var assignedOpenCount int64
if err := q.Count(&assignedOpenCount).Error; err != nil {
return fmt.Errorf("count assigned open conversations: %w", err)
}
if assignedOpenCount >= int64(limit.ConversationLimit) {
return fmt.Errorf("assignee has reached capacity for this inbox")
}
return nil
}