From 4e2399a0b39e03b0fb400a574026224909238c99 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Mon, 12 May 2025 14:39:22 -0400 Subject: [PATCH 01/30] Add async loading for mini leaderboard --- app/assets/stylesheets/leaderboard.css | 15 ++ app/controllers/static_pages_controller.rb | 26 ++-- .../leaderboards/_mini_leaderboard.html.erb | 142 +++++++++--------- .../_mini_leaderboard_loading.html.erb | 17 +++ app/views/static_pages/index.html.erb | 4 +- config/routes.rb | 1 + 6 files changed, 123 insertions(+), 82 deletions(-) create mode 100644 app/views/leaderboards/_mini_leaderboard_loading.html.erb diff --git a/app/assets/stylesheets/leaderboard.css b/app/assets/stylesheets/leaderboard.css index fd93ee4..30b8be8 100644 --- a/app/assets/stylesheets/leaderboard.css +++ b/app/assets/stylesheets/leaderboard.css @@ -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; } \ No newline at end of file diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 6ee7c15..6a30651 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -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 diff --git a/app/views/leaderboards/_mini_leaderboard.html.erb b/app/views/leaderboards/_mini_leaderboard.html.erb index 83f5d8f..879bb9d 100644 --- a/app/views/leaderboards/_mini_leaderboard.html.erb +++ b/app/views/leaderboards/_mini_leaderboard.html.erb @@ -1,78 +1,80 @@ -<% - 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 +<%= turbo_frame_tag "mini_leaderboard" do %> + <% + 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 - # Show top 3 entries (either user is in top 3 or not on leaderboard) + # Not logged in, show top 3 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? %> -
-

- 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 %> -

-
- <% mini_leaderboard_entries.each_with_index do |entry, idx| %> - <% is_competition = !show_top_entries && idx >= 2 %> -
- <% if !is_competition %> - <% rank_emoji = case entries.index(entry) - when 0 then "🥇" - when 1 then "🥈" - when 2 then "🥉" - end %> - <%= rank_emoji %> - <% else %> - <% if idx == 2 && entries.index(entry) - entries.index(mini_leaderboard_entries[1]) > 1 %> -
...
+ <% if mini_leaderboard_entries&.any? %> +
+

+ 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 %> +

+
+ <% mini_leaderboard_entries.each_with_index do |entry, idx| %> + <% is_competition = !show_top_entries && idx >= 2 %> +
+ <% if !is_competition %> + <% rank_emoji = case entries.index(entry) + when 0 then "🥇" + when 1 then "🥈" + when 2 then "🥉" + end %> + <%= rank_emoji %> + <% else %> + <% if idx == 2 && entries.index(entry) - entries.index(mini_leaderboard_entries[1]) > 1 %> +
...
+ <% end %> + <%= (entries.index(entry) + 1).ordinalize %> <% end %> - <%= (entries.index(entry) + 1).ordinalize %> - <% end %> - - <%= render "shared/user_mention", user: entry.user, show: [:neighborhood] %> - <% if entry.user == current_user && current_user.github_username.blank? %> - - <%= link_to "Link active projects", my_settings_path(anchor: "user_github_account"), target: "_blank" %> - - <% end %> - <% if @active_projects&.dig(entry.user_id).present? %> - - 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 %> - - <% end %> - <% if entry.streak_count > 7 %> - - 🔥 7+ - - <% elsif entry.streak_count > 0 %> - - 🔥 <%= entry.streak_count %> - - <% end %> - - <%= short_time_detailed entry.total_seconds %> -
- <% end %> + + <%= render "shared/user_mention", user: entry.user, show: [:neighborhood] %> + <% if entry.user == current_user && current_user.github_username.blank? %> + + <%= link_to "Link active projects", my_settings_path(anchor: "user_github_account"), target: "_blank" %> + + <% end %> + <% if @active_projects&.dig(entry.user_id).present? %> + + 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 %> + + <% end %> + <% if entry.streak_count > 7 %> + + 🔥 7+ + + <% elsif entry.streak_count > 0 %> + + 🔥 <%= entry.streak_count %> + + <% end %> + + <%= short_time_detailed entry.total_seconds %> +
+ <% end %> +
-
+ <% end %> <% end %> \ No newline at end of file diff --git a/app/views/leaderboards/_mini_leaderboard_loading.html.erb b/app/views/leaderboards/_mini_leaderboard_loading.html.erb new file mode 100644 index 0000000..239439a --- /dev/null +++ b/app/views/leaderboards/_mini_leaderboard_loading.html.erb @@ -0,0 +1,17 @@ +
+

+ 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 %> +

+
+ <% (current_user ? 5 : 3).times do %> +
+ ... + Loading... + ... +
+ <% end %> +
+
\ No newline at end of file diff --git a/app/views/static_pages/index.html.erb b/app/views/static_pages/index.html.erb index c91dc23..a25fa5e 100644 --- a/app/views/static_pages/index.html.erb +++ b/app/views/static_pages/index.html.erb @@ -93,8 +93,8 @@ <% end %>

