feat(fase-10): CU-H06 leave collective — RPC + settings UI
Migration 017 adds public.leave_collective(uuid) with three branches: - normal member: remove membership row - sole admin with other members: promote oldest-joined remaining member to admin (inline; the existing user-delete trigger does not cover the membership-delete path), then remove the row - sole member: reject with errcode P0001 so the UI can direct the user to the dissolve flow (CU-H08) Settings page gains a "Your collectives" section listing the user's memberships with a per-row Leave button; the confirmation modal calls the RPC, drops the collective from local stores, and either switches to the next collective or sends the user to /onboarding when none remain. Also seeds the Danger zone scaffolding for Fase 10.5 and adds all message keys consumed by 10.1, 10.3, 10.5 and 10.6. pgTAP 010 (7 assertions): member-leave, sole-admin-leave + auto-promote, sole-member rejected with P0001. Playwright L-01 walks Borja through the UI flow + checks the seed collective survives (content stays). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
84
supabase/migrations/017_leave_collective_rpc.sql
Normal file
84
supabase/migrations/017_leave_collective_rpc.sql
Normal file
@@ -0,0 +1,84 @@
|
||||
-- Migration 017: leave_collective(uuid) RPC — Fase 10.1 (CU-H06).
|
||||
--
|
||||
-- A user abandons one of their collectives. The content stays.
|
||||
--
|
||||
-- If the user is the only admin and other members exist, the existing
|
||||
-- `promote_oldest_member_on_admin_leave` trigger fires on DELETE FROM
|
||||
-- public.users — but that trigger only runs on user-account delete, not
|
||||
-- on collective_members delete. So we replicate the auto-promote logic
|
||||
-- inline before removing the membership row.
|
||||
--
|
||||
-- If the user is the sole member of the collective, leaving is disallowed:
|
||||
-- they must dissolve instead (CU-H08, migration 018). Errcode P0001 lets
|
||||
-- the UI distinguish this case from generic errors and link to the
|
||||
-- dissolve flow.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.leave_collective(p_collective_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, auth
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user uuid := auth.uid();
|
||||
v_member_count int;
|
||||
v_is_admin boolean;
|
||||
v_other_admins int;
|
||||
BEGIN
|
||||
IF v_user IS NULL THEN
|
||||
RAISE EXCEPTION 'not authenticated' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- Guard: caller must actually be a member of the collective.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.collective_members
|
||||
WHERE collective_id = p_collective_id AND user_id = v_user
|
||||
) THEN
|
||||
RAISE EXCEPTION 'not a member' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- How many members in the collective?
|
||||
SELECT count(*) INTO v_member_count
|
||||
FROM public.collective_members
|
||||
WHERE collective_id = p_collective_id;
|
||||
|
||||
IF v_member_count = 1 THEN
|
||||
-- Sole member — must dissolve, not leave.
|
||||
RAISE EXCEPTION 'cannot leave as sole member; dissolve instead'
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
-- Check if the departing user is the only admin.
|
||||
SELECT role = 'admin' INTO v_is_admin
|
||||
FROM public.collective_members
|
||||
WHERE collective_id = p_collective_id AND user_id = v_user;
|
||||
|
||||
IF v_is_admin THEN
|
||||
SELECT count(*) INTO v_other_admins
|
||||
FROM public.collective_members
|
||||
WHERE collective_id = p_collective_id
|
||||
AND user_id <> v_user
|
||||
AND role = 'admin';
|
||||
|
||||
-- If sole admin, promote the oldest remaining (non-departing) member.
|
||||
IF v_other_admins = 0 THEN
|
||||
UPDATE public.collective_members
|
||||
SET role = 'admin'
|
||||
WHERE (collective_id, user_id) = (
|
||||
SELECT collective_id, user_id
|
||||
FROM public.collective_members
|
||||
WHERE collective_id = p_collective_id
|
||||
AND user_id <> v_user
|
||||
ORDER BY joined_at ASC
|
||||
LIMIT 1
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
DELETE FROM public.collective_members
|
||||
WHERE collective_id = p_collective_id AND user_id = v_user;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.leave_collective(uuid) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.leave_collective(uuid) TO authenticated;
|
||||
111
supabase/tests/010_leave_collective.sql
Normal file
111
supabase/tests/010_leave_collective.sql
Normal file
@@ -0,0 +1,111 @@
|
||||
-- pgTAP: leave_collective() RPC — Fase 10.1
|
||||
-- Run with: psql -U postgres -d postgres -f supabase/tests/010_leave_collective.sql
|
||||
--
|
||||
-- Covers CU-H06:
|
||||
-- * normal member abandons OK (membership row gone)
|
||||
-- * sole admin with other members abandons OK + oldest member auto-promoted
|
||||
-- * sole member rejected with errcode P0001 (must use dissolve_collective)
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgtap;
|
||||
|
||||
BEGIN;
|
||||
SELECT plan(7);
|
||||
|
||||
-- ── Existence ────────────────────────────────────────────────────────────────
|
||||
SELECT has_function(
|
||||
'public', 'leave_collective', ARRAY['uuid'],
|
||||
'public.leave_collective(uuid) RPC exists'
|
||||
);
|
||||
|
||||
-- ── Setup: three test users + two collectives outside seed range ──────────────
|
||||
INSERT INTO public.users (id, email, display_name, language, avatar_type)
|
||||
VALUES
|
||||
('e1000000-0000-0000-0000-000000000001', 'leave-admin@local', 'Leave Admin', 'es', 'initials'),
|
||||
('e1000000-0000-0000-0000-000000000002', 'leave-member@local', 'Leave Member', 'es', 'initials'),
|
||||
('e1000000-0000-0000-0000-000000000003', 'leave-solo@local', 'Leave Solo', 'es', 'initials')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Collective A: admin + member. created_by = Borja (so deleting admin row is safe).
|
||||
INSERT INTO public.collectives (id, name, emoji, created_by)
|
||||
VALUES ('cccccccc-1111-1111-1111-111111111111', 'Leave test A', '🚪',
|
||||
'22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.collective_members (collective_id, user_id, role, joined_at)
|
||||
VALUES
|
||||
('cccccccc-1111-1111-1111-111111111111', 'e1000000-0000-0000-0000-000000000001', 'admin', now() - INTERVAL '10 days'),
|
||||
('cccccccc-1111-1111-1111-111111111111', 'e1000000-0000-0000-0000-000000000002', 'member', now() - INTERVAL '5 days')
|
||||
ON CONFLICT (collective_id, user_id) DO NOTHING;
|
||||
|
||||
-- Collective B: sole member only.
|
||||
INSERT INTO public.collectives (id, name, emoji, created_by)
|
||||
VALUES ('cccccccc-2222-2222-2222-222222222222', 'Leave test B (solo)', '🚪',
|
||||
'22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.collective_members (collective_id, user_id, role)
|
||||
VALUES
|
||||
('cccccccc-2222-2222-2222-222222222222', 'e1000000-0000-0000-0000-000000000003', 'admin')
|
||||
ON CONFLICT (collective_id, user_id) DO NOTHING;
|
||||
|
||||
-- ── Case 1: normal member leaves ──────────────────────────────────────────────
|
||||
-- Authenticate as leave-member
|
||||
SELECT set_config('request.jwt.claim.sub', 'e1000000-0000-0000-0000-000000000002', true);
|
||||
|
||||
SELECT lives_ok(
|
||||
$$ SELECT public.leave_collective('cccccccc-1111-1111-1111-111111111111') $$,
|
||||
'L-01a: member can leave without error'
|
||||
);
|
||||
|
||||
SELECT is(
|
||||
(SELECT count(*)::int FROM public.collective_members
|
||||
WHERE collective_id = 'cccccccc-1111-1111-1111-111111111111'
|
||||
AND user_id = 'e1000000-0000-0000-0000-000000000002'),
|
||||
0,
|
||||
'L-01a: membership row removed after leave'
|
||||
);
|
||||
|
||||
-- ── Case 2: sole admin with other members ─────────────────────────────────────
|
||||
-- Re-add the member to set up sole-admin scenario again.
|
||||
INSERT INTO public.collective_members (collective_id, user_id, role, joined_at)
|
||||
VALUES ('cccccccc-1111-1111-1111-111111111111',
|
||||
'e1000000-0000-0000-0000-000000000002', 'member', now() - INTERVAL '5 days')
|
||||
ON CONFLICT (collective_id, user_id) DO UPDATE SET role = EXCLUDED.role;
|
||||
|
||||
-- Authenticate as the admin
|
||||
SELECT set_config('request.jwt.claim.sub', 'e1000000-0000-0000-0000-000000000001', true);
|
||||
|
||||
SELECT lives_ok(
|
||||
$$ SELECT public.leave_collective('cccccccc-1111-1111-1111-111111111111') $$,
|
||||
'L-01b: sole admin can leave when other members exist'
|
||||
);
|
||||
|
||||
SELECT is(
|
||||
(SELECT role::text FROM public.collective_members
|
||||
WHERE collective_id = 'cccccccc-1111-1111-1111-111111111111'
|
||||
AND user_id = 'e1000000-0000-0000-0000-000000000002'),
|
||||
'admin',
|
||||
'L-01b: remaining member auto-promoted to admin'
|
||||
);
|
||||
|
||||
SELECT is(
|
||||
(SELECT count(*)::int FROM public.collective_members
|
||||
WHERE collective_id = 'cccccccc-1111-1111-1111-111111111111'
|
||||
AND user_id = 'e1000000-0000-0000-0000-000000000001'),
|
||||
0,
|
||||
'L-01b: departing admin row removed'
|
||||
);
|
||||
|
||||
-- ── Case 3: sole member tries to leave ────────────────────────────────────────
|
||||
-- Authenticate as leave-solo
|
||||
SELECT set_config('request.jwt.claim.sub', 'e1000000-0000-0000-0000-000000000003', true);
|
||||
|
||||
SELECT throws_ok(
|
||||
$$ SELECT public.leave_collective('cccccccc-2222-2222-2222-222222222222') $$,
|
||||
'P0001',
|
||||
NULL,
|
||||
'L-01c: sole member rejected with errcode P0001'
|
||||
);
|
||||
|
||||
SELECT * FROM finish();
|
||||
ROLLBACK;
|
||||
Reference in New Issue
Block a user