app/models/competitor.rb

Summary

Maintainability
C
1 day
Test Coverage
class Competitor < ApplicationRecord
  include Concerns::AttributeArrayable
  include Concerns::Domainable

  COMPETITORS = {
    'Rough Draft Ventures': Http::Rdv,
    'Y Combinator': nil,
    'First Round Capital': nil,
    'TechStars': nil,
  }.with_indifferent_access.freeze

  FUND_TYPES = {
    accelerator: 'Accelerator',
    angel: 'Angel',
    preseed: 'Pre-Seed',
    seed: 'Seed',
    series_A: 'Series A',
    series_B: 'Series B',
    venture: 'Venture',
  }.with_indifferent_access.freeze

  INDUSTRIES = {
    arvr: 'AR/VR',
    bitcoin: 'Blockchain',
    consumer: 'Consumer',
    enterprise: 'Enterprise',
    ecommerce: 'E-Commerce',
    delivery: 'Delivery',
    saas: 'SaaS',
    ai: 'AI/ML',
    robotics: 'Robotics',
    food: 'Food & Drink',
    mobile: 'Mobile',
    healthcare: 'Healthcare',
    media: 'Media',
    finance: 'Finance',
    education: 'Education',
    lifesci: 'Life Sci.',
    retail: 'Retail',
    realestate: 'Real Estate',
    travel: 'Travel',
    automotive: 'Automotive',
    sports: 'Sports',
    cleantech: 'Clean Tech',
    iot: 'IoT',
    social: 'Social',
    energy: 'Energy',
    hardware: 'Hardware',
    gaming: 'Gaming',
    space: 'Space',
    data: 'Big Data',
    transportation: 'Transportation',
    marketplace: 'Marketplace',
    security: 'Security',
    government: 'Government',
    legal: 'Legal',
  }.with_indifferent_access.freeze

  RELATED_INDUSTRIES = {
    arvr: ['Augmented Reality', 'Virtual Reality'],
    bitcoin: ['Bitcoin', 'Crypto', 'Cryptocurrency', 'Virtual Currency'],
    saas: ['Software as a Service'],
    gaming: ['Video Games'],
    transportation: ['Public Transportation', 'Ride Sharing'],
    mobile: ['Mobile Devices', 'Telecommunications', 'Mobile Apps'],
    food: ['Food and Beverage', 'Food Delivery', 'Nutrition', 'Food', 'Restaurants'],
    social: ['Social', 'Messaging', 'Social Media'],
    ai: ['Machine Learning', 'Artificial Intelligence'],
    enterprise: ['Enterprise Software', 'B2B'],
    healthcare: ['Health Care', 'Medical', 'Personal Health'],
    media: ['Entertainment', 'Music', 'Video', 'Photography'],
    finance: ['FinTech', 'Financial Services'],
    energy: ['Electric Vehicle', 'Energy Management'],
    data: ['Analytics', 'Data'],
    iot: ['Internet of Things'],
    security: ['Network Security', 'Cyber Security'],
    government: ['GovTech'],
    lifesci: ['Biotechnology', 'Pharmaceutical'],
    space: ['Aerospace', 'Satellite Communication', 'Space Travel'],
  }.with_indifferent_access.freeze

  NON_INVESTOR_TITLE = ['Executive Assistant', 'Consultant', 'Accountant', 'Team Member', 'Accounting', 'Advisor'].map(&:downcase)

  CLOSEST_INDUSTRY_THRESHOLD = 0.4
  HUB_THRESHOLD = 50

  array :industry
  array :fund_type
  array :location

  has_many :investors
  has_many :target_investors, through: :investors
  has_many :investments, dependent: :destroy
  has_many :companies, through: :investments
  has_many :notes, as: :subject, dependent: :destroy

  validates :name, presence: true, uniqueness: true
  validates :crunchbase_id, uniqueness: { allow_nil: true }
  validates :al_id, uniqueness: { allow_nil: true }

  after_commit :start_crunchbase_job, on: :create
  before_validation :normalize_location

  def self.full_industry_options
    INDUSTRIES.sort.map { |k, v| { value: k, other_labels: RELATED_INDUSTRIES[k], label: v } }
  end

  def self.closest_industry(industry)
    return nil unless industry.present?
    distances = INDUSTRIES.flat_map do |k, friendly|
      ([friendly] + (RELATED_INDUSTRIES[k] || [])).map do |s|
        [Levenshtein.distance(s, industry) / [k.length, industry.length].max.to_f, k]
      end
    end
    if (best = distances.sort.first).first < CLOSEST_INDUSTRY_THRESHOLD
      best.last
    else
      nil
    end
  end

  def self.hub?(location)
    where('competitors.location && ?', "{#{location}}").count > HUB_THRESHOLD
  end

  def self.create_from_name!(name)
    existing = where(name: name).first || search(name: name).first
    return existing if existing.present?

    if (crunchbase_id = Http::Crunchbase::Organization.find_investor_id(name)).present?
      from_crunchbase! crunchbase_id, name
    elsif (al_id = Http::AngelList::Startup.find_id(name))
      from_angelist! al_id, name
    else
      where(name: name).first_or_create!
    end
  end

  def self.create_from_domain!(domain, name)
    return nil unless domain.present?
    where(domain: domain).first_or_initialize.tap do |i|
      i.crunchbase_id ||= Http::Crunchbase::Organization.find_domain_id(domain)
      i.name = name
      i.save!
    end
  end

  def self.from_crunchbase!(crunchbase_id, name)
    return nil unless crunchbase_id.present?
    found = where(crunchbase_id: crunchbase_id).or(where(name: name)).first
    found = create!(crunchbase_id: crunchbase_id, name: name) unless found.present?
    found.tap do |competitor|
      competitor.crunchbase_id = crunchbase_id
      competitor.name = name
    end
  end

  def self.from_angelist!(al_id, name)
    return nil unless al_id.present?
    where(al_id: al_id).first_or_create! do |competitor|
      competitor.name = name
    end
  end

  def self.for_company(company)
    org = company.crunchbase_org(5)
    where(name: COMPETITORS.keys).select do |competitor|
        org.has_investor?(competitor.crunchbase_id) ||
        COMPETITORS[competitor.name]&.new&.invested?(company.name)
    end
  end

  def self.to_param(company)
    return false unless company.competitors.present?
    company.competitors.map(&:acronym).join(', ')
  end


  def self.filtered(founder, request, params, opts = {})
    CompetitorLists::Filtered.new(founder, request, params).results(**opts)
  end

  def self.filtered_count_and_cols(founder, request, params)
    list = CompetitorLists::Filtered.new(founder, request, params)
    { count: list.result_count, cols: list.meta_cols }
  end

  def self.locations(query, limit = 5)
    connection.select_values <<-SQL
      SELECT ulocations FROM (
        SELECT unnest(location)
        FROM competitors
        WHERE
          location <> '{}'
          AND location IS NOT NULL
      ) AS s(ulocations)
      WHERE ulocations ILIKE '#{query.present? ? sanitize_sql_like(connection.quote_string(query)) : ''}%'
      GROUP BY ulocations
      ORDER BY COUNT(ulocations) DESC
      #{limit.present? ? "LIMIT #{limit}" : ''}
    SQL
  end

  def self.lists(founder, request, timeout = 2.seconds)
    start = Time.now
    results = []

    lists = CompetitorLists::Base::Base.get_eligibles(founder, request)
    Parallel.each(lists, in_threads: lists.length) do |list|
      raise Parallel::Break if start < timeout.ago
      ActiveRecord::Base.connection_pool.with_connection do
        begin
          ActiveRecord::Base.connection.execute "SET statement_timeout = #{(timeout.to_f * 1000).to_i}"
          result = list.new(founder, request).as_json(json: :list)
        rescue ActiveRecord::StatementInvalid => e
          if e.cause.is_a?(PG::QueryCanceled)
            raise Parallel::Break
          else
            raise
          end
        ensure
          ActiveRecord::Base.connection.execute 'SET statement_timeout = 0'
        end
        results << result if result[:competitors].present?
      end
    end

    results
  end

  def self.list(founder, request, name)
    CompetitorLists::Base::Base.get_if_eligible(founder, request, name)&.new(founder, request)
  end

  def self.param_list(founder, request, name, cache_values)
    CompetitorLists::Base::Base.get(name).new(founder, request).tap do |list|
      list.cache_values = cache_values.symbolize_keys
    end
  end

  def as_json(options = {})
    super options.reverse_merge(
      only: [
        :industry,
        :name,
        :fund_type,
        :location,
        :photo,
        :facebook,
        :twitter,
        :domain,
      ],
      methods: [
        :acronym,
      ]
    )
  end

  def as_settings_json
    as_json(
      only: [
        :industry,
        :name,
        :fund_type,
        :location,
        :photo,
        :facebook,
        :twitter,
        :domain,
        :crunchbase_id,
        :al_id,
        :id,
      ],
      methods: []
    )
  end

  def as_meta_json
    as_json(
      only: [
        :id,
        :name,
        :location,
        :photo,
        :meta,
        :description,
        :industry,
        :fund_type,
        :domain,
        :facebook,
        :twitter,
        :al_url,
        :crunchbase_id,
        :matches,
        :coinvestors,
        :velocity,
        :verified,
        :matched_partners,
      ],
      methods: [
        :hq,
        :acronym,
        :recent_investments,
        :cb_url,
        :partners,
      ],
    )
  end

  def hq
    super || location&.first
  end

  def as_list_json
    as_json(only: [:photo, :id], methods: [:acronym])
  end

  def as_search_json
    as_json(only: [:name, :industry, :fund_type], methods: [])
  end

  def acronym
    name.split('').select { |l| /[[:upper:]]|\d/.match l }.join
  end

  def crunchbase_fund
    @crunchbase_fund ||= Http::Crunchbase::Fund.new(crunchbase_id, nil)
  end

  def angellist_startup
    @angellist_startup ||= Http::AngelList::Startup.new(al_id)
  end

  def cb_url
    "https://www.crunchbase.com/organization/#{crunchbase_id}" if crunchbase_id.present?
  end

  private

  def partners
    return investors.to_a unless self[:partners].present?
    matched_partners = self[:matched_partners] || []
    matched_ids = Set.new matched_partners.map { |p| p['id'] }
    matched_partners + self[:partners].reject { |p| matched_ids.include? p['id'] }
  end

  def velocity
    return self[:velocity] if self.attributes.key?('velocity')
    self.investments.count
  end

  def coinvestors(limit: 5)
    return self[:coinvestors] if self.attributes.key?('coinvestors')

    companies = self.companies.select('id').to_sql
    others = <<-SQL
      SELECT competitors.id, competitors.name, COUNT(investments.id) AS overlap
      FROM investments
      INNER JOIN competitors ON competitors.id = investments.competitor_id
      WHERE
        investments.company_id IN (#{companies})
        AND competitors.id != #{id}
      GROUP BY competitors.id
      ORDER BY COUNT(investments.id) DESC
      LIMIT #{limit}
    SQL
    Competitor.find_by_sql(others)
  end

  def normalize_location
    self.location = self.location.map(&Util.method(:normalize_city)) if self.location.present?
  end

  def recent_investments
    if self.attributes.key?('recent_investments')
      self[:recent_investments]
    else
      companies
        .group('companies.id', 'investments.featured', 'investments.funded_at')
        .joins(:investments)
        .order('investments.featured DESC, companies.capital_raised DESC', 'count(investments.id) DESC', 'investments.funded_at DESC NULLS LAST')
        .limit(5)
    end
  end

  def start_crunchbase_job
    CompetitorCrunchbaseJob.perform_later(id)
  end
end