From 2f0847a5a305dd339ddf4891e82ee4e3df3d5531 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 18 May 2026 02:04:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(fase-10):=20CU-H06=20leave=20collective=20?= =?UTF-8?q?=E2=80=94=20RPC=20+=20settings=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/messages/en.json | 32 ++- apps/web/messages/es.json | 32 ++- .../src/routes/(app)/settings/+page.svelte | 210 ++++++++++++++++++ apps/web/tests/e2e/leave-collective.test.ts | 62 ++++++ .../migrations/017_leave_collective_rpc.sql | 84 +++++++ supabase/tests/010_leave_collective.sql | 111 +++++++++ 6 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 apps/web/tests/e2e/leave-collective.test.ts create mode 100644 supabase/migrations/017_leave_collective_rpc.sql create mode 100644 supabase/tests/010_leave_collective.sql diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 0154a3b..ce22b11 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -190,5 +190,35 @@ "settings_theme_system": "System", "settings_sync_conflicts": "Sync conflicts", "settings_sync_conflicts_empty": "No conflicts logged.", - "settings_sync_conflicts_export": "Export JSON" + "settings_sync_conflicts_export": "Export JSON", + "settings_collectives": "Your collectives", + "settings_collectives_empty": "You don't belong to any collective.", + "settings_collectives_leave": "Leave", + "settings_collectives_confirm_leave_title": "Leave {name}?", + "settings_collectives_confirm_leave_body": "You will lose access to this collective. The content stays for the other members.", + "settings_collectives_confirm_leave_button": "Leave collective", + "settings_collectives_dissolve_hint": "You are the only member — use 'Dissolve' on the collective page instead.", + "settings_danger_zone": "Danger zone", + "settings_delete_account": "Delete my account", + "settings_delete_account_blurb": "Permanently delete your account and remove your memberships. Content you created stays in the collectives, attributed to a deleted user.", + "settings_delete_account_modal_title": "Delete your account?", + "settings_delete_account_modal_body": "This is permanent. What gets deleted: your account, your memberships, your saved language and theme. What stays: any lists, tasks or notes you created — they remain in their collectives, attributed to a deleted user. If you are the only admin of a collective, the longest-standing member is promoted automatically. Note: your sign-in account at the identity provider is not deleted; you can register again with the same email and a new profile will be created.", + "settings_delete_account_confirm_label": "Type {word} to confirm", + "settings_delete_account_confirm_word": "DELETE", + "settings_delete_account_button": "Delete account permanently", + "settings_delete_account_blocked": "You are the only admin of a collective with members who cannot be promoted. Promote someone first, or remove the other members.", + "common_cancel": "Cancel", + "manage_collective_name_label": "Collective name", + "manage_collective_emoji_label": "Icon", + "manage_collective_save": "Save", + "manage_dissolve_section": "Danger zone", + "manage_dissolve_button": "Dissolve collective", + "manage_dissolve_blurb": "Permanently delete this collective and all its content. This cannot be undone.", + "manage_dissolve_title": "Dissolve {name}?", + "manage_dissolve_warning": "{lists} lists, {tasks} tasks and {notes} notes will be permanently deleted. This is irreversible.", + "manage_dissolve_confirm_label": "Type {name} to confirm", + "manage_dissolve_confirm_button": "Dissolve permanently", + "sidebar_create_collective": "+ New collective", + "create_collective_modal_title": "New collective", + "create_collective_modal_button": "Create" } diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index c625734..a601bd0 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -190,5 +190,35 @@ "settings_theme_system": "Sistema", "settings_sync_conflicts": "Conflictos de sincronización", "settings_sync_conflicts_empty": "Sin conflictos registrados.", - "settings_sync_conflicts_export": "Exportar JSON" + "settings_sync_conflicts_export": "Exportar JSON", + "settings_collectives": "Tus colectivos", + "settings_collectives_empty": "No perteneces a ningún colectivo.", + "settings_collectives_leave": "Abandonar", + "settings_collectives_confirm_leave_title": "¿Abandonar {name}?", + "settings_collectives_confirm_leave_body": "Perderás acceso a este colectivo. El contenido se queda para los demás miembros.", + "settings_collectives_confirm_leave_button": "Abandonar colectivo", + "settings_collectives_dissolve_hint": "Eres el único miembro — usa 'Disolver' en la página del colectivo en su lugar.", + "settings_danger_zone": "Zona peligrosa", + "settings_delete_account": "Eliminar mi cuenta", + "settings_delete_account_blurb": "Elimina permanentemente tu cuenta y todas tus membresías. El contenido que hayas creado se queda en los colectivos, atribuido a un usuario eliminado.", + "settings_delete_account_modal_title": "¿Eliminar tu cuenta?", + "settings_delete_account_modal_body": "Esto es permanente. Lo que se borra: tu cuenta, tus membresías, tu idioma y tema guardados. Lo que se queda: las listas, tareas o notas que hayas creado — se quedan en sus colectivos, atribuidas a un usuario eliminado. Si eres el único admin de algún colectivo, el miembro más antiguo será promovido automáticamente. Nota: tu cuenta en el proveedor de identidad NO se elimina; puedes volver a registrarte con el mismo email y se creará un perfil nuevo.", + "settings_delete_account_confirm_label": "Escribe {word} para confirmar", + "settings_delete_account_confirm_word": "ELIMINAR", + "settings_delete_account_button": "Eliminar cuenta permanentemente", + "settings_delete_account_blocked": "Eres el único admin de un colectivo con miembros que no pueden ser promovidos. Promueve a alguien primero o elimina a los demás miembros.", + "common_cancel": "Cancelar", + "manage_collective_name_label": "Nombre del colectivo", + "manage_collective_emoji_label": "Icono", + "manage_collective_save": "Guardar", + "manage_dissolve_section": "Zona peligrosa", + "manage_dissolve_button": "Disolver colectivo", + "manage_dissolve_blurb": "Borra permanentemente este colectivo y todo su contenido. No se puede deshacer.", + "manage_dissolve_title": "¿Disolver {name}?", + "manage_dissolve_warning": "Se borrarán {lists} listas, {tasks} tareas y {notes} notas. Esta acción es irreversible.", + "manage_dissolve_confirm_label": "Escribe {name} para confirmar", + "manage_dissolve_confirm_button": "Disolver permanentemente", + "sidebar_create_collective": "+ Nuevo colectivo", + "create_collective_modal_title": "Nuevo colectivo", + "create_collective_modal_button": "Crear" } diff --git a/apps/web/src/routes/(app)/settings/+page.svelte b/apps/web/src/routes/(app)/settings/+page.svelte index 9353de7..37bc2e0 100644 --- a/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/web/src/routes/(app)/settings/+page.svelte @@ -1,7 +1,9 @@
@@ -280,6 +369,34 @@ import.meta.env.DEV inside the component itself. --> + +
+

