hackatime/app/models/user.rb

427 lines
13 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class User < ApplicationRecord
has_paper_trail
encrypts :slack_access_token, :github_access_token
validates :slack_uid, uniqueness: true, allow_nil: true
validates :github_uid, uniqueness: true, allow_nil: true
validates :timezone, inclusion: { in: TZInfo::Timezone.all_identifiers }, allow_nil: false
validates :country_code, inclusion: { in: ISO3166::Country.codes }, allow_nil: true
attribute :allow_public_stats_lookup, :boolean, default: true
def country_name
ISO3166::Country.new(country_code).common_name
end
def country_subregion
ISO3166::Country.new(country_code).subregion
end
enum :trust_level, {
yellow: 0,
red: 1,
green: 2
}
# yellow is unscored, red being convicted while green being trusted
# labels make it easier for display :okay-1:
def set_trust(level)
update!(trust_level: level)
end
# ex: .set_trust(:green) or set_trust(1) setting it to red
has_many :heartbeats
has_many :email_addresses, dependent: :destroy
has_many :email_verification_requests, dependent: :destroy
has_many :sign_in_tokens, dependent: :destroy
has_many :project_repo_mappings
has_one :mailing_address, dependent: :destroy
has_many :physical_mails
has_many :hackatime_heartbeats,
foreign_key: :user_id,
primary_key: :slack_uid,
class_name: "Hackatime::Heartbeat"
has_many :project_labels,
foreign_key: :user_id,
primary_key: :slack_uid,
class_name: "Hackatime::ProjectLabel"
has_many :api_keys
has_one :sailors_log,
foreign_key: :slack_uid,
primary_key: :slack_uid,
class_name: "SailorsLog"
has_many :wakatime_mirrors, dependent: :destroy
def streak_days
@streak_days ||= heartbeats.daily_streaks_for_users([ id ]).values.first
end
if Rails.env.development?
def self.slow_find_by_email(email)
# This is an n+1 query, but provided for developer convenience
EmailAddress.find_by(email: email)&.user
end
end
def streak_days_formatted
if streak_days > 7
"7+"
elsif streak_days < 1
nil
else
streak_days.to_s
end
end
enum :hackatime_extension_text_type, {
simple_text: 0,
clock_emoji: 1,
compliment_text: 2
}
after_save :invalidate_activity_graph_cache, if: :saved_change_to_timezone?
def data_migration_jobs
GoodJob::Job.where(
"serialized_params->>'arguments' = ?", [ id ].to_json
).where(
"job_class = ?", "MigrateUserFromHackatimeJob"
).order(created_at: :desc).limit(10).all
end
def in_progress_migration_jobs?
GoodJob::Job.where(job_class: "MigrateUserFromHackatimeJob")
.where("serialized_params->>'arguments' = ?", [ id ].to_json)
.where(finished_at: nil)
.exists?
end
def set_neighborhood_channel
return unless slack_uid.present?
self.slack_neighborhood_channel = SlackNeighborhood.find_by_id(slack_uid)&.dig("id")
end
def format_extension_text(duration)
case hackatime_extension_text_type
when "simple_text"
return "Start coding to track your time" if duration.zero?
::ApplicationController.helpers.short_time_simple(duration)
when "clock_emoji"
::ApplicationController.helpers.time_in_emoji(duration)
when "compliment_text"
FlavorText.compliment.sample
end
end
def set_timezone_from_slack
return unless slack_uid.present?
user_response = HTTP.auth("Bearer #{slack_access_token}")
.get("https://slack.com/api/users.info?user=#{slack_uid}")
user_data = JSON.parse(user_response.body.to_s)
return unless user_data["ok"]
timezone_string = user_data.dig("user", "tz")
return unless timezone_string.present?
parse_and_set_timezone(timezone_string)
end
def parse_and_set_timezone(timezone)
begin
tz = ActiveSupport::TimeZone.find_tzinfo(timezone)
self.timezone = tz.name
rescue TZInfo::InvalidTimezoneIdentifier => e
Rails.logger.error "Invalid timezone #{timezone} for user #{id}: #{e.message}"
end
end
def admin?
is_admin
end
def make_admin!
update!(is_admin: true)
end
def remove_admin!
update!(is_admin: false)
end
def raw_github_user_info
return nil unless github_uid.present?
return nil unless github_access_token.present?
@github_user_info ||= HTTP.auth("Bearer #{github_access_token}")
.get("https://api.github.com/user")
JSON.parse(@github_user_info.body.to_s)
end
def raw_slack_user_info
return nil unless slack_uid.present?
return nil unless slack_access_token.present?
@slack_user_info ||= HTTP.auth("Bearer #{slack_access_token}")
.get("https://slack.com/api/users.info?user=#{slack_uid}")
JSON.parse(@slack_user_info.body.to_s).dig("user")
end
def update_from_slack
user_data = raw_slack_user_info
return unless user_data.present?
profile = user_data.dig("profile")
self.slack_avatar_url = profile.dig("image_192") || profile.dig("image_72")
self.slack_username = profile.dig("username").presence
self.slack_username ||= profile.dig("display_name_normalized").presence
self.slack_username ||= profile.dig("real_name_normalized").presence
self.username.blank? && self.username = self.slack_username
end
def update_slack_status
return unless uses_slack_status?
# check if the user already has a custom status set if it doesn't look like
# our format, don't clobber it
current_status_response = HTTP.auth("Bearer #{slack_access_token}")
.get("https://slack.com/api/users.profile.get")
current_status = JSON.parse(current_status_response.body.to_s)
custom_status_regex = /spent on \w+ today$/
status_present = current_status.dig("profile", "status_text").present?
status_custom = !current_status.dig("profile", "status_text")&.match?(custom_status_regex)
return if status_present && status_custom
current_project = heartbeats.order(time: :desc).first&.project
Time.use_zone(timezone) do
current_project_duration = heartbeats.where(project: current_project)
.today
.duration_seconds
end
current_project_duration_formatted = ApplicationController.helpers.short_time_simple(current_project_duration)
# for 0 duration, don't set a status this will let status expire when the user has not been cooking today
return if current_project_duration.zero?
status_emoji =
case current_project_duration
when 0...30.minutes
%w[thinking cat-on-the-laptop loading-tumbleweed rac-yap]
when 30.minutes...1.hour
%w[working-parrot meow_code]
when 1.hour...2.hours
%w[working-parrot meow-code]
when 2.hours...3.hours
%w[working-parrot cat-typing bangbang]
when 3.hours...5.hours
%w[cat-typing meow-code laptop-fire bangbang]
when 5.hours...8.hours
%w[cat-typing laptop-fire hole-mantelpiece_clock keyboard-fire bangbang bangbang]
when 8.hours...15.hours
%w[laptop-fire bangbang bangbang rac_freaking rac_freaking hole-mantelpiece_clock]
when 15.hours...20.hours
%w[bangbang bangbang rac_freaking hole-mantelpiece_clock]
else
%w[areyousure time-to-stop]
end.sample
status_emoji = ":#{status_emoji}:"
status_text = "#{current_project_duration_formatted} spent on #{current_project} today"
# Update the user's status
HTTP.auth("Bearer #{slack_access_token}")
.post("https://slack.com/api/users.profile.set", form: {
profile: {
status_text:,
status_emoji:,
status_expiration: (Time.now + 10.minutes).to_i
}
})
end
def self.authorize_url(redirect_uri, close_window: false, continue_param: nil)
state = {
token: SecureRandom.hex(24),
close_window: close_window,
continue: continue_param
}.to_json
params = {
client_id: ENV["SLACK_CLIENT_ID"],
redirect_uri: redirect_uri,
state: state,
user_scope: "users.profile:read,users.profile:write,users:read,users:read.email"
}
URI.parse("https://slack.com/oauth/v2/authorize?#{params.to_query}")
end
def self.github_authorize_url(redirect_uri)
params = {
client_id: ENV["GITHUB_CLIENT_ID"],
redirect_uri: redirect_uri,
state: SecureRandom.hex(24),
scope: "user:email"
}
URI.parse("https://github.com/login/oauth/authorize?#{params.to_query}")
end
def self.from_slack_token(code, redirect_uri)
# Exchange code for token
response = HTTP.post("https://slack.com/api/oauth.v2.access", form: {
client_id: ENV["SLACK_CLIENT_ID"],
client_secret: ENV["SLACK_CLIENT_SECRET"],
code: code,
redirect_uri: redirect_uri
})
data = JSON.parse(response.body.to_s)
return nil unless data["ok"]
# Get user info
user_response = HTTP.auth("Bearer #{data['authed_user']['access_token']}")
.get("https://slack.com/api/users.info?user=#{data['authed_user']['id']}")
user_data = JSON.parse(user_response.body.to_s)
return nil unless user_data["ok"]
email = user_data.dig("user", "profile", "email")&.downcase
email_address = EmailAddress.find_or_initialize_by(email: email)
email_address.source ||= :slack
user = email_address.user
user ||= begin
u = User.find_or_initialize_by(slack_uid: data.dig("authed_user", "id"))
unless u.email_addresses.include?(email_address)
u.email_addresses << email_address
end
u
end
user.slack_uid = data.dig("authed_user", "id")
user.username ||= user_data.dig("user", "profile", "username")
user.username ||= user_data.dig("user", "profile", "display_name_normalized")
user.slack_username = user_data.dig("user", "profile", "username")
user.slack_avatar_url = user_data.dig("user", "profile", "image_192") || user_data.dig("user", "profile", "image_72")
user.parse_and_set_timezone(user_data.dig("user", "tz"))
user.slack_access_token = data["authed_user"]["access_token"]
user.slack_scopes = data["authed_user"]["scope"]&.split(/,\s*/)
user.set_neighborhood_channel
user.save!
user
rescue => e
Rails.logger.error "Error creating user from Slack data: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
nil
end
def self.from_github_token(code, redirect_uri, current_user)
return nil unless current_user
# Exchange code for token
response = HTTP.headers(accept: "application/json")
.post("https://github.com/login/oauth/access_token", form: {
client_id: ENV["GITHUB_CLIENT_ID"],
client_secret: ENV["GITHUB_CLIENT_SECRET"],
code: code,
redirect_uri: redirect_uri
})
data = JSON.parse(response.body.to_s)
Rails.logger.info "GitHub OAuth response: #{data.inspect}"
return nil unless data["access_token"]
# Get user info
user_response = HTTP.auth("Bearer #{data['access_token']}")
.get("https://api.github.com/user")
user_data = JSON.parse(user_response.body.to_s)
Rails.logger.info "GitHub user data: #{user_data.inspect}"
Rails.logger.info "GitHub user ID type: #{user_data['id'].class}"
# Update GitHub-specific fields
current_user.github_uid = user_data["id"]
current_user.username ||= user_data["login"]
current_user.github_username = user_data["login"]
current_user.github_avatar_url = user_data["avatar_url"]
current_user.github_access_token = data["access_token"]
# Save the user
current_user.save!
ScanGithubReposJob.perform_later(current_user.id)
current_user
rescue => e
Rails.logger.error "Error linking GitHub account: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
nil
end
def avatar_url
return self.slack_avatar_url if self.slack_avatar_url.present?
return self.github_avatar_url if self.github_avatar_url.present?
initials = self.email_addresses&.first&.email[0..1]&.upcase
hashed_initials = Digest::SHA256.hexdigest(initials)[0..5]
"https://i2.wp.com/ui-avatars.com/api/#{initials}/48/#{hashed_initials}/fff?ssl=1" if initials.present?
end
def display_name
return slack_username.presence.truncate(10) if slack_username.present?
return github_username.presence.truncate(10) if github_username.present?
return username.presence.truncate(10) if username.present?
# "zach@hackclub.com" -> "zach (email sign-up)"
email = email_addresses&.first&.email
return "error displaying name" unless email.present?
email.split("@")&.first.truncate(10) + " (email sign-up)"
end
def most_recent_direct_entry_heartbeat
heartbeats.where(source_type: :direct_entry).order(time: :desc).first
end
def create_email_signin_token(continue_param: nil)
sign_in_tokens.create!(auth_type: :email, continue_param: continue_param)
end
def find_valid_token(token)
sign_in_tokens.valid.find_by(token: token)
end
# New method for GitHub profile URL
def github_profile_url
"https://github.com/#{github_username}" if github_username.present?
end
private
def invalidate_activity_graph_cache
Rails.cache.delete("user_#{id}_daily_durations")
end
end