scottwillson/racing_on_rails

View on GitHub
app/models/post.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

# Archived mailing list post
class Post < ApplicationRecord
  validates :date, presence: true
  validates :from_email, presence: { on: :create }
  validates :from_name, presence: { on: :create }
  validates :mailing_list, presence: true
  validates :subject, presence: true

  validates :from_email, format: { with: /@/ }

  belongs_to :mailing_list
  belongs_to :original, class_name: "Post", inverse_of: :replies, counter_cache: :replies_count, optional: true
  has_many :replies, class_name: "Post", inverse_of: :original, foreign_key: :original_id

  scope :original, -> { where(original_id: nil) }

  attribute :date, :date, default: -> { Time.zone.now }

  # Save new or updated Post to database.
  #
  # Associate reply posts with original and update original's reply count and last reply time.
  # Reposition original based on reply time. Build or update full text search index.
  #
  # In most cases, you want to call this service method, not just save! or create!
  def self.save(post, mailing_list)
    post.subject = Post.normalize_subject(post.subject, mailing_list.subject_line_prefix)

    transaction do
      original = find_original(post)
      if original
        original.replies << post
        original.last_reply_at = post.date if original.last_reply_at.nil? || post.date > original.last_reply_at
        original.save!
      end

      post.last_reply_at = post.date
      return false unless post.save

      original&.reposition!
      ApplicationController.expire_cache
    end

    true
  end

  def self.destroy(post)
    transaction do
      original = find_original(post)
      if original
        original.replies_count = original.replies_count - 1

        original.last_reply_at = if original.replies_count == 0
                                   original.date
                                 else
                                   original.replies.reject { |r| r == post }.max_by(&:date).date
                                 end

        original.save
      end

      return false unless post.destroy

      original&.reposition!
      ApplicationController.expire_cache
    end

    true
  end

  # Find original post on this subject. Return nil if none.
  def self.find_original(post)
    subject = normalize_subject(post.subject, post.mailing_list.subject_line_prefix)
    posts = post.mailing_list.posts.where(subject: subject).original.order(:position)
    posts = posts.where.not(id: post.id) unless post.new_record?
    posts.first
  end

  # Strip whitespace, mailing list prefix, and re: and fwd:
  def self.normalize_subject(subject, subject_line_prefix)
    strip_subject remove_list_prefix(subject, subject_line_prefix)
  end

  def self.remove_list_prefix(subject, subject_line_prefix)
    return "" unless subject

    subject.gsub(/\[#{subject_line_prefix}\]\s*/, "").strip
  end

  # Remove re: and fwd:
  def self.strip_subject(subject)
    return "" unless subject

    subject
      .gsub(/\A(\s*)Re:(\s*)/i, "")
      .gsub(/\A(\s*)Fw(d):(\s*)/i, "")
      .gsub(/\s+/, " ")
      .strip
  end

  # Last few Posts from all public MailingLists
  def self.recent
    # Use two queries, not an include for DB performance
    public_mailing_lists = MailingList.is_public
    Post.original.includes(:mailing_list).where(mailing_list: public_mailing_lists).order("position desc").limit(5)
  end

  # Move Post into position in list based on last_reply_at. In practice, most new Posts
  # move to the top of the list (highest position).
  def reposition!
    new_position = Post.where("last_reply_at <= ?", last_reply_at).order("position desc").pick(:position)
    insert_at new_position if new_position && new_position != position
  end

  # Next most-recent original Post
  def newer
    @newer ||= mailing_list.posts.original.order(:position).where("position > ?", position).first
  end

  # Next oldest original Post
  def older
    @older ||= mailing_list.posts.original.order("position desc").where("position < ?", position).first
  end

  def position
    0
  end

  # Replace a couple letters from email addresses to avoid spammers
  def from_email_obscured
    return "" if from_email.blank?

    sender_parts = from_email.split("@")
    if sender_parts.size > 1
      person_name = sender_parts.first
      if person_name.length > 2
        return "#{person_name[0..(person_name.length - 3)]}..@#{sender_parts.last}"
      else
        return "..@#{sender_parts.last}"
      end
    end

    ""
  end
end