diff --git a/app/controllers/api/admin/v1/admin_controller.rb b/app/controllers/api/admin/v1/admin_controller.rb index 098afd0a3..96f5e2eb5 100644 --- a/app/controllers/api/admin/v1/admin_controller.rb +++ b/app/controllers/api/admin/v1/admin_controller.rb @@ -28,129 +28,27 @@ def visualization_quantized user = find_user_by_id return unless user - year = params[:year]&.to_i - month = params[:month]&.to_i + year = params[:year] + month = params[:month] - if year.nil? || month.nil? || month < 1 || month > 12 + if year.blank? || month.blank? render json: { error: "invalid parameters" }, status: :unprocessable_entity return end - begin - start_epoch = Time.utc(year, month, 1).to_i - end_epoch = if month == 12 - Time.utc(year + 1, 1, 1).to_i - else - Time.utc(year, month + 1, 1).to_i - end - rescue Date::Error - render json: { error: "invalid date" }, status: :unprocessable_entity - return - end + result = ::Admin::VisualizationQuantizedQuery.new( + user: user, + year: year, + month: month + ).call - quantized_query = <<-SQL - WITH base_heartbeats AS ( - SELECT - "time", - lineno, - cursorpos, - date_trunc('day', to_timestamp("time")) as day_start - FROM heartbeats - WHERE user_id = ? - AND "time" >= ? AND "time" <= ? - LIMIT 1000000 - ), - daily_stats AS ( - SELECT - *, - GREATEST(1, MAX(lineno) OVER (PARTITION BY day_start)) as max_lineno, - GREATEST(1, MAX(cursorpos) OVER (PARTITION BY day_start)) as max_cursorpos - FROM base_heartbeats - ), - quantized_heartbeats AS ( - SELECT - *, - ROUND(2 + (("time" - extract(epoch from day_start)) / 86400) * (396)) as qx, - ROUND(2 + (1 - CAST(lineno AS decimal) / max_lineno) * (96)) as qy_lineno, - ROUND(2 + (1 - CAST(cursorpos AS decimal) / max_cursorpos) * (96)) as qy_cursorpos - FROM daily_stats - ) - SELECT "time", lineno, cursorpos - FROM ( - SELECT DISTINCT ON (day_start, qx, qy_lineno) "time", lineno, cursorpos - FROM quantized_heartbeats - WHERE lineno IS NOT NULL - ORDER BY day_start, qx, qy_lineno, "time" ASC - ) AS lineno_pixels - UNION - SELECT "time", lineno, cursorpos - FROM ( - SELECT DISTINCT ON (day_start, qx, qy_cursorpos) "time", lineno, cursorpos - FROM quantized_heartbeats - WHERE cursorpos IS NOT NULL - ORDER BY day_start, qx, qy_cursorpos, "time" ASC - ) AS cursorpos_pixels - UNION - SELECT "time", lineno, cursorpos - FROM ( - SELECT DISTINCT ON (day_start, qx) "time", lineno, cursorpos - FROM quantized_heartbeats - WHERE lineno IS NULL AND cursorpos IS NULL - ORDER BY day_start, qx, "time" ASC - ) AS null_pixels - ORDER BY "time" ASC - SQL - - daily_totals_query = <<-SQL - WITH heartbeats_with_gaps AS ( - SELECT - date_trunc('day', to_timestamp("time"))::date as day, - "time" - LAG("time", 1, "time") OVER (PARTITION BY date_trunc('day', to_timestamp("time")) ORDER BY "time") as gap - FROM heartbeats - WHERE user_id = ? AND time >= ? AND time <= ? - ) - SELECT - day, - SUM(LEAST(gap, 120)) as total_seconds - FROM heartbeats_with_gaps - WHERE gap IS NOT NULL - GROUP BY day - SQL - - quantized_result = ActiveRecord::Base.connection.execute( - ActiveRecord::Base.sanitize_sql([ quantized_query, user.id, start_epoch, end_epoch ]) - ) - - daily_totals_result = ActiveRecord::Base.connection.execute( - ActiveRecord::Base.sanitize_sql([ daily_totals_query, user.id, start_epoch, end_epoch ]) - ) - - daily_totals = daily_totals_result.each_with_object({}) do |row, hash| - day = row["day"] - total_seconds = row["total_seconds"] - hash[day] = total_seconds - end - - points_by_day = quantized_result.each_with_object({}) do |row, hash| - day = Time.at(row["time"]).to_date - hash[day] ||= [] - hash[day] << { - time: row["time"], - lineno: row["lineno"], - cursorpos: row["cursorpos"] - } - end - - days = (start_epoch...end_epoch).step(86400).map do |epoch| - day = Time.at(epoch).to_date - { - date_timestamp_s: epoch, - total_seconds: daily_totals[day] || 0, - points: points_by_day[day] || [] - } + unless result.success? + error = result.error == :invalid_date ? "invalid date" : "invalid parameters" + render json: { error: error }, status: :unprocessable_entity + return end - render json: { days: days } + render json: { days: result.days } end def alt_candidates diff --git a/app/controllers/api/v1/external_slack_controller.rb b/app/controllers/api/v1/external_slack_controller.rb index c8d6ec577..15075dbce 100644 --- a/app/controllers/api/v1/external_slack_controller.rb +++ b/app/controllers/api/v1/external_slack_controller.rb @@ -30,7 +30,8 @@ def create_user user.slack_access_token = token - user_data = user.raw_slack_user_info + slack_service = Users::SlackIntegrationService.new(user) + user_data = slack_service.raw_slack_user_info return render json: { error: "Invalid Slack token" }, status: :unauthorized unless user_data.present? email = user_data.dig("profile", "email") @@ -40,7 +41,7 @@ def create_user email_address.source ||= :slack user.email_addresses << email_address unless user.email_addresses.include?(email_address) - user.update_from_slack + slack_service.update_from_slack user.parse_and_set_timezone(user_data["tz"]) if user.save diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 64b73594a..4eaf0f277 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -4,7 +4,7 @@ def hca_new Rails.logger.info("Sessions return data: #{session[:return_data]}") redirect_uri = url_for(action: :hca_create, only_path: false) - redirect_to User.hca_authorize_url(redirect_uri), + redirect_to Users::OauthAuthenticationService.hca_authorize_url(redirect_uri), host: "https://auth.hackclub.com", allow_other_host: "https://auth.hackclub.com" end @@ -23,7 +23,7 @@ def hca_create redirect_uri = url_for(action: :hca_create, only_path: false) - @user = User.from_hca_token(params[:code], redirect_uri) + @user = Users::OauthAuthenticationService.from_hca_token(params[:code], redirect_uri) if @user&.persisted? session[:user_id] = @user.id @@ -55,7 +55,7 @@ def slack_new }.to_json Rails.logger.info "Starting Slack OAuth flow with redirect URI: #{redirect_uri}" - redirect_to User.slack_authorize_url(redirect_uri, state: state_payload), + redirect_to Users::OauthAuthenticationService.slack_authorize_url(redirect_uri, state: state_payload), host: "https://slack.com", allow_other_host: "https://slack.com" end @@ -80,7 +80,7 @@ def slack_create return end - @user = User.from_slack_token(params[:code], redirect_uri) + @user = Users::OauthAuthenticationService.from_slack_token(params[:code], redirect_uri) if @user&.persisted? session[:user_id] = @user.id @@ -118,7 +118,7 @@ def github_new oauth_nonce = SecureRandom.hex(24) session[:github_oauth_state_nonce] = oauth_nonce Rails.logger.info "Starting GitHub OAuth flow with redirect URI: #{redirect_uri}" - redirect_to User.github_authorize_url(redirect_uri, state: oauth_nonce), + redirect_to Users::OauthAuthenticationService.github_authorize_url(redirect_uri, state: oauth_nonce), allow_other_host: "https://github.com" end @@ -141,7 +141,7 @@ def github_create return end - @user = User.from_github_token(params[:code], redirect_uri, current_user) + @user = Users::OauthAuthenticationService.from_github_token(params[:code], redirect_uri, current_user) if @user&.persisted? PosthogService.capture(@user, "github_linked") diff --git a/app/controllers/settings/integrations_controller.rb b/app/controllers/settings/integrations_controller.rb index 5d128a540..0fd66111f 100644 --- a/app/controllers/settings/integrations_controller.rb +++ b/app/controllers/settings/integrations_controller.rb @@ -5,7 +5,7 @@ def show def update if @user.update(integrations_params) - @user.update_slack_status if @user.uses_slack_status? + Users::SlackIntegrationService.new(@user).update_slack_status if @user.uses_slack_status? PosthogService.capture(@user, "settings_updated", { fields: integrations_params.keys }) redirect_to my_settings_integrations_path, notice: "Settings updated successfully" else diff --git a/app/jobs/one_time/backfill_email_sources_job.rb b/app/jobs/one_time/backfill_email_sources_job.rb index 61678f7d5..9f782f525 100644 --- a/app/jobs/one_time/backfill_email_sources_job.rb +++ b/app/jobs/one_time/backfill_email_sources_job.rb @@ -5,8 +5,8 @@ def perform # Backfill email addresses from Slack users.find_each do |user| - slack_user_info = user.raw_slack_user_info - github_user_info = user.raw_github_user_info + slack_user_info = Users::SlackIntegrationService.new(user).raw_slack_user_info + github_user_info = Users::GithubIntegrationService.new(user).raw_github_user_info # sleep if we hit an api sleep 1 unless slack_user_info.nil? && github_user_info.nil? diff --git a/app/jobs/one_time/set_user_timezone_from_slack_job.rb b/app/jobs/one_time/set_user_timezone_from_slack_job.rb index 5247da2e4..9b4ec16f9 100644 --- a/app/jobs/one_time/set_user_timezone_from_slack_job.rb +++ b/app/jobs/one_time/set_user_timezone_from_slack_job.rb @@ -4,7 +4,7 @@ class OneTime::SetUserTimezoneFromSlackJob < ApplicationJob def perform User.where.not(slack_uid: nil).find_each(batch_size: 100) do |user| begin - user.set_timezone_from_slack + Users::SlackIntegrationService.new(user).set_timezone_from_slack user.save! rescue => e report_error(e, message: "Failed to update timezone for user #{user.id}") diff --git a/app/jobs/slack_username_update_job.rb b/app/jobs/slack_username_update_job.rb index 5be425163..ec799eb0b 100644 --- a/app/jobs/slack_username_update_job.rb +++ b/app/jobs/slack_username_update_job.rb @@ -16,7 +16,7 @@ def perform .limit(100) .each do |user| begin - user.update_from_slack + Users::SlackIntegrationService.new(user).update_from_slack user.save! rescue => e report_error(e, message: "Failed to update Slack username and avatar for user #{user.id}") diff --git a/app/jobs/user_slack_status_update_job.rb b/app/jobs/user_slack_status_update_job.rb index 121491087..a53e9c2d6 100644 --- a/app/jobs/user_slack_status_update_job.rb +++ b/app/jobs/user_slack_status_update_job.rb @@ -6,7 +6,7 @@ class UserSlackStatusUpdateJob < ApplicationJob def perform User.where(uses_slack_status: true).find_each(batch_size: BATCH_SIZE) do |user| begin - user.update_slack_status + Users::SlackIntegrationService.new(user).update_slack_status rescue => e report_error(e, message: "Failed to update Slack status for user #{user.slack_uid}") end diff --git a/app/models/concerns/github_integration.rb b/app/models/concerns/github_integration.rb deleted file mode 100644 index 3b419b37f..000000000 --- a/app/models/concerns/github_integration.rb +++ /dev/null @@ -1,17 +0,0 @@ -module GithubIntegration - extend ActiveSupport::Concern - - 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 github_profile_url - "https://github.com/#{github_username}" if github_username.present? - end -end diff --git a/app/models/concerns/users/admin_and_trust.rb b/app/models/concerns/users/admin_and_trust.rb new file mode 100644 index 000000000..b56af3183 --- /dev/null +++ b/app/models/concerns/users/admin_and_trust.rb @@ -0,0 +1,92 @@ +module Users + module AdminAndTrust + extend ActiveSupport::Concern + + included do + enum :trust_level, { + blue: 0, + red: 1, + green: 2, + yellow: 3 + } + + enum :admin_level, { + default: 0, + superadmin: 1, + admin: 2, + viewer: 3, + ultraadmin: 4 + }, prefix: :admin_level + end + + class_methods do + def not_convicted + where.not(trust_level: trust_levels[:red]) + end + + def not_suspect + where(trust_level: [ trust_levels[:blue], trust_levels[:green] ]) + end + end + + def can_convict_users? + admin_level_superadmin? || admin_level_ultraadmin? + 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.can_convict_users? + 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 + + 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 + end +end diff --git a/app/models/concerns/users/authentication.rb b/app/models/concerns/users/authentication.rb new file mode 100644 index 000000000..af114596b --- /dev/null +++ b/app/models/concerns/users/authentication.rb @@ -0,0 +1,27 @@ +module Users + module Authentication + extend ActiveSupport::Concern + + def create_email_signin_token(continue_param: nil) + sign_in_tokens.create!(auth_type: :email, continue_param: continue_param) + end + + def rotate_api_keys! + api_keys.transaction do + api_keys.destroy_all + api_keys.create!(name: "Hackatime key") + end + end + + def rotate_single_api_key!(api_key) + raise ActiveRecord::RecordNotFound unless api_key.user_id == id + + api_key.update!(token: SecureRandom.uuid_v4) + api_key + end + + def find_valid_token(token) + sign_in_tokens.valid.find_by(token: token) + end + end +end diff --git a/app/models/concerns/users/identity.rb b/app/models/concerns/users/identity.rb new file mode 100644 index 000000000..a31be2d9b --- /dev/null +++ b/app/models/concerns/users/identity.rb @@ -0,0 +1,81 @@ +module Users + module Identity + extend ActiveSupport::Concern + + included do + 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 + } + end + + class_methods do + # Look up a user by numeric ID, slack_uid, hca_id, or username + def lookup_by_identifier(id) + return nil if id.blank? + + numeric_id = id.to_i if id.match?(/^\d+$/) + + relation = where(slack_uid: id) + .or(where(hca_id: id)) + .or(where(username: id)) + relation = where(id: numeric_id).or(relation) if numeric_id + + candidates = relation.to_a + + if numeric_id + match = candidates.find { |u| u.id == numeric_id } + return match if match + end + + candidates.find { |u| u.slack_uid == id } || + candidates.find { |u| u.hca_id == id } || + candidates.find { |u| u.username == id } + end + + if Rails.env.development? + def slow_find_by_email(email) + EmailAddress.find_by(email: email)&.user + end + end + end + + private + + 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 +end diff --git a/app/models/concerns/users/profile.rb b/app/models/concerns/users/profile.rb new file mode 100644 index 000000000..cae46802b --- /dev/null +++ b/app/models/concerns/users/profile.rb @@ -0,0 +1,80 @@ +module Users + module Profile + extend ActiveSupport::Concern + + def country_name + ISO3166::Country.new(country_code).common_name + end + + def country_subregion + ISO3166::Country.new(country_code).subregion + end + + def streak_days_formatted + if streak_days > 30 + "30+" + elsif streak_days < 1 + nil + else + streak_days.to_s + end + 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 + report_message("Invalid timezone #{timezone} for user #{id}") + end + end + + def avatar_url + return slack_avatar_url if slack_avatar_url.present? + return github_avatar_url if github_avatar_url.present? + + email = 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 + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 06d44c1eb..e9beb4207 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,9 +1,11 @@ class User < ApplicationRecord include TimezoneRegions include UserThemeConfiguration - include ::OauthAuthentication - include ::SlackIntegration - include ::GithubIntegration + + include ::Users::Identity + include ::Users::AdminAndTrust + include ::Users::Profile + include ::Users::Authentication has_subscriptions @@ -30,29 +32,6 @@ class User < ApplicationRecord 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, - ultraadmin: 4 - }, prefix: :admin_level - enum :theme, { standard: 0, neon: 1, @@ -67,72 +46,6 @@ def country_subregion amoled: 10 } - # Look up a user by numeric ID, slack_uid, hca_id, or username - def self.lookup_by_identifier(id) - return nil if id.blank? - - numeric_id = id.to_i if id.match?(/^\d+$/) - - relation = where(slack_uid: id) - .or(where(hca_id: id)) - .or(where(username: id)) - relation = where(id: numeric_id).or(relation) if numeric_id - - candidates = relation.to_a - - if numeric_id - match = candidates.find { |u| u.id == numeric_id } - return match if match - end - - candidates.find { |u| u.slack_uid == id } || - candidates.find { |u| u.hca_id == id } || - candidates.find { |u| u.username == id } - end - - def can_convict_users? - admin_level_superadmin? || admin_level_ultraadmin? - 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.can_convict_users? - 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 @@ -151,24 +64,6 @@ def set_trust(level, changed_by_user: nil, reason: nil, notes: nil) has_many :heartbeat_import_runs, 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 @@ -188,24 +83,6 @@ 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 @@ -214,22 +91,6 @@ 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, @@ -242,101 +103,18 @@ def flipper_id "User;#{id}" end - def active_remote_heartbeat_import_run? - heartbeat_import_runs.remote_imports.active_imports.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 - report_message("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}" + def github_profile_url + "https://github.com/#{github_username}" if github_username.present? 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)" + def active_remote_heartbeat_import_run? + heartbeat_import_runs.remote_imports.active_imports.exists? 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 rotate_api_keys! - api_keys.transaction do - api_keys.destroy_all - api_keys.create!(name: "Hackatime key") - end - end - - def rotate_single_api_key!(api_key) - raise ActiveRecord::RecordNotFound unless api_key.user_id == id - - api_key.update!(token: SecureRandom.uuid_v4) - api_key - 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 @@ -351,27 +129,4 @@ def track_signup def subscribe_to_default_lists subscribe("weekly_summary") 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 diff --git a/app/services/admin/visualization_quantized_query.rb b/app/services/admin/visualization_quantized_query.rb new file mode 100644 index 000000000..760f6b135 --- /dev/null +++ b/app/services/admin/visualization_quantized_query.rb @@ -0,0 +1,156 @@ +module Admin + class VisualizationQuantizedQuery + Result = Struct.new(:success?, :days, :error, keyword_init: true) + + def initialize(user:, year:, month:, connection: ActiveRecord::Base.connection) + @user = user + @year = year.to_i + @month = month.to_i + @connection = connection + end + + def call + return Result.new(success?: false, error: :invalid_parameters) unless valid_month? + + start_epoch, end_epoch = month_range + quantized_rows = execute_query(quantized_sql, @user.id, start_epoch, end_epoch) + daily_total_rows = execute_query(daily_totals_sql, @user.id, start_epoch, end_epoch) + + daily_totals = build_daily_totals(daily_total_rows) + points_by_day = build_points_by_day(quantized_rows) + + Result.new( + success?: true, + days: build_days(start_epoch, end_epoch, daily_totals, points_by_day) + ) + rescue Date::Error, ArgumentError + Result.new(success?: false, error: :invalid_date) + end + + private + + def valid_month? + @month.between?(1, 12) + end + + def month_range + start_epoch = Time.utc(@year, @month, 1).to_i + end_epoch = if @month == 12 + Time.utc(@year + 1, 1, 1).to_i + else + Time.utc(@year, @month + 1, 1).to_i + end + + [ start_epoch, end_epoch ] + end + + def execute_query(sql, *binds) + @connection.exec_query( + ActiveRecord::Base.sanitize_sql([ sql, *binds ]) + ) + end + + def build_daily_totals(rows) + rows.each_with_object({}) do |row, hash| + hash[row["day"]] = row["total_seconds"] + end + end + + def build_points_by_day(rows) + rows.each_with_object({}) do |row, hash| + day = Time.at(row["time"].to_f).utc.to_date + hash[day] ||= [] + hash[day] << { + time: row["time"], + lineno: row["lineno"], + cursorpos: row["cursorpos"] + } + end + end + + def build_days(start_epoch, end_epoch, daily_totals, points_by_day) + (start_epoch...end_epoch).step(86_400).map do |epoch| + day = Time.at(epoch).utc.to_date + { + date_timestamp_s: epoch, + total_seconds: daily_totals[day] || 0, + points: points_by_day[day] || [] + } + end + end + + def quantized_sql + <<~SQL + WITH base_heartbeats AS ( + SELECT + "time", + lineno, + cursorpos, + date_trunc('day', to_timestamp("time")) as day_start + FROM heartbeats + WHERE user_id = ? + AND "time" >= ? AND "time" < ? + ORDER BY "time" ASC + LIMIT 1000000 + ), + daily_stats AS ( + SELECT + *, + GREATEST(1, MAX(lineno) OVER (PARTITION BY day_start)) as max_lineno, + GREATEST(1, MAX(cursorpos) OVER (PARTITION BY day_start)) as max_cursorpos + FROM base_heartbeats + ), + quantized_heartbeats AS ( + SELECT + *, + ROUND(2 + (("time" - extract(epoch from day_start)) / 86400) * (396)) as qx, + ROUND(2 + (1 - CAST(lineno AS decimal) / max_lineno) * (96)) as qy_lineno, + ROUND(2 + (1 - CAST(cursorpos AS decimal) / max_cursorpos) * (96)) as qy_cursorpos + FROM daily_stats + ) + SELECT "time", lineno, cursorpos + FROM ( + SELECT DISTINCT ON (day_start, qx, qy_lineno) "time", lineno, cursorpos + FROM quantized_heartbeats + WHERE lineno IS NOT NULL + ORDER BY day_start, qx, qy_lineno, "time" ASC + ) AS lineno_pixels + UNION + SELECT "time", lineno, cursorpos + FROM ( + SELECT DISTINCT ON (day_start, qx, qy_cursorpos) "time", lineno, cursorpos + FROM quantized_heartbeats + WHERE cursorpos IS NOT NULL + ORDER BY day_start, qx, qy_cursorpos, "time" ASC + ) AS cursorpos_pixels + UNION + SELECT "time", lineno, cursorpos + FROM ( + SELECT DISTINCT ON (day_start, qx) "time", lineno, cursorpos + FROM quantized_heartbeats + WHERE lineno IS NULL AND cursorpos IS NULL + ORDER BY day_start, qx, "time" ASC + ) AS null_pixels + ORDER BY "time" ASC + SQL + end + + def daily_totals_sql + <<~SQL + WITH heartbeats_with_gaps AS ( + SELECT + date_trunc('day', to_timestamp("time"))::date as day, + "time" - LAG("time", 1, "time") OVER (PARTITION BY date_trunc('day', to_timestamp("time")) ORDER BY "time") as gap + FROM heartbeats + WHERE user_id = ? AND time >= ? AND time < ? + ) + SELECT + day, + SUM(LEAST(gap, 120)) as total_seconds + FROM heartbeats_with_gaps + WHERE gap IS NOT NULL + GROUP BY day + SQL + end + end +end diff --git a/app/services/users/github_integration_service.rb b/app/services/users/github_integration_service.rb new file mode 100644 index 000000000..d1bf855be --- /dev/null +++ b/app/services/users/github_integration_service.rb @@ -0,0 +1,21 @@ +class Users::GithubIntegrationService + attr_reader :user + + def initialize(user) + @user = user + end + + def raw_github_user_info + return nil unless user.github_uid.present? + return nil unless user.github_access_token.present? + + @github_user_info ||= HTTP.auth("Bearer #{user.github_access_token}") + .get("https://api.github.com/user") + + JSON.parse(@github_user_info.body.to_s) + end + + def github_profile_url + "https://github.com/#{user.github_username}" if user.github_username.present? + end +end diff --git a/app/models/concerns/oauth_authentication.rb b/app/services/users/oauth_authentication_service.rb similarity index 94% rename from app/models/concerns/oauth_authentication.rb rename to app/services/users/oauth_authentication_service.rb index 6cc2353c1..86bfd8fe3 100644 --- a/app/models/concerns/oauth_authentication.rb +++ b/app/services/users/oauth_authentication_service.rb @@ -1,8 +1,5 @@ -module OauthAuthentication - extend ActiveSupport::Concern - include ErrorReporting - - class_methods do +class Users::OauthAuthenticationService + class << self include ErrorReporting def hca_authorize_url(redirect_uri) @@ -60,19 +57,19 @@ def from_hca_token(code, redirect_uri) hca_data = ::HCAService.me(access_token) identity = hca_data["identity"] - @user = User.find_by_hca_id(identity["id"]) unless identity["id"].blank? - @user ||= User.find_by_slack_uid(identity["slack_id"]) unless identity["slack_id"].blank? - @user ||= begin + user = User.find_by_hca_id(identity["id"]) unless identity["id"].blank? + user ||= User.find_by_slack_uid(identity["slack_id"]) unless identity["slack_id"].blank? + user ||= begin EmailAddress.find_by(email: identity["primary_email"])&.user unless identity["primary_email"].blank? end - @user.update( + user.update( hca_scopes: hca_data["scopes"], hca_id: identity["id"], hca_access_token: access_token - ) if !!@user + ) if !!user - @user ||= begin + user ||= begin u = User.create!( hca_id: identity["id"], slack_uid: identity["slack_id"], diff --git a/app/models/concerns/slack_integration.rb b/app/services/users/slack_integration_service.rb similarity index 65% rename from app/models/concerns/slack_integration.rb rename to app/services/users/slack_integration_service.rb index c6b868a99..01ac6cdcd 100644 --- a/app/models/concerns/slack_integration.rb +++ b/app/services/users/slack_integration_service.rb @@ -1,11 +1,15 @@ -module SlackIntegration - extend ActiveSupport::Concern +class Users::SlackIntegrationService + attr_reader :user + + def initialize(user) + @user = user + end def set_timezone_from_slack - return unless slack_uid.present? + return unless user.slack_uid.present? - user_response = HTTP.auth("Bearer #{slack_access_token}") - .get("https://slack.com/api/users.info?user=#{slack_uid}") + user_response = HTTP.auth("Bearer #{user.slack_access_token}") + .get("https://slack.com/api/users.info?user=#{user.slack_uid}") user_data = JSON.parse(user_response.body.to_s) @@ -15,15 +19,15 @@ def set_timezone_from_slack return unless timezone_string.present? - parse_and_set_timezone(timezone_string) + user.parse_and_set_timezone(timezone_string) end def raw_slack_user_info - return nil unless slack_uid.present? - return nil unless slack_access_token.present? + return nil unless user.slack_uid.present? + return nil unless user.slack_access_token.present? - @slack_user_info ||= HTTP.auth("Bearer #{slack_access_token}") - .get("https://slack.com/api/users.info?user=#{slack_uid}") + @slack_user_info ||= HTTP.auth("Bearer #{user.slack_access_token}") + .get("https://slack.com/api/users.info?user=#{user.slack_uid}") JSON.parse(@slack_user_info.body.to_s).dig("user") end @@ -35,18 +39,18 @@ def update_from_slack profile = user_data["profile"] || {} - self.slack_avatar_url = profile["image_192"] || profile["image_72"] + user.slack_avatar_url = profile["image_192"] || profile["image_72"] - self.slack_username = profile["display_name_normalized"].presence - self.slack_username ||= profile["real_name_normalized"].presence - self.slack_username ||= user_data["name"].presence - self.slack_synced_at = Time.current + user.slack_username = profile["display_name_normalized"].presence + user.slack_username ||= profile["real_name_normalized"].presence + user.slack_username ||= user_data["name"].presence + user.slack_synced_at = Time.current end def update_slack_status - return unless uses_slack_status? + return unless user.uses_slack_status? - current_status_response = HTTP.auth("Bearer #{slack_access_token}") + current_status_response = HTTP.auth("Bearer #{user.slack_access_token}") .get("https://slack.com/api/users.profile.get") current_status = JSON.parse(current_status_response.body.to_s) @@ -57,9 +61,9 @@ def update_slack_status return if status_present && status_custom - current_project = heartbeats.order(time: :desc).first&.project - current_project_duration = Time.use_zone(timezone) do - heartbeats.where(project: current_project) + current_project = user.heartbeats.order(time: :desc).first&.project + current_project_duration = Time.use_zone(user.timezone) do + user.heartbeats.where(project: current_project) .today .duration_seconds end @@ -92,7 +96,7 @@ def update_slack_status status_emoji = ":#{status_emoji}:" status_text = "#{current_project_duration_formatted} spent on #{current_project} today" - HTTP.auth("Bearer #{slack_access_token}") + HTTP.auth("Bearer #{user.slack_access_token}") .post("https://slack.com/api/users.profile.set", form: { profile: { status_text:, diff --git a/spec/requests/api/admin/v1/admin_user_utils_spec.rb b/spec/requests/api/admin/v1/admin_user_utils_spec.rb index 520f7cd41..3c82487c6 100644 --- a/spec/requests/api/admin/v1/admin_user_utils_spec.rb +++ b/spec/requests/api/admin/v1/admin_user_utils_spec.rb @@ -512,6 +512,98 @@ end end + path '/api/admin/v1/users/{id}/visualization/quantized' do + get('Get quantized visualization for user') do + tags 'Admin Utils' + description 'Get quantized heartbeat points and daily totals for a user/month.' + security [ AdminToken: [] ] + produces 'application/json' + + parameter name: :id, in: :path, type: :integer, description: 'User ID' + parameter name: :year, in: :query, type: :integer, required: true, description: 'Year (UTC)' + parameter name: :month, in: :query, type: :integer, required: true, description: 'Month (1-12, UTC)' + + response(200, 'successful') do + let(:Authorization) { "Bearer dev-admin-api-key-12345" } + let(:user) do + u = User.create!(username: 'viz_user') + EmailAddress.create!(user: u, email: 'viz@example.com') + u + end + let(:id) { user.id } + let(:year) { 2026 } + let(:month) { 1 } + + before do + Heartbeat.create!( + user: user, + time: Time.utc(2026, 1, 15, 12, 0, 0).to_i, + lineno: 12, + cursorpos: 22, + source_type: :test_entry + ) + end + + schema type: :object, + properties: { + days: { + type: :array, + items: { + type: :object, + properties: { + date_timestamp_s: { type: :integer }, + total_seconds: { type: :number }, + points: { + type: :array, + items: { + type: :object, + properties: { + time: { type: :number }, + lineno: { type: :integer, nullable: true }, + cursorpos: { type: :integer, nullable: true } + } + } + } + } + } + } + } + + run_test! do |response| + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body['days']).to be_an(Array) + expect(body['days'].length).to eq(31) + + january_15 = Time.utc(2026, 1, 15).to_i + day = body['days'].find { |d| d['date_timestamp_s'] == january_15 } + expect(day).to be_present + expect(day['points']).to be_an(Array) + expect(day['points']).not_to be_empty + end + end + + response(422, 'invalid month parameter') do + let(:Authorization) { "Bearer dev-admin-api-key-12345" } + let(:user) do + u = User.create!(username: 'viz_bad_month') + EmailAddress.create!(user: u, email: 'viz-bad-month@example.com') + u + end + let(:id) { user.id } + let(:year) { 2026 } + let(:month) { 13 } + schema '$ref' => '#/components/schemas/Error' + + run_test! do |response| + expect(response).to have_http_status(:unprocessable_entity) + body = JSON.parse(response.body) + expect(body['error']).to eq('invalid parameters') + end + end + end + end + path '/api/admin/v1/user/get_user_by_email' do post('Get user by email') do tags 'Admin Utils' @@ -565,7 +657,7 @@ type: :object, properties: { id: { type: :integer }, - username: { type: :string }, + username: { type: :string, nullable: true }, slack_username: { type: :string, nullable: true }, github_username: { type: :string, nullable: true }, slack_avatar_url: { type: :string, nullable: true }, diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 740a7225e..7bfe50576 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -1305,6 +1305,69 @@ paths: type: string admin_level: type: string + "/api/admin/v1/users/{id}/visualization/quantized": + get: + summary: Get quantized visualization for user + tags: + - Admin Utils + description: Get quantized heartbeat points and daily totals for a user/month. + security: + - AdminToken: [] + parameters: + - name: id + in: path + description: User ID + required: true + schema: + type: integer + - name: year + in: query + required: true + description: Year (UTC) + schema: + type: integer + - name: month + in: query + required: true + description: Month (1-12, UTC) + schema: + type: integer + responses: + '200': + description: successful + content: + application/json: + schema: + type: object + properties: + days: + type: array + items: + type: object + properties: + date_timestamp_s: + type: integer + total_seconds: + type: number + points: + type: array + items: + type: object + properties: + time: + type: number + lineno: + type: integer + nullable: true + cursorpos: + type: integer + nullable: true + '422': + description: invalid month parameter + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" "/api/admin/v1/user/get_user_by_email": post: summary: Get user by email @@ -1358,6 +1421,7 @@ paths: type: integer username: type: string + nullable: true slack_username: type: string nullable: true diff --git a/test/services/admin/visualization_quantized_query_test.rb b/test/services/admin/visualization_quantized_query_test.rb new file mode 100644 index 000000000..f00823089 --- /dev/null +++ b/test/services/admin/visualization_quantized_query_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +module Admin + class VisualizationQuantizedQueryTest < ActiveSupport::TestCase + test "returns quantized days for a month" do + user = User.create!(timezone: "UTC") + + Heartbeat.create!( + user: user, + time: Time.utc(2026, 1, 10, 12, 0, 0).to_i, + lineno: 10, + cursorpos: 40, + source_type: :test_entry + ) + + result = VisualizationQuantizedQuery.new(user: user, year: 2026, month: 1).call + + assert result.success? + assert_equal 31, result.days.length + + day = result.days.find { |d| Time.at(d[:date_timestamp_s]).utc.to_date == Date.new(2026, 1, 10) } + assert_not_nil day + assert_equal 1, day[:points].length + assert_equal 10, day[:points].first[:lineno] + assert_equal 40, day[:points].first[:cursorpos] + end + + test "returns invalid parameters for out-of-range month" do + user = User.create!(timezone: "UTC") + + result = VisualizationQuantizedQuery.new(user: user, year: 2026, month: 13).call + + assert_not result.success? + assert_equal :invalid_parameters, result.error + end + + test "accepts very large years without crashing" do + user = User.create!(timezone: "UTC") + + result = VisualizationQuantizedQuery.new(user: user, year: 100_000, month: 1).call + + assert result.success? + assert_equal 31, result.days.length + end + end +end