Files
gochat/internal/service/conversation_participant_service.go
T

262 lines
9.3 KiB
Go

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) {
participants, err := s.AddMany(ctx, accountID, conversationID, []uint{userID}, role)
if err != nil {
return nil, err
}
if len(participants) > 0 {
return &participants[0], nil
}
return s.repo.FindByConversationAndUserID(ctx, conversationID, userID)
}
// AddMany adds the users missing from the participant set and returns only the newly added rows.
// Chatwoot create action: participants_to_be_added_ids.map { find_or_create_by(user_id:) }.
func (s *ConversationParticipantService) AddMany(ctx context.Context, accountID, conversationID uint, userIDs []uint, role string) ([]model.ConversationParticipant, error) {
conversation, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID)
if err != nil {
return nil, err
}
current, err := s.repo.FindByConversationID(ctx, conversationID)
if err != nil {
return nil, err
}
currentIDs := participantIDSet(current)
created := make([]model.ConversationParticipant, 0, len(userIDs))
for _, userID := range uniqueParticipantUserIDs(userIDs) {
if currentIDs[userID] {
continue
}
if s.assignableAgentSvc != nil {
if err := s.validateInboxAccess(ctx, accountID, conversation.InboxID, userID); err != nil {
return nil, err
}
}
participant := &model.ConversationParticipant{
AccountID: accountID,
ConversationID: conversationID,
UserID: userID,
Role: role,
}
if err := s.repo.Create(ctx, participant); err != nil {
applogger.L().Errorf("ConversationParticipantService.AddMany: %v", err)
return nil, err
}
reloaded, err := s.repo.FindByConversationAndUserID(ctx, conversationID, userID)
if err != nil {
return nil, err
}
created = append(created, *reloaded)
}
return created, 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)
}
// RemoveMany removes multiple participants. Missing users are ignored, matching Chatwoot destroy.
func (s *ConversationParticipantService) RemoveMany(ctx context.Context, accountID, conversationID uint, userIDs []uint) error {
if _, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID); err != nil {
return err
}
return s.repo.BatchDelete(ctx, conversationID, uniqueParticipantUserIDs(userIDs))
}
// 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) {
if len(removeUserIDs) == 0 {
return s.Replace(ctx, accountID, conversationID, addUserIDs, role)
}
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)
}
// Replace syncs participants to the supplied final user ID set.
// Chatwoot update action: add ids not currently present and remove ids absent from params[:user_ids].
func (s *ConversationParticipantService) Replace(ctx context.Context, accountID, conversationID uint, userIDs []uint, role string) ([]model.ConversationParticipant, error) {
conversation, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID)
if err != nil {
return nil, err
}
current, err := s.repo.FindByConversationID(ctx, conversationID)
if err != nil {
return nil, err
}
currentIDs := participantIDSet(current)
targetIDs := participantIDSetFromIDs(uniqueParticipantUserIDs(userIDs))
for userID := range targetIDs {
if currentIDs[userID] {
continue
}
if s.assignableAgentSvc != nil {
if err := s.validateInboxAccess(ctx, accountID, conversation.InboxID, userID); err != nil {
return nil, err
}
}
participant := &model.ConversationParticipant{
AccountID: accountID,
ConversationID: conversationID,
UserID: userID,
Role: role,
}
if err := s.repo.Create(ctx, participant); err != nil {
applogger.L().Errorf("Replace participants add user %d: %v", userID, err)
return nil, err
}
}
removeIDs := make([]uint, 0)
for userID := range currentIDs {
if !targetIDs[userID] {
removeIDs = append(removeIDs, userID)
}
}
if err := s.repo.BatchDelete(ctx, conversationID, removeIDs); err != nil {
return nil, err
}
return s.repo.FindByConversationID(ctx, conversationID)
}
func uniqueParticipantUserIDs(ids []uint) []uint {
seen := make(map[uint]bool, len(ids))
unique := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 || seen[id] {
continue
}
seen[id] = true
unique = append(unique, id)
}
return unique
}
func participantIDSet(participants []model.ConversationParticipant) map[uint]bool {
ids := make(map[uint]bool, len(participants))
for _, participant := range participants {
ids[participant.UserID] = true
}
return ids
}
func participantIDSetFromIDs(userIDs []uint) map[uint]bool {
ids := make(map[uint]bool, len(userIDs))
for _, userID := range userIDs {
ids[userID] = true
}
return ids
}
// 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)
}