mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-20 00:35:22 +00:00
Make large exports go to ActiveStorage (#990)
* Make em go to ActiveStorage * Oops!
This commit is contained in:
parent
44777ad644
commit
1b7e0462dc
12 changed files with 246 additions and 46 deletions
2
Gemfile
2
Gemfile
|
|
@ -165,3 +165,5 @@ gem "tailwindcss-rails", "~> 4.2"
|
|||
gem "inertia_rails", "~> 3.17"
|
||||
|
||||
gem "vite_rails", "~> 3.0"
|
||||
|
||||
gem "rubyzip", "~> 3.2"
|
||||
|
|
|
|||
|
|
@ -656,6 +656,7 @@ DEPENDENCIES
|
|||
rswag-ui
|
||||
rubocop-rails-omakase
|
||||
ruby_identicon
|
||||
rubyzip (~> 3.2)
|
||||
selenium-webdriver
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
|
|
|
|||
11
app/jobs/heartbeat_export_cleanup_job.rb
Normal file
11
app/jobs/heartbeat_export_cleanup_job.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
class HeartbeatExportCleanupJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(blob_id)
|
||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
||||
return if blob.nil?
|
||||
return unless blob.metadata["heartbeat_export"]
|
||||
|
||||
blob.purge
|
||||
end
|
||||
end
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
require "zip"
|
||||
|
||||
class HeartbeatExportJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
|
|
@ -35,17 +37,39 @@ class HeartbeatExportJob < ApplicationJob
|
|||
|
||||
export_data = build_export_data(heartbeats, start_date, end_date)
|
||||
user_identifier = user.slack_uid.presence || "user_#{user.id}"
|
||||
filename = "heartbeats_#{user_identifier}_#{start_date.strftime("%Y%m%d")}_#{end_date.strftime("%Y%m%d")}.json"
|
||||
json_filename = "heartbeats_#{user_identifier}_#{start_date.strftime("%Y%m%d")}_#{end_date.strftime("%Y%m%d")}.json"
|
||||
zip_filename = "#{File.basename(json_filename, ".json")}.zip"
|
||||
|
||||
Tempfile.create([ "heartbeat_export", ".json" ]) do |file|
|
||||
file.write(export_data.to_json)
|
||||
file.rewind
|
||||
HeartbeatExportMailer.export_ready(
|
||||
user,
|
||||
recipient_email: recipient_email,
|
||||
file_path: file.path,
|
||||
filename: filename
|
||||
).deliver_now
|
||||
|
||||
Tempfile.create([ "heartbeat_export", ".zip" ]) do |zip_file|
|
||||
Zip::File.open(zip_file.path, create: true) do |archive|
|
||||
archive.add(json_filename, file.path)
|
||||
end
|
||||
|
||||
blob = File.open(zip_file.path, "rb") do |zip_io|
|
||||
ActiveStorage::Blob.create_and_upload!(
|
||||
io: zip_io,
|
||||
filename: zip_filename,
|
||||
content_type: "application/zip",
|
||||
metadata: {
|
||||
heartbeat_export: true,
|
||||
user_id: user.id
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
HeartbeatExportCleanupJob.set(wait: 7.days).perform_later(blob.id)
|
||||
|
||||
HeartbeatExportMailer.export_ready(
|
||||
user,
|
||||
recipient_email: recipient_email,
|
||||
blob: blob,
|
||||
filename: zip_filename
|
||||
).deliver_now
|
||||
end
|
||||
end
|
||||
rescue ArgumentError => e
|
||||
Rails.logger.error("Heartbeat export failed for user #{user_id}: #{e.message}")
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
class HeartbeatExportMailer < ApplicationMailer
|
||||
def export_ready(user, recipient_email:, file_path:, filename:)
|
||||
def export_ready(user, recipient_email:, blob:, filename:)
|
||||
@user = user
|
||||
attachments[filename] = {
|
||||
mime_type: "application/json",
|
||||
content: File.binread(file_path)
|
||||
}
|
||||
@filename = filename
|
||||
@download_url = rails_blob_url(blob, disposition: "attachment")
|
||||
|
||||
mail(
|
||||
to: recipient_email,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
<h1>Your heartbeat export is ready</h1>
|
||||
<p>Hi <%= h(@user.display_name) %>,</p>
|
||||
<p>Your Hackatime heartbeat export has been generated and is attached to this email.</p>
|
||||
<p>If you didn't request this export, you can safely ignore this email.</p>
|
||||
<p>Your Hackatime heartbeat export has been generated.</p>
|
||||
<p>
|
||||
<a href="<%= @download_url %>">Download <%= @filename %></a>
|
||||
</p>
|
||||
<p>This link will stop working after 7 days.</p>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ Your heartbeat export is ready
|
|||
|
||||
Hi <%= h(@user.display_name) %>,
|
||||
|
||||
Your Hackatime heartbeat export has been generated and is attached to this email.
|
||||
Your Hackatime heartbeat export has been generated.
|
||||
|
||||
If you didn't request this export, you can safely ignore this email.
|
||||
Download file: <%= @download_url %>
|
||||
Filename: <%= @filename %>
|
||||
|
||||
This link will stop working after 7 days.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
# This migration comes from active_storage (originally 20170806125915)
|
||||
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
# Use Active Record's configured type for primary and foreign keys
|
||||
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
||||
|
||||
create_table :active_storage_blobs, id: primary_key_type do |t|
|
||||
t.string :key, null: false
|
||||
t.string :filename, null: false
|
||||
t.string :content_type
|
||||
t.text :metadata
|
||||
t.string :service_name, null: false
|
||||
t.bigint :byte_size, null: false
|
||||
t.string :checksum
|
||||
|
||||
if connection.supports_datetime_with_precision?
|
||||
t.datetime :created_at, precision: 6, null: false
|
||||
else
|
||||
t.datetime :created_at, null: false
|
||||
end
|
||||
|
||||
t.index [ :key ], unique: true
|
||||
end
|
||||
|
||||
create_table :active_storage_attachments, id: primary_key_type do |t|
|
||||
t.string :name, null: false
|
||||
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
|
||||
t.references :blob, null: false, type: foreign_key_type
|
||||
|
||||
if connection.supports_datetime_with_precision?
|
||||
t.datetime :created_at, precision: 6, null: false
|
||||
else
|
||||
t.datetime :created_at, null: false
|
||||
end
|
||||
|
||||
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
|
||||
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||
end
|
||||
|
||||
create_table :active_storage_variant_records, id: primary_key_type do |t|
|
||||
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
|
||||
t.string :variation_digest, null: false
|
||||
|
||||
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
|
||||
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def primary_and_foreign_key_types
|
||||
config = Rails.configuration.generators
|
||||
setting = config.options[config.orm][:primary_key_type]
|
||||
primary_key_type = setting || :primary_key
|
||||
foreign_key_type = setting || :bigint
|
||||
[ primary_key_type, foreign_key_type ]
|
||||
end
|
||||
end
|
||||
32
db/schema.rb
generated
32
db/schema.rb
generated
|
|
@ -10,11 +10,39 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_02_19_153152) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_02_21_113553) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "pg_stat_statements"
|
||||
|
||||
create_table "active_storage_attachments", force: :cascade do |t|
|
||||
t.bigint "blob_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.string "name", null: false
|
||||
t.bigint "record_id", null: false
|
||||
t.string "record_type", null: false
|
||||
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
|
||||
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "active_storage_blobs", force: :cascade do |t|
|
||||
t.bigint "byte_size", null: false
|
||||
t.string "checksum"
|
||||
t.string "content_type"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "filename", null: false
|
||||
t.string "key", null: false
|
||||
t.text "metadata"
|
||||
t.string "service_name", null: false
|
||||
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
|
||||
end
|
||||
|
||||
create_table "active_storage_variant_records", force: :cascade do |t|
|
||||
t.bigint "blob_id", null: false
|
||||
t.string "variation_digest", null: false
|
||||
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "admin_api_keys", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.text "name", null: false
|
||||
|
|
@ -629,6 +657,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_19_153152) do
|
|||
t.index ["user_id"], name: "index_wakatime_mirrors_on_user_id"
|
||||
end
|
||||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "admin_api_keys", "users"
|
||||
add_foreign_key "api_keys", "users"
|
||||
add_foreign_key "commits", "repositories"
|
||||
|
|
|
|||
30
test/jobs/heartbeat_export_cleanup_job_test.rb
Normal file
30
test/jobs/heartbeat_export_cleanup_job_test.rb
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
require "test_helper"
|
||||
|
||||
class HeartbeatExportCleanupJobTest < ActiveJob::TestCase
|
||||
test "purges heartbeat export blob" do
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new("{\"sample\":true}"),
|
||||
filename: "heartbeats_test.json",
|
||||
content_type: "application/json",
|
||||
metadata: { "heartbeat_export" => true }
|
||||
)
|
||||
|
||||
assert_difference -> { ActiveStorage::Blob.count }, -1 do
|
||||
HeartbeatExportCleanupJob.perform_now(blob.id)
|
||||
end
|
||||
end
|
||||
|
||||
test "does not purge blob without heartbeat export metadata" do
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new("test"),
|
||||
filename: "notes.txt",
|
||||
content_type: "text/plain"
|
||||
)
|
||||
|
||||
assert_no_difference -> { ActiveStorage::Blob.count } do
|
||||
HeartbeatExportCleanupJob.perform_now(blob.id)
|
||||
end
|
||||
|
||||
blob.purge
|
||||
end
|
||||
end
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
require "test_helper"
|
||||
require "zip"
|
||||
|
||||
class HeartbeatExportJobTest < ActiveJob::TestCase
|
||||
setup do
|
||||
ActionMailer::Base.deliveries.clear
|
||||
GoodJob::Job.delete_all
|
||||
@user = User.create!(
|
||||
timezone: "UTC",
|
||||
slack_uid: "U#{SecureRandom.hex(5)}",
|
||||
|
|
@ -14,26 +16,32 @@ class HeartbeatExportJobTest < ActiveJob::TestCase
|
|||
)
|
||||
end
|
||||
|
||||
test "all-data export sends email with attachment and export metadata" do
|
||||
test "all-data export uploads blob, schedules cleanup, and emails download link" do
|
||||
first_time = Time.utc(2026, 2, 10, 12, 0, 0)
|
||||
second_time = Time.utc(2026, 2, 12, 12, 0, 0)
|
||||
|
||||
hb1 = create_heartbeat(at_time: first_time, entity: "src/first.rb")
|
||||
hb2 = create_heartbeat(at_time: second_time, entity: "src/second.rb")
|
||||
|
||||
HeartbeatExportJob.perform_now(@user.id, all_data: true)
|
||||
assert_difference -> { ActiveStorage::Blob.count }, 1 do
|
||||
assert_difference -> { GoodJob::Job.where(job_class: "HeartbeatExportCleanupJob").count }, 1 do
|
||||
HeartbeatExportJob.perform_now(@user.id, all_data: true)
|
||||
end
|
||||
end
|
||||
|
||||
assert_equal 1, ActionMailer::Base.deliveries.size
|
||||
mail = ActionMailer::Base.deliveries.last
|
||||
assert_equal [ @user.email_addresses.first.email ], mail.to
|
||||
assert_equal "Your Hackatime heartbeat export is ready", mail.subject
|
||||
assert_equal 1, mail.attachments.size
|
||||
|
||||
attachment = mail.attachments.first
|
||||
assert_equal "application/json", attachment.mime_type
|
||||
assert_match(/\Aheartbeats_#{@user.slack_uid}_20260210_20260212\.json\z/, attachment.filename.to_s)
|
||||
blob = ActiveStorage::Blob.order(created_at: :asc).last
|
||||
assert_equal "application/zip", blob.content_type
|
||||
assert_match(/\Aheartbeats_#{@user.slack_uid}_20260210_20260212\.zip\z/, blob.filename.to_s)
|
||||
|
||||
payload = JSON.parse(attachment.body.decoded)
|
||||
payload = parse_zipped_export_payload(
|
||||
blob.download,
|
||||
"heartbeats_#{@user.slack_uid}_20260210_20260212.json"
|
||||
)
|
||||
assert_equal "2026-02-10", payload.dig("export_info", "date_range", "start_date")
|
||||
assert_equal "2026-02-12", payload.dig("export_info", "date_range", "end_date")
|
||||
assert_equal 2, payload.dig("export_info", "total_heartbeats")
|
||||
|
|
@ -41,6 +49,8 @@ class HeartbeatExportJobTest < ActiveJob::TestCase
|
|||
assert_equal [ hb1.id, hb2.id ], payload.fetch("heartbeats").map { |row| row.fetch("id") }
|
||||
assert_equal "src/first.rb", payload.fetch("heartbeats").first.fetch("entity")
|
||||
assert_equal "src/second.rb", payload.fetch("heartbeats").last.fetch("entity")
|
||||
|
||||
assert_includes mail.text_part.body.decoded, "/rails/active_storage/blobs/redirect/"
|
||||
end
|
||||
|
||||
test "date-range export includes only heartbeats in range" do
|
||||
|
|
@ -55,7 +65,10 @@ class HeartbeatExportJobTest < ActiveJob::TestCase
|
|||
end_date: "2026-02-11"
|
||||
)
|
||||
|
||||
payload = JSON.parse(ActionMailer::Base.deliveries.last.attachments.first.body.decoded)
|
||||
payload = parse_zipped_export_payload(
|
||||
ActiveStorage::Blob.order(created_at: :asc).last.download,
|
||||
"heartbeats_#{@user.slack_uid}_20260210_20260211.json"
|
||||
)
|
||||
exported_ids = payload.fetch("heartbeats").map { |row| row.fetch("id") }
|
||||
|
||||
assert_equal [ in_range_one.id, in_range_two.id ], exported_ids
|
||||
|
|
@ -117,4 +130,31 @@ class HeartbeatExportJobTest < ActiveJob::TestCase
|
|||
source_type: :test_entry
|
||||
)
|
||||
end
|
||||
|
||||
def parse_zipped_export_payload(zip_bytes, json_filename)
|
||||
zip_data = zip_bytes
|
||||
zip_data = zip_data.download if zip_data.respond_to?(:download)
|
||||
|
||||
if zip_data.respond_to?(:read)
|
||||
zip_data.rewind if zip_data.respond_to?(:rewind)
|
||||
zip_data = zip_data.read
|
||||
end
|
||||
|
||||
payload = nil
|
||||
found_expected_entry = false
|
||||
|
||||
Zip::InputStream.open(StringIO.new(zip_data.to_s)) do |stream|
|
||||
while (entry = stream.get_next_entry)
|
||||
next unless entry.name.end_with?(".json")
|
||||
|
||||
payload = JSON.parse(stream.read)
|
||||
found_expected_entry = entry.name == json_filename
|
||||
break if found_expected_entry
|
||||
end
|
||||
end
|
||||
|
||||
assert_not_nil payload, "Expected zip to include a JSON file"
|
||||
assert found_expected_entry, "Expected zip to include #{json_filename}"
|
||||
payload
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,30 +10,31 @@ class HeartbeatExportMailerTest < ActionMailer::TestCase
|
|||
@recipient_email = "mailer-export-#{SecureRandom.hex(6)}@example.com"
|
||||
end
|
||||
|
||||
test "export_ready builds recipient, body, and json attachment" do
|
||||
Tempfile.create([ "heartbeat_export_mailer", ".json" ]) do |file|
|
||||
file.write({ sample: true }.to_json)
|
||||
file.rewind
|
||||
test "export_ready builds recipient and includes download link" do
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new({ sample: true }.to_json),
|
||||
filename: "heartbeats_test.zip",
|
||||
content_type: "application/zip",
|
||||
metadata: { "heartbeat_export" => true }
|
||||
)
|
||||
|
||||
mail = HeartbeatExportMailer.export_ready(
|
||||
@user,
|
||||
recipient_email: @recipient_email,
|
||||
file_path: file.path,
|
||||
filename: "heartbeats_test.json"
|
||||
)
|
||||
mail = HeartbeatExportMailer.export_ready(
|
||||
@user,
|
||||
recipient_email: @recipient_email,
|
||||
blob: blob,
|
||||
filename: "heartbeats_test.zip"
|
||||
)
|
||||
|
||||
assert_equal [ @recipient_email ], mail.to
|
||||
assert_equal "Your Hackatime heartbeat export is ready", mail.subject
|
||||
assert_equal 1, mail.attachments.size
|
||||
assert_equal [ @recipient_email ], mail.to
|
||||
assert_equal "Your Hackatime heartbeat export is ready", mail.subject
|
||||
assert_equal 0, mail.attachments.size
|
||||
|
||||
attachment = mail.attachments.first
|
||||
assert_equal "heartbeats_test.json", attachment.filename
|
||||
assert_equal "application/json", attachment.mime_type
|
||||
assert_equal({ "sample" => true }, JSON.parse(attachment.body.decoded))
|
||||
assert_includes mail.html_part.body.decoded, "Your heartbeat export is ready"
|
||||
assert_includes mail.text_part.body.decoded, "Your Hackatime heartbeat export has been generated"
|
||||
assert_includes mail.text_part.body.decoded, @user.display_name
|
||||
assert_includes mail.text_part.body.decoded, "/rails/active_storage/blobs/redirect/"
|
||||
assert_includes mail.text_part.body.decoded, "heartbeats_test.zip"
|
||||
|
||||
assert_includes mail.html_part.body.decoded, "Your heartbeat export is ready"
|
||||
assert_includes mail.text_part.body.decoded, "Your Hackatime heartbeat export has been generated"
|
||||
assert_includes mail.text_part.body.decoded, @user.display_name
|
||||
end
|
||||
blob.purge
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue