Files
collective-lists/plan/fase-10-spec-completion.md
Oier Bravo Urtasun 93153cd750 docs(plan): brand fases 9–14 as MVP2
Stamps the six planned post-MVP fases as the MVP2 cycle (matching the
`mvp2` branch name). Each plan file gets an "MVP2 · N/6" header. README
status + index tables group them under an MVP2 bracket. CLAUDE.md project
status reframes the planned line as MVP2 and clarifies that Fase 7+8
shipped as MVP cleanup, not MVP2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:42:08 +02:00

19 KiB

Fase 10 — Spec completion ("cerrar la laguna del analisis-funcional")

MVP2 · 2/6. Segunda fase del ciclo MVP2 (rama mvp2, fases 9 → 14).

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:
    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:
    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:
    -- 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:
    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.