octobox/octobox

View on GitHub
app/models/notification.rb

Summary

Maintainability
B
5 hrs
Test Coverage
# frozen_string_literal: true
class Notification < ApplicationRecord

  include Octobox::Notifications::InclusiveScope
  include Octobox::Notifications::ExclusiveScope
  include Octobox::Notifications::SyncSubject

  SUBJECTABLE_TYPES = SUBJECT_TYPE_COMMIT_RELEASE + SUBJECT_TYPE_ISSUE_REQUEST

  include PgSearch::Model
  pg_search_scope :search_by_subject_title,
                  against: :subject_title,
                  order_within_rank: 'notifications.updated_at DESC',
                  using: {
                    tsearch: {
                      prefix: true,
                      negation: true,
                      dictionary: "english"
                    }
                  }

  belongs_to :user
  belongs_to :subject, foreign_key: :subject_url, primary_key: :url, optional: true
  belongs_to :repository, foreign_key: :repository_full_name, primary_key: :full_name, optional: true
  has_many :labels, through: :subject

  has_one :app_installation, through: :repository

  validates :subject_url, presence: true
  validates :archived, inclusion: [true, false]

  after_update :push_if_changed
  after_destroy :clean_up_subject

  class << self
    def attributes_from_api_response(api_response)
      attrs = DownloadService::API_ATTRIBUTE_MAP.map do |attr, path|
        value = api_response.to_h.dig(*path)
        value = value.dup if value.frozen?
        value.delete!("\u0000") if value.is_a?(String)
        [attr, value]
      end.to_h
      if "RepositoryInvitation" == api_response.subject.type
        attrs[:subject_url] = "#{api_response.repository.html_url}/invitations"
      end
      attrs[:updated_at] = Time.current if api_response.updated_at.nil?
      attrs
    end
  end

  def title
    return unless display_subject?

    if (body = subject.try(:body))
      body.split("\n")[0..3].join
    end
  end

  def state
    return unless display_subject?
    @state ||= subject.try(:state)
  end

  def draft?
    return unless display_subject?
    @draft ||= subject.try(:draft?)
  end

  def private?
    repository.try(:private?)
  end

  def display?
    return true unless private?
    return true unless Octobox.io?
    repository.try(:display_subject?) || user.has_personal_plan?
  end

  def self.archive(notifications, value)
    value = value ? ActiveRecord::Type::Boolean.new.cast(value) : true
    notifications.update_all(archived: value)
    user = notifications.first.try(:user)
    ArchiveWorker.perform_async_if_configured(user.id, notifications.map(&:github_id)) if user && value
  end

  def self.archive_on_github(user, notification_ids)
    conn = user.github_client.client_without_redirects
    manager = Typhoeus::Hydra.new(max_concurrency: Octobox.config.max_concurrency)
    begin
      conn.in_parallel(manager) do
        notification_ids.each do |id|
            conn.delete "notifications/threads/#{id}"
        end
      end
    rescue Octokit::Forbidden, Octokit::NotFound, Octokit::Unauthorized
      # one or more notifications are for repos the user no longer has access to
    end
  end

  def self.mark_read(notifications)
    unread = notifications.select(&:unread)
    return if unread.empty?
    user = unread.first.user
    MarkReadWorker.perform_async_if_configured(user.id, unread.map(&:github_id))
    where(id: unread.map(&:id)).update_all(unread: false)
  end

  def self.mark_read_on_github(user, notification_ids)
    conn = user.github_client.client_without_redirects
    manager = Typhoeus::Hydra.new(max_concurrency: Octobox.config.max_concurrency)
    begin
      conn.in_parallel(manager) do
        notification_ids.each do |id|
            conn.patch "notifications/threads/#{id}"
        end
      end
    rescue Octokit::Forbidden, Octokit::NotFound, Octokit::Unauthorized
      # one or more notifications are for repos the user no longer has access to
    end
  end

  def self.mute(notifications)
    return if notifications.empty?
    user = notifications.to_a.first.user
    MuteNotificationsWorker.perform_async_if_configured(user.id, notifications.map(&:github_id))
    where(id: notifications.map(&:id)).update_all(archived: true, unread: false, muted_at: Time.current)
  end

  def self.mute_on_github(user, notification_ids)
    conn = user.github_client.client_without_redirects
    manager = Typhoeus::Hydra.new(max_concurrency: Octobox.config.max_concurrency)
    begin
      conn.in_parallel(manager) do
        notification_ids.each do |id|
          conn.patch "notifications/threads/#{id}"
          conn.put "notifications/threads/#{id}/subscription", {ignored: true}.to_json
        end
      end
    rescue Octokit::Forbidden, Octokit::NotFound, Octokit::Unauthorized
      # one or more notifications are for repos the user no longer has access to
    end
  end

  def expanded_subject_url
    return subject_url unless display_subject?
    subject.try(:html_url) || subject_url # Use the sync'd HTML URL if possible, else the API one
  end

  def web_url
    Octobox::SubjectUrlParser.new(expanded_subject_url, latest_comment_url: latest_comment_url)
      .to_html_url
  end

  def repo_url
    "#{Octobox.config.github_domain}/#{repository_full_name}"
  end

  def unarchive_if_updated
    return unless self.archived?
    change = changes['updated_at']
    return unless change
    if self.archived && change[1] > change[0]
      self.archived = false
    end
  end

  def update_from_api_response(api_response)
    attrs = Notification.attributes_from_api_response(api_response)
    self.attributes = attrs
    self.archived = false if archived.nil? # fixup existing records where archived is nil
    unarchive_if_updated
    if changed?
      save(touch: false)
      update_repository(api_response)
      update_subject
    end
  end

  def github_app_installed?
    Octobox.github_app? && user.try(:github_app_authorized?) && repository.try(:github_app_installed?)
  end

  def subjectable?
    SUBJECTABLE_TYPES.include?(subject_type)
  end

  def display_subject?
    @display_subject ||= subjectable? && (Octobox.fetch_subject? || repository.try(:display_subject?) || user.has_personal_plan?)
  end

  def upgrade_required?
    return nil unless repository.present?
    repository.private? && !(repository.required_plan_available? || user.has_personal_plan?)
  end

  def prerender?
    unread? and !['closed', 'merged'].include?(state) and !display_thread?
  end

  def subject_number
    subject_url.scan(/\d+$/).first
  end

  def display_thread?
    @display_thread ||= Octobox.include_comments? && subjectable? && subject.present? && user.try(:display_comments?)
  end

  def push_if_changed
    push_to_channel if (saved_changes.keys & pushable_fields).any?
  end

  def pushable_fields
    ['archived', 'reason', 'subject_title', 'subject_url', 'subject_type', 'unread']
  end

  def push_to_channel
    notification = ApplicationController.render(partial: 'notifications/notification', locals: { notification: self})
    subject = ApplicationController.render(partial: 'notifications/thread_subject', locals: { notification: self})
    ActionCable.server.broadcast "notifications:#{user_id}", { id: self.id, notification: notification, subject: subject }
  end

  def update_repository(api_response)
    repo = repository ||  Repository.find_or_create_by(github_id: api_response['repository']['id'])
    repo.assign_attributes({
      full_name: api_response['repository']['full_name'],
      private: api_response['repository']['private'],
      owner: api_response['repository']['full_name'].split('/').first,
      github_id: api_response['repository']['id']
    })
    if repo.changed?
      repo.last_synced_at = Time.current
      repo.save
    end
  end

  def clean_up_subject
    subject.destroy if subject && subject.notifications.empty?
  end
end