mirror of
https://github.com/SrIzan10/hc-harbor.git
synced 2026-05-01 10:45:21 +00:00
Merge branch 'main' into timeline-page
This commit is contained in:
@@ -56,4 +56,6 @@ GITHUB_CLIENT_SECRET=your_github_client_secret_here
|
||||
|
||||
SKYLIGHT_AUTHENTICATION=replace_me
|
||||
|
||||
IPINFO_API_KEY=replace_me
|
||||
IPINFO_API_KEY=replace_me
|
||||
|
||||
MAIL_HACKCLUB_TOKEN=replace_me
|
||||
@@ -1 +1 @@
|
||||
ruby-3.4.1
|
||||
ruby-3.4.3
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
||||
|
||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
||||
ARG RUBY_VERSION=3.4.1
|
||||
ARG RUBY_VERSION=3.4.3
|
||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||
|
||||
# Rails app lives here
|
||||
@@ -37,7 +37,7 @@ FROM base AS build
|
||||
|
||||
# Install packages needed to build gems
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y build-essential git pkg-config libpq-dev && \
|
||||
apt-get install --no-install-recommends -y build-essential git pkg-config libpq-dev libyaml-dev && \
|
||||
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
||||
|
||||
# Install application gems
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM ruby:3.4.1
|
||||
FROM ruby:3.4.3
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update -qq && \
|
||||
@@ -6,6 +6,7 @@ RUN apt-get update -qq && \
|
||||
build-essential \
|
||||
git \
|
||||
libpq-dev \
|
||||
libyaml-dev \
|
||||
postgresql-client \
|
||||
libvips \
|
||||
pkg-config \
|
||||
|
||||
18
Gemfile.lock
18
Gemfile.lock
@@ -102,7 +102,7 @@ GEM
|
||||
device_detector (>= 1)
|
||||
safely_block (>= 0.4)
|
||||
ast (2.4.2)
|
||||
avo (3.20.0)
|
||||
avo (3.20.1)
|
||||
actionview (>= 6.1)
|
||||
active_link_to
|
||||
activerecord (>= 6.1)
|
||||
@@ -146,7 +146,7 @@ GEM
|
||||
chartkick (5.1.5)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
concurrent-ruby (1.3.5)
|
||||
concurrent-ruby (1.3.4)
|
||||
connection_pool (2.5.3)
|
||||
countries (7.1.1)
|
||||
unaccent (~> 0.3)
|
||||
@@ -268,7 +268,7 @@ GEM
|
||||
ffi-compiler (~> 1.0)
|
||||
rake (~> 13.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.24.0)
|
||||
loofah (2.24.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -343,7 +343,7 @@ GEM
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
railties (>= 7.0.0)
|
||||
psych (5.2.4)
|
||||
psych (5.2.6)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.2)
|
||||
@@ -354,7 +354,7 @@ GEM
|
||||
railties (>= 4.2)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.13)
|
||||
rack (3.1.14)
|
||||
rack-cors (2.0.2)
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (3.3.1)
|
||||
@@ -461,7 +461,7 @@ GEM
|
||||
gli
|
||||
hashie
|
||||
logger
|
||||
solid_cable (3.0.7)
|
||||
solid_cable (3.0.8)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
@@ -508,9 +508,9 @@ GEM
|
||||
uniform_notifier (1.17.0)
|
||||
uri (1.0.3)
|
||||
useragent (0.16.11)
|
||||
view_component (3.21.0)
|
||||
view_component (3.22.0)
|
||||
activesupport (>= 5.2.0, < 8.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
concurrent-ruby (= 1.3.4)
|
||||
method_source (~> 1.0)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
@@ -587,4 +587,4 @@ DEPENDENCIES
|
||||
web-console
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.2
|
||||
2.6.9
|
||||
|
||||
@@ -151,4 +151,19 @@ h3+.mini-leaderboard {
|
||||
.period-toggle-btn:hover:not(.active) {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.mini-leaderboard.loading .leaderboard-entry {
|
||||
opacity: 0.7;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 0.7; }
|
||||
50% { opacity: 0.4; }
|
||||
100% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
turbo-frame#mini_leaderboard[aria-busy="true"]::before {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
class Api::Hackatime::V1::HackatimeController < ApplicationController
|
||||
before_action :set_user, except: [ :index ]
|
||||
skip_before_action :verify_authenticity_token
|
||||
before_action :set_raw_heartbeat_upload, only: [ :push_heartbeats ]
|
||||
|
||||
def index
|
||||
redirect_to root_path
|
||||
@@ -28,7 +29,7 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
|
||||
# {
|
||||
# ...heartbeat_data
|
||||
# }
|
||||
heartbeat_array = [ heartbeat_params ]
|
||||
heartbeat_array = Array(heartbeat_params)
|
||||
new_heartbeat = handle_heartbeat(heartbeat_array)&.first&.first
|
||||
render json: new_heartbeat, status: :accepted
|
||||
end
|
||||
@@ -51,6 +52,25 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def set_raw_heartbeat_upload
|
||||
@raw_heartbeat_upload = RawHeartbeatUpload.create!(
|
||||
request_headers: headers_to_json,
|
||||
request_body: body_to_json
|
||||
)
|
||||
end
|
||||
|
||||
def headers_to_json
|
||||
request.headers
|
||||
.env
|
||||
.select { |key| key.to_s.starts_with?("HTTP_") }
|
||||
.map { |key, value| [ key.sub(/^HTTP_/, ""), value ] }
|
||||
.to_h.to_json
|
||||
end
|
||||
|
||||
def body_to_json
|
||||
params.to_unsafe_h["_json"] || {}
|
||||
end
|
||||
|
||||
def handle_heartbeat(heartbeat_array)
|
||||
results = []
|
||||
heartbeat_array.each do |heartbeat|
|
||||
@@ -68,9 +88,14 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
|
||||
source_type: source_type,
|
||||
ip_address: request.remote_ip,
|
||||
editor: parsed_ua[:editor],
|
||||
operating_system: parsed_ua[:os]
|
||||
operating_system: parsed_ua[:os],
|
||||
machine: request.headers["X-Machine-Name"]
|
||||
})
|
||||
new_heartbeat = Heartbeat.find_or_create_by(attrs)
|
||||
if @raw_heartbeat_upload.present? && new_heartbeat.persisted?
|
||||
new_heartbeat.raw_heartbeat_upload ||= @raw_heartbeat_upload
|
||||
new_heartbeat.save! if new_heartbeat.changed?
|
||||
end
|
||||
queue_project_mapping(heartbeat[:project])
|
||||
results << [ new_heartbeat.attributes, 201 ]
|
||||
rescue => e
|
||||
|
||||
@@ -35,7 +35,7 @@ module Api
|
||||
|
||||
# Format for API response using ISO8601 timestamps and returning extra fields as {} if not provided
|
||||
summary = {
|
||||
user_id: user.slack_uid,
|
||||
user_id: params[:user],
|
||||
from: Time.parse(wakatime_summary[:start]).iso8601,
|
||||
to: Time.parse(wakatime_summary[:end]).iso8601,
|
||||
projects: wakatime_summary[:projects] ? wakatime_summary[:projects].map { |item| { key: item[:name].presence || "Other", total: item[:total_seconds] } } : [],
|
||||
|
||||
@@ -5,6 +5,7 @@ module My
|
||||
|
||||
# Generate OTC if it doesn't exist
|
||||
if params[:from_fillout]
|
||||
sleep 1 # unfortunate hack to make sure the job runs after airtable gets the data
|
||||
FetchMailingAddressJob.perform_now(@user.id)
|
||||
else
|
||||
@user.update_column(:mailing_address_otc, SecureRandom.hex(8))
|
||||
|
||||
9
app/controllers/my/mailroom_controller.rb
Normal file
9
app/controllers/my/mailroom_controller.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
module My
|
||||
class MailroomController < ApplicationController
|
||||
def index
|
||||
@user = current_user
|
||||
@physical_mails = @user.physical_mails.order(created_at: :desc)
|
||||
@has_mailing_address = @user.mailing_address.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,16 +5,6 @@ class StaticPagesController < ApplicationController
|
||||
]
|
||||
|
||||
def index
|
||||
@leaderboard = Leaderboard.where.associated(:entries)
|
||||
.where(start_date: Date.current)
|
||||
.where(deleted_at: nil)
|
||||
.where(period_type: :daily)
|
||||
.distinct
|
||||
.first
|
||||
|
||||
# Get active projects for the mini leaderboard
|
||||
@active_projects = Cache::ActiveProjectsJob.perform_now
|
||||
|
||||
if current_user
|
||||
flavor_texts = FlavorText.motto + FlavorText.conditional_mottos(current_user)
|
||||
flavor_texts += FlavorText.rare_motto if Random.rand(10) < 1
|
||||
@@ -80,6 +70,22 @@ class StaticPagesController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def mini_leaderboard
|
||||
@leaderboard = Leaderboard.where.associated(:entries)
|
||||
.where(start_date: Date.current)
|
||||
.where(deleted_at: nil)
|
||||
.where(period_type: :daily)
|
||||
.distinct
|
||||
.first
|
||||
|
||||
@active_projects = Cache::ActiveProjectsJob.perform_now
|
||||
|
||||
render partial: "leaderboards/mini_leaderboard", locals: {
|
||||
leaderboard: @leaderboard,
|
||||
current_user: current_user
|
||||
}
|
||||
end
|
||||
|
||||
def project_durations
|
||||
return unless current_user
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class UsersController < ApplicationController
|
||||
if @user.uses_slack_status?
|
||||
@user.update_slack_status
|
||||
end
|
||||
redirect_to is_own_settings? ? my_settings_path : user_settings_path(@user),
|
||||
redirect_to is_own_settings? ? my_settings_path : settings_user_path(@user),
|
||||
notice: "Settings updated successfully"
|
||||
else
|
||||
flash[:error] = "Failed to update settings"
|
||||
@@ -35,7 +35,7 @@ class UsersController < ApplicationController
|
||||
def migrate_heartbeats
|
||||
MigrateUserFromHackatimeJob.perform_later(@user.id)
|
||||
|
||||
redirect_to is_own_settings? ? my_settings_path : user_settings_path(@user),
|
||||
redirect_to is_own_settings? ? my_settings_path : settings_user_path(@user),
|
||||
notice: "Heartbeats & api keys migration started"
|
||||
end
|
||||
|
||||
@@ -96,7 +96,7 @@ class UsersController < ApplicationController
|
||||
end
|
||||
|
||||
def is_own_settings?
|
||||
@is_own_settings ||= !params["id"].present?
|
||||
@is_own_settings ||= params["id"] == "my" || params["id"]&.blank?
|
||||
end
|
||||
|
||||
def user_params
|
||||
|
||||
39
app/controllers/wakatime_mirrors_controller.rb
Normal file
39
app/controllers/wakatime_mirrors_controller.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
class WakatimeMirrorsController < ApplicationController
|
||||
before_action :set_user
|
||||
before_action :require_current_user
|
||||
before_action :set_mirror, only: [ :destroy ]
|
||||
|
||||
def create
|
||||
@mirror = @user.wakatime_mirrors.build(mirror_params)
|
||||
if @mirror.save
|
||||
redirect_to my_settings_path, notice: "WakaTime mirror added successfully"
|
||||
else
|
||||
redirect_to my_settings_path, alert: "Failed to add WakaTime mirror: #{@mirror.errors.full_messages.join(', ')}"
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@mirror.destroy
|
||||
redirect_to my_settings_path, notice: "WakaTime mirror removed successfully"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = User.find(params[:user_id])
|
||||
end
|
||||
|
||||
def set_mirror
|
||||
@mirror = @user.wakatime_mirrors.find(params[:id])
|
||||
end
|
||||
|
||||
def mirror_params
|
||||
params.require(:wakatime_mirror).permit(:endpoint_url, :encrypted_api_key)
|
||||
end
|
||||
|
||||
def require_current_user
|
||||
unless @user == current_user
|
||||
redirect_to root_path, alert: "You are not authorized to access this page"
|
||||
end
|
||||
end
|
||||
end
|
||||
38
app/jobs/check_streak_physical_mail_job.rb
Normal file
38
app/jobs/check_streak_physical_mail_job.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
class CheckStreakPhysicalMailJob < ApplicationJob
|
||||
queue_as :literally_whenever
|
||||
|
||||
include GoodJob::ActiveJobExtensions::Concurrency
|
||||
|
||||
good_job_control_concurrency_with(
|
||||
total_limit: 1,
|
||||
key: -> { "check_streak_physical_mail_job" },
|
||||
drop: true
|
||||
)
|
||||
|
||||
def perform
|
||||
streaks = Heartbeat.daily_streaks_for_users(users_with_recent_heartbeats)
|
||||
|
||||
over_7_day_streaks = streaks.select { |_, streak| streak > 7 }.keys
|
||||
|
||||
over_7_day_streaks.each do |user_id|
|
||||
next if PhysicalMail.going_out.exists?(user_id: user_id, mission_type: :first_time_7_streak)
|
||||
|
||||
user = User.find(user_id)
|
||||
|
||||
# Create the physical mail record
|
||||
PhysicalMail.create!(
|
||||
user: user,
|
||||
mission_type: :first_time_7_streak,
|
||||
status: :pending
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def users_with_recent_heartbeats
|
||||
Heartbeat.where(time: 1.hour.ago..Time.current)
|
||||
.distinct
|
||||
.pluck(:user_id)
|
||||
end
|
||||
end
|
||||
@@ -28,7 +28,14 @@ class OneTime::TransferUserDataJob < ApplicationJob
|
||||
end
|
||||
|
||||
def transfer_api_keys
|
||||
ApiKey.where(user_id: @source_user_id).update_all(user_id: @target_user_id)
|
||||
ApiKey.where(user_id: @source_user_id).find_each do |api_key|
|
||||
# If target user already has an API key with this name, append a suffix
|
||||
if target_user.api_keys.exists?(name: api_key.name)
|
||||
api_key.name = "#{api_key.name} (transferred)"
|
||||
end
|
||||
api_key.user_id = @target_user_id
|
||||
api_key.save!
|
||||
end
|
||||
end
|
||||
|
||||
def transfer_heartbeats
|
||||
|
||||
@@ -30,6 +30,9 @@ class SailorsLogPollForChangesJob < ApplicationJob
|
||||
private
|
||||
|
||||
def update_sailors_log(sailors_log)
|
||||
# Skip if there's an active migration job for this user
|
||||
return [] if sailors_log.user.in_progress_migration_jobs?
|
||||
|
||||
project_updates = []
|
||||
project_durations = Heartbeat.where(user_id: sailors_log.user.id)
|
||||
.group(:project).duration_seconds
|
||||
|
||||
7
app/jobs/wakatime_mirror_sync_job.rb
Normal file
7
app/jobs/wakatime_mirror_sync_job.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class WakatimeMirrorSyncJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(mirror)
|
||||
mirror.sync_heartbeats
|
||||
end
|
||||
end
|
||||
@@ -87,9 +87,13 @@ class Heartbeat < ApplicationRecord
|
||||
self.inheritance_column = nil
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :raw_heartbeat_upload, optional: true
|
||||
has_many :wakatime_mirrors, dependent: :destroy
|
||||
|
||||
validates :time, presence: true
|
||||
|
||||
# after_create :mirror_to_wakatime
|
||||
|
||||
def self.recent_count
|
||||
Cache::HeartbeatCountsJob.perform_now[:recent_count]
|
||||
end
|
||||
@@ -128,4 +132,8 @@ class Heartbeat < ApplicationRecord
|
||||
self.fields_hash = self.class.generate_fields_hash(self.attributes)
|
||||
end
|
||||
end
|
||||
|
||||
# def mirror_to_wakatime
|
||||
# WakatimeMirror.mirror_heartbeat(self)
|
||||
# end
|
||||
end
|
||||
|
||||
73
app/models/physical_mail.rb
Normal file
73
app/models/physical_mail.rb
Normal file
@@ -0,0 +1,73 @@
|
||||
class PhysicalMail < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
scope :going_out, -> { where(status: :pending).or(where(status: :sent)) }
|
||||
|
||||
enum :status, {
|
||||
pending: 0,
|
||||
sent: 1,
|
||||
failed: 2
|
||||
}
|
||||
|
||||
enum :mission_type, {
|
||||
admin_mail: 0,
|
||||
first_time_7_streak: 1
|
||||
}
|
||||
|
||||
def link_to_theseus
|
||||
return nil if theseus_id.nil?
|
||||
|
||||
"https://hack.club/#{theseus_id}"
|
||||
end
|
||||
|
||||
def humanized_mission_type
|
||||
return "Your first 7-day streak" if first_time_7_streak?
|
||||
|
||||
mission_type.titleize
|
||||
end
|
||||
|
||||
def deliver!
|
||||
slug = "hackatime-#{mission_type.to_s.gsub("_", "-")}"
|
||||
|
||||
flavors = FlavorText.compliment
|
||||
flavors.concat(FlavorText.rare_compliment) if rand(10) == 0
|
||||
|
||||
# authorization: Bearer <token>
|
||||
response = HTTP.auth("Bearer #{ENV["MAIL_HACKCLUB_TOKEN"]}").post("https://mail.hackclub.com/api/v1/letter_queues/#{slug}", json: {
|
||||
recipient_email: user.email_addresses.first.email,
|
||||
address: {
|
||||
first_name: user.mailing_address.first_name,
|
||||
last_name: user.mailing_address.last_name,
|
||||
line_1: user.mailing_address.line_1,
|
||||
line_2: user.mailing_address.line_2,
|
||||
city: user.mailing_address.city,
|
||||
state: user.mailing_address.state,
|
||||
postal_code: user.mailing_address.zip_code,
|
||||
country: user.mailing_address.country
|
||||
},
|
||||
rubber_stamps: flavors.sample,
|
||||
idempotency_key: "physical_mail_#{id}",
|
||||
metadata: {
|
||||
attributes: attributes
|
||||
}
|
||||
})
|
||||
|
||||
if response.status.success?
|
||||
data = JSON.parse(response.body.to_s)
|
||||
puts "Successfully delivered physical mail: #{data["id"]}"
|
||||
update(status: :sent, theseus_id: data["id"])
|
||||
else
|
||||
update(status: :failed)
|
||||
raise "Failed to deliver physical mail: #{response.body}"
|
||||
end
|
||||
rescue => e
|
||||
update(status: :failed)
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_address
|
||||
user.mailing_address
|
||||
end
|
||||
end
|
||||
6
app/models/raw_heartbeat_upload.rb
Normal file
6
app/models/raw_heartbeat_upload.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class RawHeartbeatUpload < ApplicationRecord
|
||||
has_many :heartbeats
|
||||
|
||||
validates :request_headers, presence: true
|
||||
validates :request_body, presence: true
|
||||
end
|
||||
@@ -19,6 +19,7 @@ class User < ApplicationRecord
|
||||
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,
|
||||
@@ -37,10 +38,19 @@ class User < ApplicationRecord
|
||||
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+"
|
||||
@@ -67,6 +77,13 @@ class User < ApplicationRecord
|
||||
).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?
|
||||
|
||||
|
||||
61
app/models/wakatime_mirror.rb
Normal file
61
app/models/wakatime_mirror.rb
Normal file
@@ -0,0 +1,61 @@
|
||||
class WakatimeMirror < ApplicationRecord
|
||||
belongs_to :user
|
||||
has_many :heartbeats, through: :user
|
||||
|
||||
encrypts :encrypted_api_key, deterministic: false
|
||||
|
||||
validates :endpoint_url, presence: true
|
||||
validates :encrypted_api_key, presence: true
|
||||
validates :endpoint_url, uniqueness: { scope: :user_id }
|
||||
validate :endpoint_url_not_hackatime
|
||||
|
||||
after_create :schedule_initial_sync
|
||||
|
||||
def unsynced_heartbeats
|
||||
# Get heartbeats since last sync, or all heartbeats if never synced
|
||||
user.heartbeats.where("created_at > ?", last_synced_at || Time.at(0))
|
||||
end
|
||||
|
||||
def sync_heartbeats
|
||||
return unless encrypted_api_key.present?
|
||||
|
||||
# Get the next batch of heartbeats to sync (max 25 per WakaTime API limit)
|
||||
batch = unsynced_heartbeats.limit(25).to_a
|
||||
return if batch.empty?
|
||||
|
||||
# Send them all in a single request using the bulk endpoint
|
||||
begin
|
||||
body = batch.map { |h| h.attributes.slice(*Heartbeat.indexed_attributes) }
|
||||
response = HTTP.headers(
|
||||
"Authorization" => "Basic #{Base64.strict_encode64(encrypted_api_key + ':')}",
|
||||
"Content-Type" => "application/json"
|
||||
).post(
|
||||
"#{endpoint_url}/users/current/heartbeats.bulk",
|
||||
json: body
|
||||
)
|
||||
|
||||
if response.status.success?
|
||||
update_column(:last_synced_at, Time.current)
|
||||
puts "Successfully synced #{batch.size} heartbeats: #{response.body}"
|
||||
# queue another sync job
|
||||
WakatimeMirrorSyncJob.perform_later(self)
|
||||
else
|
||||
Rails.logger.error("Failed to sync heartbeats to #{endpoint_url}: #{response.body}")
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error("Error syncing heartbeats to #{endpoint_url}: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def endpoint_url_not_hackatime
|
||||
if endpoint_url.present? && endpoint_url.include?("hackatime.hackclub.com")
|
||||
errors.add(:endpoint_url, "cannot be hackatime.hackclub.com")
|
||||
end
|
||||
end
|
||||
|
||||
def schedule_initial_sync
|
||||
WakatimeMirrorSyncJob.perform_later(self)
|
||||
end
|
||||
end
|
||||
@@ -1,78 +1,82 @@
|
||||
<%
|
||||
entries = leaderboard.entries.order(total_seconds: :desc)
|
||||
if current_user
|
||||
user_rank = entries.find_index { |entry| entry.user_id == current_user.id }
|
||||
if user_rank && user_rank >= 3
|
||||
# Show top 2 entries and immediate competition
|
||||
top_entries = entries[0..1]
|
||||
competition_entries = entries[[user_rank - 1, 2].max..[user_rank + 1, entries.size - 1].min]
|
||||
mini_leaderboard_entries = top_entries + competition_entries
|
||||
show_top_entries = false
|
||||
else
|
||||
# Show top 3 entries (either user is in top 3 or not on leaderboard)
|
||||
mini_leaderboard_entries = entries.first(3)
|
||||
show_top_entries = true
|
||||
end
|
||||
else
|
||||
# Not logged in, show top 3
|
||||
mini_leaderboard_entries = entries.first(3)
|
||||
show_top_entries = true
|
||||
end
|
||||
%>
|
||||
<%= turbo_frame_tag "mini_leaderboard" do %>
|
||||
<% if leaderboard.present? %>
|
||||
<%
|
||||
entries = leaderboard.entries.order(total_seconds: :desc)
|
||||
if current_user
|
||||
user_rank = entries.find_index { |entry| entry.user_id == current_user.id }
|
||||
if user_rank && user_rank >= 3
|
||||
# Show top 2 entries and immediate competition
|
||||
top_entries = entries[0..1]
|
||||
competition_entries = entries[[user_rank - 1, 2].max..[user_rank + 1, entries.size - 1].min]
|
||||
mini_leaderboard_entries = top_entries + competition_entries
|
||||
show_top_entries = false
|
||||
else
|
||||
# Show top 3 entries (either user is in top 3 or not on leaderboard)
|
||||
mini_leaderboard_entries = entries.first(3)
|
||||
show_top_entries = true
|
||||
end
|
||||
else
|
||||
# Not logged in, show top 3
|
||||
mini_leaderboard_entries = entries.first(3)
|
||||
show_top_entries = true
|
||||
end
|
||||
%>
|
||||
|
||||
<% if mini_leaderboard_entries&.any? %>
|
||||
<div class="mini-leaderboard">
|
||||
<p class="super">
|
||||
This leaderboard is in <%= Leaderboard::GLOBAL_TIMEZONE %>.
|
||||
<% if current_user && timezone_difference_in_seconds(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) != 0 %>
|
||||
<%= timezone_difference_in_words(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) %>
|
||||
<% end %>
|
||||
</p>
|
||||
<div class="leaderboard-entries">
|
||||
<% mini_leaderboard_entries.each_with_index do |entry, idx| %>
|
||||
<% is_competition = !show_top_entries && idx >= 2 %>
|
||||
<div class="leaderboard-entry <%= 'current-user' if entry.user_id == current_user&.id %>">
|
||||
<% if !is_competition %>
|
||||
<% rank_emoji = case entries.index(entry)
|
||||
when 0 then "🥇"
|
||||
when 1 then "🥈"
|
||||
when 2 then "🥉"
|
||||
end %>
|
||||
<span class="rank"><%= rank_emoji %></span>
|
||||
<% else %>
|
||||
<% if idx == 2 && entries.index(entry) - entries.index(mini_leaderboard_entries[1]) > 1 %>
|
||||
<div class="leaderboard-break">...</div>
|
||||
<% end %>
|
||||
<span class="rank"><%= (entries.index(entry) + 1).ordinalize %></span>
|
||||
<% if mini_leaderboard_entries&.any? %>
|
||||
<div class="mini-leaderboard">
|
||||
<p class="super">
|
||||
This leaderboard is in <%= Leaderboard::GLOBAL_TIMEZONE %>.
|
||||
<% if current_user && timezone_difference_in_seconds(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) != 0 %>
|
||||
<%= timezone_difference_in_words(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) %>
|
||||
<% end %>
|
||||
<span class="user">
|
||||
<%= render "shared/user_mention", user: entry.user, show: [:neighborhood] %>
|
||||
<% if entry.user == current_user && current_user.github_username.blank? %>
|
||||
<span class="super">
|
||||
<%= link_to "Link active projects", my_settings_path(anchor: "user_github_account"), target: "_blank" %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if @active_projects&.dig(entry.user_id).present? %>
|
||||
<span class="super">
|
||||
working on <%= link_to @active_projects[entry.user_id].project_name, @active_projects[entry.user_id].repo_url, target: "_blank" %>
|
||||
<% dev_tool(nil, 'span') do %>
|
||||
<%= link_to "🌌", visualize_git_url(@active_projects[entry.user_id].repo_url), target: "_blank" %>
|
||||
</p>
|
||||
<div class="leaderboard-entries">
|
||||
<% mini_leaderboard_entries.each_with_index do |entry, idx| %>
|
||||
<% is_competition = !show_top_entries && idx >= 2 %>
|
||||
<div class="leaderboard-entry <%= 'current-user' if entry.user_id == current_user&.id %>">
|
||||
<% if !is_competition %>
|
||||
<% rank_emoji = case entries.index(entry)
|
||||
when 0 then "🥇"
|
||||
when 1 then "🥈"
|
||||
when 2 then "🥉"
|
||||
end %>
|
||||
<span class="rank"><%= rank_emoji %></span>
|
||||
<% else %>
|
||||
<% if idx == 2 && entries.index(entry) - entries.index(mini_leaderboard_entries[1]) > 1 %>
|
||||
<div class="leaderboard-break">...</div>
|
||||
<% end %>
|
||||
<span class="rank"><%= (entries.index(entry) + 1).ordinalize %></span>
|
||||
<% end %>
|
||||
<span class="user">
|
||||
<%= render "shared/user_mention", user: entry.user, show: [:neighborhood] %>
|
||||
<% if entry.user == current_user && current_user.github_username.blank? %>
|
||||
<span class="super">
|
||||
<%= link_to "Link active projects", my_settings_path(anchor: "user_github_account"), target: "_blank" %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if @active_projects&.dig(entry.user_id).present? %>
|
||||
<span class="super">
|
||||
working on <%= link_to @active_projects[entry.user_id].project_name, @active_projects[entry.user_id].repo_url, target: "_blank" %>
|
||||
<% dev_tool(nil, 'span') do %>
|
||||
<%= link_to "🌌", visualize_git_url(@active_projects[entry.user_id].repo_url), target: "_blank" %>
|
||||
<% end %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if entry.streak_count > 7 %>
|
||||
<span class="super" title="7+ daily streak">
|
||||
🔥 7+
|
||||
</span>
|
||||
<% elsif entry.streak_count > 0 %>
|
||||
<span class="super" title="<%= entry.streak_count %> day streak">
|
||||
🔥 <%= entry.streak_count %>
|
||||
</span>
|
||||
<% end %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if entry.streak_count > 7 %>
|
||||
<span class="super" title="7+ daily streak">
|
||||
🔥 7+
|
||||
</span>
|
||||
<% elsif entry.streak_count > 0 %>
|
||||
<span class="super" title="<%= entry.streak_count %> day streak">
|
||||
🔥 <%= entry.streak_count %>
|
||||
</span>
|
||||
<% end %>
|
||||
</span>
|
||||
<span class="time"><%= short_time_detailed entry.total_seconds %></span>
|
||||
<span class="time"><%= short_time_detailed entry.total_seconds %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
17
app/views/leaderboards/_mini_leaderboard_loading.html.erb
Normal file
17
app/views/leaderboards/_mini_leaderboard_loading.html.erb
Normal file
@@ -0,0 +1,17 @@
|
||||
<div class="mini-leaderboard loading">
|
||||
<p class="super">
|
||||
This leaderboard is in <%= Leaderboard::GLOBAL_TIMEZONE %>.
|
||||
<% if current_user && timezone_difference_in_seconds(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) != 0 %>
|
||||
<%= timezone_difference_in_words(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) %>
|
||||
<% end %>
|
||||
</p>
|
||||
<div class="leaderboard-entries">
|
||||
<% (current_user ? 5 : 3).times do %>
|
||||
<div class="leaderboard-entry">
|
||||
<span class="rank">...</span>
|
||||
<span class="user">Loading...</span>
|
||||
<span class="time">...</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
55
app/views/my/mailroom/index.html.erb
Normal file
55
app/views/my/mailroom/index.html.erb
Normal file
@@ -0,0 +1,55 @@
|
||||
<% content_for :title do %>
|
||||
Mailroom
|
||||
<% end %>
|
||||
|
||||
<main class="container">
|
||||
<header>
|
||||
<h1>The Mailroom</h1>
|
||||
</header>
|
||||
|
||||
<% if @has_mailing_address %>
|
||||
<section class="mailing-address-status">
|
||||
<p class="super">
|
||||
Your mailing address is set up and ready to receive mail!
|
||||
<%= link_to "View my mailing address", my_mailing_address_path %>
|
||||
</p>
|
||||
</section>
|
||||
<% else %>
|
||||
<section class="mailing-address-status warning">
|
||||
<h2>Mailing Address Required</h2>
|
||||
<p>You need to add your mailing address for your mail to be sent.</p>
|
||||
<%= link_to "Add my address", edit_my_mailing_address_path, class: "button" %>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<section class="physical-mail">
|
||||
<h2>My Mail</h2>
|
||||
<% if @physical_mails.any? %>
|
||||
<div class="mail-list" style="display: flex; flex-direction: column; gap: 1.5rem; margin-top: 1rem;">
|
||||
<% @physical_mails.each do |mail| %>
|
||||
<% theseus_link = mail.link_to_theseus %>
|
||||
<div class="mail-card" style="background: #23272f; border-radius: 12px; padding: 1.25rem 1.5rem; box-shadow: 0 2px 8px rgba(0,0,0,0.08); border: 1px solid #333; position: relative;">
|
||||
<% if theseus_link %>
|
||||
<a href="<%= theseus_link %>" target="_blank" style="position: absolute; inset: 0; z-index: 1;"></a>
|
||||
<% end %>
|
||||
<div style="position: relative; z-index: 2;">
|
||||
<h3 style="margin: 0 0 0.5rem 0; font-size: 1.25rem; font-weight: 600;">
|
||||
<%= mail.humanized_mission_type %>
|
||||
<% if theseus_link %>
|
||||
<span style="font-size: 0.9em; margin-left: 0.5em; color: #6cf;">↗</span>
|
||||
<% end %>
|
||||
</h3>
|
||||
<p style="margin: 0.25rem 0; color: #aaa;">Status: <%= mail.status.titleize %></p>
|
||||
<p style="margin: 0.25rem 0; color: #aaa;">Created: <%= mail.created_at.strftime("%B %d, %Y") %></p>
|
||||
<% if theseus_link %>
|
||||
<p style="margin: 0.5rem 0 0 0;"><a href="<%= theseus_link %>" target="_blank" style="color: #6cf; text-decoration: underline;">View on mail.hackclub.com</a></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p>No mail yet. Keep coding to earn rewards!</p>
|
||||
<% end %>
|
||||
</section>
|
||||
</main>
|
||||
@@ -67,6 +67,11 @@
|
||||
Mailing Address
|
||||
<% end %>
|
||||
<% end %>
|
||||
<li>
|
||||
<%= link_to my_mailroom_path, class: "nav-item #{current_page?(my_mailroom_path) ? 'active' : ''}" do %>
|
||||
Mailroom
|
||||
<% end %>
|
||||
</li>
|
||||
<% admin_tool(nil, "li") do %>
|
||||
<%= link_to ahoy_captain_path, class: "nav-item #{current_page?(ahoy_captain_path) ? 'active' : ''}" do %>
|
||||
Ahoy Captain
|
||||
|
||||
@@ -93,8 +93,8 @@
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<% if @leaderboard %>
|
||||
<%= render "leaderboards/mini_leaderboard", leaderboard: @leaderboard, current_user: current_user %>
|
||||
<%= turbo_frame_tag "mini_leaderboard", src: mini_leaderboard_static_pages_path do %>
|
||||
<%= render "leaderboards/mini_leaderboard_loading" %>
|
||||
<% end %>
|
||||
|
||||
<%= turbo_frame_tag "filterable_dashboard", src: filterable_dashboard_static_pages_path do %>
|
||||
|
||||
32
app/views/users/_wakatime_mirror_section.html.erb
Normal file
32
app/views/users/_wakatime_mirror_section.html.erb
Normal file
@@ -0,0 +1,32 @@
|
||||
<section>
|
||||
<h2 id="wakatime_mirror">WakaTime Mirror</h2>
|
||||
<p>Mirror your coding activity to WakaTime.</p>
|
||||
|
||||
<% if current_user.wakatime_mirrors.any? %>
|
||||
<div class="mirrors-list">
|
||||
<% current_user.wakatime_mirrors.each do |mirror| %>
|
||||
<div class="mirror-item">
|
||||
<p>
|
||||
<strong>Endpoint:</strong> <%= mirror.endpoint_url %><br>
|
||||
<strong>Last synced:</strong> <%= mirror.last_synced_at ? time_ago_in_words(mirror.last_synced_at) + " ago" : "Never" %>
|
||||
</p>
|
||||
<%= button_to "Delete", user_wakatime_mirror_path(current_user, mirror), method: :delete, class: "button", data: { confirm: "Are you sure?" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with(model: [current_user, WakatimeMirror.new], local: true) do |f| %>
|
||||
<div class="field">
|
||||
<%= f.label :endpoint_url, "WakaTime API Endpoint" %>
|
||||
<%= f.text_field :endpoint_url, value: "https://wakatime.com/api/v1", placeholder: "https://wakatime.com/api/v1" %>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= f.label :encrypted_api_key, "WakaTime API Key" %>
|
||||
<%= f.password_field :encrypted_api_key, placeholder: "Enter your WakaTime API key" %>
|
||||
</div>
|
||||
|
||||
<%= f.submit "Add Mirror", class: "button" %>
|
||||
<% end %>
|
||||
</section>
|
||||
@@ -212,6 +212,41 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<% admin_tool do %>
|
||||
<section>
|
||||
<h2 id="wakatime_mirror">WakaTime Mirror</h2>
|
||||
<p>Mirror your coding activity to WakaTime.</p>
|
||||
|
||||
<% if current_user.wakatime_mirrors.any? %>
|
||||
<div class="mirrors-list">
|
||||
<% current_user.wakatime_mirrors.each do |mirror| %>
|
||||
<div class="mirror-item">
|
||||
<p>
|
||||
<strong>Endpoint:</strong> <%= mirror.endpoint_url %><br>
|
||||
<strong>Last synced:</strong> <%= mirror.last_synced_at ? time_ago_in_words(mirror.last_synced_at) + " ago" : "Never" %>
|
||||
</p>
|
||||
<%= button_to "Delete", user_wakatime_mirror_path(current_user, mirror), method: :delete, class: "button", data: { confirm: "Are you sure?" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with(model: [current_user, WakatimeMirror.new], local: true) do |f| %>
|
||||
<div class="field">
|
||||
<%= f.label :endpoint_url, "WakaTime API Endpoint" %>
|
||||
<%= f.text_field :endpoint_url, value: "https://wakatime.com/api/v1", placeholder: "https://wakatime.com/api/v1" %>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<%= f.label :encrypted_api_key, "WakaTime API Key" %>
|
||||
<%= f.text_field :encrypted_api_key, placeholder: "Enter your WakaTime API key" %>
|
||||
</div>
|
||||
|
||||
<%= f.submit "Add Mirror", class: "button" %>
|
||||
<% end %>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<section>
|
||||
<h2 id="user_migration_assistant">Migration assistant</h2>
|
||||
<p>This will migrate your heartbeats from waka.hackclub.com to this platform.</p>
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
{
|
||||
"ignored_warnings": [
|
||||
{
|
||||
"warning_type": "SQL Injection",
|
||||
"warning_code": 0,
|
||||
"fingerprint": "06ca3650eaeb8d28e062c1c6dcbeab95fb2ccd0c5bb49165ad469bcb6b791d3e",
|
||||
"check_name": "SQL",
|
||||
"message": "Possible SQL injection",
|
||||
"file": "app/models/concerns/heartbeatable.rb",
|
||||
"line": 214,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
||||
"code": "Arel.sql(\"DATE_TRUNC('day', to_timestamp(time) AT TIME ZONE '#{(user_timezone or \"UTC\")}')\")",
|
||||
"render_path": null,
|
||||
"location": {
|
||||
"type": "method",
|
||||
"class": "Heartbeatable",
|
||||
"method": "daily_durations"
|
||||
},
|
||||
"user_input": "user_timezone",
|
||||
"confidence": "Medium",
|
||||
"cwe_id": [
|
||||
89
|
||||
],
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"warning_type": "SQL Injection",
|
||||
"warning_code": 0,
|
||||
|
||||
@@ -42,6 +42,7 @@ Rails.application.routes.draw do
|
||||
get :currently_hacking
|
||||
get :filterable_dashboard_content
|
||||
get :filterable_dashboard
|
||||
get :mini_leaderboard
|
||||
get "🃏", to: "static_pages#🃏", as: :wildcard
|
||||
get :streak
|
||||
# get :timeline # Removed: Old route for timeline
|
||||
@@ -64,9 +65,12 @@ Rails.application.routes.draw do
|
||||
# Nested under users for admin access
|
||||
resources :users, only: [] do
|
||||
get "settings", on: :member, to: "users#edit"
|
||||
patch "settings", on: :member, to: "users#update"
|
||||
member do
|
||||
patch :update_trust_level
|
||||
end
|
||||
resource :wakatime_mirrors, only: [ :create ]
|
||||
resources :wakatime_mirrors, only: [ :destroy ]
|
||||
end
|
||||
|
||||
get "my/projects", to: "my/project_repo_mappings#index", as: :my_projects
|
||||
@@ -79,6 +83,7 @@ Rails.application.routes.draw do
|
||||
namespace :my do
|
||||
resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ]
|
||||
resource :mailing_address, only: [ :show, :edit ]
|
||||
get "mailroom", to: "mailroom#index"
|
||||
end
|
||||
|
||||
get "my/wakatime_setup", to: "users#wakatime_setup"
|
||||
|
||||
14
db/migrate/20250512205858_create_wakatime_mirrors.rb
Normal file
14
db/migrate/20250512205858_create_wakatime_mirrors.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
class CreateWakatimeMirrors < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :wakatime_mirrors do |t|
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.string :endpoint_url, null: false, default: "https://wakatime.com/api/v1"
|
||||
t.string :encrypted_api_key, null: false
|
||||
t.datetime :last_synced_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :wakatime_mirrors, [ :user_id, :endpoint_url ], unique: true
|
||||
end
|
||||
end
|
||||
10
db/migrate/20250513183739_create_raw_heartbeat_uploads.rb
Normal file
10
db/migrate/20250513183739_create_raw_heartbeat_uploads.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class CreateRawHeartbeatUploads < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :raw_heartbeat_uploads do |t|
|
||||
t.jsonb :request_headers, null: false
|
||||
t.jsonb :request_body, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddRawHeartbeatUploadToHeartbeats < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_reference :heartbeats, :raw_heartbeat_upload, null: true, foreign_key: true
|
||||
end
|
||||
end
|
||||
12
db/migrate/20250514150404_create_physical_mails.rb
Normal file
12
db/migrate/20250514150404_create_physical_mails.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
class CreatePhysicalMails < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :physical_mails do |t|
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.integer :mission_type, null: false
|
||||
t.integer :status, null: false, default: 0
|
||||
t.string :theseus_id
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user