class User < ApplicationRecord include CloudinaryHelper attr_accessor :scholar_email, :new_note, :quick_match, :mentorship_note, :note_for_current_role, :add_mentor, :add_mentee, :user_status, :toggle_mentorship, :pro, :merge_user_id, :add_credits, :remove_credits, :add_org_credits, :remove_org_credits, :ghostify rolify include AlgoliaSearch include Storext.model acts_as_followable acts_as_follower belongs_to :organization, optional: true has_many :api_secrets, dependent: :destroy has_many :articles, dependent: :destroy has_many :badge_achievements, dependent: :destroy has_many :badges, through: :badge_achievements has_many :collections, dependent: :destroy has_many :comments, dependent: :destroy has_many :email_messages, class_name: "Ahoy::Message" has_many :github_repos, dependent: :destroy has_many :identities, dependent: :destroy has_many :mentions, dependent: :destroy has_many :messages, dependent: :destroy has_many :notes, as: :noteable, inverse_of: :noteable has_many :authored_notes, as: :author, inverse_of: :author, class_name: "Note" has_many :notifications, dependent: :destroy has_many :reactions, dependent: :destroy has_many :tweets, dependent: :destroy has_many :chat_channel_memberships, dependent: :destroy has_many :chat_channels, through: :chat_channel_memberships has_many :push_notification_subscriptions, dependent: :destroy has_many :feedback_messages has_many :rating_votes has_many :html_variants, dependent: :destroy has_many :page_views has_many :credits has_many :mentor_relationships_as_mentee, class_name: "MentorRelationship", foreign_key: "mentee_id", inverse_of: :mentee has_many :mentor_relationships_as_mentor, class_name: "MentorRelationship", foreign_key: "mentor_id", inverse_of: :mentor has_many :mentors, through: :mentor_relationships_as_mentee, source: :mentor has_many :mentees, through: :mentor_relationships_as_mentor, source: :mentee mount_uploader :profile_image, ProfileImageUploader devise :omniauthable, :rememberable, :registerable, :database_authenticatable, :confirmable validates :email, length: { maximum: 50 }, email: true, allow_nil: true validates :email, uniqueness: { allow_nil: true, case_sensitive: false }, if: :email_changed? validates :name, length: { minimum: 1, maximum: 100 } validates :username, presence: true, format: { with: /\A[a-zA-Z0-9_]+\Z/ }, length: { in: 2..30 }, exclusion: { in: ReservedWords.all, message: "username is reserved" } validates :username, uniqueness: { case_sensitive: false }, if: :username_changed? validates :twitter_username, uniqueness: { allow_nil: true }, if: :twitter_username_changed? validates :github_username, uniqueness: { allow_nil: true }, if: :github_username_changed? validates :experience_level, numericality: { less_than_or_equal_to: 10 }, allow_blank: true validates :text_color_hex, format: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, allow_blank: true validates :bg_color_hex, format: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, allow_blank: true validates :website_url, :employer_url, :mastodon_url, url: { allow_blank: true, no_local: true, schemes: %w[https http] } validates :facebook_url, format: /\A(http(s)?:\/\/)?(www.facebook.com|facebook.com)\/.*\Z/, allow_blank: true validates :stackoverflow_url, allow_blank: true, format: /\A(http(s)?:\/\/)?(www.stackoverflow.com|stackoverflow.com|www.stackexchange.com|stackexchange.com)\/.*\Z/ validates :behance_url, allow_blank: true, format: /\A(http(s)?:\/\/)?(www.behance.net|behance.net)\/.*\Z/ validates :linkedin_url, allow_blank: true, format: /\A(http(s)?:\/\/)?(www.linkedin.com|linkedin.com|[A-Za-z]{2}.linkedin.com)\/.*\Z/ validates :dribbble_url, allow_blank: true, format: /\A(http(s)?:\/\/)?(www.dribbble.com|dribbble.com)\/.*\Z/ validates :medium_url, allow_blank: true, format: /\A(http(s)?:\/\/)?(www.medium.com|medium.com)\/.*\Z/ validates :gitlab_url, allow_blank: true, format: /\A(http(s)?:\/\/)?(www.gitlab.com|gitlab.com)\/.*\Z/ validates :twitch_url, allow_blank: true, format: /\A(http(s)?:\/\/)?(www.twitch.tv|twitch.tv)\/.*\Z/ validates :shirt_gender, inclusion: { in: %w[unisex womens], message: "%{value} is not a valid shirt style" }, allow_blank: true validates :shirt_size, inclusion: { in: %w[xs s m l xl 2xl 3xl 4xl], message: "%{value} is not a valid size" }, allow_blank: true validates :tabs_or_spaces, inclusion: { in: %w[tabs spaces], message: "%{value} is not a valid answer" }, allow_blank: true validates :editor_version, inclusion: { in: %w[v1 v2], message: "%{value} must be either v1 or v2" } validates :config_theme, inclusion: { in: %w[default night_theme pink_theme], message: "%{value} must be either default, pink theme, or night theme" } validates :config_font, inclusion: { in: %w[default sans_serif comic_sans], message: "%{value} must be either default or sans serif" } validates :shipping_country, length: { in: 2..2 }, allow_blank: true validates :website_url, :employer_name, :employer_url, length: { maximum: 100 } validates :employment_title, :education, :location, length: { maximum: 100 } validates :mostly_work_with, :currently_learning, :currently_hacking_on, :available_for, length: { maximum: 500 } validates :mentee_description, :mentor_description, length: { maximum: 1000 } validates :inbox_type, inclusion: { in: %w[open private] } validates :currently_streaming_on, inclusion: { in: %w[twitch] }, allow_nil: true validate :conditionally_validate_summary validate :validate_mastodon_url validate :validate_feed_url, if: :feed_url_changed? validate :unique_including_orgs, if: :username_changed? scope :dev_account, -> { find_by(id: ApplicationConfig["DEVTO_USER_ID"]) } after_create :send_welcome_notification after_save :bust_cache after_save :subscribe_to_mailchimp_newsletter after_save :conditionally_resave_articles after_create :estimate_default_language! before_create :set_default_language before_update :mentorship_status_update before_validation :set_username # make sure usernames are not empty, to be able to use the database unique index before_validation :verify_twitter_username, :verify_github_username, :verify_email, :verify_twitch_username before_validation :set_config_input before_validation :downcase_email before_validation :check_for_username_change before_destroy :remove_from_algolia_index before_destroy :destroy_empty_dm_channels before_destroy :destroy_follows before_destroy :unsubscribe_from_newsletters algoliasearch per_environment: true, enqueue: :trigger_delayed_index do attribute :name add_index "searchables", id: :index_id, per_environment: true, enqueue: :trigger_delayed_index do attribute :user do { username: user.username, name: user.username, profile_image_90: profile_image_90 } end attribute :title, :path, :tag_list, :main_image, :id, :featured, :published, :published_at, :featured_number, :comments_count, :reactions_count, :positive_reactions_count, :class_name, :user_name, :user_username, :comments_blob, :body_text, :tag_keywords_for_search, :search_score, :hotness_score searchableAttributes ["unordered(title)", "body_text", "tag_list", "tag_keywords_for_search", "user_name", "user_username", "comments_blob"] attributesForFaceting [:class_name] customRanking ["desc(search_score)", "desc(hotness_score)"] end end def estimated_default_language language_settings["estimated_default_language"] end def self.trigger_delayed_index(record, remove) if remove record.delay.remove_from_index! if record&.persisted? else record.delay.index! end end def tag_line summary end def index_id "users-#{id}" end def set_remember_fields self.remember_token ||= self.class.remember_token if respond_to?(:remember_token) self.remember_created_at ||= Time.now.utc end def estimate_default_language! Users::EstimateDefaultLanguageJob.perform_later(id) end def estimate_default_language_without_delay! Users::EstimateDefaultLanguageJob.perform_now(id) end def calculate_score score = (articles.where(featured: true).size * 100) + comments.sum(:score) update_column(:score, score) end def path "/" + username.to_s end def followed_articles Article.tagged_with(cached_followed_tag_names, any: true). union(Article.where(user_id: cached_following_users_ids)). where(language: cached_preferred_langs, published: true) end def cached_following_users_ids Rails.cache.fetch( "user-#{id}-#{updated_at}-#{following_users_count}/following_users_ids", expires_in: 120.hours, ) do Follow.where(follower_id: id, followable_type: "User").limit(150).pluck(:followable_id) end end def cached_following_organizations_ids Rails.cache.fetch( "user-#{id}-#{updated_at}-#{following_orgs_count}/following_organizations_ids", expires_in: 120.hours, ) do Follow.where(follower_id: id, followable_type: "Organization").limit(150).pluck(:followable_id) end end def cached_following_podcasts_ids Rails.cache.fetch( "user-#{id}-#{updated_at}-#{last_followed_at}/following_podcasts_ids", expires_in: 120.hours, ) do Follow.where(follower_id: id, followable_type: "Podcast").pluck(:followable_id) end end def cached_preferred_langs Rails.cache.fetch("user-#{id}-#{updated_at}/cached_preferred_langs", expires_in: 24.hours) do preferred_languages_array end end # handles both old (prefer_language_*) and new (Array of language codes) formats def preferred_languages_array # return @prefer_languages_array if defined? @preferred_languages_array return @preferred_languages_array if defined?(@preferred_languages_array) if language_settings["preferred_languages"].present? @preferred_languages_array = language_settings["preferred_languages"].to_a else languages = [] language_settings.each_key do |setting| languages << setting.split("prefer_language_")[1] if language_settings[setting] && setting.include?("prefer_language_") end @preferred_languages_array = languages end @preferred_languages_array end def processed_website_url website_url.to_s.strip if website_url.present? end def remember_me true end def cached_followed_tag_names cache_name = "user-#{id}-#{updated_at}/followed_tag_names" Rails.cache.fetch(cache_name, expires_in: 24.hours) do Tag.where( id: Follow.where( follower_id: id, followable_type: "ActsAsTaggableOn::Tag", ).pluck(:followable_id), ).pluck(:name) end end # methods for Administrate field def banned has_role? :banned end def warned has_role? :warned end def banished? user.notes.where(reason: "banned", content: "spam account").any? && user.banned && user.comments.none? && user.articles.none? end def banned_from_mentorship has_role? :banned_from_mentorship end def admin? has_role?(:super_admin) end def any_admin? has_role?(:super_admin) || has_role?(:admin) end def tech_admin? has_role?(:tech_admin) || has_role?(:super_admin) end def trusted Rails.cache.fetch("user-#{id}/has_trusted_role", expires_in: 200.hours) do has_role? :trusted end end def scholar valid_pass = workshop_expiration.nil? || workshop_expiration > Time.current has_role?(:workshop_pass) && valid_pass end def comment_banned has_role? :comment_banned end def workshop_eligible? has_any_role?(:workshop_pass, :level_3_member, :level_4_member, :triple_unicorn_member) end def org_admin?(organization) user.org_admin && user.organization_id == organization.id end def unique_including_orgs errors.add(:username, "is taken.") if Organization.find_by(slug: username) end def subscribe_to_mailchimp_newsletter_without_delay return unless email.present? && email.include?("@") return if saved_changes["unconfirmed_email"] && saved_changes["confirmation_sent_at"] Users::SubscribeToMailchimpNewsletterJob.perform_now(id) end def subscribe_to_mailchimp_newsletter return unless email.present? && email.include?("@") return if saved_changes["unconfirmed_email"] && saved_changes["confirmation_sent_at"] Users::SubscribeToMailchimpNewsletterJob.perform_later(id) end def a_sustaining_member? monthly_dues.positive? end def resave_articles cache_buster = CacheBuster.new articles.find_each do |article| cache_buster.bust(article.path) if article.path cache_buster.bust(article.path + "?i=i") if article.path article.save end end def settings_tab_list tab_list = %w[ Profile Integrations Notifications Publishing\ from\ RSS Organization Billing ] tab_list << "Switch Organizations" if has_role?(:switch_between_orgs) tab_list.push("Account", "Misc") end def profile_image_90 ProfileImage.new(self).get(90) end def remove_from_algolia_index remove_from_index! index = Algolia::Index.new("searchables_#{Rails.env}") index.delay.delete_object("users-#{id}") end def unsubscribe_from_newsletters MailchimpBot.new(self).unsubscribe_all_newsletters end def tag_moderator? roles.where(name: "tag_moderator").any? end def currently_streaming? currently_streaming_on.present? end def currently_streaming_on_twitch? currently_streaming_on == "twitch" end private def set_default_language language_settings["preferred_languages"] ||= ["en"] end def send_welcome_notification Notification.send_welcome_notification(id) end def verify_twitter_username self.twitter_username = nil if twitter_username == "" end def verify_github_username self.github_username = nil if github_username == "" end def verify_email self.email = nil if email == "" end def verify_twitch_username self.twitch_username = nil if twitch_username == "" end def set_username set_temp_username if username.blank? self.username = username&.downcase end def set_temp_username self.username = if temp_name_exists? temp_username + "_" + rand(100).to_s else temp_username end end def temp_name_exists? User.find_by(username: temp_username) || Organization.find_by(slug: temp_username) end def temp_username if twitter_username twitter_username.downcase.gsub(/[^0-9a-z_]/i, "").delete(" ") elsif github_username github_username.downcase.gsub(/[^0-9a-z_]/i, "").delete(" ") end end def downcase_email self.email = email.downcase if email end def set_config_input self.config_theme = config_theme.tr(" ", "_") self.config_font = config_font.tr(" ", "_") end def check_for_username_change return unless username_changed? self.old_old_username = old_username self.old_username = username_was chat_channels.find_each do |c| c.slug = c.slug.gsub(username_was, username) c.save end articles.find_each do |a| a.path = a.path.gsub(username_was, username) a.save end end def conditionally_resave_articles delay.resave_articles if core_profile_details_changed? && !user.banned end def bust_cache Users::BustCacheJob.perform_later(id) end def core_profile_details_changed? saved_change_to_username? || saved_change_to_name? || saved_change_to_summary? || saved_change_to_bg_color_hex? || saved_change_to_text_color_hex? || saved_change_to_profile_image? || saved_change_to_github_username? || saved_change_to_twitter_username? end def conditionally_validate_summary # Grandfather people who had a too long summary before. return if summary_was && summary_was.size > 200 errors.add(:summary, "is too long.") if summary.present? && summary.size > 200 end def validate_feed_url return if feed_url.blank? errors.add(:feed_url, "is not a valid rss feed") unless RssReader.new.valid_feed_url?(feed_url) end def validate_mastodon_url return if mastodon_url.blank? uri = URI.parse(mastodon_url) return if uri.host&.in?(Constants::ALLOWED_MASTODON_INSTANCES) errors.add(:mastodon_url, "is not an allowed Mastodon instance") end def title name end def tag_list "" # Unused but necessary for search index end def main_image; end def featured true end def published true end def published_at; end def featured_number; end def positive_reactions_count reactions_count end def user self end def class_name self.class.name end def user_name username end def user_username username end def comments_blob "" # Unused but necessary for search index end def body_text "" # Unused but necessary for search index end def tag_keywords_for_search employer_name.to_s + mostly_work_with.to_s + available_for.to_s end def hotness_score search_score end def search_score counts_score = (articles_count + comments_count + reactions_count + badge_achievements_count) * 10 score = (counts_score + tag_keywords_for_search.size) * reputation_modifier score.to_i end def destroy_empty_dm_channels return if chat_channels.empty? || chat_channels.where(channel_type: "direct").empty? empty_dm_channels = chat_channels.where(channel_type: "direct"). select { |chat_channel| chat_channel.messages.empty? } empty_dm_channels.destroy_all end def destroy_follows follower_relationships = Follow.where(followable_id: id, followable_type: "User") follower_relationships.destroy_all follows.destroy_all end def mentorship_status_update self.mentor_form_updated_at = Time.current if mentor_description_changed? || offering_mentorship_changed? self.mentee_form_updated_at = Time.current if mentee_description_changed? || seeking_mentorship_changed? end end