feat(fase-10): CU-H08 dissolve collective — RPC + danger-zone UI
Migration 018 adds public.dissolve_collective(uuid): - admin-only; non-admin (member/guest/non-member) → errcode P0002 - single DELETE from public.collectives; all child tables cascade (collective_members, collective_invitations, shopping_lists → shopping_items, task_lists → tasks, notes, item_frequency, sync_conflicts — verified by audit, no schema change needed) /collective/manage gains a "Danger zone" card (admin-only) with a Dissolve button. The confirmation modal loads live counts of lists, tasks and notes, displays them in the warning, and requires the user to type the collective name exactly before the confirm button is enabled. On success, locals stores drop the collective and the user is sent to /lists (next collective) or /onboarding (none left). pgTAP 011 (8 assertions): RPC exists, member/guest/non-member all rejected with P0002, admin succeeds, collective row gone, child list cascade-deleted, members cascade-deleted. Playwright D-01..D-03 cover the happy path, the type-the-name guard, and member visibility. Auxiliary collective fixture (NOT the seed) per test — the seed collective must survive every run for downstream suites. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import { currentCollective, collectiveMembers, userCollectives } from '$lib/stores/collective';
|
||||
@@ -212,6 +213,92 @@
|
||||
currentCollective.set(c);
|
||||
userCollectives.update((list) => list.map((it) => (it.id === c.id ? c : it)));
|
||||
}
|
||||
|
||||
// ── Fase 10.3: dissolve collective (CU-H08) ─────────────────────────────
|
||||
let dissolveOpen = $state(false);
|
||||
let dissolveConfirmInput = $state('');
|
||||
let dissolveError = $state<string | null>(null);
|
||||
let dissolveInFlight = $state(false);
|
||||
let listsCount = $state(0);
|
||||
let tasksCount = $state(0);
|
||||
let notesCount = $state(0);
|
||||
|
||||
const dissolveConfirmReady = $derived(
|
||||
!!$currentCollective && dissolveConfirmInput === $currentCollective.name
|
||||
);
|
||||
|
||||
async function openDissolve() {
|
||||
if (!$currentCollective || !isAdmin) return;
|
||||
dissolveOpen = true;
|
||||
dissolveConfirmInput = '';
|
||||
dissolveError = null;
|
||||
// Best-effort counts; failures fall back to 0.
|
||||
const supabase = getSupabase();
|
||||
const [l, t, n] = await Promise.all([
|
||||
supabase
|
||||
.from('shopping_lists')
|
||||
.select('id', { head: true, count: 'exact' })
|
||||
.eq('collective_id', $currentCollective.id),
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('id', { head: true, count: 'exact' })
|
||||
.in(
|
||||
'list_id',
|
||||
(
|
||||
await supabase
|
||||
.from('task_lists')
|
||||
.select('id')
|
||||
.eq('collective_id', $currentCollective.id)
|
||||
).data?.map((r) => r.id) ?? []
|
||||
),
|
||||
supabase
|
||||
.from('notes')
|
||||
.select('id', { head: true, count: 'exact' })
|
||||
.eq('collective_id', $currentCollective.id)
|
||||
]);
|
||||
listsCount = l.count ?? 0;
|
||||
tasksCount = t.count ?? 0;
|
||||
notesCount = n.count ?? 0;
|
||||
}
|
||||
|
||||
function closeDissolve() {
|
||||
dissolveOpen = false;
|
||||
dissolveConfirmInput = '';
|
||||
dissolveError = null;
|
||||
}
|
||||
|
||||
async function confirmDissolve() {
|
||||
if (!$currentCollective || !dissolveConfirmReady || dissolveInFlight) return;
|
||||
const target = $currentCollective;
|
||||
dissolveInFlight = true;
|
||||
dissolveError = null;
|
||||
|
||||
const { error: rpcErr } = await getSupabase().rpc('dissolve_collective', {
|
||||
p_collective_id: target.id
|
||||
});
|
||||
if (rpcErr) {
|
||||
dissolveInFlight = false;
|
||||
dissolveError = rpcErr.message;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local stores. Pick the next collective or redirect to onboarding.
|
||||
const remaining = $userCollectives.filter((c) => c.id !== target.id);
|
||||
userCollectives.set(remaining);
|
||||
dissolveOpen = false;
|
||||
dissolveInFlight = false;
|
||||
|
||||
if (remaining.length > 0) {
|
||||
const next = remaining[0];
|
||||
currentCollective.set(next);
|
||||
localStorage.setItem('activeCollectiveId', next.id);
|
||||
await goto('/lists');
|
||||
} else {
|
||||
currentCollective.set(null);
|
||||
localStorage.removeItem('activeCollectiveId');
|
||||
await goto('/onboarding');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
@@ -401,7 +488,69 @@
|
||||
<p class="mt-1 text-xs text-text-secondary">{m.manage_link_expires()}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Danger zone (Fase 10.3) — admin only -->
|
||||
<section class="mt-10 rounded-lg border border-destructive/40 p-4">
|
||||
<h2 class="mb-2 text-[13px] font-semibold uppercase tracking-[0.05em] text-destructive">
|
||||
{m.manage_dissolve_section()}
|
||||
</h2>
|
||||
<p class="mb-3 text-sm text-text-secondary">{m.manage_dissolve_blurb()}</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dissolve-collective-button"
|
||||
onclick={() => void openDissolve()}
|
||||
class="rounded-md border border-destructive px-4 py-2 text-sm font-semibold text-destructive
|
||||
hover:bg-destructive/10"
|
||||
>
|
||||
{m.manage_dissolve_button()}
|
||||
</button>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if dissolveOpen && $currentCollective}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" data-testid="dissolve-modal">
|
||||
<div class="w-full max-w-md rounded-lg bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised">
|
||||
<h2 class="mb-2 text-base font-semibold text-slate-900 dark:text-slate-50">
|
||||
{m.manage_dissolve_title({ name: $currentCollective.name })}
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-text-secondary">
|
||||
{m.manage_dissolve_warning({ lists: listsCount, tasks: tasksCount, notes: notesCount })}
|
||||
</p>
|
||||
<label for="dissolve-confirm-input" class="mb-1 block text-xs font-medium text-text-secondary">
|
||||
{m.manage_dissolve_confirm_label({ name: $currentCollective.name })}
|
||||
</label>
|
||||
<input
|
||||
id="dissolve-confirm-input"
|
||||
data-testid="dissolve-confirm-input"
|
||||
type="text"
|
||||
bind:value={dissolveConfirmInput}
|
||||
class="mb-3 w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
|
||||
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
{#if dissolveError}
|
||||
<p class="mb-3 text-sm text-destructive" data-testid="dissolve-error">{dissolveError}</p>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeDissolve}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{m.common_cancel()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dissolve-confirm-button"
|
||||
disabled={!dissolveConfirmReady || dissolveInFlight}
|
||||
onclick={() => void confirmDissolve()}
|
||||
class="rounded-md bg-destructive px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{m.manage_dissolve_confirm_button()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
103
apps/web/tests/e2e/dissolve-collective.test.ts
Normal file
103
apps/web/tests/e2e/dissolve-collective.test.ts
Normal file
@@ -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<string> {
|
||||
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;
|
||||
});
|
||||
});
|
||||
40
supabase/migrations/018_dissolve_collective_rpc.sql
Normal file
40
supabase/migrations/018_dissolve_collective_rpc.sql
Normal file
@@ -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;
|
||||
110
supabase/tests/011_dissolve_collective.sql
Normal file
110
supabase/tests/011_dissolve_collective.sql
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user