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:
2026-05-18 02:11:21 +02:00
parent 5e2ba3a6d6
commit 83f613ec1e
4 changed files with 402 additions and 0 deletions

View File

@@ -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}