package service import ( "context" "fmt" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) // ConversationParticipantService implements business logic for ConversationParticipant operations. // Reference: Chatwoot app/controllers/api/v1/accounts/conversations/participants_controller.rb // Chatwoot validates inbox access via assignable_agents before allowing participants. type ConversationParticipantService struct { repo *repository.ConversationParticipantRepo conversationRepo *repository.ConversationRepo assignableAgentSvc *AssignableAgentService } // NewConversationParticipantService creates a new ConversationParticipant service. func NewConversationParticipantService(repo *repository.ConversationParticipantRepo, conversationRepo *repository.ConversationRepo) *ConversationParticipantService { return &ConversationParticipantService{repo: repo, conversationRepo: conversationRepo} } // SetAssignableAgentService injects the assignable agent service for inbox access validation. func (s *ConversationParticipantService) SetAssignableAgentService(svc *AssignableAgentService) { s.assignableAgentSvc = svc } // List retrieves all participants for a conversation. // Chatwoot: @participants = @conversation.conversation_participants func (s *ConversationParticipantService) List(ctx context.Context, accountID, conversationID uint) ([]model.ConversationParticipant, error) { if _, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID); err != nil { return nil, err } return s.repo.FindByConversationID(ctx, conversationID) } // Add adds a participant to a conversation. // Chatwoot: find_or_create_by(user_id), validates inbox access via assignable_agents func (s *ConversationParticipantService) Add(ctx context.Context, accountID, conversationID, userID uint, role string) (*model.ConversationParticipant, error) { conversation, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID) if err != nil { return nil, err } // Chatwoot: validates :ensure_inbox_access — user must be in assignable_agents if s.assignableAgentSvc != nil { if err := s.validateInboxAccess(ctx, accountID, conversation.InboxID, userID); err != nil { return nil, err } } // Chatwoot: before_validation :ensure_account_id — auto-sets account_id from conversation // find_or_create_by pattern existing, err := s.repo.FindByConversationAndUserID(ctx, conversationID, userID) if err == nil && existing != nil { return existing, nil } participant := &model.ConversationParticipant{ AccountID: accountID, ConversationID: conversationID, UserID: userID, Role: role, } if err := s.repo.Create(ctx, participant); err != nil { applogger.L().Errorf("ConversationParticipantService.Add: %v", err) return nil, err } return participant, nil } // Update updates a participant's role in a conversation. func (s *ConversationParticipantService) Update(ctx context.Context, accountID, conversationID, userID uint, role string) (*model.ConversationParticipant, error) { if _, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID); err != nil { return nil, err } if err := s.repo.UpdateRole(ctx, conversationID, userID, role); err != nil { applogger.L().Errorf("ConversationParticipantService.Update: %v", err) return nil, err } return s.repo.FindByConversationAndUserID(ctx, conversationID, userID) } // Remove removes a participant from a conversation. // Chatwoot: conversation_participants.find_by(user_id: user_id)&.destroy func (s *ConversationParticipantService) Remove(ctx context.Context, accountID, conversationID, userID uint) error { if _, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID); err != nil { return err } return s.repo.DeleteByConversationAndUserID(ctx, conversationID, userID) } // BatchUpdate adds and removes participants in bulk. // Chatwoot: update action — adds via find_or_create_by, removes via destroy func (s *ConversationParticipantService) BatchUpdate(ctx context.Context, accountID, conversationID uint, addUserIDs []uint, removeUserIDs []uint, role string) ([]model.ConversationParticipant, error) { conversation, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID) if err != nil { return nil, err } // Add new participants (find_or_create_by pattern) for _, userID := range addUserIDs { if s.assignableAgentSvc != nil { if err := s.validateInboxAccess(ctx, accountID, conversation.InboxID, userID); err != nil { return nil, err } } existing, _ := s.repo.FindByConversationAndUserID(ctx, conversationID, userID) if existing != nil { continue } participant := &model.ConversationParticipant{ AccountID: accountID, ConversationID: conversationID, UserID: userID, Role: role, } if err := s.repo.Create(ctx, participant); err != nil { applogger.L().Errorf("BatchUpdate add: %v", err) } } // Remove old participants for _, userID := range removeUserIDs { if err := s.repo.DeleteByConversationAndUserID(ctx, conversationID, userID); err != nil { applogger.L().Errorf("BatchUpdate remove user %d: %v", userID, err) } } return s.repo.FindByConversationID(ctx, conversationID) } // validateInboxAccess checks that a user has access to the conversation's inbox. // Chatwoot: errors.add(:user, 'must have inbox access') if conversation.inbox.assignable_agents.exclude?(user) func (s *ConversationParticipantService) validateInboxAccess(ctx context.Context, accountID, inboxID, userID uint) error { agents, err := s.assignableAgentSvc.FindAssignableAgents(ctx, accountID, []uint{inboxID}) if err != nil { return fmt.Errorf("failed to verify inbox access: %w", err) } for _, agent := range agents { if agent.ID == userID { return nil } } return fmt.Errorf("user %d must have inbox access", userID) }