Unsubscribe links + slow job monitoring (#1016)

* Notable + unsubscribe links

* Add the migrations/initializers!

* oops

* Fix a bug

* Address Copilot warn

* Fixes

* stop swallowing errors!!!!

* Flipper flag WeeklySummaryEmailJob

* Split WeeklySummaryEmailJob into new email
This commit is contained in:
Mahad Kalam 2026-03-01 12:11:38 +00:00 committed by GitHub
parent d5d987a8f4
commit 4dec2f44a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 203 additions and 53 deletions

View file

@ -6,3 +6,4 @@ package-lock.json
tsconfig.json
tsconfig.*.json
db/migrate/
config/languages.yml

View file

@ -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"]

View file

@ -171,3 +171,7 @@ gem "vite_rails", "~> 3.0"
gem "rubyzip", "~> 3.2"
gem "aws-sdk-s3", require: false
gem "notable"
gem "mailkick"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,

View file

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

View file

@ -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 =

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -27,6 +27,9 @@
<tr>
<td>
<div class="px-4 py-5 text-center text-xs text-gray-400">
<% if @unsubscribe_url.present? %>
<p class="my-1">Don't want to get these anymore? <%= link_to "Unsubscribe", @unsubscribe_url, style: "color: #9ca3af; text-decoration: underline;" %></p>
<% end %>
<p class="my-1">15 Falls Road, Shelburne, VT 05482, United States</p>
</div>
</td>

View file

@ -0,0 +1 @@
Mailkick.headers = true

View file

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

View file

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

View file

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

View file

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

View file

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

41
db/schema.rb generated
View file

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

View file

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

View file

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

View file

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