diff --git a/.tokeignore b/.tokeignore index 49d20cc..64c1068 100644 --- a/.tokeignore +++ b/.tokeignore @@ -6,3 +6,4 @@ package-lock.json tsconfig.json tsconfig.*.json db/migrate/ +config/languages.yml diff --git a/Dockerfile.dev b/Dockerfile.dev index 9fca8b3..76f16a5 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -48,5 +48,9 @@ RUN git config --system http.timeout 30 && \ EXPOSE 3000 EXPOSE 3036 +# Disable dev warnings +RUN bundle exec skylight disable_dev_warning +RUN bundle config set default_cli_command install --global + # Start the main process -CMD ["rails", "server", "-b", "0.0.0.0"] +CMD ["rails", "server", "-b", "0.0.0.0"] diff --git a/Gemfile b/Gemfile index f0cd11f..305fe2b 100644 --- a/Gemfile +++ b/Gemfile @@ -171,3 +171,7 @@ gem "vite_rails", "~> 3.0" gem "rubyzip", "~> 3.2" gem "aws-sdk-s3", require: false + +gem "notable" + +gem "mailkick" diff --git a/Gemfile.lock b/Gemfile.lock index f64ae2c..b7ea8f7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -304,6 +304,8 @@ GEM net-imap net-pop net-smtp + mailkick (2.0.0) + activesupport (>= 7.1) marcel (1.1.0) matrix (0.4.3) mcp (0.7.1) @@ -355,6 +357,9 @@ GEM faraday (>= 1.0, < 3.0) faraday-net_http_persistent net-http-persistent + notable (0.6.1) + activesupport (>= 7.1) + safely_block (>= 0.4) oj (3.16.15) bigdecimal (>= 3.0) ostruct (>= 0.2) @@ -529,6 +534,7 @@ GEM ruby_identicon (0.0.6) chunky_png (~> 1.4.0) rubyzip (3.2.2) + safely_block (0.5.0) sanitize (7.0.0) crass (~> 1.0.2) nokogiri (>= 1.16.8) @@ -680,8 +686,10 @@ DEPENDENCIES kamal letter_opener letter_opener_web (~> 3.0) + mailkick memory_profiler norairrecord (~> 0.5.1) + notable oj paper_trail pg diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb index bef9f77..2701580 100644 --- a/app/controllers/api/hackatime/v1/hackatime_controller.rb +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -254,6 +254,7 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController results << [ new_heartbeat.attributes, 201 ] should_enqueue_mirror_sync ||= source_type == :direct_entry rescue => e + Sentry.capture_exception(e) Rails.logger.error("Error creating heartbeat: #{e.class.name} #{e.message}") results << [ { error: e.message, type: e.class.name }, 422 ] end @@ -270,6 +271,7 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController end rescue => e # never raise an error here because it will break the heartbeat flow + Sentry.capture_exception(e) Rails.logger.error("Error queuing project mapping: #{e.class.name} #{e.message}") end @@ -278,6 +280,7 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController MirrorFanoutEnqueueJob.perform_later(@user.id) rescue => e + Sentry.capture_exception(e) Rails.logger.error("Error enqueuing mirror sync fanout: #{e.class.name} #{e.message}") end diff --git a/app/controllers/api/v1/external_slack_controller.rb b/app/controllers/api/v1/external_slack_controller.rb index cefaac5..21cb661 100644 --- a/app/controllers/api/v1/external_slack_controller.rb +++ b/app/controllers/api/v1/external_slack_controller.rb @@ -53,6 +53,7 @@ module Api render json: { error: user.errors.full_messages }, status: :unprocessable_entity end rescue => e + Sentry.capture_exception(e) Rails.logger.error "Error creating user from external Slack data: #{e.message}" render json: { error: "Internal server error" }, status: :internal_server_error end diff --git a/app/controllers/api/v1/stats_controller.rb b/app/controllers/api/v1/stats_controller.rb index b8d916d..83a9351 100644 --- a/app/controllers/api/v1/stats_controller.rb +++ b/app/controllers/api/v1/stats_controller.rb @@ -280,6 +280,7 @@ class Api::V1::StatsController < ApplicationController JSON.parse(response.body)["user"]["id"] rescue => e + Sentry.capture_exception(e) Rails.logger.error("Error finding user by email: #{e}") nil end diff --git a/app/controllers/my/heartbeat_imports_controller.rb b/app/controllers/my/heartbeat_imports_controller.rb index 424d01f..24ebbaa 100644 --- a/app/controllers/my/heartbeat_imports_controller.rb +++ b/app/controllers/my/heartbeat_imports_controller.rb @@ -22,6 +22,7 @@ class My::HeartbeatImportsController < ApplicationController status: status }, status: :accepted rescue => e + Sentry.capture_exception(e) Rails.logger.error("Error starting heartbeat import for user #{current_user&.id}: #{e.message}") render json: { error: "error reading file: #{e.message}" }, status: :internal_server_error end diff --git a/app/controllers/settings/access_controller.rb b/app/controllers/settings/access_controller.rb index 39faa4f..2640e24 100644 --- a/app/controllers/settings/access_controller.rb +++ b/app/controllers/settings/access_controller.rb @@ -23,6 +23,7 @@ class Settings::AccessController < Settings::BaseController render json: { token: new_api_key.token }, status: :ok end rescue => e + Sentry.capture_exception(e) Rails.logger.error("error rotate #{e.class.name} #{e.message}") render json: { error: "cant rotate" }, status: :unprocessable_entity end diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index a8c7718..2eddcbd 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -103,7 +103,7 @@ class Settings::BaseController < InertiaController username: @user.username, theme: @user.theme, uses_slack_status: @user.uses_slack_status, - weekly_summary_email_enabled: @user.weekly_summary_email_enabled, + weekly_summary_email_enabled: @user.subscribed?("weekly_summary"), hackatime_extension_text_type: @user.hackatime_extension_text_type, allow_public_stats_lookup: @user.allow_public_stats_lookup, trust_level: @user.trust_level, diff --git a/app/controllers/settings/notifications_controller.rb b/app/controllers/settings/notifications_controller.rb index dd490a6..0c0d8e8 100644 --- a/app/controllers/settings/notifications_controller.rb +++ b/app/controllers/settings/notifications_controller.rb @@ -4,11 +4,22 @@ class Settings::NotificationsController < Settings::BaseController end def update - if @user.update(notifications_params) - PosthogService.capture(@user, "settings_updated", { fields: notifications_params.keys }) + list = "weekly_summary" + enabled = params.dig(:user, :weekly_summary_email_enabled) + + begin + if enabled == "1" || enabled == true + @user.subscribe(list) unless @user.subscribed?(list) + else + @user.unsubscribe(list) if @user.subscribed?(list) + end + + PosthogService.capture(@user, "settings_updated", { fields: [ "weekly_summary_email_enabled" ] }) redirect_to my_settings_notifications_path, notice: "Settings updated successfully" - else - flash.now[:error] = @user.errors.full_messages.to_sentence.presence || "Failed to update settings" + rescue => e + Sentry.capture_exception(e) + Rails.logger.error("Failed to update notification settings: #{e.message}") + flash.now[:error] = "Failed to update settings, sorry :(" render_notifications(status: :unprocessable_entity) end end @@ -22,8 +33,4 @@ class Settings::NotificationsController < Settings::BaseController status: status ) end - - def notifications_params - params.require(:user).permit(:weekly_summary_email_enabled) - end end diff --git a/app/javascript/pages/Users/Settings/Notifications.svelte b/app/javascript/pages/Users/Settings/Notifications.svelte index f860539..606564d 100644 --- a/app/javascript/pages/Users/Settings/Notifications.svelte +++ b/app/javascript/pages/Users/Settings/Notifications.svelte @@ -18,11 +18,7 @@ }: NotificationsPageProps = $props(); let csrfToken = $state(""); - let weeklySummaryEmailEnabled = $state(true); - - $effect(() => { - weeklySummaryEmailEnabled = user.weekly_summary_email_enabled; - }); + let weeklySummaryEmailEnabled = $state(user.weekly_summary_email_enabled); onMount(() => { csrfToken = diff --git a/app/jobs/weekly_summary_email_job.rb b/app/jobs/weekly_summary_email_job.rb index 52737ff..38fa04f 100644 --- a/app/jobs/weekly_summary_email_job.rb +++ b/app/jobs/weekly_summary_email_job.rb @@ -2,37 +2,17 @@ class WeeklySummaryEmailJob < ApplicationJob queue_as :literally_whenever def perform(reference_time = Time.current) - # Weekly summary delivery is intentionally disabled for now. - # Context: https://hackclub.slack.com/archives/D083UR1DR7V/p1772321709715969 - # Keep this no-op until we explicitly decide to turn the campaign back on. - reference_time + return unless Flipper.enabled?(:weekly_summary_emails) - # now_utc = reference_time.utc - # return unless send_window?(now_utc) + now_utc = reference_time.utc + cutoff = 3.weeks.ago - # User.where(weekly_summary_email_enabled: true).find_each do |user| - # recipient_email = user.email_addresses.order(:id).pick(:email) - # next if recipient_email.blank? - - # user_timezone = ActiveSupport::TimeZone[user.timezone] || ActiveSupport::TimeZone["UTC"] - # user_now = now_utc.in_time_zone(user_timezone) - # ends_at_local = user_now.beginning_of_week(:monday) - # starts_at_local = ends_at_local - 1.week - - # WeeklySummaryMailer.weekly_summary( - # user, - # recipient_email: recipient_email, - # starts_at: starts_at_local.utc, - # ends_at: ends_at_local.utc - # ).deliver_now - # rescue StandardError => e - # Rails.logger.error("Weekly summary email failed for user #{user.id}: #{e.class} #{e.message}") - # end - end - - private - - def send_window?(time) - time.friday? && time.hour == 17 && time.min == 30 + User.subscribed("weekly_summary") + .where(created_at: cutoff..) + .or(User.subscribed("weekly_summary").joins(:heartbeats).where(heartbeats: { time: cutoff.. })) + .distinct + .find_each do |user| + WeeklySummaryUserEmailJob.perform_later(user.id, now_utc.iso8601) + end end end diff --git a/app/jobs/weekly_summary_user_email_job.rb b/app/jobs/weekly_summary_user_email_job.rb new file mode 100644 index 0000000..f307b65 --- /dev/null +++ b/app/jobs/weekly_summary_user_email_job.rb @@ -0,0 +1,24 @@ +class WeeklySummaryUserEmailJob < ApplicationJob + queue_as :literally_whenever + + def perform(user_id, now_utc_iso8601) + user = User.find_by(id: user_id) + return if user.nil? + + recipient_email = user.email_addresses.order(:id).pick(:email) + return if recipient_email.blank? + + now_utc = Time.zone.parse(now_utc_iso8601) + user_timezone = ActiveSupport::TimeZone[user.timezone] || ActiveSupport::TimeZone["UTC"] + user_now = now_utc.in_time_zone(user_timezone) + ends_at_local = user_now.beginning_of_week(:monday) + starts_at_local = ends_at_local - 1.week + + WeeklySummaryMailer.weekly_summary( + user, + recipient_email: recipient_email, + starts_at: starts_at_local.utc, + ends_at: ends_at_local.utc + ).deliver_now + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 0d6e196..298fd80 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,6 @@ class ApplicationMailer < ActionMailer::Base + include Mailkick::UrlHelper + default from: ENV.fetch("SMTP_FROM_EMAIL", "noreply@timedump.hackclub.com") layout "mailer" end diff --git a/app/mailers/weekly_summary_mailer.rb b/app/mailers/weekly_summary_mailer.rb index bac5b42..ce41ae7 100644 --- a/app/mailers/weekly_summary_mailer.rb +++ b/app/mailers/weekly_summary_mailer.rb @@ -3,6 +3,7 @@ class WeeklySummaryMailer < ApplicationMailer def weekly_summary(user, recipient_email:, starts_at:, ends_at:) @user = user + @unsubscribe_url = mailkick_unsubscribe_url(@user, "weekly_summary") user_timezone = ActiveSupport::TimeZone[@user.timezone] @timezone = user_timezone || ActiveSupport::TimeZone["UTC"] @timezone_label = user_timezone ? @user.timezone : @timezone.tzinfo.identifier diff --git a/app/models/user.rb b/app/models/user.rb index c86b6c5..218076c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,11 +5,14 @@ class User < ApplicationRecord include ::SlackIntegration include ::GithubIntegration + has_subscriptions + USERNAME_MAX_LENGTH = 21 # going over 21 overflows the navbar has_paper_trail after_create :track_signup + after_create :subscribe_to_default_lists before_validation :normalize_username encrypts :slack_access_token, :github_access_token, :hca_access_token @@ -26,7 +29,6 @@ class User < ApplicationRecord attribute :allow_public_stats_lookup, :boolean, default: true attribute :default_timezone_leaderboard, :boolean, default: true - attribute :weekly_summary_email_enabled, :boolean, default: true def country_name ISO3166::Country.new(country_code).common_name @@ -326,6 +328,10 @@ class User < ApplicationRecord PosthogService.capture(self, "account_created", { source: "signup" }) end + def subscribe_to_default_lists + subscribe("weekly_summary") + end + def normalize_username original = username @username_cleared_for_invisible = false diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index ef70c3d..6cf367b 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -27,6 +27,9 @@
Don't want to get these anymore? <%= link_to "Unsubscribe", @unsubscribe_url, style: "color: #9ca3af; text-decoration: underline;" %>
+ <% end %>15 Falls Road, Shelburne, VT 05482, United States