Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
443 lines
17 KiB
PL/PgSQL
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;
|