hackatime/app/controllers/api/v1/stats_controller.rb
Mahad Kalam 1f695850e5
Bring back Mailkick (#1021)
* Sources say Charlie Kick is stable. Please god

* Some tests + guards + unsub URL fix

* Fix lockfile!

* bin/rubocop -A

* if this does not work I am going to kms

* phew
2026-03-01 14:26:54 +00:00

340 lines
11 KiB
Ruby

class Api::V1::StatsController < ApplicationController
before_action :ensure_authenticated!, only: [ :show ], unless: -> { Rails.env.development? }
before_action :set_user, only: [ :user_stats, :user_spans, :user_projects, :user_project, :user_projects_details ]
def show
# take either user_id with a start date & end date
start_date = parse_date_param(:start_date, default: 10.years.ago, boundary: :start)
return if performed?
end_date = parse_date_param(:end_date, default: Date.today.end_of_day, boundary: :end)
return if performed?
query = Heartbeat.where(time: start_date..end_date)
if params[:username].present?
user = User.find_by(username: params[:username]) || User.find_by(slack_uid: params[:username])
return render json: { error: "User not found" }, status: :not_found unless user
query = query.where(user_id: user.id)
end
if params[:user_email].present?
user_id = EmailAddress.find_by(email: params[:user_email])&.user_id || find_by_email(params[:user_email])
return render json: { error: "User not found" }, status: :not_found unless user_id.present?
query = query.where(user_id: user_id)
end
render plain: query.duration_seconds.to_s
end
def user_stats
# Used by the github stats page feature
return render json: { error: "User not found" }, status: :not_found unless @user.present?
if !@user.allow_public_stats_lookup && (!current_user || current_user != @user)
return render json: { error: "user has disabled public stats" }, status: :forbidden
end
start_date = parse_datetime_param(:start_date, default: 10.years.ago)
return if performed?
end_date = parse_datetime_param(:end_date, default: Date.today.end_of_day)
return if performed?
# /api/v1/users/current/stats?filter_by_project=harbor,high-seas
scope = nil
if params[:filter_by_project].present?
filter_by_project = params[:filter_by_project].split(",")
scope = Heartbeat.where(project: filter_by_project)
end
limit = params[:limit].to_i
enabled_features = params[:features]&.split(",")&.map(&:to_sym)
enabled_features ||= %i[languages]
service_params = {}
service_params[:user] = @user
service_params[:specific_filters] = enabled_features
service_params[:allow_cache] = false
service_params[:limit] = limit
service_params[:start_date] = start_date
service_params[:end_date] = end_date
service_params[:scope] = scope if scope.present?
# use TestWakatimeService when test_param=true for all requests
if params[:test_param] == "true"
service_params[:boundary_aware] = true # always and i mean always use boundary aware in testwakatime service
if params[:total_seconds] == "true"
summary = TestWakatimeService.new(**service_params).generate_summary
return render json: { total_seconds: summary[:total_seconds] }
end
summary = TestWakatimeService.new(**service_params).generate_summary
else
if params[:total_seconds] == "true"
query = @user.heartbeats
.coding_only
.with_valid_timestamps
.where(time: start_date..end_date)
if params[:filter_by_project].present?
filter_by_project = params[:filter_by_project].split(",")
query = query.where(project: filter_by_project)
end
if params[:filter_by_category].present?
filter_by_category = params[:filter_by_category].split(",")
query = query.where(category: filter_by_category)
end
# do the boundary thingie if requested
use_boundary_aware = params[:boundary_aware] == "true"
total_seconds = if use_boundary_aware
Heartbeat.duration_seconds_boundary_aware(query, start_date.to_f, end_date.to_f) || 0
else
query.duration_seconds || 0
end
return render json: { total_seconds: total_seconds }
end
summary = WakatimeService.new(**service_params).generate_summary
end
if params[:features]&.include?("projects") && params[:filter_by_project].present?
filter_by_project = params[:filter_by_project].split(",")
heartbeats = @user.heartbeats
.coding_only
.with_valid_timestamps
.where(time: start_date..end_date, project: filter_by_project)
unique_seconds = unique_heartbeat_seconds(heartbeats)
summary[:unique_total_seconds] = unique_seconds
end
trust_level = @user.trust_level
trust_level = "blue" if trust_level == "yellow"
trust_value = User.trust_levels[trust_level]
trust_info = {
trust_level: trust_level,
trust_value: trust_value
}
summary[:streak] = @user.streak_days
render json: {
data: summary,
trust_factor: trust_info
}
end
def user_spans
return render json: { error: "User not found" }, status: :not_found unless @user
start_date = parse_datetime_param(:start_date, default: 10.years.ago)
return if performed?
end_date = parse_datetime_param(:end_date, default: Date.today.end_of_day)
return if performed?
timespan = (start_date.to_f..end_date.to_f)
heartbeats = @user.heartbeats.where(time: timespan)
if params[:project].present?
heartbeats = heartbeats.where(project: params[:project])
elsif params[:filter_by_project].present?
heartbeats = heartbeats.where(project: params[:filter_by_project].split(","))
end
render json: { spans: heartbeats.to_span }
end
def trust_factor
id = params[:username] || params[:username_or_id] || params[:user_id]
return render json: { error: "User not found" }, status: :not_found if id.blank?
query = User.where(slack_uid: id).or(User.where(username: id))
query = query.or(User.where(id: id)) if id.match?(/^\d+$/)
level = query.pick(:trust_level)
return render json: { error: "User not found" }, status: :not_found unless level
level = "blue" if level == "yellow"
render json: { trust_level: level, trust_value: User.trust_levels[level] }
end
def banned_users_counts
now = Time.current
day_ago = now - 1.day
week_ago = now - 1.week
month_ago = now - 1.month
day_count = TrustLevelAuditLog.where(new_trust_level: "red")
.where("created_at >= ?", day_ago)
.distinct
.count(:user_id)
week_count = TrustLevelAuditLog.where(new_trust_level: "red")
.where("created_at >= ?", week_ago)
.distinct
.count(:user_id)
month_count = TrustLevelAuditLog.where(new_trust_level: "red")
.where("created_at >= ?", month_ago)
.distinct
.count(:user_id)
render json: {
day: day_count,
week: week_count,
month: month_count
}
end
def user_projects
return render json: { error: "User not found" }, status: :not_found unless @user
render json: { projects: project_stats_query(include_archived: true).project_names }
end
def user_project
return render json: { error: "User not found" }, status: :not_found unless @user
project_name = params[:project_name]
return render json: { error: "whats the name?" }, status: :bad_request unless project_name.present?
project_data = project_stats_query.project_details(names: [ project_name ]).first
return render json: { error: "found nuthin" }, status: :not_found unless project_data
render json: project_data
end
def user_projects_details
return render json: { error: "User not found" }, status: :not_found unless @user
names = params[:projects]&.split(",")&.map(&:strip)
data = project_stats_query.project_details(names: names)
render json: { projects: data }
end
private
def set_user
token = request.headers["Authorization"]&.split(" ")&.last
identifier = params[:username] || params[:username_or_id] || params[:user_id]
@user = begin
if identifier == "my" && token.present?
ApiKey.find_by(token: token)&.user
else
lookup_user(identifier)
end
end
end
def lookup_user(id)
return nil if id.blank?
if id.match?(/^\d+$/)
user = User.find_by(id: id)
return user if user
end
user = User.find_by(slack_uid: id)
return user if user
# email lookup, but you really should not be using this cuz like wtf
# if identifier.include?("@")
# email_record = EmailAddress.find_by(email: identifier)
# return email_record.user if email_record
# end
user = User.find_by(username: id)
return user if user
# skill issue zone
nil
end
def ensure_authenticated!
token = request.headers["Authorization"]&.split(" ")&.last
token ||= params[:api_key]
# Rails.logger.info "Auth Debug: Token=#{token.inspect}, Expected=#{ENV['STATS_API_KEY'].inspect}"
render json: { error: "Unauthorized" }, status: :unauthorized unless token == ENV["STATS_API_KEY"]
end
def find_by_email(email)
cache_key = "user_id_by_email/#{email}"
slack_id = Rails.cache.fetch(cache_key, expires_in: 1.week) do
response = HTTP
.auth("Bearer #{ENV["SLACK_USER_OAUTH_TOKEN"]}")
.get("https://slack.com/api/users.lookupByEmail", params: { email: email })
JSON.parse(response.body)["user"]["id"]
rescue => e
Sentry.capture_exception(e)
Rails.logger.error("Error finding user by email: #{e}")
nil
end
Rails.cache.delete(cache_key) if slack_id.nil?
slack_id
end
def project_stats_query(include_archived: false)
@project_stats_queries ||= {}
@project_stats_queries[include_archived] ||= ProjectStatsQuery.new(
user: @user,
params: params,
include_archived: include_archived
)
end
def unique_heartbeat_seconds(heartbeats)
timestamps = heartbeats.order(:time).pluck(:time)
return 0 if timestamps.empty?
total_seconds = 0
timestamps.each_cons(2) do |prev_time, curr_time|
gap = curr_time - prev_time
if gap > 0 && gap <= 2.minutes
total_seconds += gap
end
end
total_seconds.to_i
end
def parse_date_param(param_name, default:, boundary:)
raw_value = params[param_name]
return default if raw_value.blank?
parsed_date = Date.iso8601(raw_value)
boundary == :start ? parsed_date.beginning_of_day : parsed_date.end_of_day
rescue ArgumentError, Date::Error, TypeError
render json: { error: "Invalid #{param_name}" }, status: :unprocessable_entity
nil
end
def parse_datetime_param(param_name, default:)
raw_value = params[param_name]
return default if raw_value.blank?
parsed_time = Time.zone.parse(raw_value.to_s)
raise ArgumentError if parsed_time.nil?
parsed_time
rescue ArgumentError, TypeError
render json: { error: "Invalid #{param_name}" }, status: :unprocessable_entity
nil
end
end