Adds a tiny planned-phase doc for visual feedback during async operations. - Single SVG-circle Spinner.svelte component, three sizes (sm/md/lg = 16 / 24 / 40 px), color via currentColor (theme-aware), motion-reduce-aware, role="status" + sr-only m.loading() for a11y. - Integration at 5 existing sites that already render m.loading() text or own a `loading` flag: auth splash, /search, /notes/archive, CreateCollectiveModal "creating" state, /collective/manage sub-sections. - 5 unit + 3 e2e tests planned. No DB migration. No new Paraglide strings. Rebrand: MVP2 reopens from 7/7 ✅ to in-progress 7/8 ✅. Headers in fases 9–15 bump "·N/7" + "fases 9 → 15" to "·N/8" + "fases 9 → 16". README + CLAUDE.md updated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
19 KiB
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: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
/settingslistar colectivos del usuario con botón "Abandonar" por fila. Modal de confirmación simple (un click). Si la RPC devuelve errorP0001, 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/onboardingsi 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/managedesde 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 sicurrentRole === '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:
$currentCollectivedebe 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:Pre-check obligatorio: auditar que todas las tablas concreate 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;collective_idtienenon 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
$currentCollectivey cualquier caché local, redirigir a/onboardingsi 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 enbeforeAll, 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:Ambas con-- 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...;security definery 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_atqueda 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.usersyreferences public.usersen todas las migraciones → clasificar comportamiento on-delete → migración019_user_deletion_fks.sqlpara normalizar aset nulldonde 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
ELIMINARpara 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-outabsorbe 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 deauth.usersdesaparece; sole-admin-con-members-no-elegibles rechazado con P0003; sole-admin-con-promotable OK + promoción triggereada;created_byen 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 NULLo aún no existe la fila), leerAccept-Languagedel request. Si la cabecera contienees(cualquier variante) con q-score ≥ 0.5, establecerusers.language = 'es'; en cualquier otro caso,'en'. - 10.8.2 Persistir UPDATE en
public.usersjunto 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-Languagecon 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.5para un usuario nuevo →users.languageacaba 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-auditlimpio — las 4 nuevas RPCs + las modificaciones de FK no introducen holes.just test-rate-limitsigue 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.mdcon:- 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
-
Auditoría de on-delete FKs (10.5.1) puede destapar más migraciones de las previstas. Si la mayoría de tablas usan
cascadeen lugar deset 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. -
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.
-
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.
-
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.
-
Accept-Language + usuarios existentes. La detección sólo aplica a filas nuevas; los usuarios ya registrados conservan su
users.languageactual (correcto — respeta su elección previa). -
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 helperresetEva()de Fase 7 (ficheroapps/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-Languagepara 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.