package service import ( "context" "errors" "fmt" "gorm.io/gorm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" ) // TeamService implements business logic for Team operations. // Reference: Chatwoot app/controllers/api/v1/teams_controller.rb type TeamService struct { teamRepo *repository.TeamRepo teamMemberRepo *repository.TeamMemberRepo db *gorm.DB } // NewTeamService creates a new Team service. func NewTeamService(teamRepo *repository.TeamRepo, teamMemberRepo *repository.TeamMemberRepo, db *gorm.DB) *TeamService { return &TeamService{teamRepo: teamRepo, teamMemberRepo: teamMemberRepo, db: db} } // CreateTeamRequest is the DTO for creating a team. type CreateTeamRequest struct { Name string `json:"name" validate:"required,min=2"` Description string `json:"description,omitempty"` AllowAutoAssignment *bool `json:"allow_auto_assignment,omitempty"` } // UpdateTeamRequest is the DTO for updating a team. type UpdateTeamRequest struct { Name string `json:"name,omitempty" validate:"omitempty,min=2"` Description string `json:"description,omitempty"` AllowAutoAssignment *bool `json:"allow_auto_assignment,omitempty"` } // TeamMemberRequest is the DTO for adding/removing team members. type TeamMemberRequest struct { UserIDs []uint `json:"user_ids" validate:"required,min=1"` } // List retrieves all teams for an account. func (s *TeamService) List(ctx context.Context, accountID uint, offset, limit int) ([]model.Team, int64, error) { return s.teamRepo.ListByAccount(ctx, accountID, offset, limit) } // Get retrieves a single team by ID scoped to an account. func (s *TeamService) Get(ctx context.Context, id, accountID uint) (*model.Team, error) { team, err := s.teamRepo.FindByIDAndAccount(ctx, id, accountID) if err != nil { return nil, fmt.Errorf("team not found: %w", err) } return team, nil } // Create creates a new team within an account. func (s *TeamService) Create(ctx context.Context, accountID uint, req CreateTeamRequest) (*model.Team, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } autoAssign := true // default if req.AllowAutoAssignment != nil { autoAssign = *req.AllowAutoAssignment } team := &model.Team{ AccountID: accountID, Name: req.Name, Description: req.Description, AllowAutoAssignment: autoAssign, } if err := s.teamRepo.Create(ctx, team); err != nil { applogger.L().Errorf("failed to create team: %v", err) return nil, fmt.Errorf("failed to create team: %w", err) } return team, nil } // Update updates an existing team scoped to an account. func (s *TeamService) Update(ctx context.Context, id, accountID uint, req UpdateTeamRequest) (*model.Team, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } team, err := s.teamRepo.FindByIDAndAccount(ctx, id, accountID) if err != nil { return nil, fmt.Errorf("team not found: %w", err) } if req.Name != "" { team.Name = req.Name } // Allow empty description to clear it team.Description = req.Description if req.AllowAutoAssignment != nil { team.AllowAutoAssignment = *req.AllowAutoAssignment } if err := s.teamRepo.Update(ctx, team); err != nil { applogger.L().Errorf("failed to update team: %v", err) return nil, fmt.Errorf("failed to update team: %w", err) } return team, nil } // Delete soft-deletes a team scoped to an account. func (s *TeamService) Delete(ctx context.Context, id, accountID uint) error { // Remove all members first if err := s.teamMemberRepo.DeleteByTeam(ctx, id); err != nil { applogger.L().Warnf("failed to remove team members during delete: %v", err) } if err := s.teamRepo.Delete(ctx, id, accountID); err != nil { return fmt.Errorf("failed to delete team: %w", err) } return nil } // AddMembers adds users to a team. func (s *TeamService) AddMembers(ctx context.Context, teamID, accountID uint, userIDs []uint) ([]model.TeamMember, error) { // Verify team exists and belongs to account _, err := s.teamRepo.FindByIDAndAccount(ctx, teamID, accountID) if err != nil { return nil, fmt.Errorf("team not found: %w", err) } members := make([]model.TeamMember, 0, len(userIDs)) for _, uid := range userIDs { // Skip if already a member existing, _ := s.teamMemberRepo.FindByTeamAndUser(ctx, teamID, uid) if existing != nil { continue } members = append(members, model.TeamMember{ TeamID: teamID, UserID: uid, AvailabilityStatus: "offline", }) } if len(members) == 0 { return nil, errors.New("all users are already team members") } if err := s.teamMemberRepo.CreateBatch(ctx, members); err != nil { applogger.L().Errorf("failed to add team members: %v", err) return nil, fmt.Errorf("failed to add team members: %w", err) } return members, nil } // RemoveMember removes a user from a team. func (s *TeamService) RemoveMember(ctx context.Context, teamID, userID, accountID uint) error { // Verify team exists and belongs to account _, err := s.teamRepo.FindByIDAndAccount(ctx, teamID, accountID) if err != nil { return fmt.Errorf("team not found: %w", err) } if err := s.teamMemberRepo.Delete(ctx, teamID, userID); err != nil { return fmt.Errorf("failed to remove team member: %w", err) } return nil } // ListMembers retrieves all members of a team. func (s *TeamService) ListMembers(ctx context.Context, teamID, accountID uint) ([]model.TeamMember, error) { // Verify team exists and belongs to account _, err := s.teamRepo.FindByIDAndAccount(ctx, teamID, accountID) if err != nil { return nil, fmt.Errorf("team not found: %w", err) } return s.teamMemberRepo.FindByTeam(ctx, teamID) } // UpdateMembers adds/removes members to match the provided user_ids list. // Reference: Chatwoot team_members#update — calculates add/remove diffs from user_ids func (s *TeamService) UpdateMembers(ctx context.Context, teamID, accountID uint, userIDs []uint) ([]model.TeamMember, error) { // Get current member IDs currentMembers, err := s.teamMemberRepo.FindByTeam(ctx, teamID) if err != nil { return nil, err } currentIDs := make([]uint, len(currentMembers)) for i, m := range currentMembers { currentIDs[i] = m.UserID } // Calculate IDs to add and remove toAdd := difference(userIDs, currentIDs) toRemove := difference(currentIDs, userIDs) // Validate new user IDs belong to the account if len(toAdd) > 0 { // Chatwoot validates: invalid IDs (not in account) → 401 if !s.validateUserIDsBelongToAccount(ctx, accountID, toAdd) { return nil, fmt.Errorf("unauthorized: invalid user IDs") } newMembers := make([]model.TeamMember, len(toAdd)) for i, uid := range toAdd { newMembers[i] = model.TeamMember{TeamID: teamID, UserID: uid} } if err := s.teamMemberRepo.CreateBatch(ctx, newMembers); err != nil { return nil, err } } // Remove members not in the new list for _, uid := range toRemove { s.teamMemberRepo.Delete(ctx, teamID, uid) } // Return updated member list return s.teamMemberRepo.FindByTeam(ctx, teamID) } // difference returns elements in a that are not in b. func difference(a, b []uint) []uint { bSet := make(map[uint]bool, len(b)) for _, v := range b { bSet[v] = true } var result []uint for _, v := range a { if !bSet[v] { result = append(result, v) } } return result } // validateUserIDsBelongToAccount checks that all user IDs belong to the given account. // Reference: Chatwoot team_members#validate_member_id_params — invalid IDs → 401 func (s *TeamService) validateUserIDsBelongToAccount(ctx context.Context, accountID uint, userIDs []uint) bool { for _, uid := range userIDs { var count int64 s.db.WithContext(ctx).Model(&model.AccountUser{}). Where("account_id = ? AND user_id = ?", accountID, uid). Count(&count) if count == 0 { return false } } return true }