assemblymade/meta

View on GitHub
app/models/idea.rb

Summary

Maintainability
B
5 hrs
Test Coverage
class Idea < ActiveRecord::Base
  include ActiveRecord::UUID
  include Kaminari::ActiveRecordModelExtension

  extend FriendlyId

  friendly_id :slug_candidates, use: :slugged

  belongs_to :product
  belongs_to :user

  has_many :markings, as: :markable
  has_many :marks, through: :markings
  has_one :news_feed_item, foreign_key: 'target_id'

  delegate :news_feed_item_comments, to: :news_feed_item

  validates :name, presence: true,
                   length: { minimum: 2, maximum: 255 },
                   exclusion: { in: Product::EXCLUSIONS }
  validates :tilting_threshold, presence: true
  validate :idea_and_product_have_same_user

  before_validation :set_tilting_threshold!, on: :create

  after_commit :ensure_news_feed_item, on: :create
  after_commit :touch_news_feed_item, on: :update

  default_scope -> { where(deleted_at: nil) }

  scope :by, -> (user) { where(user_id: user.id) }
  scope :hearts, -> { includes(:news_feed_item).order('news_feed_items.hearts_count DESC') }
  scope :greenlit, -> { where.not(greenlit_at: nil) }
  scope :newness, -> { order(created_at: :desc) }
  scope :trending, -> { order(score: :desc) }
  scope :with_mark,  -> (name) { joins(:marks).where(marks: { name: name }) }
  scope :with_percentile, -> (percentile) {
    all.sort_by(&:percentile).
    take(percentile * all.count/100)
  }
  scope :with_topic, -> (topic) { where("? = ANY(topics)", topic) }
  scope :recently_tilted, -> { joins(:product).unscope(where: :product_id).merge(Product.where('products.created_at > ?', 2.weeks.ago)).where.not(product_id: nil) }

  HEARTBURN = 30.days  # period for 100% inflation, equivalent to half-life
  EPOCH_START = Time.new(2013, 6, 6)

  DEFAULT_TILTING_THRESHOLD = 15
  COMMENT_MINIMUM = 5

  CATEGORY_NAMES = [
    "Ideas searching for names",
    "Fresh ideas discussing strategy"
  ]

  CATEGORY_SLUGS = CATEGORY_NAMES.map { |name| name.downcase.gsub(/ /, "-") }

  TOPIC_NAMES = [
    "Art & Design",
    "Education",
    "Entertainment & Games",
    "Family & Lifestyle",
    "Mobile",
    "Productivity & Tools",
    "SaaS",
    "Social"
  ]

  TOPIC_SLUGS = TOPIC_NAMES.map { |name| name.downcase.gsub(/ /, "-") }

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

  def idea_and_product_have_same_user
    if product
      user == product.user
    else
      true
    end
  end

  def self.create_with_discussion(user, idea_params)
    idea = transaction do
      idea = user.ideas.create(idea_params)
      idea.push_to_news_feed
      user.touch
      idea
    end
  end

  def ensure_news_feed_item
    push_to_news_feed if news_feed_item.nil?
  end

  # this is for heart emails, but I think any 'thread' should have a title
  def title
    name
  end

  def comments
    if news_feed_item
      news_feed_item.comments
    else
      []
    end
  end

  def creator_username
    user.username
  end

  def push_to_news_feed
    NewsFeedItem.create_with_target(self)
  end

  def touch_news_feed_item
    if news_feed_item
      news_feed_item.touch
    end
  end

  def add_marks(mark_names)
    return false unless mark_names.is_a? Array

    if mark_names.reject(&:blank?).empty?
      return self.markings.destroy_all
    else
      mark_names.each do |mark_name|
        self.add_mark(mark_name)
      end
    end
  end

  def add_mark(mark_name)
    MakeMarks.new.mark_with_name(self, mark_name)
  end

  def mark_names
    marks.map(&:name)
  end

  def mark_names=(names)
    self.marks = Array.wrap(names).flatten.map do |n|
      Mark.find_or_create_by!(name: n.strip)
    end
  end

  def greenlight!
    update(greenlit_at: Time.now)
    send_greenlit_notifications
  end

  def should_greenlight?
    hearts_count >= tilting_threshold
  end

  def send_greenlit_notifications
    # no-op for now
  end

  def love
    self.news_feed_item.hearts.count
  end

  def hearted
    add_score
  end

  def tiltable
    hearts = self.love >= DEFAULT_TILTING_THRESHOLD
    name = self.name.present? && (self.name != "Discuss potential names in the comments")
    comments = self.comments.count >= COMMENT_MINIMUM

    hearts && name && comments && !self.product_id.present?
  end

  def unhearted(heart)
    decrement_score(heart)
  end

  def checklist_state
    checklists = []
    checklists.append(ChecklistHandler.checklist_hearts_idea(idea))
    checklists.append(ChecklistHandler.checklist_name_idea(idea))
    checklists.append(ChecklistHandler.checklist_comments_idea(idea))
    checklists
  end

  def update_last_score_update(new_score)
    self.update!({last_score_update: DateTime.now, score: new_score})
  end

  def add_score
    lovescore = self.score

    news_feed_item.hearts.where('created_at > ?', last_score_update).each do |h|
      lovescore = lovescore + score_multiplier_heart(h)
    end

    update_last_score_update(lovescore)

    if self.love == DEFAULT_TILTING_THRESHOLD
      send_tilt_email
    end
  end

  def score_multiplier_heart(heart)
    time_since = heart.created_at - EPOCH_START
    love_change = 2 ** (time_since.to_f / HEARTBURN.to_f)
  end

  def send_tilt_email
    the_key = "tilt_notification_" + self.slug + Time.now.strftime("%d%b%Y")
    recipient_id = self.user.id
    EmailLog.send_once(recipient_id, the_key) do
      TiltMailer.delay(queue: 'mailer').create(recipient_id, self.id)
    end
    self.update!({last_tilt_email_sent: DateTime.now})
  end

  def decrement_score(heart)
    lovescore = self.score - score_multiplier_heart(heart)
    update!({last_score_update: DateTime.now,
      score: lovescore})
  end

  def url_params
    [self]
  end

  def rank
    # Scores are being stored as double precision floats in PG but
    # Rails doesn't send through the correct precision float. Adding the
    # Epsiilon helps differentiate the scores.
    Idea.where(greenlit_at: nil, flagged_at: nil, deleted_at: nil)
        .where('score > ?', score + 0.00000001)
        .count + 1
  end

  def percentile
    rank.to_f / Idea.count * 100.round(2)
  end

  def set_tilting_threshold!
    return unless tilting_threshold.nil?

    threshold = heart_distance_from_percentile
    previous_threshold = Idea.order(created_at: :desc)
                             .limit(1)
                             .first
                             .try(:tilting_threshold)

    if threshold < previous_threshold.to_i
      threshold = previous_threshold.to_i
    end
    threshold = DEFAULT_TILTING_THRESHOLD if threshold < DEFAULT_TILTING_THRESHOLD
    update(tilting_threshold: threshold)
  end

  def score_multiplier
    time_since = Time.now - EPOCH_START
    multiplier = 2 ** (time_since.to_f / HEARTBURN.to_f)
  end

  # Top percentile is 0, not 100
  def heart_distance_from_percentile(goal_percentile=20)
    index = (Idea.where(greenlit_at: nil).count * goal_percentile.to_f/100).to_i

    if last_idea = Idea.order(score: :desc).limit(index == 0 ? 1 : index).last
      expected_score = last_idea.score
      hearts_missing = (expected_score - score) / score_multiplier
      (hearts_missing + 0.999).to_i
    else
      DEFAULT_TILTING_THRESHOLD
    end
  end

  def hearts_count
    news_feed_item.hearts_count
  end

  def reconstitute_score
    lovescore = 0

    news_feed_item.hearts.each do |h|
      lovescore = lovescore + score_multiplier
    end

    update!({
      last_score_update: DateTime.now,
      score: lovescore
    })
  end

  def twitter_description
    if self.body
      self.body.truncate(199)
    else
      ''
    end
  end

  def twitter_title
    self.name.truncate(69)
  end

  def tweet_creation
    Tweeter.new.tweet_idea(self)
  end

  def participants
    commenters = self.news_feed_item.comments.map{|a| a.user}
    lovers = self.news_feed_item.hearts.map{|b| b.user}
    creator = [self.user]
    (commenters + lovers + creator).uniq
  end

end