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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import { userCollectives, currentCollective } from '$lib/stores/collective';
|
||||
import { logout } from '$lib/auth';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import ImageCropper from '$lib/components/ImageCropper.svelte';
|
||||
@@ -156,6 +158,93 @@
|
||||
.update({ language: lang })
|
||||
.eq('id', $currentUser!.id);
|
||||
}
|
||||
|
||||
// ── Fase 10.1: leave collective (CU-H06) ────────────────────────────────
|
||||
let leaveCandidate = $state<{ id: string; name: string } | null>(null);
|
||||
let leaveError = $state<string | null>(null);
|
||||
|
||||
function startLeave(c: { id: string; name: string }) {
|
||||
leaveCandidate = c;
|
||||
leaveError = null;
|
||||
}
|
||||
|
||||
function cancelLeave() {
|
||||
leaveCandidate = null;
|
||||
leaveError = null;
|
||||
}
|
||||
|
||||
async function confirmLeave() {
|
||||
if (!leaveCandidate) return;
|
||||
const target = leaveCandidate;
|
||||
const supabase = getSupabase();
|
||||
const { error } = await supabase.rpc('leave_collective', { p_collective_id: target.id });
|
||||
if (error) {
|
||||
// P0001 = sole member → instruct to dissolve instead.
|
||||
leaveError = (error as { code?: string }).code === 'P0001'
|
||||
? m.settings_collectives_dissolve_hint()
|
||||
: error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local stores: drop the collective from userCollectives. If it
|
||||
// was the active one, switch to the next or kick to /onboarding.
|
||||
const remaining = $userCollectives.filter((c) => c.id !== target.id);
|
||||
userCollectives.set(remaining);
|
||||
|
||||
if ($currentCollective?.id === target.id) {
|
||||
if (remaining.length > 0) {
|
||||
const next = remaining[0];
|
||||
currentCollective.set(next);
|
||||
localStorage.setItem('activeCollectiveId', next.id);
|
||||
} else {
|
||||
currentCollective.set(null);
|
||||
localStorage.removeItem('activeCollectiveId');
|
||||
leaveCandidate = null;
|
||||
await goto('/onboarding');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
leaveCandidate = null;
|
||||
}
|
||||
|
||||
// ── Fase 10.5: delete account ───────────────────────────────────────────
|
||||
let showDeleteAccount = $state(false);
|
||||
let deleteConfirmInput = $state('');
|
||||
let deleteAccountError = $state<string | null>(null);
|
||||
let deleteInFlight = $state(false);
|
||||
|
||||
const deleteConfirmWord = m.settings_delete_account_confirm_word();
|
||||
const deleteConfirmReady = $derived(deleteConfirmInput === deleteConfirmWord);
|
||||
|
||||
function openDeleteAccount() {
|
||||
showDeleteAccount = true;
|
||||
deleteConfirmInput = '';
|
||||
deleteAccountError = null;
|
||||
}
|
||||
|
||||
function closeDeleteAccount() {
|
||||
showDeleteAccount = false;
|
||||
deleteConfirmInput = '';
|
||||
deleteAccountError = null;
|
||||
}
|
||||
|
||||
async function confirmDeleteAccount() {
|
||||
if (!deleteConfirmReady || deleteInFlight) return;
|
||||
deleteInFlight = true;
|
||||
deleteAccountError = null;
|
||||
const { error } = await getSupabase().rpc('delete_account');
|
||||
if (error) {
|
||||
deleteInFlight = false;
|
||||
deleteAccountError = (error as { code?: string }).code === 'P0003'
|
||||
? m.settings_delete_account_blocked()
|
||||
: error.message;
|
||||
return;
|
||||
}
|
||||
// GoTrue row gone; clear local + start logout (RP-initiated) so Keycloak
|
||||
// session is also ended. /logged-out is the post-logout landing.
|
||||
await logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
@@ -280,6 +369,34 @@
|
||||
import.meta.env.DEV inside the component itself. -->
|
||||
<SyncConflictsPanel />
|
||||
|
||||
<!-- Collectives (Fase 10.1) -->
|
||||
<section>
|
||||
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
{m.settings_collectives()}
|
||||
</p>
|
||||
{#if $userCollectives.length === 0}
|
||||
<p class="text-sm text-text-secondary">{m.settings_collectives_empty()}</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each $userCollectives as c (c.id)}
|
||||
<li data-testid="collective-row-{c.id}" class="flex items-center gap-3 py-2">
|
||||
<span class="text-lg">{c.emoji}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-slate-900 dark:text-slate-50">{c.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="leave-collective-{c.id}"
|
||||
onclick={() => startLeave(c)}
|
||||
class="rounded-md border border-slate-300 px-3 py-1 text-xs font-medium text-slate-700
|
||||
hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{m.settings_collectives_leave()}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Account — spacing creates the separation, not a border line -->
|
||||
<section class="pt-8">
|
||||
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
@@ -300,6 +417,23 @@
|
||||
{m.settings_logout()}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Danger zone (Fase 10.5) -->
|
||||
<section class="pt-8">
|
||||
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-destructive">
|
||||
{m.settings_danger_zone()}
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-text-secondary">{m.settings_delete_account_blurb()}</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="delete-account-open"
|
||||
onclick={openDeleteAccount}
|
||||
class="rounded-md border border-destructive px-4 py-2 text-sm font-semibold text-destructive
|
||||
hover:bg-destructive/10"
|
||||
>
|
||||
{m.settings_delete_account()}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -311,3 +445,79 @@
|
||||
onCancel={() => (cropperFile = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Leave-collective confirmation modal (Fase 10.1) -->
|
||||
{#if leaveCandidate}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" data-testid="leave-modal">
|
||||
<div class="w-full max-w-sm 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.settings_collectives_confirm_leave_title({ name: leaveCandidate.name })}
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-text-secondary">{m.settings_collectives_confirm_leave_body()}</p>
|
||||
{#if leaveError}
|
||||
<p class="mb-3 text-sm text-destructive" data-testid="leave-error">{leaveError}</p>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelLeave}
|
||||
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="confirm-leave-collective"
|
||||
onclick={confirmLeave}
|
||||
class="rounded-md bg-destructive px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90"
|
||||
>
|
||||
{m.settings_collectives_confirm_leave_button()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Delete-account modal (Fase 10.5) -->
|
||||
{#if showDeleteAccount}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" data-testid="delete-account-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.settings_delete_account_modal_title()}
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-text-secondary">{m.settings_delete_account_modal_body()}</p>
|
||||
<label for="delete-account-input" class="mb-1 block text-xs font-medium text-text-secondary">
|
||||
{m.settings_delete_account_confirm_label({ word: deleteConfirmWord })}
|
||||
</label>
|
||||
<input
|
||||
id="delete-account-input"
|
||||
data-testid="delete-account-confirm-input"
|
||||
type="text"
|
||||
bind:value={deleteConfirmInput}
|
||||
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 deleteAccountError}
|
||||
<p class="mb-3 text-sm text-destructive" data-testid="delete-account-error">{deleteAccountError}</p>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeDeleteAccount}
|
||||
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="confirm-delete-account"
|
||||
disabled={!deleteConfirmReady || deleteInFlight}
|
||||
onclick={confirmDeleteAccount}
|
||||
class="rounded-md bg-destructive px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{m.settings_delete_account_button()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
62
apps/web/tests/e2e/leave-collective.test.ts
Normal file
62
apps/web/tests/e2e/leave-collective.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
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