Files
Jyotisha/frontend/supabase/migrations/20260930020000_account_deletion_requests.sql
T
Jesse_ChenandClaude Opus 5.5 3177ffe61d
Independent Staging Quality Gate / validate (push) Successful in 12m54s
Independent Staging Quality Gate / publish (push) Successful in 3m31s
fix(account): deletion freeze trigger reads amount via jsonb so shared inserts don't fail; list the new tables (BUG-1118)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-09-30 10:15:06 +08:00

443 lines
17 KiB
PL/PgSQL

-- Self-service account deletion with a 7-day cooling-off period (2026-09-30).
--
-- request_account_deletion: records a pending request, signs the user out
-- everywhere (identity sessions deleted). Charges are refused while pending.
-- cancel_account_deletion: within the 7 days, the user signs in again and
-- restores the account.
-- purge_deleted_account: after the 7 days, in ONE transaction, deletes every
-- personal-content row the user owns and anonymises the identity. Financial
-- records (orders, credit ledger, usage, subscriptions, billing) are kept
-- for bookkeeping; their user_id then points at an anonymous tombstone
-- identity with no email, name or login. A failure rolls everything back;
-- the worker retries.
--
-- Add-only: new table, new functions, new triggers. No existing column,
-- constraint or function changes.
begin;
do $migration$
begin
if current_user <> 'schema_owner' then
raise exception 'account_deletion_requests_requires_schema_owner'
using errcode = '42501';
end if;
end
$migration$;
create table if not exists public.account_deletion_requests (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
status text not null default 'pending'
check (status in ('pending', 'cancelled', 'completed')),
requested_at timestamptz not null default now(),
scheduled_for timestamptz not null,
cancelled_at timestamptz,
completed_at timestamptz,
attempt_count integer not null default 0 check (attempt_count >= 0),
error_code text
check (error_code is null or error_code ~ '^[a-z][a-z0-9_]{0,63}$'),
-- Counts per table only; never names, emails or content.
outcome jsonb,
updated_at timestamptz not null default now(),
constraint account_deletion_requests_schedule_check
check (scheduled_for > requested_at),
constraint account_deletion_requests_cancelled_check
check ((status = 'cancelled') = (cancelled_at is not null)),
constraint account_deletion_requests_completed_check
check ((status = 'completed') = (completed_at is not null))
);
create unique index if not exists account_deletion_requests_one_pending_idx
on public.account_deletion_requests (user_id)
where status = 'pending';
create index if not exists account_deletion_requests_due_idx
on public.account_deletion_requests (scheduled_for)
where status = 'pending';
alter table public.account_deletion_requests enable row level security;
revoke all on table public.account_deletion_requests
from public, anon, authenticated, service_role;
revoke all on table public.account_deletion_requests
from app_runtime, admin_runtime, migration_runner, backup_reader;
drop policy if exists account_deletion_requests_select_own
on public.account_deletion_requests;
create policy account_deletion_requests_select_own
on public.account_deletion_requests
for select
to authenticated
using (auth.uid() = user_id);
grant select on table public.account_deletion_requests to authenticated;
grant select, insert, update on table public.account_deletion_requests to service_role;
-- Operators read the list (status and dates only; the table holds no email).
do $$
begin
if exists (select 1 from pg_roles where rolname = 'admin_runtime') then
grant select on table public.account_deletion_requests to admin_runtime;
drop policy if exists account_deletion_requests_admin_runtime_read on public.account_deletion_requests;
create policy account_deletion_requests_admin_runtime_read
on public.account_deletion_requests
for select to admin_runtime using (true);
end if;
end;
$$;
-- Tables whose rows are KEPT (de-identified through the tombstone identity).
-- Everything else in public that belongs to the user is deleted.
create or replace function public.account_deletion_kept_tables()
returns text[]
language sql
immutable
set search_path = pg_catalog
as $$
select array[
'account_deletion_requests',
'admin_session_revocations',
'admin_user_roles',
'admin_users',
'birth_time_rectification_billing',
'consultation_requests',
'credit_request_cancellations',
'credit_transactions',
'payment_orders',
'pricing_experiment_events',
'redemption_attempts',
'redemption_codes',
'usage_ledger',
'usage_reservations',
'user_product_redemptions',
'user_subscriptions',
-- Complaints and feedback are kept for handling records (2026-09-30,
-- product decision); the typed-in contact is cleared at purge below.
'user_feedback'
]::text[];
$$;
create or replace function public.account_deletion_scheduled_for(p_user_id uuid)
returns timestamptz
language sql
stable
security definer
set search_path = pg_catalog, public
as $$
select scheduled_for
from public.account_deletion_requests
where user_id = p_user_id and status = 'pending'
limit 1;
$$;
create or replace function public.request_account_deletion(p_user_id uuid)
returns jsonb
language plpgsql
security definer
set search_path = pg_catalog, public
as $$
declare
v_row public.account_deletion_requests%rowtype;
begin
if p_user_id is null then
raise exception 'account_deletion_user_required' using errcode = '22023';
end if;
if not exists (select 1 from auth.users where id = p_user_id) then
raise exception 'account_deletion_user_not_found' using errcode = '22023';
end if;
-- Operators are removed through the admin role flow, not self-service.
if exists (
select 1 from public.admin_users
where user_id = p_user_id and revoked_at is null
) then
raise exception 'account_deletion_admin_account' using errcode = '42501';
end if;
select * into v_row
from public.account_deletion_requests
where user_id = p_user_id and status = 'pending'
for update;
if not found then
insert into public.account_deletion_requests (user_id, status, requested_at, scheduled_for)
values (p_user_id, 'pending', now(), now() + interval '7 days')
returning * into v_row;
end if;
-- Signed out everywhere, at once.
if to_regclass('identity.sessions') is not null then
execute 'delete from identity.sessions where user_id = $1' using p_user_id;
end if;
return jsonb_build_object(
'status', v_row.status,
'requestedAt', v_row.requested_at,
'scheduledFor', v_row.scheduled_for
);
end;
$$;
create or replace function public.cancel_account_deletion(p_user_id uuid)
returns boolean
language plpgsql
security definer
set search_path = pg_catalog, public
as $$
declare
v_count integer;
begin
update public.account_deletion_requests
set status = 'cancelled',
cancelled_at = now(),
updated_at = now()
where user_id = p_user_id
and status = 'pending'
and scheduled_for > now();
get diagnostics v_count = row_count;
return v_count > 0;
end;
$$;
-- Charges are refused while a deletion is pending: a second line behind the
-- routes' own check, so no code path can bill a frozen account.
create or replace function public.refuse_charge_while_deletion_pending()
returns trigger
language plpgsql
security definer
set search_path = pg_catalog, public
as $$
begin
-- The same function guards three tables, and only credit_transactions has an
-- amount column: read it through jsonb, because `new.amount` fails at run
-- time on the other two even behind the table-name test.
if tg_table_name = 'credit_transactions'
and coalesce((to_jsonb(new) ->> 'amount')::integer, 0) >= 0 then
return new;
end if;
if exists (
select 1 from public.account_deletion_requests
where user_id = new.user_id and status = 'pending'
) then
raise exception 'account_deletion_pending' using errcode = '55000';
end if;
return new;
end;
$$;
drop trigger if exists usage_reservations_refuse_while_deletion_pending on public.usage_reservations;
create trigger usage_reservations_refuse_while_deletion_pending
before insert on public.usage_reservations
for each row execute function public.refuse_charge_while_deletion_pending();
drop trigger if exists rectification_billing_refuse_while_deletion_pending on public.birth_time_rectification_billing;
create trigger rectification_billing_refuse_while_deletion_pending
before insert on public.birth_time_rectification_billing
for each row execute function public.refuse_charge_while_deletion_pending();
drop trigger if exists credit_transactions_refuse_debit_while_deletion_pending on public.credit_transactions;
create trigger credit_transactions_refuse_debit_while_deletion_pending
before insert on public.credit_transactions
for each row execute function public.refuse_charge_while_deletion_pending();
-- The permanent step. Idempotent and all-or-nothing: rows are deleted in
-- repeated passes (so a child table blocked by a restrict foreign key is
-- retried after its parent is gone); if anything the user owns is still
-- there at the end, the whole transaction is rolled back.
create or replace function public.purge_deleted_account(p_request_id uuid)
returns jsonb
language plpgsql
security definer
set search_path = pg_catalog, public
as $$
declare
v_request public.account_deletion_requests%rowtype;
v_user uuid;
v_kept text[] := public.account_deletion_kept_tables();
v_target record;
v_pass integer;
v_progress boolean;
v_deleted jsonb := '{}'::jsonb;
v_count bigint;
v_remaining bigint;
v_left text[] := array[]::text[];
begin
select * into v_request
from public.account_deletion_requests
where id = p_request_id
for update;
if not found then
raise exception 'account_deletion_request_not_found' using errcode = '22023';
end if;
if v_request.status <> 'pending' then
return jsonb_build_object('status', 'skipped', 'reason', v_request.status);
end if;
if v_request.scheduled_for > now() then
return jsonb_build_object('status', 'skipped', 'reason', 'not_due');
end if;
v_user := v_request.user_id;
-- Every public base table that holds the user's rows: by a user_id foreign
-- key to auth.users (and profiles.id), or by a uuid user_id column without one.
create temporary table if not exists account_purge_targets (
table_name text not null,
column_name text not null,
primary key (table_name, column_name)
) on commit drop;
truncate account_purge_targets;
insert into account_purge_targets (table_name, column_name)
select distinct cls.relname, att.attname
from pg_constraint con
join pg_class cls on cls.oid = con.conrelid
join pg_namespace nsp on nsp.oid = cls.relnamespace
join pg_attribute att on att.attrelid = con.conrelid and att.attnum = con.conkey[1]
where con.contype = 'f'
and con.confrelid = 'auth.users'::regclass
and array_length(con.conkey, 1) = 1
and nsp.nspname = 'public'
and cls.relkind = 'r'
-- Ownership columns only. created_by / updated_by / assigned_by point at
-- operators who authored configuration; those rows are not the user's.
and (att.attname = 'user_id' or (cls.relname = 'profiles' and att.attname = 'id'))
and not (cls.relname = any (v_kept))
on conflict do nothing;
insert into account_purge_targets (table_name, column_name)
select col.table_name, col.column_name
from information_schema.columns col
join information_schema.tables tab
on tab.table_schema = col.table_schema and tab.table_name = col.table_name
where col.table_schema = 'public'
and col.column_name = 'user_id'
and col.data_type = 'uuid'
and tab.table_type = 'BASE TABLE'
and not (col.table_name = any (v_kept))
on conflict do nothing;
for v_pass in 1..6 loop
v_progress := false;
for v_target in select table_name, column_name from account_purge_targets order by table_name loop
begin
execute format('delete from public.%I where %I = $1', v_target.table_name, v_target.column_name)
using v_user;
get diagnostics v_count = row_count;
if v_count > 0 then
v_progress := true;
v_deleted := jsonb_set(
v_deleted,
array[v_target.table_name],
to_jsonb(coalesce((v_deleted ->> v_target.table_name)::bigint, 0) + v_count)
);
end if;
exception when foreign_key_violation then
-- A kept or not-yet-deleted row still points here; try again next pass.
null;
end;
end loop;
exit when not v_progress;
end loop;
for v_target in select table_name, column_name from account_purge_targets loop
execute format('select count(*) from public.%I where %I = $1', v_target.table_name, v_target.column_name)
into v_remaining using v_user;
if v_remaining > 0 then
v_left := v_left || v_target.table_name;
end if;
end loop;
if array_length(v_left, 1) > 0 then
raise exception 'account_deletion_purge_incomplete: %', array_to_string(v_left, ',')
using errcode = '55000';
end if;
-- Kept complaints lose the contact the user typed in (it may be a phone or
-- an email); the body stays for the handling record.
if to_regclass('public.user_feedback') is not null then
execute 'update public.user_feedback set contact = null, updated_at = now() where user_id = $1'
using v_user;
end if;
-- The identity becomes an anonymous tombstone: no email, name, image,
-- login method, session or pending verification. Kept financial rows point
-- at it and at nothing else.
if to_regclass('identity.users') is not null then
execute 'delete from identity.sessions where user_id = $1' using v_user;
execute 'delete from identity.accounts where user_id = $1' using v_user;
if to_regclass('identity.two_factors') is not null then
execute 'delete from identity.two_factors where user_id = $1' using v_user;
end if;
execute 'delete from identity.verifications where identifier in (
select email from identity.users where id = $1
union all select ''sign-in-otp-'' || email from identity.users where id = $1
union all select ''email-verification-otp-'' || email from identity.users where id = $1
union all select ''forget-password-otp-'' || email from identity.users where id = $1)'
using v_user;
execute 'update identity.users
set name = ''已注销用户'',
email = ''deleted+'' || id::text || ''@deleted.invalid'',
email_verified = false,
email_verified_at = null,
image = null,
banned = true,
ban_reason = ''account_deleted'',
updated_at = now()
where id = $1'
using v_user;
end if;
-- The identity trigger mirrors the email; the metadata is cleared here too,
-- and directly when there is no identity schema.
update auth.users
set email = 'deleted+' || id::text || '@deleted.invalid',
raw_user_meta_data = '{}'::jsonb,
email_confirmed_at = null,
updated_at = now()
where id = v_user;
update public.account_deletion_requests
set status = 'completed',
completed_at = now(),
error_code = null,
outcome = jsonb_build_object('deleted', v_deleted, 'kept', to_jsonb(v_kept)),
updated_at = now()
where id = p_request_id;
return jsonb_build_object('status', 'completed', 'deleted', v_deleted);
end;
$$;
-- The worker records a failed attempt outside the rolled-back purge.
create or replace function public.mark_account_deletion_attempt_failed(p_request_id uuid, p_error_code text)
returns void
language plpgsql
security definer
set search_path = pg_catalog, public
as $$
begin
update public.account_deletion_requests
set attempt_count = attempt_count + 1,
error_code = case
when p_error_code ~ '^[a-z][a-z0-9_]{0,63}$' then p_error_code
else 'purge_failed'
end,
updated_at = now()
where id = p_request_id and status = 'pending';
end;
$$;
revoke all on function public.account_deletion_kept_tables() from public, anon, authenticated;
revoke all on function public.account_deletion_scheduled_for(uuid) from public, anon, authenticated;
revoke all on function public.request_account_deletion(uuid) from public, anon, authenticated;
revoke all on function public.cancel_account_deletion(uuid) from public, anon, authenticated;
revoke all on function public.refuse_charge_while_deletion_pending() from public, anon, authenticated;
revoke all on function public.purge_deleted_account(uuid) from public, anon, authenticated;
revoke all on function public.mark_account_deletion_attempt_failed(uuid, text) from public, anon, authenticated;
grant execute on function public.account_deletion_kept_tables() to service_role;
grant execute on function public.account_deletion_scheduled_for(uuid) to service_role;
grant execute on function public.request_account_deletion(uuid) to service_role;
grant execute on function public.cancel_account_deletion(uuid) to service_role;
grant execute on function public.purge_deleted_account(uuid) to service_role;
grant execute on function public.mark_account_deletion_attempt_failed(uuid, text) to service_role;
commit;