sharetribe/sharetribe

View on GitHub
app/models/person.rb

Summary

Maintainability
D
2 days
Test Coverage
# == Schema Information
#
# Table name: people
#
#  id                                 :string(22)       not null, primary key
#  uuid                               :binary(16)       not null
#  community_id                       :integer          not null
#  created_at                         :datetime
#  updated_at                         :datetime
#  is_admin                           :integer          default(0)
#  locale                             :string(255)      default("fi")
#  preferences                        :text(65535)
#  active_days_count                  :integer          default(0)
#  last_page_load_date                :datetime
#  test_group_number                  :integer          default(1)
#  username                           :string(255)      not null
#  email                              :string(255)
#  encrypted_password                 :string(255)      default(""), not null
#  legacy_encrypted_password          :string(255)
#  reset_password_token               :string(255)
#  reset_password_sent_at             :datetime
#  remember_created_at                :datetime
#  sign_in_count                      :integer          default(0)
#  current_sign_in_at                 :datetime
#  last_sign_in_at                    :datetime
#  current_sign_in_ip                 :string(255)
#  last_sign_in_ip                    :string(255)
#  password_salt                      :string(255)
#  given_name                         :string(255)
#  family_name                        :string(255)
#  display_name                       :string(255)
#  phone_number                       :string(255)
#  description                        :text(65535)
#  image_file_name                    :string(255)
#  image_content_type                 :string(255)
#  image_file_size                    :integer
#  image_updated_at                   :datetime
#  image_processing                   :boolean
#  facebook_id                        :string(255)
#  authentication_token               :string(255)
#  community_updates_last_sent_at     :datetime
#  min_days_between_community_updates :integer          default(1)
#  deleted                            :boolean          default(FALSE)
#  cloned_from                        :string(22)
#  google_oauth2_id                   :string(255)
#  linkedin_id                        :string(255)
#
# Indexes
#
#  index_people_on_authentication_token               (authentication_token)
#  index_people_on_community_id                       (community_id)
#  index_people_on_community_id_and_google_oauth2_id  (community_id,google_oauth2_id)
#  index_people_on_community_id_and_linkedin_id       (community_id,linkedin_id)
#  index_people_on_email                              (email) UNIQUE
#  index_people_on_facebook_id                        (facebook_id)
#  index_people_on_facebook_id_and_community_id       (facebook_id,community_id) UNIQUE
#  index_people_on_google_oauth2_id                   (google_oauth2_id)
#  index_people_on_id                                 (id)
#  index_people_on_linkedin_id                        (linkedin_id)
#  index_people_on_reset_password_token               (reset_password_token) UNIQUE
#  index_people_on_username                           (username)
#  index_people_on_username_and_community_id          (username,community_id) UNIQUE
#  index_people_on_uuid                               (uuid) UNIQUE
#

require 'json'
require 'rest_client'
require "open-uri"

