assemblymade/meta

View on GitHub
app/models/product.rb

Summary

Maintainability
F
3 days
Test Coverage
require 'money'
require './lib/poster_image'
require 'elasticsearch/model'

class Product < ActiveRecord::Base
  include ActiveRecord::UUID
  include Kaminari::ActiveRecordModelExtension
  include Elasticsearch::Model
  include GlobalID::Identification
  include Workflow

  DEFAULT_BOUNTY_SIZE=10000
  PITCH_WEEK_REQUIRED_BUILDERS=10
  DEFAULT_IMAGE_PATH='/assets/app_icon.png'
  MARK_SEARCH_THRESHOLD=0.10

  extend FriendlyId

  friendly_id :slug_candidates, use: :slugged

  attr_encryptor :wallet_private_key, :key => ENV["PRODUCT_ENCRYPTION_KEY"], :encode => true, :mode => :per_attribute_iv_and_salt, :unless => Rails.env.test?

  attr_accessor :partner_ids

  belongs_to :user
  belongs_to :evaluator, class_name: 'User'
  belongs_to :main_thread, class_name: 'Discussion'
  belongs_to :logo, class_name: 'Asset', foreign_key: 'logo_id'

  has_one :coin_info
  has_one :idea
  has_one :product_trend

  has_many :activities
  has_many :assets
  has_many :auto_tip_contracts
  has_many :chat_rooms
  has_many :contract_holders
  has_many :core_team, through: :core_team_memberships, source: :user
  has_many :core_team_memberships, -> { where(is_core: true) }, class_name: 'TeamMembership'
  has_many :daily_metrics
  has_many :discussions
  has_many :domains
  has_many :event_activities, through: :events, source: :activities
  has_many :events, :through => :wips
  has_many :expense_claims
  has_many :financial_accounts, class_name: 'Financial::Account'
  has_many :financial_transactions, class_name: 'Financial::Transaction'
  has_many :integrations
  has_many :invites, as: :via
  has_many :markings, as: :markable
  has_many :marks, through: :markings
  has_many :milestones
  has_many :monthly_metrics
  has_many :news_feed_items
  has_many :news_feed_item_posts
  has_many :pitch_week_applications
  has_many :posts
  has_many :profit_reports
  has_many :proposals
  has_many :rooms
  has_many :screenshots, through: :assets
  has_many :showcase_entries
  has_many :showcases, through: :showcase_entries
  has_many :status_messages
  has_many :stream_events
  has_many :subscribers
  has_many :tasks
  has_many :team_memberships
  has_many :transaction_log_entries
  has_many :viewings, as: :viewable
  has_many :votes, as: :voteable
  has_many :watchers, through: :watchings, source: :user
  has_many :watchings, as: :watchable
  has_many :weekly_metrics
  has_many :wip_activities, through: :wips, source: :activities
  has_many :wips
  has_many :work
  has_many :ownership_statuses

  PRIVATE = ((ENV['PRIVATE_PRODUCTS'] || '').split(','))

  def self.private_ids
    @private_ids ||= (PRIVATE.any? ? Product.where(slug: PRIVATE).pluck(:id) : [])
  end

  def self.meta_id
    @meta_id ||= Product.find_by(slug: 'meta').try(:id)
  end

  default_scope -> { where(deleted_at: nil) }

  scope :featured,         -> {
    where.not(featured_on: nil).order(featured_on: :desc)
  }
  scope :created_in_month, ->(date) {
    where('date(products.created_at) >= ? and date(products.created_at) < ?',
      date.beginning_of_month, date.beginning_of_month + 1.month
    )
  }
  scope :created_in_week, ->(date) {
    where('date(products.created_at) >= ? and date(products.created_at) < ?',
      date.beginning_of_week, date.beginning_of_week + 1.week
    )
  }
  scope :greenlit,     -> { public_products.where(state: 'greenlit') }
  scope :latest,           -> { where(flagged_at: nil).order(updated_at: :desc)}
  scope :live,         -> { where.not(try_url: [nil, '']) }
  scope :no_meta,         -> { where.not(id: self.meta_id) }
  scope :ordered_by_trend, -> { joins(:product_trend).order('product_trends.score DESC') }
  scope :profitable,   -> { public_products.where(state: 'profitable') }
  scope :public_products,  -> { where.not(id: Product.private_ids).where(flagged_at: nil).where.not(state: ['stealth', 'reviewing']) }
  scope :repos_gt,         ->(count) { where('array_length(repos,1) > ?', count) }
  scope :since,            ->(time) { where('created_at >= ?', time) }
  scope :stealth,      -> { where(state: 'stealth') }
  scope :tagged_with_any,  ->(tags) { where('tags && ARRAY[?]::varchar[]', tags) }
  scope :team_building, -> { public_products.where(state: 'team_building') }
  scope :untagged, -> { where('array_length(tags, 1) IS NULL') }
  scope :validating,       -> { where(greenlit_at: nil) }
  scope :visible_to, ->(user) { user.nil? ? public_products : where.not(id: Product.private_ids).where(flagged_at: nil) }
  scope :waiting_approval, -> { where('submitted_at is not null and evaluated_at is null') }
  scope :with_repo,        ->(repo) { where('? = ANY(repos)', repo) }
  scope :with_logo,        ->{ where.not(poster: nil).where.not(poster: '') }
  scope :with_mark,   -> (name) { joins(:marks).where(marks: { name: name }) }
  scope :with_topic,   -> (topic) { where('topics @> ARRAY[?]::varchar[]', topic) }

  EXCLUSIONS = %w(admin about core hello ideas if owner product script start-conversation)

  validates :slug, uniqueness: { allow_nil: true },
                   exclusion: { in: EXCLUSIONS }
  validates :name, presence: true,
                   length: { minimum: 2, maximum: 255 },
                   exclusion: { in: EXCLUSIONS }
  validates :pitch, presence: true,
                    length: { maximum: 255 }

  before_create :generate_authentication_token
  before_validation :generate_asmlytics_key, on: :create

  after_commit :retrieve_key_pair, on: :create
  after_commit -> { add_to_event_stream }, on: :create
  after_commit -> { Indexer.perform_async(:index, Product.to_s, self.id) }, on: :create
  after_commit -> { CoinInfo.create_from_product!(self) }, on: :create


  after_update :update_elasticsearch

  serialize :repos, Repo::Github

  INITIAL_COINS = 6000
  NON_PROFIT = %w(meta)

  INFO_FIELDS = %w(goals key_features target_audience competing_products competitive_advantage monetization_strategy)

  store_accessor :info, *INFO_FIELDS.map(&:to_sym)

  workflow_column :state

  workflow do
    state :stealth do
      event :submit,
        transitions_to: :reviewing
    end

    state :reviewing do
      event :accept,
        transitions_to: :team_building

      event :reject,
        transitions_to: :stealth
    end

    state :team_building do
      event :greenlight,
        transitions_to: :greenlit

      event :reject,
        transitions_to: :stealth
    end

    state :greenlit do
      event :launch,
        transitions_to: :profitable

      event :remove,
        transitions_to: :stealth
    end

    state :profitable do
      event :remove, transitions_to: :stealth end
  end

  def self.unique_tags
    pluck('distinct unnest(tags)').sort_by{|t| t.downcase }
  end

  def self.active_product_count
    joins(:activities).where('activities.created_at > ?', 30.days.ago).group('products.id').having('count(*) > 5').count.count
  end

  def sum_viewings
    Viewing.where(viewable: self).count
  end

  def most_active_contributor_ids(limit=6)
    activities.group('actor_id').order('count_id desc').limit(limit).count('id').keys
  end

  def most_active_contributors(limit=6)
    User.where(id: most_active_contributor_ids(limit))
  end

  def on_stealth_entry(prev_state, event, *args)
    update!(
      started_team_building_at: nil,
      greenlit_at: nil,
      profitable_at: nil
    )
  end

  def on_team_building_entry(prev_state, event, *args)
    update!(
      started_team_building_at: Time.now,
      greenlit_at: nil,
      profitable_at: nil
    )
  end

  def on_greenlit_entry(prev_state, event, *args)
    update!(
      greenlit_at: Time.now,
      profitable_at: nil
    )
    AssemblyCoin::GreenlightProduct.new.perform(self.id)
  end

  def on_profitable_entry(prev_state, event, *args)
    update!(profitable_at: Time.now)
  end

  def public?
    self.flagged_at.nil? &&
      !Product.private_ids.include?(self.id) &&
      !%w(stealth reviewing).include?(self.state)
  end

  def wallet_private_key_salt
    # http://ruby-doc.org/stdlib-2.1.0/libdoc/openssl/rdoc/OpenSSL/Cipher.html#class-OpenSSL::Cipher-label-Encrypting+and+decrypting+some+data
    cipher = OpenSSL::Cipher::AES256.new(:CBC)
    cipher.encrypt
    cipher.random_key
  end

  def partners(limit=nil)
    TransactionLogEntry.product_partners(self.id).order('sum(cents) desc').limit(limit)
  end

  def distinct_wallets_unqueued
    TransactionLogEntry.product_partners_with_balances_unqueued(self.id)
  end

  def mark_all_transactions_as_queued
    TransactionLogEntry.where(product_id: self.id).where(queue_id: nil).all.each do |a|
      a.update!({queue_id: Time.now.to_s})
    end
  end

  def reset_all_transactions_as_unqueued  #dont run this command without consulting a lvl 5 wizard or above
    TransactionLogEntry.where(product_id: self.id).where.not(queue_id: nil).all.each do |a|
      a.update!({queue_id: nil})
    end
  end

  def launched?
    current_state >= :team_building
  end

  def stopped_team_building_at
    started_team_building_at + 30.days
  end

  def team_building_days_left
    [(stopped_team_building_at.to_date - Date.today).to_i, 1].max
  end

  def team_building_percentage
    [product.bio_memberships_count, 10].min * 10
  end

  def founded_at
    read_attribute(:founded_at) || created_at
  end

  def public_at
    read_attribute(:public_at) || created_at
  end

  def for_profit?
    not NON_PROFIT.include?(slug)
  end

  def partners_before_date(date)
    partners.where('tle.created_at < ?', date)
  end

  def partner_ids
    partners.pluck(:id)
  end

  def has_metrics?
    for_profit?
  end

  def contributors(limit=10)
    User.where(id: (contributor_ids | [user_id])).take(limit)
  end

  def contributor_ids
    wip_creator_ids | event_creator_ids
  end

  def contributors_with_no_activity_since(since)
    contributors.select do |contributor|
      contributor.last_contribution.created_at < since
    end
  end

  def core_team?(user)
    return false if user.nil?
    team_memberships.core_team.active.find_by(user_id: user.id).present?
  end

  def member?(user)
    return false if user.nil?
    team_memberships.active.find_by(user_id: user.id)
  end

  def partner?(user)
    return false if user.nil?
    partners.where(id: user.id).exists?
  end

  def finished_first_steps?
    posts.exists? && tasks.count >= 3 && repos.present?
  end

  def awaiting_approval?
    pitch_week_applications.to_review.exists?
  end

  def open_discussions?
    discussions.where(closed_at: nil).exists?
  end

  def open_discussions_count
    discussions.where(closed_at: nil).count
  end

  def open_tasks?
    wips.where(closed_at: nil).exists?
  end

  def revenue?
    profit_reports.any?
  end

  def open_tasks_count
    tasks.where(closed_at: nil).count
  end

  def voted_for_by?(user)
    user && user.voted_for?(self)
  end

  def number_of_code_tasks
    number_of_open_tasks(:code)
  end

  def number_of_design_tasks
    number_of_open_tasks(:design)
  end

  def number_of_copy_tasks
    number_of_open_tasks(:copy)
  end

  def number_of_other_tasks
    number_of_open_tasks(:other)
  end

  def number_of_open_tasks(deliverable_type)
    tasks.where(state: 'open', deliverable: deliverable_type).count
  end

  def count_contributors
    (wip_creator_ids | event_creator_ids).size
  end

  def stories(limit=nil)
    s = Story.joins(:activities).
      where(activities: { product_id: self.id}).
      order(created_at: :desc)
    if limit
      s.limit(limit)
    end
  end

  def event_creator_ids
    ::Event.joins(:wip).where('wips.product_id = ?', self.id).group('events.user_id').count.keys
  end

  def wip_creator_ids
    Wip.where('wips.product_id = ?', self.id).group('wips.user_id').count.keys
  end

  def submitted?
    !!submitted_at
  end

  def greenlit?
    !greenlit_at.nil?
  end

  def feature!
    touch(:featured_on)
  end

  def main_chat_room
    chat_rooms.first || ChatRoom.general
  end

  def count_presignups
    votes.select(:user_id).distinct.count
  end

  def slug_candidates
    [
      :name,
      [:creator_username, :name],
    ]
  end

  def creator_username
    user.username
  end

  def voted_by?(user)
    votes.where(user: user).any?
  end

  def combined_watchers_and_voters
    (votes.map {|vote| vote.user } + watchers).uniq
  end

  def asset_address
    self.coin_info.asset_address
  end

  def set_asset_address
    a = OpenAssets::Transactions.new.get_asset_address(self.wallet_public_address)
    self.coin_info.update!({asset_address: a['asset_address']})
  end

  def assign_asset_address
    if self.coin_info
      if !self.coin_info.asset_address
        set_asset_address
      else
        if self.coin_info.asset_address.length < 10
          set_asset_address
        end
      end
    end
  end

  def core_team_members
    self.core_team_memberships.map{ |a| User.find(a.user_id) }
  end

  def tags_with_count
    Wip::Tag.joins(:taggings => :wip).
        where('wips.closed_at is null').
        where('wips.product_id' => self.id).
        group('wip_tags.id').
        order('count(*) desc').
        count('*').map do |tag_id, count|
      [Wip::Tag.find(tag_id), count]
    end
  end

  def to_param
    slug || id
  end

  # following
  def watch!(user)
    transaction do
      Watching.watch!(user, self)
      Subscriber.unsubscribe!(self, user)
    end
  end

  # not following, will receive announcements
  def announcements!(user)
    transaction do
      Watching.unwatch!(user, self)
      Subscriber.upsert!(self, user)
    end
  end

  # not following
  def unwatch!(user)
    transaction do
      Watching.unwatch!(user, self)
      Subscriber.unsubscribe!(self, user)
    end
  end

  def visible_watchers
    system_user_ids = User.where(username: 'kernel').pluck(:id)
    watchers.where.not(id: system_user_ids)
  end

  # only people following the product, ie. excludes people on announcements only
  def followers
    watchers
  end

  def follower_ids
    watchings.pluck(:user_id)
  end

  def followed_by?(user)
    Watching.following?(user, self)
  end

  def auto_watch!(user)
    Watching.auto_watch!(user, self)
  end

  def watching?(user)
    Watching.watched?(user, self)
  end

  def poster_image
    self.logo || PosterImage.new(self)
  end

  def logo_url
    if logo
      logo.url
    elsif poster
      poster_image.url
    else
      DEFAULT_IMAGE_PATH
    end
  end

  def generate_authentication_token
    loop do
      self.authentication_token = Devise.friendly_token
      break authentication_token unless Product.find_by(authentication_token: authentication_token)
    end
  end

  def generate_asmlytics_key
    self.asmlytics_key = Digest::SHA1.hexdigest(ENV['ASMLYTICS_SECRET'].to_s + SecureRandom.uuid)
  end

  def product
    self
  end

  def average_bounty
    bounties = TransactionLogEntry.bounty_values_on_product(product)
    return DEFAULT_BOUNTY_SIZE if bounties.none?
    bounties.inject(0, &:+) / bounties.size
  end

  def coins_minted
    transaction_log_entries.with_cents.sum(:cents)
  end

  def profit_last_month
    last_report = profit_reports.order('end_at DESC').first
    (last_report && last_report.profit) || 0
  end

  def ownership
    ProductOwnership.new(self)
  end

  def update_partners_count_cache
    self.partners_count = ownership.user_cents.size
  end

  def update_watchings_count!
    update! watchings_count: (subscribers.count + followers.count)
  end

  def tags_string
    tags.join(', ')
  end

  def tags_string=(new_tags_string)
    self.tags = new_tags_string.split(',').map(&:strip)
  end

  def topic=(new_topic)
    self.topics = [new_topic]
  end

  def showcase=(showcase_slug)
    Showcase.find_by!(slug: showcase_slug).add!(self)
  end

  def assembly?
    slug == 'asm'
  end

  def meta?
    slug == 'meta'
  end

  def draft?
    self.description.blank? && (self.info || {}).values.all?(&:blank?)
  end

  def bounty_postings
    BountyPosting.joins(:bounty).where('wips.product_id = ?', id)
  end

  # elasticsearch
  def update_elasticsearch
    return unless (['name', 'pitch', 'description'] - self.changed).any?

    Indexer.perform_async(:index, Product.to_s, self.id)
  end

  mappings do
    indexes :name, type: 'multi_field' do
      indexes :name
      indexes :raw, analyzer: 'keyword'
    end

    indexes :pitch,       analyzer: 'snowball'
    indexes :description, analyzer: 'snowball'
    indexes :tech,        analyzer: 'keyword'

    indexes :marks do
      indexes :name
      indexes :weight, type: 'float'
    end

    indexes :suggest, type: 'completion', payloads: true, index_analyzer: 'simple', search_analyzer: 'simple'
  end

  def as_indexed_json(options={})
    as_json(
      root: false,
      only: [:slug, :name, :pitch, :poster],
      methods: [:tech, :hidden, :sanitized_description, :suggest, :trend_score]
    ).merge(marks: mark_weights, logo_url: full_logo_url, search_tags: tags)
  end

  def mark_weights
    markings.sort_by{|marking| -marking.weight }.
             take(5).
             map{|marking| { weight: marking.weight, name: marking.mark.name } }
  end

  def trend_score
    product_trend.try(:score).to_i
  end

  def suggest
    {
      input: [name, pitch] + name.split(' ') + pitch.split(' '),
      output: id,
      weight: trend_score,
      payload: {
        id: id,
        slug: slug,
        name: name,
        pitch: pitch,
        logo_url: full_logo_url,
      }
    }
  end

  def full_logo_url
    # this is a hack to get a full url into elasticsearch, so firesize can resize it correctly.
    # DEFAULT_IMAGE_PATH is a relative image path
    logo_url == DEFAULT_IMAGE_PATH ? File.join(Rails.application.routes.url_helpers.root_url, DEFAULT_IMAGE_PATH) : logo_url
  end

  def tech
    Search::TechFilter.matching(tags).map(&:slug)
  end

  def poster_image_url
    unless self.logo.nil?
      self.logo.url
    else
      PosterImage.new(self).url
    end
  end

  def hidden
    PRIVATE.include?(slug) || flagged?
  end

  def flagged?
    !!flagged_at
  end

  def sanitized_description
    description && Search::Sanitizer.new.sanitize(description)
  end

  # pusher

  def push_channel
    slug
  end

  def retrieve_key_pair
    AssemblyCoin::AssignBitcoinKeyPairWorker.perform_async(
      self.to_global_id,
      :assign_key_pair
    )
  end

  def assign_key_pair(key_pair)
    update!(
      wallet_public_address: key_pair["public_address"],
      wallet_private_key: key_pair["private_key"]
    )
  end

  def unvested_coins
    unvested = 10_000_000 - transaction_log_entries.sum(:cents)
    if unvested < 0
      unvested = 0
    end
    unvested
  end

  def mark_vector
    QueryMarks.new.mark_vector_for_object(self)
  end

  def normalized_mark_vector()
    QueryMarks.new.normalize_mark_vector(mark_vector)
  end

  # this bears commenting. If new_try_url is blank, set this to nil
  def try_url=(new_try_url)
    super(new_try_url.presence)
  end

  protected

  def add_to_event_stream
    StreamEvent.add_create_event!(actor: user, subject: self)
  end
end