mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 23:32:53 +00:00
* Add goals * Fix up some migrations * Formatting * Simplify migration * Update test/controllers/settings_goals_controller_test.rb Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update test/controllers/settings_goals_controller_test.rb Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Fix svelte-check issues, make CI less janky on dev * svelte-check/fix tests * Fix N+1s * Formatting! * More tests, fix anonymization and N+1 --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
530 lines
14 KiB
Ruby
530 lines
14 KiB
Ruby
class User < ApplicationRecord
|
|
include TimezoneRegions
|
|
include ::OauthAuthentication
|
|
include ::SlackIntegration
|
|
include ::GithubIntegration
|
|
|
|
USERNAME_MAX_LENGTH = 21 # going over 21 overflows the navbar
|
|
DEFAULT_THEME = "gruvbox_dark".freeze
|
|
THEME_OPTIONS = [
|
|
{
|
|
value: "standard",
|
|
label: "Classic",
|
|
description: "Current Hackatime look.",
|
|
color_scheme: "dark",
|
|
theme_color: "#c8394f",
|
|
preview: {
|
|
darker: "#1f1617",
|
|
dark: "#2a1f21",
|
|
darkless: "#4a2d31",
|
|
primary: "#c8394f",
|
|
content: "#f3ecee",
|
|
info: "#5bc0de",
|
|
success: "#33d6a6",
|
|
warning: "#f1c40f"
|
|
}
|
|
},
|
|
{
|
|
value: "neon",
|
|
label: "Neon",
|
|
description: "Dark mode with neon green primary.",
|
|
color_scheme: "dark",
|
|
theme_color: "#d7ff6a",
|
|
preview: {
|
|
darker: "#050505",
|
|
dark: "#101312",
|
|
darkless: "#242a28",
|
|
primary: "#d7ff6a",
|
|
content: "#e8eee9",
|
|
info: "#6fd7ff",
|
|
success: "#7dfc6a",
|
|
warning: "#ffd166"
|
|
}
|
|
},
|
|
{
|
|
value: "catppuccin_mocha",
|
|
label: "Catppuccin Mocha",
|
|
description: "Warm purple-tinted dark palette.",
|
|
color_scheme: "dark",
|
|
theme_color: "#cba6f7",
|
|
preview: {
|
|
darker: "#11111b",
|
|
dark: "#181825",
|
|
darkless: "#313244",
|
|
primary: "#cba6f7",
|
|
content: "#cdd6f4",
|
|
info: "#89b4fa",
|
|
success: "#a6e3a1",
|
|
warning: "#f9e2af"
|
|
}
|
|
},
|
|
{
|
|
value: "catppuccin_iced_latte",
|
|
label: "Catppuccin Iced Latte",
|
|
description: "Cool light Catppuccin variant.",
|
|
color_scheme: "light",
|
|
theme_color: "#1e66f5",
|
|
preview: {
|
|
darker: "#ccd0da",
|
|
dark: "#dce0e8",
|
|
darkless: "#bac2de",
|
|
primary: "#1e66f5",
|
|
content: "#4c4f69",
|
|
info: "#209fb5",
|
|
success: "#40a02b",
|
|
warning: "#df8e1d"
|
|
}
|
|
},
|
|
{
|
|
value: "gruvbox_dark",
|
|
label: "Gruvbox Dark",
|
|
description: "Retro warm dark tones.",
|
|
color_scheme: "dark",
|
|
theme_color: "#d8a657",
|
|
preview: {
|
|
darker: "#1d2021",
|
|
dark: "#282828",
|
|
darkless: "#3c3836",
|
|
primary: "#d8a657",
|
|
content: "#ebdbb2",
|
|
info: "#83a598",
|
|
success: "#b8bb26",
|
|
warning: "#fabd2f"
|
|
}
|
|
},
|
|
{
|
|
value: "github_dark",
|
|
label: "GitHub Dark",
|
|
description: "GitHub's classic dark palette.",
|
|
color_scheme: "dark",
|
|
theme_color: "#0366d6",
|
|
preview: {
|
|
darker: "#1b1f23",
|
|
dark: "#1f2428",
|
|
darkless: "#2f363d",
|
|
primary: "#0366d6",
|
|
content: "#e1e4e8",
|
|
info: "#79b8ff",
|
|
success: "#34d058",
|
|
warning: "#ffab70"
|
|
}
|
|
},
|
|
{
|
|
value: "github_light",
|
|
label: "GitHub Light",
|
|
description: "GitHub's classic light palette.",
|
|
color_scheme: "light",
|
|
theme_color: "#2188ff",
|
|
preview: {
|
|
darker: "#d1d5da",
|
|
dark: "#e1e4e8",
|
|
darkless: "#f6f8fa",
|
|
primary: "#2188ff",
|
|
content: "#24292e",
|
|
info: "#0366d6",
|
|
success: "#28a745",
|
|
warning: "#e36209"
|
|
}
|
|
},
|
|
{
|
|
value: "nord",
|
|
label: "Nord",
|
|
description: "Arctic blue-gray contrast.",
|
|
color_scheme: "dark",
|
|
theme_color: "#88c0d0",
|
|
preview: {
|
|
darker: "#2e3440",
|
|
dark: "#3b4252",
|
|
darkless: "#434c5e",
|
|
primary: "#88c0d0",
|
|
content: "#e5e9f0",
|
|
info: "#81a1c1",
|
|
success: "#a3be8c",
|
|
warning: "#ebcb8b"
|
|
}
|
|
},
|
|
{
|
|
value: "rose",
|
|
label: "Rose Pine",
|
|
description: "Rose Pine inspired dark palette.",
|
|
color_scheme: "dark",
|
|
theme_color: "#eb6f92",
|
|
preview: {
|
|
darker: "#191724",
|
|
dark: "#1f1d2e",
|
|
darkless: "#26233a",
|
|
primary: "#eb6f92",
|
|
content: "#e0def4",
|
|
info: "#9ccfd8",
|
|
success: "#31748f",
|
|
warning: "#f6c177"
|
|
}
|
|
},
|
|
{
|
|
value: "rose_pine_dawn",
|
|
label: "Rose Pine Dawn",
|
|
description: "Rose Pine inspired light palette.",
|
|
color_scheme: "light",
|
|
theme_color: "#aa586f",
|
|
preview: {
|
|
darker: "#dfdad9",
|
|
dark: "#f2e9e1",
|
|
darkless: "#cecacd",
|
|
primary: "#aa586f",
|
|
content: "#575279",
|
|
info: "#56949f",
|
|
success: "#286983",
|
|
warning: "#a35a00"
|
|
}
|
|
}
|
|
].freeze
|
|
THEME_OPTION_BY_VALUE = THEME_OPTIONS.index_by { |theme| theme[:value] }.freeze
|
|
|
|
has_paper_trail
|
|
|
|
after_create :track_signup
|
|
before_validation :normalize_username
|
|
encrypts :slack_access_token, :github_access_token, :hca_access_token
|
|
|
|
validates :slack_uid, uniqueness: true, allow_nil: true
|
|
validates :github_uid, uniqueness: { conditions: -> { where.not(github_access_token: nil) } }, 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
|
|
validates :username,
|
|
length: { maximum: USERNAME_MAX_LENGTH },
|
|
format: { with: /\A[A-Za-z0-9_-]+\z/, message: "may only include letters, numbers, '-', and '_'" },
|
|
uniqueness: { case_sensitive: false, message: "has already been taken" },
|
|
allow_nil: true
|
|
validate :username_must_be_visible
|
|
|
|
attribute :allow_public_stats_lookup, :boolean, default: true
|
|
attribute :default_timezone_leaderboard, :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, {
|
|
blue: 0, # unscored
|
|
red: 1, # convicted
|
|
green: 2, # trusted
|
|
yellow: 3 # suspected (invisible to user)
|
|
}
|
|
|
|
enum :admin_level, {
|
|
default: 0, # pleebs
|
|
superadmin: 1,
|
|
admin: 2,
|
|
viewer: 3
|
|
}, prefix: :admin_level
|
|
|
|
enum :theme, {
|
|
standard: 0,
|
|
neon: 1,
|
|
catppuccin_mocha: 2,
|
|
catppuccin_iced_latte: 3,
|
|
gruvbox_dark: 4,
|
|
github_dark: 5,
|
|
github_light: 6,
|
|
nord: 7,
|
|
rose: 8,
|
|
rose_pine_dawn: 9
|
|
}
|
|
|
|
def can_convict_users?
|
|
admin_level_superadmin?
|
|
end
|
|
|
|
def set_admin_level(level)
|
|
return false unless level.present? && self.class.admin_levels.key?(level)
|
|
|
|
previous_level = admin_level
|
|
|
|
if previous_level != level.to_s
|
|
update!(admin_level: level.to_s)
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def set_trust(level, changed_by_user: nil, reason: nil, notes: nil)
|
|
return false unless level.present?
|
|
|
|
previous_level = trust_level
|
|
|
|
if changed_by_user.present? && level.to_s == "red" && !(changed_by_user.admin_level_superadmin?)
|
|
return false
|
|
end
|
|
|
|
if previous_level != level.to_s
|
|
if changed_by_user.present?
|
|
trust_level_audit_logs.create!(
|
|
changed_by: changed_by_user,
|
|
previous_trust_level: previous_level,
|
|
new_trust_level: level.to_s,
|
|
reason: reason,
|
|
notes: notes
|
|
)
|
|
end
|
|
|
|
update!(trust_level: level)
|
|
end
|
|
|
|
true
|
|
end
|
|
# ex: .set_trust(:green) or set_trust(1) setting it to red
|
|
|
|
has_many :heartbeats
|
|
has_many :goals, dependent: :destroy
|
|
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_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_many :admin_api_keys, dependent: :destroy
|
|
has_many :oauth_applications, as: :owner, dependent: :destroy
|
|
|
|
has_one :sailors_log,
|
|
foreign_key: :slack_uid,
|
|
primary_key: :slack_uid,
|
|
class_name: "SailorsLog"
|
|
|
|
has_many :wakatime_mirrors, dependent: :destroy
|
|
|
|
scope :search_identity, ->(term) {
|
|
term = term.to_s.strip.downcase
|
|
return none if term.blank?
|
|
|
|
pattern = "%#{sanitize_sql_like(term)}%"
|
|
|
|
left_joins(:email_addresses)
|
|
.where(
|
|
"LOWER(users.username) LIKE :p OR " \
|
|
"LOWER(users.slack_username) LIKE :p OR " \
|
|
"LOWER(users.github_username) LIKE :p OR " \
|
|
"LOWER(email_addresses.email) LIKE :p OR " \
|
|
"CAST(users.id AS TEXT) LIKE :p",
|
|
p: pattern
|
|
)
|
|
.distinct
|
|
}
|
|
|
|
has_many :trust_level_audit_logs, dependent: :destroy
|
|
has_many :trust_level_changes_made, class_name: "TrustLevelAuditLog", foreign_key: "changed_by_id", dependent: :destroy
|
|
has_many :deletion_requests, dependent: :restrict_with_error
|
|
has_many :deletion_approvals, class_name: "DeletionRequest", foreign_key: "admin_approved_by_id"
|
|
|
|
has_many :access_grants,
|
|
class_name: "Doorkeeper::AccessGrant",
|
|
foreign_key: :resource_owner_id,
|
|
dependent: :delete_all
|
|
|
|
has_many :access_tokens,
|
|
class_name: "Doorkeeper::AccessToken",
|
|
foreign_key: :resource_owner_id,
|
|
dependent: :delete_all
|
|
|
|
def streak_days
|
|
@streak_days ||= heartbeats.daily_streaks_for_users([ id ]).values.first
|
|
end
|
|
|
|
def active_deletion_request
|
|
deletion_requests.active.order(created_at: :desc).first
|
|
end
|
|
|
|
def pending_deletion?
|
|
active_deletion_request.present?
|
|
end
|
|
|
|
def can_request_deletion?
|
|
return false if pending_deletion?
|
|
return true unless red?
|
|
|
|
last_audit = trust_level_audit_logs.where(new_trust_level: :red).order(created_at: :desc).first
|
|
return true unless last_audit
|
|
|
|
last_audit.created_at <= 365.days.ago
|
|
end
|
|
|
|
def can_delete_emails?
|
|
email_addresses.size > 1
|
|
end
|
|
|
|
def can_delete_email_address?(email)
|
|
email.can_unlink? && can_delete_emails?
|
|
end
|
|
|
|
if Rails.env.development?
|
|
def self.slow_find_by_email(email)
|
|
EmailAddress.find_by(email: email)&.user
|
|
end
|
|
end
|
|
|
|
def streak_days_formatted
|
|
if streak_days > 30
|
|
"30+"
|
|
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
|
|
}
|
|
|
|
def self.theme_options
|
|
THEME_OPTIONS.map(&:deep_dup)
|
|
end
|
|
|
|
def self.theme_metadata(theme_name)
|
|
THEME_OPTION_BY_VALUE[theme_name.to_s] || THEME_OPTION_BY_VALUE[DEFAULT_THEME]
|
|
end
|
|
|
|
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 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 parse_and_set_timezone(timezone)
|
|
as_tz = ActiveSupport::TimeZone[timezone]
|
|
|
|
unless as_tz
|
|
begin
|
|
tzinfo = TZInfo::Timezone.get(timezone)
|
|
as_tz = ActiveSupport::TimeZone.all.find do |z|
|
|
z.tzinfo.identifier == tzinfo.identifier
|
|
end
|
|
rescue TZInfo::InvalidTimezoneIdentifier
|
|
end
|
|
end
|
|
|
|
if as_tz
|
|
self.timezone = as_tz.name
|
|
else
|
|
Rails.logger.error "Invalid timezone #{timezone} for user #{id}"
|
|
end
|
|
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?
|
|
|
|
email = self.email_addresses&.first&.email
|
|
if email.present?
|
|
initials = email[0..1]&.upcase
|
|
hashed_initials = Digest::SHA256.hexdigest(initials)[0..5]
|
|
return "https://i2.wp.com/ui-avatars.com/api/#{initials}/48/#{hashed_initials}/fff?ssl=1"
|
|
end
|
|
|
|
base64_identicon = RubyIdenticon.create_base64(id.to_s)
|
|
"data:image/png;base64,#{base64_identicon}"
|
|
end
|
|
|
|
def display_name
|
|
name = slack_username || github_username || username
|
|
return name if name.present?
|
|
|
|
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
|
|
|
|
def self.not_convicted
|
|
where.not(trust_level: User.trust_levels[:red])
|
|
end
|
|
|
|
def self.not_suspect
|
|
where(trust_level: [ User.trust_levels[:blue], User.trust_levels[:green] ])
|
|
end
|
|
|
|
private
|
|
|
|
def invalidate_activity_graph_cache
|
|
Rails.cache.delete("user_#{id}_daily_durations")
|
|
end
|
|
|
|
def track_signup
|
|
PosthogService.identify(self)
|
|
PosthogService.capture(self, "account_created", { source: "signup" })
|
|
end
|
|
|
|
def normalize_username
|
|
original = username
|
|
@username_cleared_for_invisible = false
|
|
|
|
return if original.nil?
|
|
|
|
cleaned = original.gsub(/\p{Cf}/, "")
|
|
stripped = cleaned.strip
|
|
|
|
if stripped.empty?
|
|
self.username = nil
|
|
@username_cleared_for_invisible = original.length.positive?
|
|
else
|
|
self.username = stripped
|
|
end
|
|
end
|
|
|
|
def username_must_be_visible
|
|
if instance_variable_defined?(:@username_cleared_for_invisible) && @username_cleared_for_invisible
|
|
errors.add(:username, "must include visible characters")
|
|
end
|
|
end
|
|
end
|