+ {m.settings_collectives()} +

+ {#if $userCollectives.length === 0} +

{m.settings_collectives_empty()}

+ {:else} +
    + {#each $userCollectives as c (c.id)} +
  • + {c.emoji} + {c.name} + +
  • + {/each} +
+ {/if} +
+

@@ -300,6 +417,23 @@ {m.settings_logout()}

+ + +
+

+ {m.settings_danger_zone()} +

+

{m.settings_delete_account_blurb()}

+ +
@@ -311,3 +445,79 @@ onCancel={() => (cropperFile = null)} /> {/if} + + +{#if leaveCandidate} +
+
+

+ {m.settings_collectives_confirm_leave_title({ name: leaveCandidate.name })} +

+

{m.settings_collectives_confirm_leave_body()}

+ {#if leaveError} +

{leaveError}

+ {/if} +
+ + +
+
+
+{/if} + + +{#if showDeleteAccount} +
+
+

+ {m.settings_delete_account_modal_title()} +

+

{m.settings_delete_account_modal_body()}

+ + + {#if deleteAccountError} +

{deleteAccountError}

+ {/if} +
+ + +
+
+
+{/if} diff --git a/apps/web/tests/e2e/leave-collective.test.ts b/apps/web/tests/e2e/leave-collective.test.ts new file mode 100644 index 0000000..2092e0e --- /dev/null +++ b/apps/web/tests/e2e/leave-collective.test.ts @@ -0,0 +1,62 @@ +/** + * L-series: /settings → leave collective (Fase 10.1, CU-H06). + * + * Borja (member of the seed collective) abandons it. The collective and its + * content stay; Borja's row in collective_members disappears; Ana — viewing + * /collective/manage — no longer sees Borja in the roster. + * + * Borja is then a no-collective user and is auto-redirected to /onboarding. + * + * Cleanup: re-insert Borja's seed membership in afterAll so downstream suites + * that rely on Borja being a member don't blow up. + */ +import { test, expect } from '@playwright/test'; +import { USERS } from '../fixtures/users.js'; +import { loginAs } from '../fixtures/login.js'; +import { closePool, sql, COLLECTIVE_ID } from '@colectivo/test-utils'; + +test.describe.configure({ mode: 'serial' }); + +test.describe('Leave collective (CU-H06)', () => { + test.afterAll(async () => { + // Restore Borja's seed membership so other suites pass. + await sql( + `INSERT INTO public.collective_members (collective_id, user_id, role) + VALUES ($1, $2, 'member') + ON CONFLICT (collective_id, user_id) DO UPDATE SET role = EXCLUDED.role`, + [COLLECTIVE_ID, USERS.borja.id] + ); + await closePool(); + }); + + test('L-01: Borja leaves the seed collective → row gone, lands on onboarding', async ({ + page + }) => { + await loginAs(page, USERS.borja); + await page.goto('/settings'); + + // Settings page renders the collectives section; Borja's seed collective + // has a leave button next to it. + const leaveButton = page.getByTestId(`leave-collective-${COLLECTIVE_ID}`); + await expect(leaveButton).toBeVisible({ timeout: 15_000 }); + await leaveButton.click(); + + // Confirm in the modal. + await page.getByTestId('confirm-leave-collective').click(); + + // Borja had only one collective; he gets bounced to /onboarding. + await expect(page).toHaveURL(/\/onboarding$/, { timeout: 15_000 }); + + // DB-side: membership row is gone, but the collective row + content survive. + const m = await sql( + 'SELECT count(*)::int AS n FROM public.collective_members WHERE collective_id = $1 AND user_id = $2', + [COLLECTIVE_ID, USERS.borja.id] + ); + expect(m.rows[0].n).toBe(0); + + const c = await sql('SELECT count(*)::int AS n FROM public.collectives WHERE id = $1', [ + COLLECTIVE_ID + ]); + expect(c.rows[0].n).toBe(1); + }); +}); diff --git a/supabase/migrations/017_leave_collective_rpc.sql b/supabase/migrations/017_leave_collective_rpc.sql new file mode 100644 index 0000000..754a759 --- /dev/null +++ b/supabase/migrations/017_leave_collective_rpc.sql @@ -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; diff --git a/supabase/tests/010_leave_collective.sql b/supabase/tests/010_leave_collective.sql new file mode 100644 index 0000000..c5c3c34 --- /dev/null +++ b/supabase/tests/010_leave_collective.sql @@ -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;