package service import ( "context" "errors" "fmt" "strconv" "strings" "time" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/search" 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 } // 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} } func (s *ConversationService) SetSearchIndexer(indexer SearchIndexer) { s.searchIndexer = indexer } func (s *ConversationService) SetAppliedSlaService(appliedSlaSvc *AppliedSlaService) { s.appliedSlaSvc = appliedSlaSvc } 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:"required,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 } newStatus := model.ConversationStatus(req.Status) oldStatus := model.ConversationStatus(conversation.Status) // 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 err := s.repo.Delete(ctx, conversation.ID); err != nil { return err } // Dispatch EventConversationDeleted s.dispatchConversationEvent(ctx, channel.EventConversationDeleted, conversation) s.deleteConversationIndex(ctx, 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 } // Mark conversation as muted via labels or a dedicated field // For now we use a "muted" label convention if conversation.Labels != "" && !strings.Contains(conversation.Labels, "muted") { conversation.Labels += ",muted" } else if conversation.Labels == "" { conversation.Labels = "muted" } if err := s.repo.UpdateLabels(ctx, conversation.ID, conversation.Labels); err != nil { return nil, err } // 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 } // Remove muted label if strings.Contains(conversation.Labels, "muted") { labels := strings.Split(conversation.Labels, ",") filtered := make([]string, 0, len(labels)) for _, l := range labels { if l != "muted" { filtered = append(filtered, l) } } conversation.Labels = strings.Join(filtered, ",") if err := s.repo.UpdateLabels(ctx, conversation.ID, conversation.Labels); err != nil { return nil, err } } // 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 — set agent_last_seen_at to nil if err := s.repo.MarkUnread(ctx, id); err != nil { return nil, err } } else { // Set agent_last_seen_at to last_incoming_message.CreatedAt - 1 second lastSeenAt := lastIncomingMsg.CreatedAt.Add(-1 * time.Second) if err := s.repo.UpdateAgentLastSeenAt(ctx, id, lastSeenAt); 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 { conversation, err := s.repo.FindByAccountAndID(ctx, accountID, conversationID) if err != nil { return err } // In a full implementation, this would generate the transcript and send via an email service. // For now, we log the transcript request for future integration. applogger.L().Infof("Transcript request: conversation=%d, account=%d, email=%s, contact=%d", conversation.ID, conversation.AccountID, email, conversation.ContactID) // Validate email is not empty if strings.TrimSpace(email) == "" { return errors.New("email address is required for transcript") } return 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") // 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 uint, typingStatus string) error { conversation, err := s.GetByAccountAndID(ctx, accountID, conversationID) if err != nil { return err } event := channel.NewChannelEvent(channel.EventConversationTyping, channel.ChannelAPI, accountID, conversation.InboxID) event.ConversationID = conversationID event.Data["typing_status"] = typingStatus s.dispatcher.Dispatch(ctx, event) return nil } // UpdateLastSeen sets the agent_last_seen_at timestamp on a conversation. // Reference: Chatwoot conversations_controller.rb #update_last_seen func (s *ConversationService) UpdateLastSeen(ctx context.Context, accountID, conversationID uint) error { conversation, err := s.GetByAccountAndID(ctx, accountID, conversationID) if err != nil { return err } now := time.Now().Unix() conversation.AgentLastSeenAt = &now return s.repo.Update(ctx, conversation) } // 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 }