module JamRuby # not a active_record model; just a search result container class Search attr_accessor :results, :search_type, :query attr_accessor :user_counters, :page_num, :page_count LIMIT = 10 SEARCH_TEXT_TYPES = [:musicians, :bands, :fans] SEARCH_TEXT_TYPE_ID = :search_text_type PARAM_SESSION_INVITE = :srch_sessinv PARAM_MUSICIAN = :srch_m PARAM_BAND = :srch_b PARAM_FEED = :srch_f PARAM_JAMTRACK = :srch_j F_PER_PAGE = B_PER_PAGE = M_PER_PAGE = 20 M_MILES_DEFAULT = 500 B_MILES_DEFAULT = 0 M_ORDER_FOLLOWS = ['Most Followed', :followed] M_ORDER_PLAYS = ['Most Plays', :plays] M_ORDER_PLAYING = ['Playing Now', :playing] M_ORDER_LATENCY = ['Latency To Me', :latency] M_ORDER_DISTANCE = ['Distance To Me', :distance] M_ORDERINGS = [M_ORDER_LATENCY, M_ORDER_DISTANCE, M_ORDER_FOLLOWS, M_ORDER_PLAYS] ORDERINGS = B_ORDERINGS = [M_ORDER_FOLLOWS, M_ORDER_PLAYS, M_ORDER_PLAYING] M_ORDERING_KEYS = M_ORDERINGS.collect { |oo| oo[1] } B_ORDERING_KEYS = B_ORDERINGS.collect { |oo| oo[1] } DISTANCE_OPTS = B_DISTANCE_OPTS = M_DISTANCE_OPTS = [[25.to_s, 25], [50.to_s, 50], [100.to_s, 100], [250.to_s, 250], [500.to_s, 500], [1000.to_s, 1000] ] # the values for score ranges are raw roundtrip scores. david often talks of one way scores (<= 20 is good), but # the client reports scores as roundtrip and the server uses those values throughout GOOD_SCORE = '.-40' MODERATE_SCORE = '40-80' POOR_SCORE = '80-120' UNACCEPTABLE_SCORE = '120-.' SCORED_SCORE = '.-.' # does not appear in menu choices TEST_SCORE = '.-60' # does not appear in menu choices ANY_SCORE = '' M_SCORE_OPTS = [['Any', ANY_SCORE], ['Good', GOOD_SCORE], ['Moderate', MODERATE_SCORE], ['Poor', POOR_SCORE], ['Unacceptable', UNACCEPTABLE_SCORE]] M_SCORE_DEFAULT = ANY_SCORE M_DISTANCE_DEFAULT = 500 F_SORT_RECENT = ['Most Recent', :date] F_SORT_OLDEST = ['Most Liked', :likes] F_SORT_LENGTH = ['Most Played', :plays] F_SORT_OPTS = [F_SORT_RECENT, F_SORT_LENGTH, F_SORT_OLDEST] SHOW_BOTH = ['Sessions & Recordings', :all] SHOW_SESSIONS = ['Sessions', :music_session] SHOW_RECORDINGS = ['Recordings', :recording] SHOW_OPTS = [SHOW_BOTH, SHOW_SESSIONS, SHOW_RECORDINGS] DATE_OPTS = [['Today', 'today'], ['This Week', 'week'], ['This Month', 'month'], ['All Time', 'all']] def initialize(search_results=nil) @results = [] self end def is_blank? !!@query && @query.empty? end def text_search(params, user = nil) @query = params[:query] tsquery = Search.create_tsquery(params[:query]) return [] if tsquery.blank? rel = case params[SEARCH_TEXT_TYPE_ID].to_s when 'bands' @search_type = :bands Band.where(nil) when 'fans' @search_type = :fans User.fans.not_deleted else @search_type = :musicians User.musicians.not_deleted end @results = rel.where("(name_tsv @@ to_tsquery('jamenglish', ?))", tsquery).limit(10) @results = Search.scope_schools_together(@results, user) end class << self def band_search(txt, user = nil) self.text_search({ SEARCH_TEXT_TYPE_ID => :bands, :query => txt }, user) end def fan_search(txt, user = nil) self.text_search({ SEARCH_TEXT_TYPE_ID => :fans, :query => txt }, user) end def musician_search(txt, user = nil) self.text_search({ SEARCH_TEXT_TYPE_ID => :musicians, :query => txt }, user) end def session_invite_search(query, user) srch = Search.new srch.search_type = :session_invite like_str = "%#{query.downcase}%" rel = User .musicians .where(["users.id IN (SELECT friend_id FROM friendships WHERE user_id = '#{user.id}')"]) .where(["first_name ILIKE ? OR last_name ILIKE ?", like_str, like_str]) .limit(10) .order([:last_name, :first_name]) srch.results = rel.all srch end def scope_schools_together_feeds(rel, user) if user.nil? return rel.where("feeds.school_id is null") end # platform instructors can search anybody (non-school and school). So no nothing special for them. if !user.is_platform_instructor # for everyone else... # make sure you can only see same-school. Or in the case of 'null school', you'll get other non-schoolers (i.e. normies) # also, make sure anyone will find platform_instructors if user.school_id.nil? rel = rel.where("feeds.school_id is null") else rel = rel.where("feeds.school_id = #{user.school_id} OR feeds.is_platform_instructor") end end rel end def scope_schools_together_chats(rel, user) # TODO: return rel end def scope_schools_together_sessions(rel, user, table_name = 'active_music_sessions') if user.nil? return rel.where("#{table_name}.school_id is null") end # platform instructors can search anybody (non-school and school). So no nothing special for them. if !user.is_platform_instructor # for everyone else... # make sure you can only see same-school. Or in the case of 'null school', you'll get other non-schoolers (i.e. normies) # also, make sure anyone will find platform_instructors if user.school_id.nil? rel = rel.where("#{table_name}.school_id is null") else rel = rel.where("#{table_name}.school_id = #{user.school_id} OR #{table_name}.is_platform_instructor") end end rel end def scope_schools_together(rel, user) if user.nil? return rel.where("users.school_id is null") end # platform instructors can search anybody (non-school and school). So no nothing special for them. if !user.is_platform_instructor # for everyone else... # make sure you can only see same-school. Or in the case of 'null school', you'll get other non-schoolers (i.e. normies) # also, make sure anyone will find platform_instructors if user.school_id.nil? rel = rel.where("users.school_id is null") else rel = rel.where("users.school_id = #{user.school_id} OR users.is_platform_instructor") end end rel end def text_search(params, user = nil) srch = Search.new unless (params.blank? || params[:query].blank? || 2 > params[:query].length) srch.text_search(params, user) end srch end def create_tsquery(query) return nil if query.blank? search_terms = query.split return nil if search_terms.length == 0 args = nil search_terms.each do |search_term| # remove ( ) ! : from query terms. parser blows up search_term.gsub!(/[\(\)!:]/, '') if args == nil args = '"' + search_term + '"' else args = args + " & " + '"' + search_term + '"' end end args = args + ":*" args end def order_param(params, keys=M_ORDERING_KEYS) ordering = params[:orderby] ordering.blank? ? keys[0] : keys.detect { |oo| oo.to_s == ordering } end # produce a list of musicians (users where musician is true) # params: # instrument - instrument to search for or blank # score_limit - a range specification for score, see M_SCORE_OPTS above. # handled by relation_pagination: # page - page number to fetch (origin 1) # per_page - number of entries per page # handled by order_param: # orderby - what sort of search, also defines order (followed, plays, playing) # previously handled by where_latlng: # distance - defunct! # city - defunct! # remote_ip - defunct! def musician_filter(params={}, user=nil) rel = User.musicians.not_deleted # not musicians_geocoded on purpose; we allow 'unknowns' to surface in the search page rel = rel.select('users.*') rel = rel.group('users.id') unless (instrument = params[:instrument]).blank? rel = rel.joins("inner JOIN musicians_instruments AS minst ON minst.player_id = users.id") .where(['minst.instrument_id = ?', instrument]) end rel = scope_schools_together(rel, user) # to find appropriate musicians we need to join users with scores to get to those with no scores or bad scores # weeded out # filter on scores using selections from params # see M_SCORE_OPTS score_limit = ANY_SCORE l = params[:score_limit] unless l.nil? score_limit = l end locidispid = user.nil? ? 0 : (user.last_jam_locidispid || 0) # user can override their location with these 3 values country = params[:country] region = params[:region] city = params[:city] my_locid = nil # this is used for distance searches only #if country && region && city # geoiplocation = GeoIpLocations.where(countrycode: country, region: region, city: city).first # my_locid = geoiplocation.locid #end unless my_locid my_locid = locidispid/1000000 # if the user didn't specify a location to search on, user their account locidispid end if !locidispid.nil? && !user.nil? # score_join of left allows for null scores, whereas score_join of inner requires a score however good or bad # this is ANY_SCORE: score_join = 'left outer' # or 'inner' score_min = nil score_max = nil # these score_min, score_max come from here (doubled): https://jamkazam.atlassian.net/browse/VRFS-1962 case score_limit when GOOD_SCORE score_join = 'inner' score_min = nil score_max = 40 when MODERATE_SCORE score_join = 'inner' score_min = 40 score_max = 70 when POOR_SCORE score_join = 'inner' score_min = 80 score_max = 100 when UNACCEPTABLE_SCORE score_join = 'inner' score_min = 100 score_max = nil when SCORED_SCORE score_join = 'inner' score_min = nil score_max = nil when TEST_SCORE score_join = 'inner' score_min = nil score_max = 60 when ANY_SCORE # the default of ANY setup above applies else # the default of ANY setup above applies end rel = rel.joins("LEFT JOIN current_scores ON current_scores.a_userid = users.id AND current_scores.b_userid = '#{user.id}'") rel = rel.joins('LEFT JOIN regions ON regions.countrycode = users.country AND regions.region = users.state') rel = rel.where(['current_scores.full_score > ?', score_min]) unless score_min.nil? rel = rel.where(['current_scores.full_score <= ?', score_max]) unless score_max.nil? rel = rel.select('current_scores.full_score, current_scores.score, regions.regionname') rel = rel.group('current_scores.full_score, current_scores.score, regions.regionname') end ordering = self.order_param(params) case ordering when :latency # nothing to do. the sort added below 'current_scores.score ASC NULLS LAST' handles this when :distance # convert miles to meters for PostGIS functions miles = params[:distance].blank? ? 500 : params[:distance].to_i meters = miles * 1609.34 #rel = rel.joins("INNER JOIN geoiplocations AS my_geo ON #{my_locid} = my_geo.locid") #rel = rel.joins("INNER JOIN geoiplocations AS other_geo ON users.last_jam_locidispid/1000000 = other_geo.locid") #rel = rel.where("users.last_jam_locidispid/1000000 IN (SELECT locid FROM geoiplocations WHERE geog && st_buffer((SELECT geog FROM geoiplocations WHERE locid = #{my_locid}), #{meters}))") #rel = rel.group("my_geo.geog, other_geo.geog") #rel = rel.order('st_distance(my_geo.geog, other_geo.geog)') when :plays # FIXME: double counting? # sel_str = "COUNT(records)+COUNT(sessions) AS play_count, #{sel_str}" rel = rel.select('COUNT(records.id)+COUNT(sessions.id) AS search_play_count') rel = rel.joins("LEFT JOIN music_sessions AS sessions ON sessions.user_id = users.id") rel = rel.joins("LEFT JOIN recordings AS records ON records.owner_id = users.id") rel = rel.order("search_play_count DESC") when :followed rel = rel.joins('left outer join follows on follows.followable_id = users.id') rel = rel.select('count(follows.user_id) as search_follow_count') rel = rel.order('search_follow_count DESC') when :playing rel = rel.joins("inner JOIN connections ON connections.user_id = users.id") rel = rel.where(['connections.aasm_state != ?', 'expired']) end if !locidispid.nil? && !user.nil? rel = rel.order('current_scores.full_score ASC NULLS LAST') end rel = rel.order('users.created_at DESC') rel, page = self.relation_pagination(rel, params) rel = rel.includes([:instruments, :followings, :friends]) # XXX: DOES THIS MEAN ALL MATCHING USERS ARE RETURNED? objs = rel.all srch = Search.new srch.search_type = :musicians_filter srch.page_num, srch.page_count = page, objs.total_pages srch.musician_results_for_user(objs, user) end def relation_pagination(rel, params) perpage = [(params[:per_page] || M_PER_PAGE).to_i, 100].min page = [params[:page].to_i, 1].max [rel.paginate(:page => page, :per_page => perpage), page] end def new_musicians(usr, since_date) # this attempts to find interesting musicians to tell another musician about where interesting # is "has a good score and was created recently" # we're sort of depending upon usr being a musicians_geocoded as well... # this appears to only be called from EmailBatchNewMusician#deliver_batch_sets! which is # an offline process and thus uses the last jam location as "home base" locidispid = usr.last_jam_locidispid score_limit = 70 limit = 50 rel = User.musicians_geocoded .where(['users.created_at >= ? AND users.id != ?', since_date, usr.id]) .joins('inner join current_scores on users.id = current_scores.a_userid') .where(['current_scores.b_userid = ?', usr.id]) .where(['current_scores.full_score <= ?', score_limit]) .order('current_scores.full_score') # best scores first .order('users.created_at DESC') # then most recent .limit(limit) objs = rel.all.to_a if block_given? yield(objs) if 0 < objs.count else return objs end end def band_filter(params={}, current_user=nil) rel = Band.scoped unless (genre = params[:genre]).blank? rel = Band.joins("RIGHT JOIN genre_players AS bgenres ON bgenres.player_id = bands.id AND bgenres.player_type = 'JamRuby::Band'") .where(['bgenres.genre_id = ? AND bands.id IS NOT NULL', genre]) end rel = GeoIpLocations.where_latlng(rel, params, current_user) sel_str = 'bands.*' case ordering = self.order_param(params, B_ORDERING_KEYS) when :plays # FIXME: double counting? sel_str = "COUNT(records)+COUNT(msh) AS play_count, #{sel_str}" rel = rel.joins("LEFT JOIN music_sessions AS msh ON msh.band_id = bands.id") .joins("LEFT JOIN recordings AS records ON records.band_id = bands.id") .group("bands.id") .order("play_count DESC, bands.created_at DESC") when :followed sel_str = "COUNT(follows) AS search_follow_count, #{sel_str}" rel = rel.joins("LEFT JOIN follows ON follows.followable_id = bands.id") .group("bands.id") .order("COUNT(follows) DESC, bands.created_at DESC") when :playing rel = rel.joins("LEFT JOIN music_sessions AS msh ON msh.band_id = bands.id") .where('msh.music_session_id IS NOT NULL AND msh.session_removed_at IS NULL') .order("bands.created_at DESC") end rel = rel.select(sel_str) rel, page = self.relation_pagination(rel, params) rel = rel.includes([{ :users => :instruments }, :genres ]) objs = rel.all srch = Search.new srch.search_type = :band_filter srch.page_num, srch.page_count = page, objs.total_pages if 1 == page && current_user.bands.present? current_user.bands.order('created_at DESC').each { |bb| objs.unshift(bb) } end if current_user && current_user.is_a?(User) srch.band_results_for_user(objs, current_user) end end RESULT_FOLLOW = :follows RESULT_FRIEND = :friends COUNT_FRIEND = :count_friend COUNT_FOLLOW = :count_follow COUNT_RECORD = :count_record COUNT_SESSION = :count_session COUNTERS = [COUNT_FRIEND, COUNT_FOLLOW, COUNT_RECORD, COUNT_SESSION] def musician_results_for_user(_results, user) @results = _results if user @user_counters = @results.inject({}) { |hh,val| hh[val.id] = []; hh } mids = "'#{@results.map(&:id).join("','")}'" # this gets counts for each search result on friends/follows/records/sessions @results.each do |uu| counters = { } counters[COUNT_FRIEND] = Friendship.where(:user_id => uu.id).count counters[COUNT_FOLLOW] = Follow.where(:followable_id => uu.id).count counters[COUNT_RECORD] = ClaimedRecording.where(:user_id => uu.id).count counters[COUNT_SESSION] = MusicSession.where(:user_id => uu.id).count @user_counters[uu.id] << counters end # this section determines follow/like/friend status for each search result # so that action links can be activated or not rel = User.select("users.id AS uid") rel = rel.joins("LEFT JOIN follows ON follows.user_id = '#{user.id}'") rel = rel.where(["users.id IN (#{mids}) AND follows.followable_id = users.id"]) rel.all.each { |val| @user_counters[val.uid] << RESULT_FOLLOW } rel = User.select("users.id AS uid") rel = rel.joins("LEFT JOIN friendships AS friends ON friends.friend_id = '#{user.id}'") rel = rel.where(["users.id IN (#{mids}) AND friends.user_id = users.id"]) rel.all.each { |val| @user_counters[val.uid] << RESULT_FRIEND } else @user_counters = {} end self end private def _count(musician, key) if mm = @user_counters[musician.id] return mm.detect { |ii| ii.is_a?(Hash) }[key] end if @user_counters 0 end public def session_invite_search? :session_invite == @search_type end def musicians_text_search? :musicians == @search_type end def fans_text_search? :fans == @search_type end def bands_text_search? :bands == @search_type end def musicians_filter_search? :musicians_filter == @search_type end def bands_filter_search? :band_filter == @search_type end def follow_count(musician) _count(musician, COUNT_FOLLOW) end def friend_count(musician) _count(musician, COUNT_FRIEND) end def record_count(musician) _count(musician, COUNT_RECORD) end def session_count(musician) _count(musician, COUNT_SESSION) end def is_friend?(musician) if mm = @user_counters[musician.id] return mm.include?(RESULT_FRIEND) end if @user_counters false end def is_follower?(musician) if mm = @user_counters[musician.id] return mm.include?(RESULT_FOLLOW) end if @user_counters false end def band_results_for_user(_results, user) @results = _results if user @user_counters = @results.inject({}) { |hh,val| hh[val.id] = []; hh } mids = "'#{@results.map(&:id).join("','")}'" # this gets counts for each search result @results.each do |bb| counters = { } counters[COUNT_FOLLOW] = Follow.where(:followable_id => bb.id).count counters[COUNT_RECORD] = Recording.where(:band_id => bb.id).count counters[COUNT_SESSION] = MusicSession.where(:band_id => bb.id).count @user_counters[bb.id] << counters end # this section determines follow/like/friend status for each search result # so that action links can be activated or not rel = Band.select("bands.id AS bid") rel = rel.joins("LEFT JOIN follows ON follows.user_id = '#{user.id}'") rel = rel.where(["bands.id IN (#{mids}) AND follows.followable_id = bands.id"]) rel.all.each { |val| @user_counters[val.bid] << RESULT_FOLLOW } else @user_counters = {} end self end end end