- <% 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 %> diff --git a/config/routes.rb b/config/routes.rb index f3579dc..1e7e4ca 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,6 +38,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 end From 1e6e5c0517c5233e62569fda98e70edf06c99ec8 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Mon, 12 May 2025 14:41:30 -0400 Subject: [PATCH 02/30] Remove trailing whitespace --- app/controllers/static_pages_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 6a30651..c87921d 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -80,7 +80,7 @@ class StaticPagesController < ApplicationController @active_projects = Cache::ActiveProjectsJob.perform_now - render partial: "leaderboards/mini_leaderboard", locals: { + render partial: "leaderboards/mini_leaderboard", locals: { leaderboard: @leaderboard, current_user: current_user } From 2d3b2283af13269d2c9828b74d25f84c4df72c36 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Mon, 12 May 2025 14:58:55 -0400 Subject: [PATCH 03/30] Suspend sailors log poll for users with active migrations (#221) --- app/jobs/sailors_log_poll_for_changes_job.rb | 3 +++ app/models/user.rb | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/app/jobs/sailors_log_poll_for_changes_job.rb b/app/jobs/sailors_log_poll_for_changes_job.rb index 97658a8..560c382 100644 --- a/app/jobs/sailors_log_poll_for_changes_job.rb +++ b/app/jobs/sailors_log_poll_for_changes_job.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 603bf1e..db6f3c6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -67,6 +67,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? From 9e8304ad1543ecfcbdfd70a0c1e6f3cc8710c63d Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Mon, 12 May 2025 15:01:38 -0400 Subject: [PATCH 04/30] Ignore brakeman warning This isn't a sql injection because we sanitize the input in a guard right above it --- config/brakeman.ignore | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 21c2dea..284ad9b 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -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, From a250ba986f269394eb1500fca6bc8c85e0a5353a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 May 2025 15:11:01 -0400 Subject: [PATCH 05/30] Bump avo from 3.20.0 to 3.20.1 (#220) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 035e549..41f6a96 100644 --- a/Gemfile.lock +++ b/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) @@ -145,7 +145,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) @@ -267,7 +267,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) @@ -342,7 +342,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) @@ -353,7 +353,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) @@ -507,9 +507,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) From 0a8a48521a23dba7c18c1eb8f6790e48fd365998 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 May 2025 15:11:09 -0400 Subject: [PATCH 06/30] Bump solid_cable from 3.0.7 to 3.0.8 (#219) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 41f6a96..2680e5b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -460,7 +460,7 @@ GEM gli hashie logger - solid_cable (3.0.7) + solid_cable (3.0.8) actioncable (>= 7.2) activejob (>= 7.2) activerecord (>= 7.2) From d954b5596cb6073644990f03249e65e924e45f00 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Mon, 12 May 2025 15:23:40 -0400 Subject: [PATCH 07/30] Handle duplicated api key names in transfer job --- app/jobs/one_time/transfer_user_data_job.rb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/jobs/one_time/transfer_user_data_job.rb b/app/jobs/one_time/transfer_user_data_job.rb index 3030989..0233ec6 100644 --- a/app/jobs/one_time/transfer_user_data_job.rb +++ b/app/jobs/one_time/transfer_user_data_job.rb @@ -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 From e43f3e5b68ad9a0e6cb7a235302e31c634f3e530 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Mon, 12 May 2025 15:23:49 -0400 Subject: [PATCH 08/30] Add slow_find_by_email helper in development --- app/models/user.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/models/user.rb b/app/models/user.rb index db6f3c6..e75dfdc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -41,6 +41,13 @@ class User < ApplicationRecord @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+" From a7da9fd8fdac56ee8f82cb25f4df0909a1b49256 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Mon, 12 May 2025 17:36:48 -0400 Subject: [PATCH 09/30] Handle load if no leaderboard found --- .../leaderboards/_mini_leaderboard.html.erb | 142 +++++++++--------- 1 file changed, 72 insertions(+), 70 deletions(-) diff --git a/app/views/leaderboards/_mini_leaderboard.html.erb b/app/views/leaderboards/_mini_leaderboard.html.erb index 879bb9d..1e74d77 100644 --- a/app/views/leaderboards/_mini_leaderboard.html.erb +++ b/app/views/leaderboards/_mini_leaderboard.html.erb @@ -1,80 +1,82 @@ <%= turbo_frame_tag "mini_leaderboard" do %> - <% - 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 + <% 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 - # Show top 3 entries (either user is in top 3 or not on leaderboard) + # Not logged in, show top 3 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? %> -
-

- 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 %> -

-
- <% mini_leaderboard_entries.each_with_index do |entry, idx| %> - <% is_competition = !show_top_entries && idx >= 2 %> -
- <% if !is_competition %> - <% rank_emoji = case entries.index(entry) - when 0 then "🥇" - when 1 then "🥈" - when 2 then "🥉" - end %> - <%= rank_emoji %> - <% else %> - <% if idx == 2 && entries.index(entry) - entries.index(mini_leaderboard_entries[1]) > 1 %> -
...
+ <% if mini_leaderboard_entries&.any? %> +
+

+ 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 %> +

+
+ <% mini_leaderboard_entries.each_with_index do |entry, idx| %> + <% is_competition = !show_top_entries && idx >= 2 %> +
+ <% if !is_competition %> + <% rank_emoji = case entries.index(entry) + when 0 then "🥇" + when 1 then "🥈" + when 2 then "🥉" + end %> + <%= rank_emoji %> + <% else %> + <% if idx == 2 && entries.index(entry) - entries.index(mini_leaderboard_entries[1]) > 1 %> +
...
+ <% end %> + <%= (entries.index(entry) + 1).ordinalize %> <% end %> - <%= (entries.index(entry) + 1).ordinalize %> - <% end %> - - <%= render "shared/user_mention", user: entry.user, show: [:neighborhood] %> - <% if entry.user == current_user && current_user.github_username.blank? %> - - <%= link_to "Link active projects", my_settings_path(anchor: "user_github_account"), target: "_blank" %> - - <% end %> - <% if @active_projects&.dig(entry.user_id).present? %> - - 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 %> - - <% end %> - <% if entry.streak_count > 7 %> - - 🔥 7+ - - <% elsif entry.streak_count > 0 %> - - 🔥 <%= entry.streak_count %> - - <% end %> - - <%= short_time_detailed entry.total_seconds %> -
- <% end %> + + <%= render "shared/user_mention", user: entry.user, show: [:neighborhood] %> + <% if entry.user == current_user && current_user.github_username.blank? %> + + <%= link_to "Link active projects", my_settings_path(anchor: "user_github_account"), target: "_blank" %> + + <% end %> + <% if @active_projects&.dig(entry.user_id).present? %> + + 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 %> + + <% end %> + <% if entry.streak_count > 7 %> + + 🔥 7+ + + <% elsif entry.streak_count > 0 %> + + 🔥 <%= entry.streak_count %> + + <% end %> + + <%= short_time_detailed entry.total_seconds %> +
+ <% end %> +
-
+ <% end %> <% end %> <% end %> \ No newline at end of file From ce04f80b47643b4d24f31b06b2e46b31901d6d19 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Mon, 12 May 2025 17:39:45 -0400 Subject: [PATCH 10/30] Handle duplicated api key names in transfer job (#222) --- .../wakatime_mirrors_controller.rb | 39 ++++++++++ app/jobs/wakatime_mirror_sync_job.rb | 7 ++ app/models/heartbeat.rb | 7 ++ app/models/user.rb | 2 + app/models/wakatime_mirror.rb | 77 +++++++++++++++++++ .../users/_wakatime_mirror_section.html.erb | 32 ++++++++ app/views/users/edit.html.erb | 35 +++++++++ config/routes.rb | 2 + .../20250512205858_create_wakatime_mirrors.rb | 14 ++++ db/schema.rb | 14 +++- 10 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 app/controllers/wakatime_mirrors_controller.rb create mode 100644 app/jobs/wakatime_mirror_sync_job.rb create mode 100644 app/models/wakatime_mirror.rb create mode 100644 app/views/users/_wakatime_mirror_section.html.erb create mode 100644 db/migrate/20250512205858_create_wakatime_mirrors.rb diff --git a/app/controllers/wakatime_mirrors_controller.rb b/app/controllers/wakatime_mirrors_controller.rb new file mode 100644 index 0000000..c57c71b --- /dev/null +++ b/app/controllers/wakatime_mirrors_controller.rb @@ -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 diff --git a/app/jobs/wakatime_mirror_sync_job.rb b/app/jobs/wakatime_mirror_sync_job.rb new file mode 100644 index 0000000..2cb01e0 --- /dev/null +++ b/app/jobs/wakatime_mirror_sync_job.rb @@ -0,0 +1,7 @@ +class WakatimeMirrorSyncJob < ApplicationJob + queue_as :default + + def perform(mirror) + mirror.sync_heartbeats + end +end diff --git a/app/models/heartbeat.rb b/app/models/heartbeat.rb index 2507938..3f2d699 100644 --- a/app/models/heartbeat.rb +++ b/app/models/heartbeat.rb @@ -87,9 +87,12 @@ class Heartbeat < ApplicationRecord self.inheritance_column = nil belongs_to :user + 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 +131,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 diff --git a/app/models/user.rb b/app/models/user.rb index e75dfdc..265c879 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -37,6 +37,8 @@ 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 diff --git a/app/models/wakatime_mirror.rb b/app/models/wakatime_mirror.rb new file mode 100644 index 0000000..525cda5 --- /dev/null +++ b/app/models/wakatime_mirror.rb @@ -0,0 +1,77 @@ +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 } + + after_create :schedule_initial_sync + + def unsynced_heartbeats + # For testing: sync the 100 most recent heartbeats + heartbeats.order(time: :desc).limit(100) + 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? + + # Print timestamps of heartbeats being synced + puts "\nSyncing heartbeats:" + batch.each do |h| + puts " #{Time.at(h.time).strftime('%Y-%m-%d %H:%M:%S')} - #{h.entity}" + end + puts "" + + # Send them all in a single request using the bulk endpoint + begin + response = HTTP.headers( + "Authorization" => "Basic #{Base64.strict_encode64(encrypted_api_key + ':')}", + "Content-Type" => "application/json" + ).post( + "#{endpoint_url}/users/current/heartbeats.bulk", + json: batch.map { |h| h.attributes.slice( + :branch, + :category, + :dependencies, + :editor, + :entity, + :language, + :machine, + :operating_system, + :project, + :type, + :user_agent, + :line_additions, + :line_deletions, + :lineno, + :lines, + :cursorpos, + :project_root_count, + :time, + :is_write + ) } + ) + + if response.status.success? + update_column(:last_synced_at, Time.current) + 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 schedule_initial_sync + WakatimeMirrorSyncJob.perform_later(self) + end +end diff --git a/app/views/users/_wakatime_mirror_section.html.erb b/app/views/users/_wakatime_mirror_section.html.erb new file mode 100644 index 0000000..8b1c527 --- /dev/null +++ b/app/views/users/_wakatime_mirror_section.html.erb @@ -0,0 +1,32 @@ +
+

WakaTime Mirror

+

Mirror your coding activity to WakaTime.

+ + <% if current_user.wakatime_mirrors.any? %> +
+ <% current_user.wakatime_mirrors.each do |mirror| %> +
+

+ Endpoint: <%= mirror.endpoint_url %>
+ Last synced: <%= mirror.last_synced_at ? time_ago_in_words(mirror.last_synced_at) + " ago" : "Never" %> +

+ <%= button_to "Delete", user_wakatime_mirror_path(current_user, mirror), method: :delete, class: "button", data: { confirm: "Are you sure?" } %> +
+ <% end %> +
+ <% end %> + + <%= form_with(model: [current_user, WakatimeMirror.new], local: true) do |f| %> +
+ <%= f.label :endpoint_url, "WakaTime API Endpoint" %> + <%= f.text_field :endpoint_url, value: "https://wakatime.com/api/v1", placeholder: "https://wakatime.com/api/v1" %> +
+ +
+ <%= f.label :encrypted_api_key, "WakaTime API Key" %> + <%= f.password_field :encrypted_api_key, placeholder: "Enter your WakaTime API key" %> +
+ + <%= f.submit "Add Mirror", class: "button" %> + <% end %> +
\ No newline at end of file diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 85df30a..29b7d17 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -212,6 +212,41 @@

+ <% admin_tool do %> +
+

WakaTime Mirror

+

Mirror your coding activity to WakaTime.

+ + <% if current_user.wakatime_mirrors.any? %> +
+ <% current_user.wakatime_mirrors.each do |mirror| %> +
+

+ Endpoint: <%= mirror.endpoint_url %>
+ Last synced: <%= mirror.last_synced_at ? time_ago_in_words(mirror.last_synced_at) + " ago" : "Never" %> +

+ <%= button_to "Delete", user_wakatime_mirror_path(current_user, mirror), method: :delete, class: "button", data: { confirm: "Are you sure?" } %> +
+ <% end %> +
+ <% end %> + + <%= form_with(model: [current_user, WakatimeMirror.new], local: true) do |f| %> +
+ <%= f.label :endpoint_url, "WakaTime API Endpoint" %> + <%= f.text_field :endpoint_url, value: "https://wakatime.com/api/v1", placeholder: "https://wakatime.com/api/v1" %> +
+ +
+ <%= f.label :encrypted_api_key, "WakaTime API Key" %> + <%= f.text_field :encrypted_api_key, placeholder: "Enter your WakaTime API key" %> +
+ + <%= f.submit "Add Mirror", class: "button" %> + <% end %> +
+ <% end %> +

Migration assistant

This will migrate your heartbeats from waka.hackclub.com to this platform.

diff --git a/config/routes.rb b/config/routes.rb index 1e7e4ca..bfacfbf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -63,6 +63,8 @@ Rails.application.routes.draw do 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 diff --git a/db/migrate/20250512205858_create_wakatime_mirrors.rb b/db/migrate/20250512205858_create_wakatime_mirrors.rb new file mode 100644 index 0000000..5349edf --- /dev/null +++ b/db/migrate/20250512205858_create_wakatime_mirrors.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 564ce0a..8151523 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_05_09_191155) do +ActiveRecord::Schema[8.0].define(version: 2025_05_12_205858) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -344,6 +344,17 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_09_191155) do t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" end + create_table "wakatime_mirrors", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "endpoint_url", default: "https://wakatime.com/api/v1", null: false + t.string "encrypted_api_key", null: false + t.datetime "last_synced_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id", "endpoint_url"], name: "index_wakatime_mirrors_on_user_id_and_endpoint_url", unique: true + t.index ["user_id"], name: "index_wakatime_mirrors_on_user_id" + end + add_foreign_key "api_keys", "users" add_foreign_key "email_addresses", "users" add_foreign_key "email_verification_requests", "users" @@ -353,4 +364,5 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_09_191155) do add_foreign_key "mailing_addresses", "users" add_foreign_key "project_repo_mappings", "users" add_foreign_key "sign_in_tokens", "users" + add_foreign_key "wakatime_mirrors", "users" end From 6d3d1ac069a3604197dd20a1778d2c80f1db069a Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 12 May 2025 21:40:06 +0000 Subject: [PATCH 11/30] bug: fix nul on user not found (#218) --- app/controllers/api/summary_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api/summary_controller.rb b/app/controllers/api/summary_controller.rb index 4688ecb..412cb70 100644 --- a/app/controllers/api/summary_controller.rb +++ b/app/controllers/api/summary_controller.rb @@ -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] } } : [], From b74f0cff662321825fe224fb94be274ceaa642cb Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Mon, 12 May 2025 18:35:56 -0400 Subject: [PATCH 12/30] Patch up waka syncing job --- app/models/wakatime_mirror.rb | 44 +++++++++++------------------------ 1 file changed, 14 insertions(+), 30 deletions(-) diff --git a/app/models/wakatime_mirror.rb b/app/models/wakatime_mirror.rb index 525cda5..b247e3f 100644 --- a/app/models/wakatime_mirror.rb +++ b/app/models/wakatime_mirror.rb @@ -7,12 +7,13 @@ class WakatimeMirror < ApplicationRecord 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 - # For testing: sync the 100 most recent heartbeats - heartbeats.order(time: :desc).limit(100) + # 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 @@ -22,45 +23,22 @@ class WakatimeMirror < ApplicationRecord batch = unsynced_heartbeats.limit(25).to_a return if batch.empty? - # Print timestamps of heartbeats being synced - puts "\nSyncing heartbeats:" - batch.each do |h| - puts " #{Time.at(h.time).strftime('%Y-%m-%d %H:%M:%S')} - #{h.entity}" - end - puts "" - # 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: batch.map { |h| h.attributes.slice( - :branch, - :category, - :dependencies, - :editor, - :entity, - :language, - :machine, - :operating_system, - :project, - :type, - :user_agent, - :line_additions, - :line_deletions, - :lineno, - :lines, - :cursorpos, - :project_root_count, - :time, - :is_write - ) } + 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 @@ -71,6 +49,12 @@ class WakatimeMirror < ApplicationRecord 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 From 47c5e2fea5907dba07727beec8566e3411e342e1 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Mon, 12 May 2025 18:36:49 -0400 Subject: [PATCH 13/30] Turn off mirror_to_wakatime --- app/models/heartbeat.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/models/heartbeat.rb b/app/models/heartbeat.rb index 3f2d699..becc7da 100644 --- a/app/models/heartbeat.rb +++ b/app/models/heartbeat.rb @@ -91,7 +91,7 @@ class Heartbeat < ApplicationRecord validates :time, presence: true - after_create :mirror_to_wakatime + # after_create :mirror_to_wakatime def self.recent_count Cache::HeartbeatCountsJob.perform_now[:recent_count] @@ -132,7 +132,7 @@ class Heartbeat < ApplicationRecord end end - def mirror_to_wakatime - WakatimeMirror.mirror_heartbeat(self) - end + # def mirror_to_wakatime + # WakatimeMirror.mirror_heartbeat(self) + # end end From 00663cedfd91ad90cf610de80e7aae7ec6c07674 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Tue, 13 May 2025 11:23:10 -0400 Subject: [PATCH 14/30] Rubocop format --- app/models/wakatime_mirror.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/wakatime_mirror.rb b/app/models/wakatime_mirror.rb index b247e3f..41faf40 100644 --- a/app/models/wakatime_mirror.rb +++ b/app/models/wakatime_mirror.rb @@ -50,7 +50,7 @@ class WakatimeMirror < ApplicationRecord private def endpoint_url_not_hackatime - if endpoint_url.present? && endpoint_url.include?('hackatime.hackclub.com') + if endpoint_url.present? && endpoint_url.include?("hackatime.hackclub.com") errors.add(:endpoint_url, "cannot be hackatime.hackclub.com") end end From f4db51b5107a401e14f3ec59dd7f237abafffbb8 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Tue, 13 May 2025 11:24:53 -0400 Subject: [PATCH 15/30] Ruby 3.4.1 -> 3.4.3 (#224) --- .ruby-version | 2 +- Dockerfile | 2 +- Dockerfile.dev | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.ruby-version b/.ruby-version index 408069a..753ec9c 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-3.4.1 +ruby-3.4.3 diff --git a/Dockerfile b/Dockerfile index 0da043a..1ffb05a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Dockerfile.dev b/Dockerfile.dev index 868245c..fe3a01d 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM ruby:3.4.1 +FROM ruby:3.4.3 # Install system dependencies RUN apt-get update -qq && \ From 764bf24e4de2a0601711a905ba81be11ebc32cb0 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Tue, 13 May 2025 14:34:24 -0400 Subject: [PATCH 16/30] Add machine name from req headers --- app/controllers/api/hackatime/v1/hackatime_controller.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb index 8bd8b6c..6f2c3ca 100644 --- a/app/controllers/api/hackatime/v1/hackatime_controller.rb +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -68,7 +68,8 @@ 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"] }) new_heartbeat = Heartbeat.find_or_create_by(attrs) queue_project_mapping(heartbeat[:project]) From 1db16233cb5f81262504d7a4fc186cbd35b7e533 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Tue, 13 May 2025 14:48:24 -0400 Subject: [PATCH 17/30] Add raw heartbeat request storing --- .../api/hackatime/v1/hackatime_controller.rb | 12 +++++++++++- app/models/heartbeat.rb | 1 + app/models/raw_heartbeat_upload.rb | 6 ++++++ .../20250513183739_create_raw_heartbeat_uploads.rb | 10 ++++++++++ ...3184040_add_raw_heartbeat_upload_to_heartbeats.rb | 5 +++++ db/schema.rb | 12 +++++++++++- 6 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 app/models/raw_heartbeat_upload.rb create mode 100644 db/migrate/20250513183739_create_raw_heartbeat_uploads.rb create mode 100644 db/migrate/20250513184040_add_raw_heartbeat_upload_to_heartbeats.rb diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb index 6f2c3ca..5012610 100644 --- a/app/controllers/api/hackatime/v1/hackatime_controller.rb +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -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,13 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController private + def set_raw_heartbeat_upload + @raw_heartbeat_upload = RawHeartbeatUpload.create!( + request_headers: request.headers, + request_body: params + ) + end + def handle_heartbeat(heartbeat_array) results = [] heartbeat_array.each do |heartbeat| @@ -72,6 +80,8 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController machine: request.headers["X-Machine"] }) new_heartbeat = Heartbeat.find_or_create_by(attrs) + new_heartbeat.raw_heartbeat_upload = @raw_heartbeat_upload + new_heartbeat.save! queue_project_mapping(heartbeat[:project]) results << [ new_heartbeat.attributes, 201 ] rescue => e diff --git a/app/models/heartbeat.rb b/app/models/heartbeat.rb index becc7da..30a6e75 100644 --- a/app/models/heartbeat.rb +++ b/app/models/heartbeat.rb @@ -87,6 +87,7 @@ 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 diff --git a/app/models/raw_heartbeat_upload.rb b/app/models/raw_heartbeat_upload.rb new file mode 100644 index 0000000..08fd4f0 --- /dev/null +++ b/app/models/raw_heartbeat_upload.rb @@ -0,0 +1,6 @@ +class RawHeartbeatUpload < ApplicationRecord + has_many :heartbeats + + validates :request_headers, presence: true + validates :request_body, presence: true +end diff --git a/db/migrate/20250513183739_create_raw_heartbeat_uploads.rb b/db/migrate/20250513183739_create_raw_heartbeat_uploads.rb new file mode 100644 index 0000000..b2d7ad5 --- /dev/null +++ b/db/migrate/20250513183739_create_raw_heartbeat_uploads.rb @@ -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 diff --git a/db/migrate/20250513184040_add_raw_heartbeat_upload_to_heartbeats.rb b/db/migrate/20250513184040_add_raw_heartbeat_upload_to_heartbeats.rb new file mode 100644 index 0000000..0ff1721 --- /dev/null +++ b/db/migrate/20250513184040_add_raw_heartbeat_upload_to_heartbeats.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 8151523..cc3a51f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_05_12_205858) do +ActiveRecord::Schema[8.0].define(version: 2025_05_13_184040) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -209,8 +209,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_12_205858) do t.integer "ysws_program", default: 0, null: false t.datetime "deleted_at" t.jsonb "raw_data" + t.bigint "raw_heartbeat_upload_id" t.index ["category", "time"], name: "index_heartbeats_on_category_and_time" t.index ["fields_hash"], name: "index_heartbeats_on_fields_hash_when_not_deleted", unique: true, where: "(deleted_at IS NULL)" + t.index ["raw_heartbeat_upload_id"], name: "index_heartbeats_on_raw_heartbeat_upload_id" t.index ["user_id", "time"], name: "idx_heartbeats_user_time_active", where: "(deleted_at IS NULL)" t.index ["user_id"], name: "index_heartbeats_on_user_id" end @@ -261,6 +263,13 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_12_205858) do t.index ["user_id"], name: "index_project_repo_mappings_on_user_id" end + create_table "raw_heartbeat_uploads", force: :cascade do |t| + t.jsonb "request_headers", null: false + t.jsonb "request_body", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "sailors_log_leaderboards", force: :cascade do |t| t.string "slack_channel_id" t.string "slack_uid" @@ -358,6 +367,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_12_205858) do add_foreign_key "api_keys", "users" add_foreign_key "email_addresses", "users" add_foreign_key "email_verification_requests", "users" + add_foreign_key "heartbeats", "raw_heartbeat_uploads" add_foreign_key "heartbeats", "users" add_foreign_key "leaderboard_entries", "leaderboards" add_foreign_key "leaderboard_entries", "users" From a5a65ed0c6a7fd0f6f7e3966974cd22595d99e98 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Tue, 13 May 2025 14:53:03 -0400 Subject: [PATCH 18/30] Bump bundler version --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2680e5b..7290f16 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -585,4 +585,4 @@ DEPENDENCIES web-console BUNDLED WITH - 2.6.2 + 2.6.9 From 4bd00f28a0cd14aad917da68ffabb074d632bad0 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Tue, 13 May 2025 14:55:28 -0400 Subject: [PATCH 19/30] Add libyaml to docker builds --- Dockerfile | 2 +- Dockerfile.dev | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1ffb05a..ed99a27 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Dockerfile.dev b/Dockerfile.dev index fe3a01d..2e3897e 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -6,6 +6,7 @@ RUN apt-get update -qq && \ build-essential \ git \ libpq-dev \ + libyaml-dev \ postgresql-client \ libvips \ pkg-config \ From 86a69b36af8b5c6a0623906458ad543561aa4b8d Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Tue, 13 May 2025 15:01:57 -0400 Subject: [PATCH 20/30] Ignore raw heartbeat upload code in prod for now --- app/controllers/api/hackatime/v1/hackatime_controller.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb index 5012610..7a0a7a8 100644 --- a/app/controllers/api/hackatime/v1/hackatime_controller.rb +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -1,7 +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 ] + # before_action :set_raw_heartbeat_upload, only: [ :push_heartbeats ] def index redirect_to root_path @@ -80,8 +80,8 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController machine: request.headers["X-Machine"] }) new_heartbeat = Heartbeat.find_or_create_by(attrs) - new_heartbeat.raw_heartbeat_upload = @raw_heartbeat_upload - new_heartbeat.save! + # new_heartbeat.raw_heartbeat_upload = @raw_heartbeat_upload + # new_heartbeat.save! queue_project_mapping(heartbeat[:project]) results << [ new_heartbeat.attributes, 201 ] rescue => e From 4eb01fb8bcd4d12cb1e282055fcdb7016ba6e340 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Tue, 13 May 2025 16:02:12 -0400 Subject: [PATCH 21/30] Fix machine name req header --- app/controllers/api/hackatime/v1/hackatime_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb index 7a0a7a8..eb593da 100644 --- a/app/controllers/api/hackatime/v1/hackatime_controller.rb +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -77,7 +77,7 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController ip_address: request.remote_ip, editor: parsed_ua[:editor], operating_system: parsed_ua[:os], - machine: request.headers["X-Machine"] + machine: request.headers["X-Machine-Name"] }) new_heartbeat = Heartbeat.find_or_create_by(attrs) # new_heartbeat.raw_heartbeat_upload = @raw_heartbeat_upload From 0c15cd2581735fe9065b4234a6d9b935a9284fa1 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Tue, 13 May 2025 16:02:26 -0400 Subject: [PATCH 22/30] Add raw heartbeat uploading --- .../api/hackatime/v1/hackatime_controller.rb | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb index eb593da..ed589c0 100644 --- a/app/controllers/api/hackatime/v1/hackatime_controller.rb +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -54,11 +54,23 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController def set_raw_heartbeat_upload @raw_heartbeat_upload = RawHeartbeatUpload.create!( - request_headers: request.headers, - request_body: params + 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| @@ -80,8 +92,10 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController machine: request.headers["X-Machine-Name"] }) new_heartbeat = Heartbeat.find_or_create_by(attrs) - # new_heartbeat.raw_heartbeat_upload = @raw_heartbeat_upload - # new_heartbeat.save! + 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 From b2984dae4a3bd10a7e0cad60b5c4071a9564463d Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Tue, 13 May 2025 16:02:45 -0400 Subject: [PATCH 23/30] Enable raw heartbeat uploading --- app/controllers/api/hackatime/v1/hackatime_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb index ed589c0..aff8f36 100644 --- a/app/controllers/api/hackatime/v1/hackatime_controller.rb +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -1,7 +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 ] + before_action :set_raw_heartbeat_upload, only: [ :push_heartbeats ] def index redirect_to root_path From 86439c86308fa5cff6dec87abdd74c2ded1c2022 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Tue, 13 May 2025 17:31:06 -0400 Subject: [PATCH 24/30] Fix patching on user settings page --- app/controllers/users_controller.rb | 4 ++-- config/routes.rb | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index b568684..1620230 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -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" @@ -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 diff --git a/config/routes.rb b/config/routes.rb index bfacfbf..9c4d9d2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -60,6 +60,7 @@ 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 From 4405b5bdccc08bf63a98068bc9dd597e570f02b1 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Tue, 13 May 2025 17:36:48 -0400 Subject: [PATCH 25/30] Attempt to fix migration endpoint redirect --- app/controllers/users_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 1620230..d6f375a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -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 From 55503499ff0d886866992fde17b3b585614acba8 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Wed, 14 May 2025 11:50:50 -0400 Subject: [PATCH 26/30] Add physical mail model --- .env.example | 4 +- app/jobs/check_streak_physical_mail_job.rb | 38 ++++++++++++++ app/models/physical_mail.rb | 49 +++++++++++++++++++ .../20250514150404_create_physical_mails.rb | 12 +++++ db/schema.rb | 13 ++++- 5 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 app/jobs/check_streak_physical_mail_job.rb create mode 100644 app/models/physical_mail.rb create mode 100644 db/migrate/20250514150404_create_physical_mails.rb diff --git a/.env.example b/.env.example index 8d66193..4b3ea5c 100644 --- a/.env.example +++ b/.env.example @@ -56,4 +56,6 @@ GITHUB_CLIENT_SECRET=your_github_client_secret_here SKYLIGHT_AUTHENTICATION=replace_me -IPINFO_API_KEY=replace_me \ No newline at end of file +IPINFO_API_KEY=replace_me + +MAIL_HACKCLUB_TOKEN=replace_me \ No newline at end of file diff --git a/app/jobs/check_streak_physical_mail_job.rb b/app/jobs/check_streak_physical_mail_job.rb new file mode 100644 index 0000000..c7ec5e9 --- /dev/null +++ b/app/jobs/check_streak_physical_mail_job.rb @@ -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_streak) + + user = User.find(user_id) + + # Create the physical mail record + PhysicalMail.create!( + user: user, + mission_type: :first_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 diff --git a/app/models/physical_mail.rb b/app/models/physical_mail.rb new file mode 100644 index 0000000..242b7c9 --- /dev/null +++ b/app/models/physical_mail.rb @@ -0,0 +1,49 @@ +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, { + hackatime_first_time_7_streak: 0 + } + + def deliver! + slug = "hackatime_#{mission_type.to_s.underscore.gsub("_", "-")}" + + flavors = FlavorText.compliment + flavors.concat(FlavorText.rare_compliment) if rand(10) == 0 + + # authorization: Bearer + response = HTTP.auth("Bearer #{ENV["MAIL_HACKCLUB_TOKEN"]}").post("https://mail.hackclub.com/api/v1/letter_queues/#{slug}", json: { + recipient_email: user.email, + address: user_address, + rubber_stamps: flavors.sample, + idempotency_key: "physical_mail_#{id}", + metadata: { + attributes: attributes + } + }) + + if response.status.success? + update(status: :sent) + 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.address + end +end diff --git a/db/migrate/20250514150404_create_physical_mails.rb b/db/migrate/20250514150404_create_physical_mails.rb new file mode 100644 index 0000000..73772c0 --- /dev/null +++ b/db/migrate/20250514150404_create_physical_mails.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index cc3a51f..515fb37 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_05_13_184040) do +ActiveRecord::Schema[8.0].define(version: 2025_05_14_150404) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -253,6 +253,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_13_184040) do t.index ["user_id"], name: "index_mailing_addresses_on_user_id" end + create_table "physical_mails", force: :cascade do |t| + t.bigint "user_id", null: false + t.integer "mission_type", null: false + t.integer "status", default: 0, null: false + t.string "theseus_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_physical_mails_on_user_id" + end + create_table "project_repo_mappings", force: :cascade do |t| t.bigint "user_id", null: false t.string "project_name", null: false @@ -372,6 +382,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_13_184040) do add_foreign_key "leaderboard_entries", "leaderboards" add_foreign_key "leaderboard_entries", "users" add_foreign_key "mailing_addresses", "users" + add_foreign_key "physical_mails", "users" add_foreign_key "project_repo_mappings", "users" add_foreign_key "sign_in_tokens", "users" add_foreign_key "wakatime_mirrors", "users" From 46f192297e71b8668f5863dc13153b965c728d60 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Wed, 14 May 2025 12:32:38 -0400 Subject: [PATCH 27/30] Add mailroom --- app/controllers/my/mailroom_controller.rb | 9 ++++ app/jobs/check_streak_physical_mail_job.rb | 4 +- app/models/physical_mail.rb | 30 +++++++++--- app/models/user.rb | 1 + app/views/my/mailroom/index.html.erb | 55 ++++++++++++++++++++++ app/views/shared/_nav.html.erb | 5 ++ config/routes.rb | 1 + 7 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 app/controllers/my/mailroom_controller.rb create mode 100644 app/views/my/mailroom/index.html.erb diff --git a/app/controllers/my/mailroom_controller.rb b/app/controllers/my/mailroom_controller.rb new file mode 100644 index 0000000..8c167fa --- /dev/null +++ b/app/controllers/my/mailroom_controller.rb @@ -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 diff --git a/app/jobs/check_streak_physical_mail_job.rb b/app/jobs/check_streak_physical_mail_job.rb index c7ec5e9..7ed7d24 100644 --- a/app/jobs/check_streak_physical_mail_job.rb +++ b/app/jobs/check_streak_physical_mail_job.rb @@ -15,14 +15,14 @@ class CheckStreakPhysicalMailJob < ApplicationJob 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_streak) + 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_streak, + mission_type: :first_time_7_streak, status: :pending ) end diff --git a/app/models/physical_mail.rb b/app/models/physical_mail.rb index 242b7c9..89199ff 100644 --- a/app/models/physical_mail.rb +++ b/app/models/physical_mail.rb @@ -10,19 +10,35 @@ class PhysicalMail < ApplicationRecord } enum :mission_type, { - hackatime_first_time_7_streak: 0 + 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 deliver! - slug = "hackatime_#{mission_type.to_s.underscore.gsub("_", "-")}" + slug = "hackatime-#{mission_type.to_s.gsub("_", "-")}" flavors = FlavorText.compliment flavors.concat(FlavorText.rare_compliment) if rand(10) == 0 # authorization: Bearer response = HTTP.auth("Bearer #{ENV["MAIL_HACKCLUB_TOKEN"]}").post("https://mail.hackclub.com/api/v1/letter_queues/#{slug}", json: { - recipient_email: user.email, - address: user_address, + 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: { @@ -31,7 +47,9 @@ class PhysicalMail < ApplicationRecord }) if response.status.success? - update(status: :sent) + 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}" @@ -44,6 +62,6 @@ class PhysicalMail < ApplicationRecord private def user_address - user.address + user.mailing_address end end diff --git a/app/models/user.rb b/app/models/user.rb index 265c879..6ba344e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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, diff --git a/app/views/my/mailroom/index.html.erb b/app/views/my/mailroom/index.html.erb new file mode 100644 index 0000000..c3c6093 --- /dev/null +++ b/app/views/my/mailroom/index.html.erb @@ -0,0 +1,55 @@ +<% content_for :title do %> + Mailroom +<% end %> + +
+
+

The Mailroom

+
+ + <% if @has_mailing_address %> +
+

+ Your mailing address is set up and ready to receive mail! + <%= link_to "View my mailing address", my_mailing_address_path %> +

+
+ <% else %> +
+

Mailing Address Required

+

You need to add your mailing address for your mail to be sent.

+ <%= link_to "Add my address", edit_my_mailing_address_path, class: "button" %> +
+ <% end %> + +
+

My Mail

+ <% if @physical_mails.any? %> +
+ <% @physical_mails.each do |mail| %> + <% theseus_link = mail.link_to_theseus %> +
+ <% if theseus_link %> + + <% end %> +
+

+ <%= mail.mission_type.titleize %> + <% if theseus_link %> + + <% end %> +

+

Status: <%= mail.status.titleize %>

+

Created: <%= mail.created_at.strftime("%B %d, %Y") %>

+ <% if theseus_link %> +

View on Theseus

+ <% end %> +
+
+ <% end %> +
+ <% else %> +

No mail yet. Keep coding to earn rewards!

+ <% end %> +
+
\ No newline at end of file diff --git a/app/views/shared/_nav.html.erb b/app/views/shared/_nav.html.erb index f222b6d..d74e3d2 100644 --- a/app/views/shared/_nav.html.erb +++ b/app/views/shared/_nav.html.erb @@ -62,6 +62,11 @@ Mailing Address <% end %> <% end %> +
  • + <%= link_to my_mailroom_path, class: "nav-item #{current_page?(my_mailroom_path) ? 'active' : ''}" do %> + Mailroom + <% end %> +
  • <% admin_tool(nil, "li") do %> <%= link_to ahoy_captain_path, class: "nav-item #{current_page?(ahoy_captain_path) ? 'active' : ''}" do %> Ahoy Captain diff --git a/config/routes.rb b/config/routes.rb index 9c4d9d2..4667b53 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -78,6 +78,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" From d9585d794e48a77b98ce9bdc2da1d3645bace438 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Wed, 14 May 2025 12:33:36 -0400 Subject: [PATCH 28/30] Theseus -> mail.hackclub.com --- app/views/my/mailroom/index.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/my/mailroom/index.html.erb b/app/views/my/mailroom/index.html.erb index c3c6093..61fad6b 100644 --- a/app/views/my/mailroom/index.html.erb +++ b/app/views/my/mailroom/index.html.erb @@ -42,7 +42,7 @@

    Status: <%= mail.status.titleize %>

    Created: <%= mail.created_at.strftime("%B %d, %Y") %>

    <% if theseus_link %> -

    View on Theseus

    +

    View on mail.hackclub.com

    <% end %>
    From 5936597dbe40f757b6d66e6bceed28e36d0911a6 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Wed, 14 May 2025 12:42:21 -0400 Subject: [PATCH 29/30] Sleep to make address updating work more consistently --- app/controllers/my/mailing_addresses_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/my/mailing_addresses_controller.rb b/app/controllers/my/mailing_addresses_controller.rb index cf8a227..992ca85 100644 --- a/app/controllers/my/mailing_addresses_controller.rb +++ b/app/controllers/my/mailing_addresses_controller.rb @@ -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)) From 5bee2a838220f8edbd6eb77fae701d0331c721c0 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Wed, 14 May 2025 12:43:54 -0400 Subject: [PATCH 30/30] Humanize mail mission names --- app/models/physical_mail.rb | 6 ++++++ app/views/my/mailroom/index.html.erb | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/models/physical_mail.rb b/app/models/physical_mail.rb index 89199ff..84954a2 100644 --- a/app/models/physical_mail.rb +++ b/app/models/physical_mail.rb @@ -20,6 +20,12 @@ class PhysicalMail < ApplicationRecord "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("_", "-")}" diff --git a/app/views/my/mailroom/index.html.erb b/app/views/my/mailroom/index.html.erb index 61fad6b..5013dcf 100644 --- a/app/views/my/mailroom/index.html.erb +++ b/app/views/my/mailroom/index.html.erb @@ -34,7 +34,7 @@ <% end %>

    - <%= mail.mission_type.titleize %> + <%= mail.humanized_mission_type %> <% if theseus_link %> <% end %>