diff --git a/apps/web/src/routes/(app)/collective/manage/+page.svelte b/apps/web/src/routes/(app)/collective/manage/+page.svelte index fc66cb6..b2f7012 100644 --- a/apps/web/src/routes/(app)/collective/manage/+page.svelte +++ b/apps/web/src/routes/(app)/collective/manage/+page.svelte @@ -1,5 +1,6 @@
@@ -401,7 +488,69 @@

{m.manage_link_expires()}

{/if} + + +
+

+ {m.manage_dissolve_section()} +

+

{m.manage_dissolve_blurb()}

+ +
{/if}
+ +{#if dissolveOpen && $currentCollective} +
+
+

+ {m.manage_dissolve_title({ name: $currentCollective.name })} +

+

+ {m.manage_dissolve_warning({ lists: listsCount, tasks: tasksCount, notes: notesCount })} +

+ + + {#if dissolveError} +

{dissolveError}

+ {/if} +
+ + +
+
+
+{/if} diff --git a/apps/web/tests/e2e/dissolve-collective.test.ts b/apps/web/tests/e2e/dissolve-collective.test.ts new file mode 100644 index 0000000..e9ed86d --- /dev/null +++ b/apps/web/tests/e2e/dissolve-collective.test.ts @@ -0,0 +1,103 @@ +/** + * D-series: /collective/manage → dissolve (Fase 10.3, CU-H08). + * + * ⚠️ This suite must NOT dissolve the seed collective. We create an auxiliary + * collective per test, manipulate it, then either let the test dissolve it + * (D-01) or clean it up in afterEach. + */ +import { test, expect } from '@playwright/test'; +import { USERS } from '../fixtures/users.js'; +import { loginAs } from '../fixtures/login.js'; +import { closePool, sql } from '@colectivo/test-utils'; + +test.describe.configure({ mode: 'serial' }); + +const aux: { id: string | null } = { id: null }; +const auxName = `Aux dissolve ${Date.now()}`; + +/** + * Create an auxiliary collective owned (created_by) by Borja but with Ana + * as the sole admin (Borja is dropped from membership). This lets Ana + * dissolve it from the UI without touching the seed. + */ +async function createAuxCollective(): Promise { + const r = await sql( + `INSERT INTO public.collectives (name, emoji, created_by) + VALUES ($1, '💥', $2) RETURNING id`, + [auxName, USERS.borja.id] + ); + const id = r.rows[0].id as string; + await sql( + `INSERT INTO public.collective_members (collective_id, user_id, role) + VALUES ($1, $2, 'admin'), ($1, $3, 'member')`, + [id, USERS.ana.id, USERS.borja.id] + ); + return id; +} + +test.describe('Dissolve collective (CU-H08)', () => { + test.afterEach(async () => { + if (aux.id) { + // Best-effort cleanup if the test didn't dissolve it. + await sql('DELETE FROM public.collectives WHERE id = $1', [aux.id]); + aux.id = null; + } + }); + + test.afterAll(async () => { + await closePool(); + }); + + test('D-03: Borja (member) does NOT see the dissolve button', async ({ page }) => { + aux.id = await createAuxCollective(); + await loginAs(page, USERS.borja); + // Switch active collective to the aux one in localStorage, then reload. + await page.evaluate((id) => localStorage.setItem('activeCollectiveId', id), aux.id); + await page.goto('/collective/manage'); + // Make sure the page is hydrated by waiting on a known member row. + await expect(page.getByTestId(`member-row-${USERS.ana.id}`)).toBeVisible({ timeout: 15_000 }); + // Borja must not see the dissolve button (admin-gated). + await expect(page.getByTestId('dissolve-collective-button')).toHaveCount(0); + }); + + test('D-02: dissolve button disabled until input matches collective name exactly', async ({ + page + }) => { + aux.id = await createAuxCollective(); + await loginAs(page, USERS.ana); + await page.evaluate((id) => localStorage.setItem('activeCollectiveId', id), aux.id); + await page.goto('/collective/manage'); + await page.getByTestId('dissolve-collective-button').click(); + + const confirm = page.getByTestId('dissolve-confirm-button'); + await expect(confirm).toBeDisabled(); + + await page.getByTestId('dissolve-confirm-input').fill('wrong name'); + await expect(confirm).toBeDisabled(); + + await page.getByTestId('dissolve-confirm-input').fill(auxName); + await expect(confirm).toBeEnabled(); + }); + + test('D-01: Ana dissolves auxiliary collective → redirect + row gone', async ({ page }) => { + aux.id = await createAuxCollective(); + await loginAs(page, USERS.ana); + await page.evaluate((id) => localStorage.setItem('activeCollectiveId', id), aux.id); + await page.goto('/collective/manage'); + await page.getByTestId('dissolve-collective-button').click(); + + await page.getByTestId('dissolve-confirm-input').fill(auxName); + await page.getByTestId('dissolve-confirm-button').click(); + + // After dissolve we either land on /lists (other collectives remain) or + // /onboarding (no collectives). Ana still has the seed one, so /lists. + await expect(page).toHaveURL(/\/(lists|onboarding)$/, { timeout: 15_000 }); + + const r = await sql('SELECT count(*)::int AS n FROM public.collectives WHERE id = $1', [ + aux.id + ]); + expect(r.rows[0].n).toBe(0); + // Test cleanup already happened via cascade. + aux.id = null; + }); +}); diff --git a/supabase/migrations/018_dissolve_collective_rpc.sql b/supabase/migrations/018_dissolve_collective_rpc.sql new file mode 100644 index 0000000..99868fb --- /dev/null +++ b/supabase/migrations/018_dissolve_collective_rpc.sql @@ -0,0 +1,40 @@ +-- Migration 018: dissolve_collective(uuid) RPC — Fase 10.3 (CU-H08). +-- +-- Permanently delete a collective and all its content. Admin-only. Errcode +-- P0002 so the UI can distinguish "you're not admin" from generic errors. +-- +-- All content tables that reference public.collectives.collective_id are +-- already ON DELETE CASCADE (collective_members, collective_invitations, +-- shopping_lists → shopping_items, task_lists → tasks, notes, item_frequency, +-- sync_conflicts). Deleting the parent row cleans up the entire subtree. +-- See plan/fase-10-spec-completion.md §10.3 for the audit + Riesgo 2. + +CREATE OR REPLACE FUNCTION public.dissolve_collective(p_collective_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, auth +AS $$ +DECLARE + v_user uuid := auth.uid(); + v_role public.member_role; +BEGIN + IF v_user IS NULL THEN + RAISE EXCEPTION 'not authenticated' USING ERRCODE = '28000'; + END IF; + + SELECT role INTO v_role + FROM public.collective_members + WHERE collective_id = p_collective_id AND user_id = v_user; + + IF v_role IS DISTINCT FROM 'admin' THEN + RAISE EXCEPTION 'only admins can dissolve a collective' + USING ERRCODE = 'P0002'; + END IF; + + DELETE FROM public.collectives WHERE id = p_collective_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.dissolve_collective(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.dissolve_collective(uuid) TO authenticated; diff --git a/supabase/tests/011_dissolve_collective.sql b/supabase/tests/011_dissolve_collective.sql new file mode 100644 index 0000000..88d0fbf --- /dev/null +++ b/supabase/tests/011_dissolve_collective.sql @@ -0,0 +1,110 @@ +-- pgTAP: dissolve_collective() RPC — Fase 10.3 (CU-H08) +-- Run with: psql -U postgres -d postgres -f supabase/tests/011_dissolve_collective.sql +-- +-- Covers: +-- * admin disolves: collective + all content cascade-deleted +-- * member rejected (errcode P0002) +-- * guest rejected +-- * non-member rejected + +CREATE EXTENSION IF NOT EXISTS pgtap; + +BEGIN; +SELECT plan(8); + +-- ── Existence ──────────────────────────────────────────────────────────────── +SELECT has_function( + 'public', 'dissolve_collective', ARRAY['uuid'], + 'public.dissolve_collective(uuid) RPC exists' +); + +-- ── Setup: one collective with content, three members ──────────────────────── +INSERT INTO public.users (id, email, display_name, language, avatar_type) +VALUES + ('e2000000-0000-0000-0000-000000000001', 'diss-admin@local', 'Diss Admin', 'es', 'initials'), + ('e2000000-0000-0000-0000-000000000002', 'diss-member@local', 'Diss Member', 'es', 'initials'), + ('e2000000-0000-0000-0000-000000000003', 'diss-guest@local', 'Diss Guest', 'es', 'initials') +ON CONFLICT (id) DO NOTHING; + +-- Collective owned by Borja so we can delete the test admin user later. +INSERT INTO public.collectives (id, name, emoji, created_by) +VALUES ('cccccccc-3333-3333-3333-333333333333', 'Dissolve test', '💥', + '22222222-2222-2222-2222-222222222222') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.collective_members (collective_id, user_id, role) +VALUES + ('cccccccc-3333-3333-3333-333333333333', 'e2000000-0000-0000-0000-000000000001', 'admin'), + ('cccccccc-3333-3333-3333-333333333333', 'e2000000-0000-0000-0000-000000000002', 'member'), + ('cccccccc-3333-3333-3333-333333333333', 'e2000000-0000-0000-0000-000000000003', 'guest') +ON CONFLICT (collective_id, user_id) DO NOTHING; + +INSERT INTO public.shopping_lists (id, collective_id, name, status, created_by) +VALUES ('cccccccc-3333-3333-3333-3333deadbeef', 'cccccccc-3333-3333-3333-333333333333', + 'List to nuke', 'active', 'e2000000-0000-0000-0000-000000000001') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.shopping_items (list_id, name, sort_order, created_by) +VALUES ('cccccccc-3333-3333-3333-3333deadbeef', 'Milk', 1, 'e2000000-0000-0000-0000-000000000001'); + +-- ── Case 1: member tries to dissolve → P0002 ───────────────────────────────── +SELECT set_config('request.jwt.claim.sub', 'e2000000-0000-0000-0000-000000000002', true); + +SELECT throws_ok( + $$ SELECT public.dissolve_collective('cccccccc-3333-3333-3333-333333333333') $$, + 'P0002', + NULL, + 'D-pgtap-a: member rejected with P0002' +); + +-- ── Case 2: guest tries to dissolve → P0002 ────────────────────────────────── +SELECT set_config('request.jwt.claim.sub', 'e2000000-0000-0000-0000-000000000003', true); + +SELECT throws_ok( + $$ SELECT public.dissolve_collective('cccccccc-3333-3333-3333-333333333333') $$, + 'P0002', + NULL, + 'D-pgtap-b: guest rejected with P0002' +); + +-- ── Case 3: non-member rejected ────────────────────────────────────────────── +SELECT set_config('request.jwt.claim.sub', '55555555-5555-5555-5555-555555555555', true); -- Eva + +SELECT throws_ok( + $$ SELECT public.dissolve_collective('cccccccc-3333-3333-3333-333333333333') $$, + 'P0002', + NULL, + 'D-pgtap-c: non-member rejected with P0002' +); + +-- ── Case 4: admin succeeds + cascade ───────────────────────────────────────── +SELECT set_config('request.jwt.claim.sub', 'e2000000-0000-0000-0000-000000000001', true); + +SELECT lives_ok( + $$ SELECT public.dissolve_collective('cccccccc-3333-3333-3333-333333333333') $$, + 'D-pgtap-d: admin can dissolve without error' +); + +SELECT is( + (SELECT count(*)::int FROM public.collectives + WHERE id = 'cccccccc-3333-3333-3333-333333333333'), + 0, + 'D-pgtap-d: collective row gone' +); + +SELECT is( + (SELECT count(*)::int FROM public.shopping_lists + WHERE id = 'cccccccc-3333-3333-3333-3333deadbeef'), + 0, + 'D-pgtap-d: child shopping_list cascade-deleted' +); + +SELECT is( + (SELECT count(*)::int FROM public.collective_members + WHERE collective_id = 'cccccccc-3333-3333-3333-333333333333'), + 0, + 'D-pgtap-d: collective_members cascade-deleted' +); + +SELECT * FROM finish(); +ROLLBACK;