mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
* Add post-trial inactive family cleanup with data archival Families that expire their trial without subscribing now get cleaned up daily. Empty families (no accounts) are destroyed immediately after a 14-day grace period. Families with meaningful data (12+ transactions, some recent) get their data exported as NDJSON/ZIP to an ArchivedExport record before deletion, downloadable via a token-based URL for 90 days. - Add InactiveFamilyCleanerJob (scheduled daily at 4 AM, managed mode only) - Add ArchivedExport model with token-based downloads - Add inactive_trial_for_cleanup scope and requires_data_archive? to Family - Extend DataCleanerJob to purge expired archived exports - Add ArchivedExportsController for unauthenticated token downloads https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Fix Brakeman redirect warning in ArchivedExportsController Use rails_blob_path instead of redirecting directly to the ActiveStorage attachment, which avoids the allow_other_host: true open redirect. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Update schema.rb with archived_exports table Add the archived_exports table definition to schema.rb to match the pending migration, unblocking CI tests. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Fix broken CI tests for ArchivedExports and InactiveFamilyCleaner - ArchivedExportsController 404 test: use assert_response :not_found instead of assert_raises since Rails rescues RecordNotFound in integration tests and returns a 404 response. - InactiveFamilyCleanerJob test: remove assert_no_difference on Family.count since the inactive_trial fixture gets cleaned up by the job. The test intent is to verify the active family survives, which is checked by assert Family.exists?. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Wrap ArchivedExport creation in a transaction Ensure the ArchivedExport record and its file attachment succeed atomically. If the attach fails, the transaction rolls back so no orphaned record is left without an export file. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Store only a digest of the download token for ArchivedExport Replace plaintext download_token column with download_token_digest (SHA-256 hex). The raw token is generated via SecureRandom on create, exposed transiently via attr_reader for use in emails/logs, and only its digest is persisted. Lookup uses find_by_download_token! which digests the incoming token before querying. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Remove raw download token from cleanup job logs Log a truncated digest prefix instead of the raw token, which is the sole credential for the unauthenticated download endpoint. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Fix empty assert_no_difference block in cleaner job test Wrap the perform_now call with both assertions so the ArchivedExport.count check actually exercises the job. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ --------- Co-authored-by: Claude <noreply@anthropic.com>
65 lines
2.1 KiB
Ruby
65 lines
2.1 KiB
Ruby
class InactiveFamilyCleanerJob < ApplicationJob
|
|
queue_as :scheduled
|
|
|
|
BATCH_SIZE = 500
|
|
ARCHIVE_EXPIRY = 90.days
|
|
|
|
def perform(dry_run: false)
|
|
return unless Rails.application.config.app_mode.managed?
|
|
|
|
families = Family.inactive_trial_for_cleanup.limit(BATCH_SIZE)
|
|
count = families.count
|
|
|
|
if count == 0
|
|
Rails.logger.info("InactiveFamilyCleanerJob: No inactive families to clean up")
|
|
return
|
|
end
|
|
|
|
Rails.logger.info("InactiveFamilyCleanerJob: Found #{count} inactive families to clean up#{' (dry run)' if dry_run}")
|
|
|
|
families.find_each do |family|
|
|
if family.requires_data_archive?
|
|
if dry_run
|
|
Rails.logger.info("InactiveFamilyCleanerJob: Would archive data for family #{family.id}")
|
|
else
|
|
archive_family_data(family)
|
|
end
|
|
end
|
|
|
|
if dry_run
|
|
Rails.logger.info("InactiveFamilyCleanerJob: Would destroy family #{family.id} (created: #{family.created_at})")
|
|
else
|
|
Rails.logger.info("InactiveFamilyCleanerJob: Destroying family #{family.id} (created: #{family.created_at})")
|
|
family.destroy
|
|
end
|
|
end
|
|
|
|
Rails.logger.info("InactiveFamilyCleanerJob: Completed cleanup of #{count} families#{' (dry run)' if dry_run}")
|
|
end
|
|
|
|
private
|
|
|
|
def archive_family_data(family)
|
|
export_data = Family::DataExporter.new(family).generate_export
|
|
email = family.users.order(:created_at).first&.email
|
|
|
|
ActiveRecord::Base.transaction do
|
|
archive = ArchivedExport.create!(
|
|
email: email || "unknown",
|
|
family_name: family.name,
|
|
expires_at: ARCHIVE_EXPIRY.from_now
|
|
)
|
|
|
|
archive.export_file.attach(
|
|
io: export_data,
|
|
filename: "sure_archive_#{family.id}.zip",
|
|
content_type: "application/zip"
|
|
)
|
|
|
|
raise ActiveRecord::Rollback, "File attach failed" unless archive.export_file.attached?
|
|
|
|
Rails.logger.info("InactiveFamilyCleanerJob: Archived data for family #{family.id} (email: #{email}, token_digest: #{archive.download_token_digest.first(8)}...)")
|
|
end
|
|
end
|
|
end
|