mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
Add post-trial inactive Family cleanup with data archival (#1199)
* 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>
This commit is contained in:
29
app/models/archived_export.rb
Normal file
29
app/models/archived_export.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
class ArchivedExport < ApplicationRecord
|
||||
has_one_attached :export_file, dependent: :purge_later
|
||||
|
||||
scope :expired, -> { where(expires_at: ...Time.current) }
|
||||
|
||||
attr_reader :download_token
|
||||
|
||||
before_create :set_download_token_digest
|
||||
|
||||
def downloadable?
|
||||
expires_at > Time.current && export_file.attached?
|
||||
end
|
||||
|
||||
def self.find_by_download_token!(token)
|
||||
find_by!(download_token_digest: digest_token(token))
|
||||
end
|
||||
|
||||
def self.digest_token(token)
|
||||
OpenSSL::Digest::SHA256.hexdigest(token)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_download_token_digest
|
||||
raw_token = SecureRandom.urlsafe_base64(24)
|
||||
@download_token = raw_token
|
||||
self.download_token_digest = self.class.digest_token(raw_token)
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,27 @@
|
||||
module Family::Subscribeable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
CLEANUP_GRACE_PERIOD = 14.days
|
||||
ARCHIVE_TRANSACTION_THRESHOLD = 12
|
||||
ARCHIVE_RECENT_ACTIVITY_WINDOW = 14.days
|
||||
|
||||
included do
|
||||
has_one :subscription, dependent: :destroy
|
||||
|
||||
scope :inactive_trial_for_cleanup, -> {
|
||||
cutoff_with_sub = CLEANUP_GRACE_PERIOD.ago
|
||||
cutoff_without_sub = (Subscription::TRIAL_DAYS.days + CLEANUP_GRACE_PERIOD).ago
|
||||
|
||||
expired_trial = left_joins(:subscription)
|
||||
.where(subscriptions: { status: [ "paused", "trialing" ] })
|
||||
.where(subscriptions: { trial_ends_at: ...cutoff_with_sub })
|
||||
|
||||
no_subscription = left_joins(:subscription)
|
||||
.where(subscriptions: { id: nil })
|
||||
.where(families: { created_at: ...cutoff_without_sub })
|
||||
|
||||
where(id: expired_trial).or(where(id: no_subscription))
|
||||
}
|
||||
end
|
||||
|
||||
def payment_email
|
||||
@@ -85,4 +104,13 @@ module Family::Subscribeable
|
||||
subscription.update!(status: "paused")
|
||||
end
|
||||
end
|
||||
|
||||
def requires_data_archive?
|
||||
return false unless transactions.count > ARCHIVE_TRANSACTION_THRESHOLD
|
||||
|
||||
trial_end = subscription&.trial_ends_at || (created_at + Subscription::TRIAL_DAYS.days)
|
||||
recent_window_start = trial_end - ARCHIVE_RECENT_ACTIVITY_WINDOW
|
||||
|
||||
entries.where(date: recent_window_start..trial_end).exists?
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user