### Fase 10 — Spec completion ("cerrar la laguna del `analisis-funcional`") **MVP2 · 2/8.** Segunda fase del ciclo MVP2 (rama `mvp2`, fases 9 → 16). **Estado: ✅ Completa (2026-05-18). Cierra las 8 piezas de "completion debt" del audit 2026-04-22. Migraciones 017–021, pgTAP 010–013, 6 specs E2E + 2 integración + 12 unit. Ver `docs/history/fase-10-spec-completion.md`.** **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 **``** 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.