# This class represents a person (a user of Sharetribe).
class Person < ApplicationRecord

  include ErrorsHelper
  include ApplicationHelper
  include DeletePerson
  include Person::ToView

  self.primary_key = "id"

  # Include default devise modules. Others available are:
  # :lockable, :timeoutable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable,
         :omniauthable

  attr_accessor :guid, :form_login,
                :form_given_name, :form_family_name, :form_password,
                :form_password2, :form_email,
                :input_again, :send_notifications
  attr_writer :password2, :consent

  # Virtual attribute for authenticating by either username or email
  # This is in addition to a real persisted field like 'username'
  attr_accessor :login

  has_many :listings, -> { exist }, :dependent => :destroy, :foreign_key => "author_id", :inverse_of => :author
  has_many :emails, :dependent => :destroy, :inverse_of => :person

  has_one :location, -> { where(location_type: :person) }, :dependent => :destroy, :inverse_of => :person

  has_many :participations, :dependent => :destroy
  has_many :conversations, :through => :participations, :dependent => :destroy
  has_many :authored_testimonials, :class_name => "Testimonial", :foreign_key => "author_id", :dependent => :destroy, :inverse_of => :author
  has_many :received_testimonials, -> { id_order.non_blocked }, :class_name => "Testimonial", :foreign_key => "receiver_id", :dependent => :destroy, :inverse_of => :receiver
  has_many :received_positive_testimonials, -> { positive.id_order.non_blocked }, :class_name => "Testimonial", :foreign_key => "receiver_id", :inverse_of => :receiver
  has_many :received_negative_testimonials, -> { negative.id_order.non_blocked }, :class_name => "Testimonial", :foreign_key => "receiver_id", :inverse_of => :receiver
  has_many :messages, :foreign_key => "sender_id", :dependent => :destroy, :inverse_of => :sender
  has_many :authored_comments, :class_name => "Comment", :foreign_key => "author_id", :dependent => :destroy, :inverse_of => :author
  belongs_to :community
  has_many :community_memberships, :dependent => :destroy
  has_many :communities, -> { where("community_memberships.status = 'accepted'") }, :through => :community_memberships
  has_one  :community_membership, :dependent => :destroy
  has_one  :accepted_community, -> { where("community_memberships.status= 'accepted'") }, through: :community_membership, source: :community
  has_many :invitations, :foreign_key => "inviter_id", :dependent => :destroy, :inverse_of => :inviter
  has_many :auth_tokens, :dependent => :destroy
  has_many :follower_relationships, :dependent => :destroy
  has_many :followers, :through => :follower_relationships, :foreign_key => "person_id"
  has_many :inverse_follower_relationships, :class_name => "FollowerRelationship", :foreign_key => "follower_id", :dependent => :destroy, :inverse_of => :follower
  has_many :followed_people, :through => :inverse_follower_relationships, :source => "person"

  has_and_belongs_to_many :followed_listings, :class_name => "Listing", :join_table => "listing_followers"
  has_many :custom_field_values, :dependent => :destroy
  has_many :custom_dropdown_field_values, :class_name => "DropdownFieldValue", :dependent => :destroy
  has_many :custom_checkbox_field_values, :class_name => "CheckboxFieldValue", :dependent => :destroy
  has_one :stripe_account, :dependent => :destroy
  has_one :paypal_account, :dependent => :destroy
  has_many :starter_transactions, :class_name => "Transaction", :foreign_key => "starter_id", :dependent => :destroy, :inverse_of => :starter
  has_many :payer_stripe_payments, :class_name => "StripePayment", :foreign_key => "payer_id", :dependent => :destroy, :inverse_of => :payer
  has_many :receiver_stripe_payments, :class_name => "StripePayment", :foreign_key => "receiver_id", :dependent => :destroy, :inverse_of => :receiver

  deprecate communities: "Use accepted_community instead.",
            community_memberships: "Use community_membership instead.",
            deprecator: MethodDeprecator.new

  scope :by_community, ->(community_id) { where(community_id: community_id) }
  scope :search_name_or_email, ->(community_id, pattern) {
    by_community(community_id)
      .joins(:emails)
      .where("#{Person.search_by_pattern_sql('people')}
        OR emails.address like :pattern", pattern: pattern)
  }
  scope :has_listings, ->(community) do
    joins("INNER JOIN `listings` ON `listings`.`author_id` = `people`.`id` AND `listings`.`community_id` = #{community.id} AND `listings`.`deleted` = 0").distinct
  end
  scope :has_no_listings, ->(community) do
    joins("LEFT OUTER JOIN `listings` ON `listings`.`author_id` = `people`.`id` AND `listings`.`community_id` = #{community.id} AND `listings`.`deleted` = 0")
    .where(listings: {author_id: nil}).distinct
  end
  scope :has_stripe_account, ->(community) do
    where(id: StripeAccount.active_users.by_community(community).select(:person_id))
  end
  scope :has_no_stripe_account, ->(community) do
    where.not(id: StripeAccount.active_users.by_community(community).select(:person_id))
  end
  scope :has_paypal_account, ->(community) do
    where(id: PaypalAccount.active_users.by_community(community).select(:person_id))
  end
  scope :has_no_paypal_account, ->(community) do
    where.not(id: PaypalAccount.active_users.by_community(community).select(:person_id))
  end
  scope :has_payment_account, ->(community) { has_stripe_account(community).or(has_paypal_account(community)) }
  scope :has_started_transactions, ->(community) do
    joins("INNER JOIN `transactions` ON `transactions`.`starter_id` = `people`.`id` AND `transactions`.`community_id` = #{community.id} AND `transactions`.`current_state` IN ('paid', 'confirmed')").distinct
  end
  scope :is_admin, -> { where(is_admin: 1) }
  scope :by_email, ->(email) do
    joins(:emails).merge(Email.confirmed.by_address(email))
  end
  scope :by_unconfirmed_email, ->(email) do
    joins(:emails).merge(Email.unconfirmed.by_address(email))
  end
  scope :username_exists, ->(username, community) do
    where("username = :username AND (is_admin = '1' OR community_id = :cid)", username: username, cid: community.id)
  end

  accepts_nested_attributes_for :custom_field_values

  def to_param
    username
  end

  DEFAULT_TIME_FOR_COMMUNITY_UPDATES = 7.days

  # These are the email notifications, excluding newsletters settings
  EMAIL_NOTIFICATION_TYPES = [
    "email_about_new_messages",
    "email_about_new_comments_to_own_listing",
    "email_when_conversation_accepted",
    "email_when_conversation_rejected",
    "email_about_new_received_testimonials",
    "email_about_confirm_reminders",
    "email_about_testimonial_reminders",
    "email_about_completed_transactions",
    "email_about_new_payments",
    "email_about_new_listings_by_followed_people",
    "email_listing_new_comment",
    "email_listing_updated"
    # These should not yet be shown in UI, although they might be stored in DB
    # "email_when_new_friend_request",
    # "email_when_new_feedback_on_transaction",
    # "email_when_new_listing_from_friend"
  ]
  EMAIL_NEWSLETTER_TYPES = [
    "email_from_admins"
  ]

  serialize :preferences

  validates_length_of :phone_number, :maximum => 25, :allow_nil => true, :allow_blank => true
  validates_length_of :given_name, :within => 1..30, :allow_nil => true, :allow_blank => true
  validates_length_of :family_name, :within => 1..30, :allow_nil => true, :allow_blank => true
  validates_length_of :display_name, :within => 1..100, :allow_nil => true, :allow_blank => true

  USERNAME_BLACKLIST = YAML.load_file("#{Rails.root}/config/username_blacklist.yml")

  validates :username, exclusion: {in: USERNAME_BLACKLIST, message: :username_is_invalid},
                       uniqueness: {scope: :community_id},
                       length: {within: 3..20},
                       format: {with: /\A[A-Z0-9_]*\z/i, message: :username_is_invalid}

  has_attached_file :image,
                    styles: {
                      medium: "288x288#",
                      small: "108x108#",
                      thumb: "48x48#",
                      original: "600x800>"
                    },
                    convert_options: { all: "-strip" }

  process_in_background :image, priority: 1

  #validates_attachment_presence :image
  validates_attachment_size :image, :less_than => 9.megabytes
  validates_attachment_content_type :image,
                                    :content_type => IMAGE_CONTENT_TYPE

  before_validation(:on => :create) do
    self.id = SecureRandom.urlsafe_base64
    self.username = self.username.presence || UserService::API::Users.generate_username(given_name, family_name, community_id)
    set_default_preferences unless self.preferences
  end

  after_initialize :add_uuid
  def add_uuid
    self.uuid ||= UUIDUtils.create_raw
  end

  def uuid_object
    if self[:uuid].nil?
      nil
    else
      UUIDUtils.parse_raw(self[:uuid])
    end
  end

  # Creates a new email
  def email_attributes=(attributes)
    ActiveSupport::Deprecation.warn(
      ["Person.email_attributes is deprecated.",
       "Instead of using nested attributes, build each associated",
       "model individually inside a DB transaction in the controller."].join(" "))

    emails.build(attributes)
  end

  def set_emails_that_receive_notifications(email_ids)
    if email_ids
      emails.each do |email|
        email.update_attribute(:send_notifications, (email_ids.include?(email.id.to_s) && email.confirmed_at))
      end
    end
  end

  def last_community_updates_at
    community_updates_last_sent_at || DEFAULT_TIME_FOR_COMMUNITY_UPDATES.ago
  end

  def self.username_blacklist
    USERNAME_BLACKLIST
  end

  def self.username_available?(username, community, current_user = nil)
    current_scope = current_user ? self.where.not(id: current_user.id) : self
    !USERNAME_BLACKLIST.include?(username.downcase) &&
      !current_scope.username_exists(username, community).present?
  end

  def set_given_name(name)
    update({:given_name => name })
  end

  def street_address
    if location
      return location.address
    else
      return nil
    end
  end

  def custom_update(params)
    if params[:preferences]
      update(params)
    else

      #Handle location information
      if params[:location]
        if self.location && self.location.address != params[:street_address]
          #delete location and create a new one
          self.location.delete
        end

        # Set the address part of the location to be similar to what the user wrote.
        # the google_address field will store the longer string for the exact position.
        params[:location][:address] = params[:street_address] if params[:street_address]

        self.location = Location.new(params[:location])
        params[:location].each {|key| params[:location].delete(key)}
        params.delete(:location)
      end

      save
      update(params.except("password2", "street_address"))
    end
  end

  def picture_from_url(url)
    self.image = URI.open(url) # rubocop:disable Security/Open
    self.save
  end

  def offers
    listings.offers
  end

  def requests
    listings.requests
  end

  def feedback_positive_percentage_in_community(community)
    received = received_testimonials.by_community(community)
    positive = received_positive_testimonials.by_community(community)
    negative = received_negative_testimonials.by_community(community)

    if positive.size > 0
      if negative.size > 0
        (positive.size.to_f/received.size.to_f*100).round
      else
        return 100
      end
    elsif negative.size > 0
      return 0
    end
  end

  def set_default_preferences
    self.preferences = {}
    EMAIL_NOTIFICATION_TYPES.each { |t| self.preferences[t] = true }
    EMAIL_NEWSLETTER_TYPES.each { |t| self.preferences[t] = true }
    save
  end

  def password2
    if new_record?
      form_password2 || ""
    end
  end

  def can_delete_email(email)
    EmailService.can_delete_email(self.emails,
                                  email,
                                  self.accepted_community.allowed_emails)[:result]
  end

  # Returns true if the person has global admin rights in Sharetribe.
  def is_admin?
    is_admin == 1
  end

  # Starts following a listing
  def follow(listing)
    followed_listings << listing
  end

  # Unfollows a listing
  def unfollow(listing)
    followed_listings.delete(listing)
  end

  # Checks if this user is following the given listing
  def is_following?(listing)
    followed_listings.include?(listing)
  end

  # Updates the user following status based on the given status
  # for the given listing
  def update_follow_status(listing, status)
    unless id == listing.author.id
      if status
        follow(listing) unless is_following?(listing)
      else
        unfollow(listing) if is_following?(listing)
      end
    end
  end

  def read(conversation)
    conversation.participations.where(["person_id LIKE ?", self.id]).first.update_attribute(:is_read, true)
  end

  def consent
    community_membership.consent
  end

  def is_marketplace_admin?(community)
    community_membership.community_id == community.id && community_membership.admin?
  end

  def has_admin_rights?(community)
    is_admin? || is_marketplace_admin?(community)
  end

  def should_receive?(email_type)
    return false if banned?

    confirmed_email = !confirmed_notification_emails.empty?
    if email_type == "community_updates"
      # this is handled outside prefenrences so answer separately
      return confirmed_email && min_days_between_community_updates < 100000
    end

    confirmed_email && preferences && preferences[email_type]
  end

  def profile_info_empty?
    (phone_number.nil? || phone_number.blank?) && (description.nil? || description.blank?) && location.nil?
  end

  def member_of?(community)
    community.members.include?(self)
  end

  def banned?
    community_membership&.banned?
  end

  def has_email?(address)
    Email.find_by_address_and_person_id(address, self.id).present?
  end

  def confirmed_notification_emails
    emails.send_notifications.confirmed
  end

  def confirmed_notification_email_addresses
    self.confirmed_notification_emails.collect(&:address)
  end

  def valid_email
    confirmed_notification_email_addresses.first || primary_email.try(:address)
  end

  # Notice: If no confirmed notification emails is found, this
  # method returns the first confirmed emails
  def confirmed_notification_emails_to
    send_message_to = EmailService.emails_to_send_message(emails)
    EmailService.emails_to_smtp_addresses(send_message_to)
  end

  # Primary email is the first email address that is
  #
  # - confirmed
  # - notifications allowed
  #
  # Returns Email record
  #
  def primary_email
    EmailService.emails_to_send_message(emails).first
  end

  # Notice: If no confirmed notification emails is found, this
  # method returns the first confirmed emails
  def confirmed_notification_email_to
    send_message_to = EmailService.emails_to_send_message(emails).first

    Maybe(send_message_to).map { |email|
      EmailService.emails_to_smtp_addresses([email])
    }.or_else(nil)
  end

  # Returns true if the address given as a parameter is confirmed
  def has_confirmed_email?(address)
    email = Email.find_by_address_and_person_id(address, self.id)
    email.present? && email.confirmed_at.present?
  end

  def has_valid_email_for_community?(community)
    community.can_accept_user_based_on_email?(self)
  end

  def self.find_by_email_address_and_community_id(email_address, community_id)
    Maybe(
      Email.find_by_address_and_community_id(email_address, community_id)
    ).person.or_else(nil)
  end

  def reset_password_token_if_needed
    # Devise 3.1.0 doesn't expose methods to generate reset_password_token without
    # sending the email, so this code is copy-pasted from Recoverable module
    raw, enc = Devise.token_generator.generate(self.class, :reset_password_token)
    self.reset_password_token   = enc
    self.reset_password_sent_at = Time.now.utc
    save(:validate => false)
    raw
  end

  # If image_file_name is null, it means the user
  # does not have a profile picture.
  def has_profile_picture?
    image_file_name.present?
  end

  # Tell Devise that email is not required
  def email_required?
    false
  end

  # Tell Devise that email is not required
  def email_changed?
    false
  end

  # A person inherits some default settings from the community in which she is joining
  def inherit_settings_from(current_community)
    self.min_days_between_community_updates = current_community.default_min_days_between_community_updates
  end

  def should_receive_community_updates_now?
    return false unless should_receive?("community_updates")
    # return whether or not enought time has passed. The - 45.minutes is because the sending takes some time so we want
    # 1 day limit to match even if there's 23.55 minutes passed since last sending.
    return true if community_updates_last_sent_at.nil?

    return community_updates_last_sent_at + min_days_between_community_updates.days - 45.minutes < Time.now
  end

  # Returns and email that is pending confirmation
  # If community is given as parameter, in case of many pending
  # emails the one required by the community is returned
  def latest_pending_email_address(community=nil)
    pending_emails = Email.where(:person_id => id, :confirmed_at => nil).pluck(:address)

    allowed_emails = if community&.allowed_emails
      pending_emails.select do |e|
        community.email_allowed?(e)
      end
    else
      pending_emails
    end

    allowed_emails.last
  end

  def follows?(person)
    followed_people_by_id.include?(person.id)
  end

  def followed_people_by_id
    @followed_people_by_id ||= followed_people.group_by(&:id)
  end

  def self.members_of(community)
    joins(:communities).where("communities.id" => community.id)
  end


  # Overrides method injected from Devise::DatabaseAuthenticatable
  # Updates password with password that has been rehashed with new algorithm.
  # Removes legacy password and salt.
  def valid_password?(password)
    if self.legacy_encrypted_password.present?
      if digest(password, self.password_salt).casecmp(self.legacy_encrypted_password) == 0
        self.password = password
        self.legacy_encrypted_password = nil
        self.password_salt = nil
        self.save!
        true
      else
        false
      end
    else
      super
    end
  end

  # Overrides method injected from Devise::DatabaseAuthenticatable
  # Removes legacy pashsword and salt.
  def password=(*args)
    self.legacy_encrypted_password = nil
    self.password_salt = nil
    super
  end

  def unsubscribe_from_community_updates
    self.min_days_between_community_updates = 100000
    self.save!
  end

  def custom_field_value_for(custom_field)
    custom_field_values.by_question(custom_field).first
  end

  private

  def digest(password, salt)
    str = [password, salt].flatten.compact.join
    ::Digest::SHA256.hexdigest(str)
  end

  def logger
    @logger ||= SharetribeLogger.new(:person, logger_metadata.keys).tap { |logger|
      logger.add_metadata(logger_metadata)
    }
  end

  def logger_metadata
    { person_uuid: uuid }
  end

  class << self
    def search_by_pattern_sql(table, pattern=':pattern')
      "(#{table}.given_name LIKE #{pattern} OR #{table}.family_name LIKE #{pattern} OR #{table}.display_name LIKE #{pattern})"
    end
  end
end