feat(fase-10): account hard-delete — FK relax + RPC (10.5)
Migration 019 normalizes every \`created_by\` / \`updated_by\` FK from \`NOT NULL REFERENCES users(id) ON DELETE RESTRICT\` to \`NULL REFERENCES users(id) ON DELETE SET NULL\`. Affected tables: collectives, collective_invitations, shopping_lists, shopping_items, task_lists, tasks, notes (created_by + updated_by). Without this, delete_account() would be blocked the first time the user creates any content. Spec §6.3: content survives the user; the row is orphaned with the FK set to NULL (UI shows "Deleted user" — to be polished later). Migration 021 adds delete_account(): - two-step delete: public.users (fires the existing promote-oldest- admin trigger from migration 002, then CASCADEs collective_members and SET-NULLs content FKs), then auth.users (CASCADEs sync_conflicts + drops the GoTrue identity row + token rows) - guard: errcode P0003 when the caller is the sole admin of a collective where every other member is a guest (nobody promotable); the user must promote someone manually first - Keycloak is NOT touched (documented limitation in the UI body); the user can re-register with the same email and will receive a fresh UUID-distinct profile Settings UI (already shipped with 10.1) wires the modal: explicit body listing what is and isn't deleted; confirm button only enables when the user types DELETE exactly. Post-success calls logout() so the Keycloak SSO cookie is ended too. pgTAP 013 (8 assertions): RPC exists, regular user delete + cascade + content-orphan, sole-admin-with-promotable promotes, sole-admin-with- only-guest blocked with P0003. Playwright DEL-01 covers the type-the-word UI guard; full "delete + re-login fails" is left to pgTAP because seeding ephemeral Keycloak accounts in E2E would contaminate every downstream suite. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
70
supabase/migrations/021_delete_account_rpc.sql
Normal file
70
supabase/migrations/021_delete_account_rpc.sql
Normal file
@@ -0,0 +1,70 @@
|
||||
-- Migration 021: delete_account() RPC — Fase 10.5.
|
||||
--
|
||||
-- Hard-deletes the caller's account. The content they created stays in the
|
||||
-- collectives (FKs were relaxed in migration 019 to SET NULL).
|
||||
--
|
||||
-- Guard (errcode P0003): if the caller is the only admin of any collective
|
||||
-- AND no eligible member (member or admin, not guest) exists to be
|
||||
-- auto-promoted, the deletion is blocked — otherwise the collective ends
|
||||
-- up with zero admins and nobody can manage it. The user is asked to
|
||||
-- promote someone manually or kick the remaining non-eligible members.
|
||||
--
|
||||
-- Order of deletes matters:
|
||||
-- 1. DELETE FROM public.users — fires `promote_oldest_member_on_admin_leave`
|
||||
-- (migration 002) BEFORE the cascade removes collective_members. That
|
||||
-- trigger promotes the oldest non-departing member to admin. Then the
|
||||
-- cascade removes the user's memberships. Content rows with
|
||||
-- created_by = v_user fall to NULL.
|
||||
-- 2. DELETE FROM auth.users — cascades sync_conflicts, removes the GoTrue
|
||||
-- identity. We do NOT touch Keycloak; the user can re-register with a
|
||||
-- new UUID (documented limitation, see plan/fase-10 §10.5.4).
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_account()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, auth
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user uuid := auth.uid();
|
||||
v_blocked boolean;
|
||||
BEGIN
|
||||
IF v_user IS NULL THEN
|
||||
RAISE EXCEPTION 'not authenticated' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- Pre-flight: is the user the sole admin of any collective that has
|
||||
-- members but no other promotable member?
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.collective_members cm
|
||||
WHERE cm.user_id = v_user
|
||||
AND cm.role = 'admin'
|
||||
-- More than one member in the collective
|
||||
AND (SELECT count(*) FROM public.collective_members c2
|
||||
WHERE c2.collective_id = cm.collective_id) > 1
|
||||
-- And nobody else is admin OR member (only guests + the caller)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.collective_members c3
|
||||
WHERE c3.collective_id = cm.collective_id
|
||||
AND c3.user_id <> v_user
|
||||
AND c3.role IN ('admin', 'member')
|
||||
)
|
||||
) INTO v_blocked;
|
||||
|
||||
IF v_blocked THEN
|
||||
RAISE EXCEPTION 'sole admin with non-promotable members'
|
||||
USING ERRCODE = 'P0003';
|
||||
END IF;
|
||||
|
||||
-- Step 1: public.users — trigger promotes oldest member if needed,
|
||||
-- then cascade clears collective_members, SET NULL on content FKs.
|
||||
DELETE FROM public.users WHERE id = v_user;
|
||||
|
||||
-- Step 2: auth.users — sweeps sync_conflicts (CASCADE) + GoTrue row.
|
||||
DELETE FROM auth.users WHERE id = v_user;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.delete_account() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.delete_account() TO authenticated;
|
||||
Reference in New Issue
Block a user