data deletion oneshot (#691)

This commit is contained in:
Echo
2025-12-07 22:49:40 -05:00
committed by GitHub
parent f05f0e2fae
commit 64f9d9cb34
19 changed files with 615 additions and 18 deletions

View File

@@ -5,8 +5,6 @@ body {
main {
flex: 1;
margin-left: 250px;
max-width: calc(100% - 250px);
padding: 20px;
margin-bottom: 100px;
transition: margin-left 0.3s ease, max-width 0.3s ease;

View File

@@ -0,0 +1,35 @@
class Admin::DeletionRequestsController < Admin::BaseController
before_action :set_deletion_request, only: [ :show, :approve, :reject ]
before_action :require_admin, only: [ :index, :show, :approve, :reject ]
def index
@pending = DeletionRequest.pending.includes(:user).order(requested_at: :asc)
@approved = DeletionRequest.approved.includes(:user, :admin_approved_by).order(scheduled_deletion_at: :asc)
@done = DeletionRequest.completed.includes(:user, :admin_approved_by).order(completed_at: :desc).limit(25)
end
def show
end
def approve
@deletion_request.approve!(current_user)
redirect_to admin_deletion_requests_path, notice: "they gonna go kerblam on #{@deletion_request.scheduled_deletion_at.strftime('%B %d, %Y')}."
end
def reject
@deletion_request.cancel!
redirect_to admin_deletion_requests_path, notice: "ratioed + stay mad"
end
private
def set_deletion_request
@deletion_request = DeletionRequest.find(params[:id])
end
def require_admin
unless current_user && current_user.admin_level.in?([ "superadmin" ])
redirect_to root_path, alert: "no perms lmaooo"
end
end
end

View File

@@ -1,6 +1,8 @@
class Api::Hackatime::V1::HackatimeController < ApplicationController
before_action :set_user
skip_before_action :verify_authenticity_token
skip_before_action :enforce_lockout
before_action :check_lockout, only: [ :push_heartbeats ]
before_action :set_raw_heartbeat_upload, only: [ :push_heartbeats ], if: :is_blank?
def push_heartbeats
@@ -309,6 +311,11 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
Rails.logger.error("Error queuing heartbeat public activity: #{e.class.name} #{e.message}")
end
def check_lockout
return unless @user&.pending_deletion?
render json: { error: "Account pending deletion" }, status: :forbidden
end
def set_user
api_header = request.headers["Authorization"]
raw_token = api_header&.split(" ")&.last

View File

@@ -5,6 +5,7 @@ class ApplicationController < ActionController::Base
before_action :try_rack_mini_profiler_enable
before_action :track_request
before_action :set_public_activity
before_action :enforce_lockout
after_action :track_action
around_action :switch_time_zone, if: :current_user
@@ -64,6 +65,12 @@ class ApplicationController < ActionController::Base
end
end
def enforce_lockout
return unless current_user&.pending_deletion?
return if %w[deletion_requests sessions].include?(controller_name)
redirect_to deletion_path
end
def initialize_cache_counters
Thread.current[:cache_hits] = 0
Thread.current[:cache_misses] = 0

View File

@@ -0,0 +1,39 @@
class DeletionRequestsController < ApplicationController
before_action :require_login
before_action :check_can_request, only: [ :create ]
def show
@deletion_request = current_user.active_deletion_request
redirect_to root_path, alert: "no request" unless @deletion_request
end
def create
@deletion_request = DeletionRequest.create_for_user!(current_user)
redirect_to deletion_path
rescue ActiveRecord::RecordInvalid => e
Sentry.capture_exception(e)
redirect_to my_settings_path
end
def cancel
@deletion_request = current_user.active_deletion_request
if @deletion_request&.can_be_cancelled?
@deletion_request.cancel!
redirect_to my_settings_path, notice: "Your deletion request has been cancelled!"
else
redirect_to deletion_path
end
end
private
def require_login
redirect_to root_path, alert: "who?" unless current_user
end
def check_can_request
unless current_user.can_request_deletion?
redirect_to my_settings_path, alert: "You can't request deletion right now."
end
end
end

View File

@@ -0,0 +1,18 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
connect() {
console.log("AccountDeletionController connected");
}
confirm(event) {
event.preventDefault();
console.log("AccountDeletionController#confirm called");
const modal = document.getElementById("account-deletion-confirm-modal");
if (modal) {
modal.dispatchEvent(new CustomEvent("modal:open", { bubbles: true }));
} else {
console.error("Modal not found: account-deletion-confirm-modal");
}
}
}

View File

@@ -0,0 +1,20 @@
class ProcessAccountDeletionsJob < ApplicationJob
queue_as :default
def perform
DeletionRequest.ready_for_deletion.find_each do |deletion_request|
Rails.logger.info "kerblamming ##{deletion_request.user_id}"
begin
AnonymizeUserService.call(deletion_request.user)
deletion_request.complete!
Rails.logger.info "kerblamed account ##{deletion_request.user_id}"
rescue StandardError => e
Sentry.capture_exception(e, extra: { user_id: deletion_request.user_id })
Rails.logger.error "failed to kerblam ##{deletion_request.user_id}: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
end
end
end
end

View File

@@ -0,0 +1,70 @@
class DeletionRequest < ApplicationRecord
belongs_to :user
belongs_to :admin_approved_by, class_name: "User", optional: true
enum :status, {
pending: 0,
approved: 1,
cancelled: 2,
completed: 3
}
validates :requested_at, presence: true
validate :user_not_banned_from_deletion, on: :create
scope :active, -> { where(status: [ :pending, :approved ]) }
scope :ready_for_deletion, -> { approved.where("scheduled_deletion_at <= ?", Time.current) }
def self.create_for_user!(user)
create!(
user: user,
requested_at: Time.current,
status: :pending
)
end
def approve!(admin)
update!(
status: :approved,
admin_approved_by: admin,
admin_approved_at: Time.current,
scheduled_deletion_at: Time.current + 30.days # grace period, if shit changes, change this
)
end
def cancel!
update!(
status: :cancelled,
cancelled_at: Time.current
)
end
def complete!
update!(
status: :completed,
completed_at: Time.current
)
end
def days_until_deletion
return nil unless scheduled_deletion_at.present?
[ (scheduled_deletion_at.to_date - Date.current).to_i, 0 ].max
end
def can_be_cancelled?
pending? || approved?
end
private
def user_not_banned_from_deletion
return unless user.present?
if user.red?
last_audit = user.trust_level_audit_logs.order(created_at: :desc).first
if last_audit && last_audit.created_at > 365.days.ago
errors.add(:base, "You can not request data deletion due to a recent ban")
end
end
end
end

View File

@@ -9,7 +9,8 @@ class EmailAddress < ApplicationRecord
enum :source, {
signing_in: 0,
github: 1,
slack: 2
slack: 2,
preserved_for_deletion: 3
}, prefix: true
before_validation :downcase_email

View File

@@ -118,6 +118,8 @@ class User < ApplicationRecord
has_many :trust_level_audit_logs, dependent: :destroy
has_many :trust_level_changes_made, class_name: "TrustLevelAuditLog", foreign_key: "changed_by_id", dependent: :destroy
has_many :deletion_requests, dependent: :destroy
has_many :deletion_approvals, class_name: "DeletionRequest", foreign_key: "admin_approved_by_id"
has_many :access_grants,
class_name: "Doorkeeper::AccessGrant",
@@ -133,6 +135,24 @@ class User < ApplicationRecord
@streak_days ||= heartbeats.daily_streaks_for_users([ id ]).values.first
end
def active_deletion_request
deletion_requests.active.order(created_at: :desc).first
end
def pending_deletion?
active_deletion_request.present?
end
def can_request_deletion?
return false if pending_deletion?
return true unless red?
last_audit = trust_level_audit_logs.order(created_at: :desc).first
return true unless last_audit
last_audit.created_at <= 365.days.ago
end
if Rails.env.development?
def self.slow_find_by_email(email)
# This is an n+1 query, but provided for developer convenience

View File

@@ -0,0 +1,70 @@
class AnonymizeUserService
def self.call(user)
new(user).call
end
def initialize(user)
@user = user
end
def call
ActiveRecord::Base.transaction do
preserve_emails_for_ban_tracking
anonymize_user_data
destroy_associated_records
invalidate_sessions
end
end
private
attr_reader :user
def preserve_emails_for_ban_tracking
user.email_addresses.update_all(
user_id: user.id,
source: EmailAddress.sources[:preserved_for_deletion]
)
end
def anonymize_user_data
user.update!(
slack_uid: nil,
slack_username: nil,
slack_avatar_url: nil,
slack_access_token: nil,
slack_scopes: [],
slack_neighborhood_channel: nil,
github_uid: nil,
github_username: nil,
github_avatar_url: nil,
github_access_token: nil,
hca_id: nil,
hca_access_token: nil,
hca_scopes: [],
username: "deleted_user_#{user.id}",
uses_slack_status: false,
country_code: nil,
mailing_address_otc: nil,
deprecated_name: nil
)
end
def destroy_associated_records
user.api_keys.destroy_all
user.admin_api_keys.destroy_all
user.sign_in_tokens.destroy_all
user.email_verification_requests.destroy_all
user.wakatime_mirrors.destroy_all
user.project_repo_mappings.destroy_all
user.mailing_address&.destroy
user.heartbeats.destroy_all
user.access_grants.destroy_all
user.access_tokens.destroy_all
end
def invalidate_sessions
user.sign_in_tokens.destroy_all
end
end

View File

@@ -0,0 +1,131 @@
<div class="max-w-6xl mx-auto p-6 space-y-6">
<header class="text-center mb-8">
<h1 class="text-4xl font-bold text-white mb-2">gdpr nerds</h1>
</header>
<div class="border border-primary rounded-xl p-6 bg-dark">
<h2 class="text-2xl font-semibold text-yellow-400 mb-4">approval queue (<%= @pending.count %>)</h2>
<% if @pending.any? %>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead>
<tr class="border-b border-gray-700">
<th class="py-3 px-4">goober</th>
<th class="py-3 px-4">email</th>
<th class="py-3 px-4">date</th>
<th class="py-3 px-4">trust</th>
<th class="py-3 px-4">exec</th>
</tr>
</thead>
<tbody>
<% @pending.each do |request| %>
<tr class="border-b border-gray-800 hover:bg-gray-800/50">
<td class="py-3 px-4">
<div class="flex items-center gap-2">
<img src="<%= request.user.avatar_url %>" alt="Avatar" class="w-8 h-8 rounded-full">
<span class="text-white"><%= request.user.display_name %></span>
</div>
</td>
<td class="py-3 px-4 text-gray-300"><%= request.user.email_addresses.first&.email || "N/A" %></td>
<td class="py-3 px-4 text-gray-300"><%= time_ago_in_words(request.requested_at) %> ago</td>
<td class="py-3 px-4">
<span class="px-2 py-1 rounded text-xs font-medium
<%= case request.user.trust_level
when 'green' then 'bg-green-600/20 text-green-400'
when 'yellow' then 'bg-yellow-600/20 text-yellow-400'
when 'red' then 'bg-red-600/20 text-red-400'
else 'bg-blue-600/20 text-blue-400'
end %>">
<%= request.user.trust_level %>
</span>
</td>
<td class="py-3 px-4">
<div class="flex gap-2">
<%= button_to "yuh", approve_admin_deletion_request_path(request),
method: :post,
class: "px-3 py-1 bg-green-600 hover:bg-green-500 text-white text-sm font-medium rounded transition-colors cursor-pointer" %>
<%= button_to "nah", reject_admin_deletion_request_path(request),
method: :post,
class: "px-3 py-1 bg-red-600 hover:bg-red-500 text-white text-sm font-medium rounded transition-colors cursor-pointer",
data: { confirm: "yo " } %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="text-gray-400">nuthing here</p>
<% end %>
</div>
<div class="border border-primary rounded-xl p-6 bg-dark">
<h2 class="text-2xl font-semibold text-red-400 mb-4">accounts waiting to go kerblam (<%= @approved.count %>)</h2>
<% if @approved.any? %>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead>
<tr class="border-b border-gray-700">
<th class="py-3 px-4">goober</th>
<th class="py-3 px-4">approver</th>
<th class="py-3 px-4">approved</th>
<th class="py-3 px-4">exploded</th>
<th class="py-3 px-4">eta</th>
</tr>
</thead>
<tbody>
<% @approved.each do |request| %>
<tr class="border-b border-gray-800 hover:bg-gray-800/50">
<td class="py-3 px-4">
<div class="flex items-center gap-2">
<img src="<%= request.user.avatar_url %>" alt="Avatar" class="w-8 h-8 rounded-full">
<span class="text-white"><%= request.user.display_name %></span>
</div>
</td>
<td class="py-3 px-4 text-gray-300"><%= request.admin_approved_by&.display_name || "N/A" %></td>
<td class="py-3 px-4 text-gray-300"><%= request.admin_approved_at&.strftime("%b %d, %Y") %></td>
<td class="py-3 px-4 text-red-400"><%= request.scheduled_deletion_at&.strftime("%b %d, %Y") %></td>
<td class="py-3 px-4">
<span class="px-2 py-1 rounded text-xs font-medium bg-red-600/20 text-red-400">
<%= request.days_until_deletion %> days
</span>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="text-gray-400">nuthing here</p>
<% end %>
</div>
<div class="border border-primary rounded-xl p-6 bg-dark">
<h2 class="text-2xl font-semibold text-gray-400 mb-4">recently kerblamed</h2>
<% if @done.any? %>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead>
<tr class="border-b border-gray-700">
<th class="py-3 px-4">goober</th>
<th class="py-3 px-4">approver</th>
<th class="py-3 px-4">kerblamed</th>
</tr>
</thead>
<tbody>
<% @done.each do |request| %>
<tr class="border-b border-gray-800 hover:bg-gray-800/50">
<td class="py-3 px-4 text-gray-300">#<%= request.user_id %></td>
<td class="py-3 px-4 text-gray-300"><%= request.admin_approved_by&.display_name || "N/A" %></td>
<td class="py-3 px-4 text-gray-300"><%= request.completed_at&.strftime("%b %d, %Y at %I:%M %p") %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="text-gray-400">nuthing here</p>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,77 @@
<% content_for :title do %>
Account Deletion Pending
<% end %>
<% content_for :hide_nav, true %>
<div class="flex items-center justify-center p-6">
<div class="max-w-2xl w-full space-y-6">
<header class="text-center mb-8">
<h1 class="text-4xl font-bold text-white mb-2">Account Scheduled for Deletion</h1>
<p class="text-muted text-lg">Your account is scheduled to self-destruct soon.</p>
</header>
<div class="border border-primary rounded-xl p-6 bg-dark">
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-gray-300">Status</span>
<span class="px-3 py-1 rounded-full text-sm font-medium
<%= @deletion_request.approved? ? 'bg-green-600/20 text-green-400' : 'bg-yellow-600/20 text-yellow-400' %>">
<%= @deletion_request.status.humanize %>
</span>
</div>
<div class="flex items-center justify-between ">
<span class="text-gray-300">Requested</span>
<span class="text-white"><%= @deletion_request.requested_at.strftime("%B %d, %Y at %I:%M %p") %></span>
</div>
<% if @deletion_request.pending? %>
<div class="p-4 bg-yellow-900/30 border border-yellow-700 rounded">
<p class="text-yellow-200">
Your deletion request is pending approval. During this time, we will review your request and get back to you as soon as possible.
</p>
</div>
<% elsif @deletion_request.approved? %>
<div class="flex items-center justify-between">
<span class="text-gray-300">Deletion Date</span>
<span class="text-red-400 font-semibold">
<%= @deletion_request.scheduled_deletion_at.strftime("%B %d, %Y") %>
(<%= @deletion_request.days_until_deletion %> days remaining)
</span>
</div>
<div class="p-4 bg-red-900/30 border border-red-700 rounded">
<p class="text-red-200">
Your account will be permanently deleted on <%= @deletion_request.scheduled_deletion_at.strftime("%B %d, %Y") %>.
After deletion, your email address will be retained on file, but all other personal information will be removed or anonymized.
</p>
</div>
<% end %>
<h3 class="text-white font-medium mb-2">Important Information</h3>
<ul class="text-gray-300 text-sm space-y-2">
<li>• During the 30-day waiting period, you cannot upload data, download data, or use your account for Hack Club programs.</li>
<li>• You can cancel this request at any time before the deletion date.</li>
<li>• After deletion, your email address will be retained to prevent ban evasion.</li>
</ul>
</div>
<div class="mt-6 grid grid-cols-2 gap-3 items-center">
<%= link_to signout_path, method: :delete, class: "w-full inline-flex justify-center px-4 py-3 border border-gray-600 text-gray-300 hover:bg-darkless font-medium rounded text-center transition-colors duration-200" do %>
Return to Login
<% end %>
<% if @deletion_request.can_be_cancelled? %>
<%= button_to "I changed my mind",
cancel_deletion_path,
method: :delete,
form: { class: "w-full" },
class: "w-full px-4 py-3 bg-primary hover:bg-red-600 text-white font-medium rounded text-center transition-colors duration-200 cursor-pointer" %>
<% else %>
<div class="w-full"></div>
<% end %>
</div>
</div>
</div>
</div>

View File

@@ -177,6 +177,7 @@
</head>
<body class="<%= content_for(:body_class) %> flex min-h-screen bg-darker" data-controller="nav">
<% unless content_for?(:hide_nav) %>
<button class="mobile-nav-button"
data-action="click->nav#toggle"
data-nav-target="button"
@@ -186,13 +187,12 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div class="nav-overlay" data-nav-target="overlay" data-action="click->nav#close"></div>
<%= render "shared/nav" %>
<% end %>
<!-- 250px is defined in nav.css -->
<main class="flex-1 lg:ml-[250px] lg:max-w-[calc(100%-250px)] p-5 mb-[100px] pt-16 lg:pt-5 transition-all duration-300 ease-in-out">
<main class="flex-1 <%= 'lg:ml-[250px] lg:max-w-[calc(100%-250px)]' unless content_for?(:hide_nav) %> p-5 mb-[100px] pt-16 lg:pt-5 transition-all duration-300 ease-in-out">
<%= yield %>
<footer class="relative w-full mt-12 mb-5 p-2.5 text-center text-xs text-gray-600 hover:text-gray-300 transition-colors duration-200">
<div class="container">

View File

@@ -166,6 +166,31 @@
<p class="text-xs text-gray-400">When enabled, others can view your coding statistics through public APIs. Many Hack Club YSWS programs use this to track your progress. Disabling this can prevent you from participating in some programs.</p>
<%= f.submit "Save Settings", class: "w-full px-4 py-2 bg-primary text-white font-medium rounded transition-colors duration-200 cursor-pointer" %>
<% end %>
<div class="border-t border-gray-700 pt-4 mt-4 space-y-3">
<div class="flex items-center gap-3">
<div class="p-2 bg-red-600/10 rounded">
<span class="text-2xl">🗑️</span>
</div>
<h3 class="text-lg font-semibold text-white" id="delete_account">Delete Account</h3>
</div>
<% if @user.can_request_deletion? %>
<p class="text-gray-300 text-sm">
Permanently delete your account and all associated data. This action cannot be undone after the 30-day grace period.
</p>
<button type="button"
data-controller="account-deletion"
data-action="click->account-deletion#confirm"
class="w-full px-4 py-2 bg-primary text-white font-medium rounded cursor-pointer">
Request Account Deletion
</button>
<% else %>
<p class="text-white text-sm">
Due to your account standing, you cannot request account deletion at this time. Reach out in #hackatime-v2 if this is a mistake.
</p>
<% end %>
</div>
</div>
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
@@ -592,3 +617,29 @@
],
custom: '<div class="w-full mb-4"><div class="bg-darker rounded-lg p-3"><code id="new-api-key-display" class="text-sm text-white break-all" data-token=""></code></div></div>' %>
</div>
<% if @user.can_request_deletion? %>
<div data-controller="account-deletion">
<%= render "shared/modal",
modal_id: "account-deletion-confirm-modal",
title: "Delete Your Account?",
description: "This will permanently delete your account after a 30 day waiting period. During this time, you won't be able to use your account for any Hack Club programs.",
icon_svg: '<path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>',
icon_color: "text-primary",
buttons: [
{
text: "Cancel",
class: "border border-gray-600 text-gray-300 hover:bg-darkless",
action: "click->modal#close"
},
{
text: "Delete My Account",
class: "bg-primary text-white hover:bg-red-600 font-medium",
form: true,
url: create_deletion_path,
method: "post"
}
] %>
</div>
<% end %>

View File

@@ -131,6 +131,11 @@ Rails.application.configure do
cleanup_successful_jobs: {
cron: "0 0 * * *",
class: "CleanupSuccessfulJobsJob"
},
process_account_deletions: {
cron: "0 2 * * *",
class: "ProcessAccountDeletionsJob",
description: "nuke accounts after 30 days"
}
# sync_stale_repo_metadata: {
# cron: "0 4 * * *", # Daily at 4 AM

View File

@@ -39,6 +39,12 @@ Rails.application.routes.draw do
resources :trust_level_audit_logs, only: [ :index, :show ]
resources :admin_api_keys, except: [ :edit, :update ]
resources :deletion_requests, only: [ :index, :show ] do
member do
post :approve
post :reject
end
end
end
get "/impersonate/:id", to: "sessions#impersonate", as: :impersonate_user
end
@@ -124,6 +130,10 @@ Rails.application.routes.draw do
end
end
get "deletion", to: "deletion_requests#show", as: :deletion
post "deletion", to: "deletion_requests#create", as: :create_deletion
delete "deletion", to: "deletion_requests#cancel", as: :cancel_deletion
get "my/wakatime_setup", to: "users#wakatime_setup"
get "my/wakatime_setup/step-2", to: "users#wakatime_setup_step_2"
get "my/wakatime_setup/step-3", to: "users#wakatime_setup_step_3"

View File

@@ -0,0 +1,20 @@
class CreateDeletionRequests < ActiveRecord::Migration[8.1]
def change
create_table :deletion_requests do |t|
t.references :user, null: false, foreign_key: true
t.datetime :requested_at, null: false
t.datetime :admin_approved_at
t.bigint :admin_approved_by_id
t.datetime :cancelled_at
t.datetime :scheduled_deletion_at
t.datetime :completed_at
t.integer :status, null: false, default: 0
t.timestamps
end
add_foreign_key :deletion_requests, :users, column: :admin_approved_by_id
add_index :deletion_requests, :status
add_index :deletion_requests, [ :user_id, :status ]
end
end

20
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: 2025_12_05_211711) do
ActiveRecord::Schema[8.1].define(version: 2025_12_08_020226) do
create_schema "pganalyze"
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -116,6 +116,22 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_05_211711) do
t.index ["user_id"], name: "index_commits_on_user_id"
end
create_table "deletion_requests", force: :cascade do |t|
t.datetime "admin_approved_at"
t.bigint "admin_approved_by_id"
t.datetime "cancelled_at"
t.datetime "completed_at"
t.datetime "created_at", null: false
t.datetime "requested_at", null: false
t.datetime "scheduled_deletion_at"
t.integer "status", default: 0, null: false
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
t.index ["status"], name: "index_deletion_requests_on_status"
t.index ["user_id", "status"], name: "index_deletion_requests_on_user_id_and_status"
t.index ["user_id"], name: "index_deletion_requests_on_user_id"
end
create_table "email_addresses", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "email"
@@ -609,6 +625,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_05_211711) do
add_foreign_key "api_keys", "users"
add_foreign_key "commits", "repositories"
add_foreign_key "commits", "users"
add_foreign_key "deletion_requests", "users"
add_foreign_key "deletion_requests", "users", column: "admin_approved_by_id"
add_foreign_key "email_addresses", "users"
add_foreign_key "email_verification_requests", "users"
add_foreign_key "heartbeats", "raw_heartbeat_uploads"