package service import ( "context" "errors" "fmt" "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) { 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 } 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) } } // 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) } // 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) s.dispatcher.Dispatch(ctx, channel.NewChannelEvent(channel.EventMessageCreated, channel.ChannelAPI, accountID, req.InboxID)) } } 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 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 } // Dispatch EventConversationUpdated s.dispatchConversationEvent(ctx, channel.EventConversationUpdated, conversation) 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.dispatchConversationEvent(ctx, channel.EventConversationResolved, conversation) case model.ConversationStatusOpen: s.dispatchConversationEvent(ctx, channel.EventConversationOpened, conversation) } } 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!) s.dispatchConversationEvent(ctx, channel.EventConversationOpened, conversation) 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 switch newStatus { case model.ConversationStatusResolved: s.dispatchConversationEvent(ctx, channel.EventConversationResolved, conversation) case model.ConversationStatusOpen: s.dispatchConversationEvent(ctx, channel.EventConversationOpened, conversation) default: // For pending/snoozed, dispatch generic updated event s.dispatchConversationEvent(ctx, channel.EventConversationUpdated, conversation) } 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 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 // 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 } // 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 } // Build base query scoped to account var conversations []model.Conversation var total int64 query := s.repo.DB().WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID) // 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 != "" { searchTerm := "%" + params.Query + "%" query = query.Joins("JOIN messages ON messages.conversation_id = conversations.id"). Where("messages.content ILIKE ?", searchTerm). Where("messages.message_type IN ?", []int{0, 1}) // incoming=0, outgoing=1 } // 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) } if err := query.Count(&total).Error; err != nil { return nil, err } // 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" } err := query.Offset(offset).Limit(limit).Order(orderClause).Find(&conversations).Error if err != nil { return nil, err } // 1:1 Chatwoot: set_count_for_all_conversations // Build count queries from the base filtered query (before pagination) var mineCount, unassignedCount, allCount int64 s.repo.DB().WithContext(ctx).Model(&model.Conversation{}). Where("account_id = ? AND assignee_id = ?", accountID, userID).Count(&mineCount) s.repo.DB().WithContext(ctx).Model(&model.Conversation{}). Where("account_id = ? AND assignee_id IS NULL", accountID).Count(&unassignedCount) s.repo.DB().WithContext(ctx).Model(&model.Conversation{}). Where("account_id = ?", accountID).Count(&allCount) return &FilterResult{ Conversations: conversations, Count: FilterCountMeta{ MineCount: mineCount, AssignedCount: allCount - unassignedCount, UnassignedCount: unassignedCount, AllCount: 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 } conversation.Priority = string(newPriority) if err := s.repo.Update(ctx, conversation); err != nil { return nil, err } // Dispatch EventConversationPriorityUpdated s.dispatchConversationEvent(ctx, channel.EventConversationPriorityUpdated, conversation) s.indexConversation(ctx, conversation) return conversation, nil } // GetMeta retrieves aggregated conversation metadata (status counts, label counts) for an account. // Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb#meta func (s *ConversationService) GetMeta(ctx context.Context, accountID uint) (*repository.ConversationMeta, error) { return s.repo.GetMeta(ctx, accountID) } // 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[string]int64 `json:"labels"` Teams map[uint]int64 `json:"teams"` } // 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 uint) (*UnreadCountsPayload, error) { inboxCounts, err := s.repo.GetUnreadCountsByInbox(ctx, accountID) if err != nil { return nil, err } teamCounts, err := s.repo.GetUnreadCountsByTeam(ctx, accountID) if err != nil { return nil, err } labelCounts, err := s.repo.GetUnreadCountsByLabel(ctx, accountID) 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[string]int64) for _, r := range labelCounts { labels[r.Label] = r.Count } return &UnreadCountsPayload{ Inboxes: inboxes, Labels: labels, Teams: teams, }, 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 }