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 @@
+ <% if @unsubscribe_url.present? %> +

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

diff --git a/config/initializers/mailkick.rb b/config/initializers/mailkick.rb new file mode 100644 index 0000000..c009b7e --- /dev/null +++ b/config/initializers/mailkick.rb @@ -0,0 +1 @@ +Mailkick.headers = true diff --git a/db/migrate/20260301111352_create_notable_requests.rb b/db/migrate/20260301111352_create_notable_requests.rb new file mode 100644 index 0000000..3779fb0 --- /dev/null +++ b/db/migrate/20260301111352_create_notable_requests.rb @@ -0,0 +1,19 @@ +class CreateNotableRequests < ActiveRecord::Migration[8.1] + def change + create_table :notable_requests do |t| + t.string :note_type + t.text :note + t.references :user, polymorphic: true + t.text :action + t.integer :status + t.text :url + t.string :request_id + t.string :ip + t.text :user_agent + t.text :referrer + t.text :params + t.float :request_time + t.datetime :created_at + end + end +end diff --git a/db/migrate/20260301111406_create_notable_jobs.rb b/db/migrate/20260301111406_create_notable_jobs.rb new file mode 100644 index 0000000..b682cf4 --- /dev/null +++ b/db/migrate/20260301111406_create_notable_jobs.rb @@ -0,0 +1,14 @@ +class CreateNotableJobs < ActiveRecord::Migration[8.1] + def change + create_table :notable_jobs do |t| + t.string :note_type + t.text :note + t.text :job + t.string :job_id + t.string :queue + t.float :runtime + t.float :queued_time + t.datetime :created_at + end + end +end diff --git a/db/migrate/20260301112503_create_mailkick_subscriptions.rb b/db/migrate/20260301112503_create_mailkick_subscriptions.rb new file mode 100644 index 0000000..393769c --- /dev/null +++ b/db/migrate/20260301112503_create_mailkick_subscriptions.rb @@ -0,0 +1,11 @@ +class CreateMailkickSubscriptions < ActiveRecord::Migration[8.1] + def change + create_table :mailkick_subscriptions do |t| + t.references :subscriber, polymorphic: true, index: false + t.string :list + t.timestamps + end + + add_index :mailkick_subscriptions, [ :subscriber_type, :subscriber_id, :list ], unique: true, name: "index_mailkick_subscriptions_on_subscriber_and_list" + end +end diff --git a/db/migrate/20260301112514_seed_mailkick_subscriptions_from_weekly_summary.rb b/db/migrate/20260301112514_seed_mailkick_subscriptions_from_weekly_summary.rb new file mode 100644 index 0000000..409d6a0 --- /dev/null +++ b/db/migrate/20260301112514_seed_mailkick_subscriptions_from_weekly_summary.rb @@ -0,0 +1,17 @@ +class SeedMailkickSubscriptionsFromWeeklySummary < ActiveRecord::Migration[8.1] + def up + execute <<~SQL + INSERT INTO mailkick_subscriptions (subscriber_type, subscriber_id, list, created_at, updated_at) + SELECT 'User', id, 'weekly_summary', NOW(), NOW() + FROM users + WHERE weekly_summary_email_enabled = TRUE + ON CONFLICT (subscriber_type, subscriber_id, list) DO NOTHING + SQL + end + + def down + execute <<~SQL + DELETE FROM mailkick_subscriptions WHERE list = 'weekly_summary' + SQL + end +end diff --git a/db/migrate/20260301112933_remove_weekly_summary_email_enabled_from_users.rb b/db/migrate/20260301112933_remove_weekly_summary_email_enabled_from_users.rb new file mode 100644 index 0000000..6dd0170 --- /dev/null +++ b/db/migrate/20260301112933_remove_weekly_summary_email_enabled_from_users.rb @@ -0,0 +1,5 @@ +class RemoveWeeklySummaryEmailEnabledFromUsers < ActiveRecord::Migration[8.1] + def change + remove_column :users, :weekly_summary_email_enabled, :boolean, default: true, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 14c9baa..c8b2882 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.1].define(version: 2026_02_23_212054) do +ActiveRecord::Schema[8.1].define(version: 2026_03_01_112933) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_stat_statements" @@ -410,6 +410,44 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_23_212054) do t.index ["start_date"], name: "index_leaderboards_on_start_date", where: "(deleted_at IS NULL)" end + create_table "mailkick_subscriptions", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "list" + t.bigint "subscriber_id" + t.string "subscriber_type" + t.datetime "updated_at", null: false + t.index ["subscriber_type", "subscriber_id", "list"], name: "index_mailkick_subscriptions_on_subscriber_and_list", unique: true + end + + create_table "notable_jobs", force: :cascade do |t| + t.datetime "created_at" + t.text "job" + t.string "job_id" + t.text "note" + t.string "note_type" + t.string "queue" + t.float "queued_time" + t.float "runtime" + end + + create_table "notable_requests", force: :cascade do |t| + t.text "action" + t.datetime "created_at" + t.string "ip" + t.text "note" + t.string "note_type" + t.text "params" + t.text "referrer" + t.string "request_id" + t.float "request_time" + t.integer "status" + t.text "url" + t.text "user_agent" + t.bigint "user_id" + t.string "user_type" + t.index ["user_type", "user_id"], name: "index_notable_requests_on_user" + end + create_table "oauth_access_grants", force: :cascade do |t| t.bigint "application_id", null: false t.datetime "created_at", null: false @@ -647,7 +685,6 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_23_212054) do t.datetime "updated_at", null: false t.string "username" t.boolean "uses_slack_status", default: false, null: false - t.boolean "weekly_summary_email_enabled", default: true, null: false t.index ["github_uid", "github_access_token"], name: "index_users_on_github_uid_and_access_token" t.index ["github_uid"], name: "index_users_on_github_uid" t.index ["slack_uid"], name: "index_users_on_slack_uid", unique: true diff --git a/test/controllers/settings_notifications_controller_test.rb b/test/controllers/settings_notifications_controller_test.rb index 90fe881..d730618 100644 --- a/test/controllers/settings_notifications_controller_test.rb +++ b/test/controllers/settings_notifications_controller_test.rb @@ -17,10 +17,13 @@ class SettingsNotificationsControllerTest < ActionDispatch::IntegrationTest user = users(:one) sign_in_as(user) + user.subscribe("weekly_summary") + assert user.subscribed?("weekly_summary") + patch my_settings_notifications_path, params: { user: { weekly_summary_email_enabled: "0" } } assert_response :redirect assert_redirected_to my_settings_notifications_path - assert_equal false, user.reload.weekly_summary_email_enabled + assert_not user.reload.subscribed?("weekly_summary") end end diff --git a/test/mailers/weekly_summary_mailer_test.rb b/test/mailers/weekly_summary_mailer_test.rb index 8f6f1a9..b9f857e 100644 --- a/test/mailers/weekly_summary_mailer_test.rb +++ b/test/mailers/weekly_summary_mailer_test.rb @@ -3,9 +3,9 @@ require "test_helper" class WeeklySummaryMailerTest < ActionMailer::TestCase setup do @user = User.create!( - timezone: "UTC", - weekly_summary_email_enabled: true + timezone: "UTC" ) + @user.subscribe("weekly_summary") @recipient_email = "weekly-mailer-#{SecureRandom.hex(4)}@example.com" @user.email_addresses.create!(email: @recipient_email, source: :signing_in) end diff --git a/test/system/settings/notifications_settings_test.rb b/test/system/settings/notifications_settings_test.rb index b59ea1b..50ad8f5 100644 --- a/test/system/settings/notifications_settings_test.rb +++ b/test/system/settings/notifications_settings_test.rb @@ -19,7 +19,7 @@ class NotificationsSettingsTest < ApplicationSystemTestCase end test "notifications settings updates weekly summary email preference" do - @user.update!(weekly_summary_email_enabled: true) + @user.subscribe("weekly_summary") visit my_settings_notifications_path @@ -30,6 +30,6 @@ class NotificationsSettingsTest < ApplicationSystemTestCase click_on "Save notification settings" assert_text "Settings updated successfully" - assert_equal false, @user.reload.weekly_summary_email_enabled + assert_not @user.reload.subscribed?("weekly_summary") end end