1187 lines
46 KiB
Go
1187 lines
46 KiB
Go
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) {
|
|
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)
|
|
}
|
|
|
|
// 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 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
|
|
}
|
|
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 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
|
|
}
|