Files
collective-lists/plan/fase-10-spec-completion.md
Oier Bravo Urtasun 0e4ada0083 docs(plan): fase-9 (polish + dark mode) and fase-10 (spec completion)
Fase 9 bundles dark mode as the flagship feature plus the four polish items
parked from Fase 5/6 (sync_conflicts wiring, presence avatars still blocked
on supabase/realtime #1617). Fase 10 closes the completion debt found in the
2026-04-22 audit: CU-H06/H07/H08 (leave/dissolve/transfer admin), trash
restore, reset-from-detail, sidebar create-collective, account deletion, and
automatic language detection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:54:26 +02:00

282 lines
19 KiB
Markdown

### Fase 10 — Spec completion ("cerrar la laguna del `analisis-funcional`")
**Estado: 📋 Planificada. Cubre las 8 piezas de "completion debt" detectadas en el audit del 2026-04-22: 3 CU de gestión de colectivo (H06/H07/H08), 3 afordances de contenido ya construido (trash restore, reset-from-detail, sidebar create-collective), eliminación de cuenta, y detección automática de idioma.**
**Objetivo: llevar la paridad con `analysis/analisis-funcional.md` al 100% en el eje de gestión de colectivo y ciclo de vida de usuario/contenido — todo lo que los usuarios empiezan a pedir desde el primer día de uso real.**
---
#### 10.1 CU-H06 — Abandonar colectivo
**Fichero:** nueva RPC + botón en `/settings` → sección "Colectivos".
**Regla clave (spec §2.3):** el usuario sale, el contenido permanece. Si es el único admin y hay otros miembros, disparar `promote_oldest_admin` antes de eliminar la fila de `collective_members`. Si es el único miembro, equivale a dissolve (bloquear y redirigir al usuario a H08).
**Tareas:**
- [ ] **10.1.1** Migración `supabase/migrations/017_leave_collective_rpc.sql`:
```sql
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_is_last_member boolean;
begin
if v_user is null then raise exception 'not authenticated'; end if;
select count(*) = 1 into v_is_last_member
from collective_members where collective_id = p_collective_id;
if v_is_last_member then
raise exception 'cannot leave as sole member; dissolve instead' using errcode = 'P0001';
end if;
delete from collective_members
where collective_id = p_collective_id and user_id = v_user;
-- el trigger de auto-promote cubre el caso "solo admin sale"
end; $$;
grant execute on function public.leave_collective(uuid) to authenticated;
```
- [ ] **10.1.2** UI: en `/settings` listar colectivos del usuario con botón "Abandonar" por fila. Modal de confirmación simple (un click). Si la RPC devuelve error `P0001`, mostrar "Eres el único miembro — usa 'Disolver' en lugar de abandonar" con enlace a H08.
- [ ] **10.1.3** Después del éxito: si el colectivo abandonado era el `$activeCollective`, hacer switch al más antiguo restante o redirect a `/onboarding` si queda sin colectivos.
- [ ] **10.1.4** Tests pgTAP (`010_leave_collective.sql`): abandonar como member normal OK; abandonar como único admin con otros miembros OK + promoción triggereada; abandonar como único miembro rechazado con errcode P0001.
- [ ] **10.1.5** Playwright `tests/e2e/leave-collective.test.ts`: L-01 Borja abandona el colectivo semilla → desaparece de `/collective/manage` desde la vista de Ana + Borja aterriza en `/onboarding`.
---
#### 10.2 CU-H07 — Renombrar colectivo / cambiar emoji
**Fichero:** `apps/web/src/routes/(app)/collective/manage/+page.svelte`.
**Regla clave:** sólo `admin` puede editar. Las policies UPDATE ya lo permiten — sólo falta UI.
**Tareas:**
- [ ] **10.2.1** Input inline editable para `name` (click-to-edit, Enter para guardar, Esc para cancelar) — visible sólo si `currentRole === 'admin'`.
- [ ] **10.2.2** Picker de emoji al lado del avatar (reusar el emoji picker del `/onboarding`).
- [ ] **10.2.3** UPDATE directo por PostgREST (`supabase.from('collectives').update({ name, avatar_emoji }).eq('id', ...)`); error toasteado si RLS falla.
- [ ] **10.2.4** Reactividad: `$currentCollective` debe actualizarse en el store sin reload (subscribir a la respuesta del update).
- [ ] **10.2.5** Playwright: reactivar el test MC-01 dropped en Fase 7 — Ana renombra el colectivo, reload, nombre persiste; Borja (member) no ve el input editable.
**Criterio de aceptación:** admin edita nombre+emoji sin reload; member/guest ven valores de sólo lectura.
---
#### 10.3 CU-H08 — Disolver colectivo
**Fichero:** nueva RPC con CASCADE explícito + modal con confirmación por nombre + botón en `/collective/manage`.
**Regla clave (spec §2.3):** elimina el colectivo y **todo** su contenido permanentemente. Sólo admin.
**Decisión (confirmada con el usuario):** **confirmación tipeando el nombre del colectivo** (no checkbox) — patrón GitHub/Vercel para operaciones destructivas.
**Tareas:**
- [ ] **10.3.1** Migración `supabase/migrations/018_dissolve_collective_rpc.sql`:
```sql
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 text;
begin
if v_user is null then raise exception 'not authenticated'; end if;
select role into v_role from 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' using errcode = 'P0002';
end if;
-- CASCADE: todas las FKs de contenido apuntan a collective_id con on delete cascade
delete from collectives where id = p_collective_id;
end; $$;
grant execute on function public.dissolve_collective(uuid) to authenticated;
```
**Pre-check obligatorio:** auditar que **todas** las tablas con `collective_id` tienen `on delete cascade`. En particular: `shopping_lists`, `shopping_items`, `task_lists`, `tasks`, `notes`, `collective_invitations`, `collective_members`, `trashed_lists` (si existe tabla separada), `sync_conflicts` (recién creada en Fase 9). Si alguna no lo tiene, migración adicional 019 para añadirlo antes de exponer la RPC.
- [ ] **10.3.2** UI: botón "Disolver colectivo" al final de `/collective/manage`, en zona "Peligro" con separador visual y color danger. Sólo visible para admin.
- [ ] **10.3.3** Modal de confirmación:
- Título: "¿Disolver [nombre]?".
- Texto: advertencia explícita ("Se borrarán N listas, M tareas, K notas. Esta acción es irreversible.") con counts reales leídos antes de abrir el modal.
- Input: "Escribe **`<nombre del colectivo>`** para confirmar".
- Botón "Disolver permanentemente" disabled hasta que el input iguale exactamente el nombre (case-sensitive).
- [ ] **10.3.4** Post-éxito: limpiar `$currentCollective` y cualquier caché local, redirigir a `/onboarding` si era el único colectivo del usuario, o al siguiente más antiguo.
- [ ] **10.3.5** Tests pgTAP (`011_dissolve_collective.sql`): admin disuelve OK + contenido desaparece en cascada; member rechazado con P0002; guest rechazado.
- [ ] **10.3.6** Playwright `tests/e2e/dissolve-collective.test.ts`: D-01 Ana disuelve un colectivo auxiliar (creado en `beforeAll`, no el semilla!) tipeando el nombre → redirect + colectivo desaparece de la sidebar; D-02 botón "Disolver" permanece disabled mientras el input no matchee exactamente; D-03 Borja (member) no ve el botón "Disolver".
**⚠️ Crítico:** el test semilla (`seed.sql`) NO debe ser disoluble por accidente en tests — crear colectivos auxiliares con fixture dedicado. Si se disuelve la semilla, toda la suite aguas abajo rompe.
---
#### 10.4 Trash drawer — restore + hard delete
**Fichero:** `apps/web/src/routes/(app)/lists/+page.svelte` (o el componente del drawer).
**Estado actual:** el drawer ya muestra listas tachadas; sin botones. Triggers ya borran permanentemente a los 7 días.
**Tareas:**
- [ ] **10.4.1** Migración `supabase/migrations/020_restore_list_rpc.sql` — dos funciones:
```sql
-- restore
create or replace function public.restore_list(p_list_id uuid) returns void
...
update shopping_lists set deleted_at = null where id = p_list_id and ...rls check...;
-- hard delete (antes del ttl de 7 días)
create or replace function public.hard_delete_list(p_list_id uuid) returns void
...
delete from shopping_lists where id = p_list_id and deleted_at is not null and ...rls check...;
```
Ambas con `security definer` y guarda de membresía (member o admin del colectivo).
- [ ] **10.4.2** En cada fila del drawer: dos botones (icon-only con tooltip) — "Restaurar" y "Eliminar para siempre".
- [ ] **10.4.3** "Eliminar para siempre" abre un confirm simple (no hace falta el patrón de nombre — ya está soft-deleted, el daño potencial es menor).
- [ ] **10.4.4** Tests pgTAP (`012_trash_rpcs.sql`): restore de lista propia OK + `deleted_at` queda null; restore de lista ajena falla; hard delete de lista ya soft-deleted OK; hard delete de lista no soft-deleted falla (guardia).
- [ ] **10.4.5** Integración Vitest: simular borrado + restore, verificar que la lista reaparece en el listado activo.
---
#### 10.5 Eliminación de cuenta (hard delete)
**Decisión (confirmada con el usuario):** **hard delete, con nota informativa clara al usuario antes de confirmar** explicando qué se borra y qué se queda.
**Regla clave (spec §6.3):** borrar una cuenta NO borra el contenido del colectivo; el contenido queda huérfano con `created_by` apuntando a una fila inexistente (protegido por `on delete set null` o equivalente).
**Auditoría previa (no-trivial):** confirmar que `created_by`, `checked_by`, `completed_by` en las tablas relevantes tienen `on delete set null` y no `cascade`. Si alguna usa `cascade`, migración previa para cambiarlo.
**Tareas:**
- [ ] **10.5.1** Auditoría de FKs: grep de `references auth.users` y `references public.users` en todas las migraciones → clasificar comportamiento on-delete → migración `019_user_deletion_fks.sql` para normalizar a `set null` donde no lo esté.
- [ ] **10.5.2** Migración `021_delete_account_rpc.sql`:
```sql
create or replace function public.delete_account() returns void
language plpgsql
security definer
set search_path = public, auth
as $$
declare
v_user uuid := auth.uid();
v_is_sole_admin_with_members boolean;
begin
if v_user is null then raise exception 'not authenticated'; end if;
-- bloquear si el usuario es único admin de un colectivo con otros miembros
-- y no hay member elegible para auto-promoción (ya hay trigger, pero blindar)
select exists(
select 1 from collective_members cm
where cm.user_id = v_user and cm.role = 'admin'
and (select count(*) from collective_members c2 where c2.collective_id = cm.collective_id) > 1
and not exists (
select 1 from collective_members c3
where c3.collective_id = cm.collective_id
and c3.user_id <> v_user
and c3.role <> 'guest'
)
) into v_is_sole_admin_with_members;
if v_is_sole_admin_with_members then
raise exception 'sole admin with non-eligible members' using errcode = 'P0003';
end if;
-- el trigger de promoción auto-asciende al member más antiguo cuando el admin sale
delete from auth.users where id = v_user;
-- CASCADE borra collective_members del usuario; created_by etc. van a null por la migración 019
end; $$;
grant execute on function public.delete_account() to authenticated;
```
- [ ] **10.5.3** UI en `/settings` → zona "Peligro":
- Botón "Eliminar mi cuenta".
- Modal con **nota informativa explícita**: "Se eliminará permanentemente: tu cuenta, tus membresías en N colectivos, tu idioma/tema guardado. **NO** se eliminará: el contenido que hayas creado (listas, tareas, notas) — queda en los colectivos, atribuido a 'Usuario eliminado'. Si eres el único admin de algún colectivo, el miembro más antiguo será promovido automáticamente."
- Confirmación: escribir la palabra `ELIMINAR` para habilitar el botón.
- [ ] **10.5.4** Post-éxito: llamar a `logout()` (que dispara el flow RP-initiated a Keycloak) — la cuenta de GoTrue ya no existe, pero Keycloak sí. El `/logged-out` absorbe la redirección.
- **⚠️ decisión de scope:** NO borramos el usuario en Keycloak (requeriría admin API token y es siguiente nivel de trabajo). El usuario sigue pudiendo registrarse de nuevo con el mismo email y obtendrá una cuenta nueva (nuevo UUID). Documentar en la nota informativa como comportamiento conocido.
- [ ] **10.5.5** Tests pgTAP (`013_delete_account.sql`): 4 asserts — delete propio OK + fila de `auth.users` desaparece; sole-admin-con-members-no-elegibles rechazado con P0003; sole-admin-con-promotable OK + promoción triggereada; `created_by` en items del usuario borrado queda null.
- [ ] **10.5.6** Playwright `tests/e2e/account-deletion.test.ts`: DEL-01 crear usuario efímero, delete account, intento de login falla; DEL-02 contenido creado por el usuario borrado sigue visible en la UI para otros miembros, con autor "Usuario eliminado".
---
#### 10.6 "Crear nuevo colectivo" desde la sidebar
**Fichero:** dropdown de selector de colectivo en la sidebar + reuso de `create_collective()` RPC de migración 012.
**Estado actual:** el switcher existe (cambio entre colectivos) pero no tiene entrada "+ Nuevo". Eva puede crear UNO en `/onboarding`, después está atascada.
**Tareas:**
- [ ] **10.6.1** Añadir entrada "+ Nuevo colectivo" al final del dropdown del sidebar.
- [ ] **10.6.2** Abre un modal con el mismo formulario de `/onboarding` (nombre + emoji); al éxito, switch automático al nuevo colectivo + redirect a `/lists`.
- [ ] **10.6.3** Recupera (por fin) el test O-04 que se dropeó en Fase 7: "Eva con colectivo activo crea un segundo → ambos aparecen en el switcher".
**Criterio de aceptación:** usuarios con ≥ 1 colectivo pueden crear otros desde la sidebar sin salir a `/onboarding`.
---
#### 10.7 Botón "Reiniciar lista" en vista de detalle
**Fichero:** `apps/web/src/routes/(app)/lists/[id]/+page.svelte`.
**Estado actual:** `resetList()` RPC existe; botón sólo aparece en `/lists` (overview). Desde la vista detalle no se puede resetear — UX roto: el usuario acaba de completar la lista, ve la pantalla de "lista completada", y tiene que volver al overview para reiniciarla.
**Tareas:**
- [ ] **10.7.1** Añadir botón "Reiniciar lista" en el estado "lista completada" de la vista detalle.
- [ ] **10.7.2** Llamar a la misma RPC `reset_list()`; al éxito, la vista vuelve al modo activo en el mismo renderer sin redirect.
- [ ] **10.7.3** Playwright: extender el test existente de reset para cubrir también el botón de la detalle view.
---
#### 10.8 Detección automática de idioma
**Fichero:** hook de servidor (`apps/web/src/hooks.server.ts` si existe) o `(app)/+layout.ts`.
**Estado actual:** un usuario nuevo obtiene `users.language = 'en'` por default en el seed; no se inspecciona `Accept-Language` del navegador.
**Tareas:**
- [ ] **10.8.1** Al primer login (cuando `users.language IS NULL` o aún no existe la fila), leer `Accept-Language` del request. Si la cabecera contiene `es` (cualquier variante) con q-score ≥ 0.5, establecer `users.language = 'es'`; en cualquier otro caso, `'en'`.
- [ ] **10.8.2** Persistir UPDATE en `public.users` junto con el resto de la inicialización del usuario (ya existe ese path en post-login).
- [ ] **10.8.3** Fallback duro: si por cualquier motivo el parseo falla, default `'en'`.
- [ ] **10.8.4** Test unit: parser de `Accept-Language` con 6 casos (es, en, es-ES, es;q=0.9,en;q=0.8, en-US,es;q=0.3, vacío).
- [ ] **10.8.5** Test integración: simular login con header `Accept-Language: es-ES,es;q=0.9,en;q=0.5` para un usuario nuevo → `users.language` acaba en `'es'`.
**Criterio de aceptación:** un hispanohablante aterriza en la UI en español sin tocar nada; `/settings` sigue permitiendo override manual.
---
#### 10.Z Verificación + cierre
- [ ] `just test-all` → 267 (baseline Fase 9) + deltas estimados (~18 nuevos: pgTAP ~14, Playwright ~8, unit/integration ~6) = **~285 verdes**, 3 skip (2 presencia + 1 rate-limit).
- [ ] `just rls-audit` limpio — las 4 nuevas RPCs + las modificaciones de FK no introducen holes.
- [ ] `just test-rate-limit` sigue pasando.
- [ ] CLAUDE.md actualizado: total tests + línea "Fase 10 complete: spec-completion (H06/H07/H08 + trash restore + account deletion + sidebar-create + reset-from-detail + Accept-Language)".
- [ ] `docs/history/fase-10-spec-completion.md` con:
- Diagrama de FKs on-delete por tabla (resultado de la auditoría 10.5.1) — referencia valiosa para futuros cambios.
- Decisión documentada: Keycloak NO se limpia al delete-account (y por qué).
- Nueva tabla total de cobertura del spec (objetivo: 100% de las CU-H*).
---
### Riesgos / notas
1. **Auditoría de on-delete FKs (10.5.1) puede destapar más migraciones de las previstas.** Si la mayoría de tablas usan `cascade` en lugar de `set null`, el blast radius de 10.5 se multiplica. Mitigación: hacer la auditoría como primera tarea de la fase (antes de escribir ningún código) y recalibrar scope si hace falta.
2. **Verificación de CASCADE completo en dissolve (10.3.1).** Un colectivo sin borrado en cascada completo dejaría filas huérfanas que romperían RLS a los otros usuarios del colectivo. Mitigación: el pre-check listado es obligatorio; si falta alguna, bloquear el merge hasta añadirlo.
3. **Race entre delete-account y sesión activa en otro dispositivo.** El usuario A borra su cuenta en el móvil; su pestaña de escritorio sigue abierta con JWT válido hasta que expire. Mientras, todas sus queries devolverán resultados "zombi" (PostgREST no revoca JWTs). Mitigación: documentar como comportamiento esperado — el JWT caduca en pocos minutos, el usuario en el otro dispositivo verá errores 401 al siguiente refresh. No es bloqueante para la fase.
4. **Dissolve vs leave para último miembro.** 10.1.1 bloquea "leave" si el usuario es el único miembro, forzando a usar dissolve. Alternativa (más amigable): auto-convertir leave-as-sole-member en dissolve con confirmación extra. Decidido el primero por explicitud; reconsiderar si UX testing lo demanda.
5. **Accept-Language + usuarios existentes.** La detección sólo aplica a filas nuevas; los usuarios ya registrados conservan su `users.language` actual (correcto — respeta su elección previa).
6. **Test O-04 (rescatado en 10.6.3)** depende de que `resetEva()` borre también cualquier colectivo que Eva haya creado en el test anterior, no sólo las membresías. Revisar el helper `resetEva()` de Fase 7 (fichero `apps/web/tests/fixtures/db.ts`) antes de escribir O-04.
---
### Scope explícito fuera
- **No** se tocan features completamente nuevas de dominio (recurring lists, barcode scan, push notifications, recipes, budget). Eso es Fase 11+.
- **No** se re-introduce el campo estructurado de cantidad/unidad en items (regresión de migración 011). Esa decisión es producto, no completion debt.
- **No** se borra el usuario en Keycloak durante delete-account. Queda como deuda conocida documentada.
- **No** se migra el idioma detectado a `Accept-Language` para usuarios existentes — sólo altas nuevas.
- **No** se cambia el TTL de 7 días del trash — 10.4 sólo expone las afordances ya implícitas en los triggers existentes.