Records the closing state of Fase 10: spec coverage now 100% on the H-series CUs, full FK on-delete audit table, 5 architectural decisions (Keycloak not cleaned up on account delete, sole-member must dissolve, type-the-name dissolve confirmation, client-side language detection, trash via RPC). Bumps the MVP2 progress to 2/6 and the test totals to 360 active + 3 skipped. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
8.2 KiB
Fase 10 — Spec completion (MVP2 2/6)
Status: ✅ complete · 2026-05-18.
Closes the 8 functional-spec gaps audited on 2026-04-22:
- CU-H06 Abandonar colectivo
- CU-H07 Renombrar colectivo + cambiar emoji
- CU-H08 Disolver colectivo
- Trash drawer — restore + hard delete
- Eliminación de cuenta (hard delete)
- "Crear nuevo colectivo" desde la sidebar
- Botón "Reiniciar lista" en vista de detalle
- Detección automática de idioma (Accept-Language)
Spec coverage on the CU-H* axis (collective management + user lifecycle) is now 100 %.
Migrations introduced
| # | File | Purpose |
|---|---|---|
| 017 | 017_leave_collective_rpc.sql |
public.leave_collective(uuid) — sole-admin auto-promote inline; P0001 for sole-member |
| 018 | 018_dissolve_collective_rpc.sql |
public.dissolve_collective(uuid) — admin only (P0002), cascade-driven |
| 019 | 019_user_deletion_fks.sql |
Relax every created_by / updated_by FK from NOT NULL RESTRICT → NULL SET NULL |
| 020 | 020_restore_list_rpc.sql |
public.restore_list(uuid) + public.hard_delete_list(uuid) with explicit role guards |
| 021 | 021_delete_account_rpc.sql |
public.delete_account() — two-step (public.users then auth.users); P0003 for sole-admin-stuck |
Migration 019 was held back from the trash work (10.4) and used here in 10.5 — that's why the trash RPC migration is 020, leaving 019 as the FK-normalization seat.
FK on-delete audit (Fase 10.5.1)
Before migration 019, deleting a public.users row would have been blocked
by every table where the user had ever created content. The relaxed
behaviour after 019:
| Table | Column | Before | After |
|---|---|---|---|
collectives |
created_by |
NOT NULL RESTRICT |
NULL SET NULL |
collective_members |
user_id |
NOT NULL CASCADE |
(unchanged — membership belongs to the user) |
collective_invitations |
created_by |
NOT NULL CASCADE |
NULL SET NULL |
collective_invitations |
accepted_by |
NULL SET NULL |
(unchanged) |
shopping_lists |
created_by |
NOT NULL RESTRICT |
NULL SET NULL |
shopping_items |
created_by |
NOT NULL RESTRICT |
NULL SET NULL |
shopping_items |
checked_by |
NULL SET NULL |
(unchanged) |
task_lists |
created_by |
NOT NULL RESTRICT |
NULL SET NULL |
tasks |
created_by |
NOT NULL RESTRICT |
NULL SET NULL |
tasks |
completed_by |
NULL SET NULL |
(unchanged) |
notes |
created_by |
NOT NULL RESTRICT |
NULL SET NULL |
notes |
updated_by |
NOT NULL RESTRICT |
NULL SET NULL |
sync_conflicts |
user_id |
NOT NULL CASCADE (→ auth.users) |
(unchanged — log entries vanish with the user) |
Implication: any UI that renders the author of a row must defensively
handle NULL. The intended label is "Deleted user" — the visual polish
is deferred to a later cosmetic pass; the data layer is correct now.
Decisions documented
-
Keycloak is NOT cleaned up on
delete_account(). The Keycloak admin API requires an admin token (separate auth realm), and is the external operator's domain. Afterdelete_account()the row is gone from GoTrue'sauth.usersand the identity link inauth.identities, but the Keycloak account itself survives. The next OIDC login with the same email reaches GoTrue with no matching identity, GoTrue provisions a newauth.usersrow with a fresh UUID, thehandle_new_usertrigger creates the correspondingpublic.usersrow, and the user is effectively a brand-new profile. The settings modal makes this explicit. -
Sole-member "leave" is rejected, not auto-converted to dissolve.
leave_collectiveraises errcode P0001 in that case; the UI surfaces the "use Dissolve instead" hint with no follow-up navigation. This keeps the destructive paths explicit (per plan §Riesgo 4). -
Dissolve confirmation by typing the collective name, not a checkbox — pattern from GitHub/Vercel for destructive operations. Plan §10.3 confirmed; D-02 enforces it.
-
Auto-language detection lives client-side (
navigator.languages) not server-side (Accept-Languageheader). The OIDC dance (browser ↔ Keycloak ↔ GoTrue) bypasses SvelteKit entirely, so ahooks.server.tswould never see those requests. The bootstrap is gated to the post-SIGNED_INpath on the layout, fires only once per account (60-second-old +language='en'heuristic), and skips any user who has explicitly set a language before. Hispanic-locale users land in Spanish without touching anything; existing users are not retroactively re-detected. -
Trash UI uses RPCs even though the existing PostgREST UPDATE/DELETE already works. The RPC route closes a
is_active_memberambiguity (guest is excluded today via WITH CHECK, but the surface area was broader than the two intents) and pre-emptively flags the case where someone tries to hard-delete a still-active list. Direct UPDATEs continue to pass C-07..C-12 inrls-lists.test.ts— the policies are unchanged.
Test deltas
| Suite | Baseline (Fase 9) | After Fase 10 | Δ |
|---|---|---|---|
| pgTAP | 65 | 96 | +31 (010 leave +7, 011 dissolve +8, 012 trash-rpcs +8, 013 delete-account +8) |
| Vitest integration | 144 | 151 | +7 (trash-rpcs +5, language-bootstrap +2) |
| Vitest unit | 26 | 38 | +12 (accept-language) |
| Playwright E2E | 65 | 74 | +9 (L-01, MC-01a/b, D-01/02/03, O-04, RST-01, DEL-01) |
| Rate-limit (gated) | 1 | 1 | — |
| Total active | 301 | 360 | +59 |
| Skipped | 3 | 3 | — |
All suites green at end of fase. RLS audit clean.
Files added
supabase/migrations/017_leave_collective_rpc.sqlsupabase/migrations/018_dissolve_collective_rpc.sqlsupabase/migrations/019_user_deletion_fks.sqlsupabase/migrations/020_restore_list_rpc.sqlsupabase/migrations/021_delete_account_rpc.sqlsupabase/tests/010_leave_collective.sqlsupabase/tests/011_dissolve_collective.sqlsupabase/tests/012_trash_rpcs.sqlsupabase/tests/013_delete_account.sqlapps/web/src/lib/utils/accept-language.tsapps/web/src/lib/utils/accept-language.test.tsapps/web/src/lib/components/CreateCollectiveModal.svelteapps/web/tests/e2e/leave-collective.test.tsapps/web/tests/e2e/rename-collective.test.tsapps/web/tests/e2e/dissolve-collective.test.tsapps/web/tests/e2e/account-deletion.test.tsapps/web/tests/e2e/sidebar-create-collective.test.tsapps/web/tests/e2e/reset-from-detail.test.tspackages/test-utils/tests/trash-rpcs.test.tspackages/test-utils/tests/language-bootstrap.test.ts
Files modified
apps/web/src/routes/(app)/settings/+page.svelte— Collectives section, Danger zone, delete-account modalapps/web/src/routes/(app)/collective/manage/+page.svelte— rename + emoji picker, Dissolve danger zone, dissolve confirm modalapps/web/src/routes/(app)/lists/[id]/+page.svelte— prominent Reset button on the completed-list viewapps/web/src/lib/stores/lists.ts— restore/hard-delete go through the new RPCsapps/web/src/lib/components/layout/DesktopSidebar.svelte— switcher always-open, "+ New collective" entryapps/web/src/lib/components/layout/MobileDrawer.svelte— matching "+ New collective" entryapps/web/src/routes/+layout.svelte—maybeBootstrapLanguage()after SIGNED_INapps/web/messages/en.json+apps/web/messages/es.json— ~30 new keys
Spec coverage table (analysis/analisis-funcional.md)
| CU | Description | Status before | Status after |
|---|---|---|---|
| CU-H01 | Crear cuenta | ✅ | ✅ |
| CU-H02 | Crear colectivo | ✅ (onboarding only) | ✅ + sidebar |
| CU-H03 | Invitar miembros | ✅ | ✅ |
| CU-H04 | Aceptar invitación | ✅ | ✅ |
| CU-H05 | Cambiar de colectivo | ✅ | ✅ |
| CU-H06 | Abandonar colectivo | ❌ | ✅ |
| CU-H07 | Renombrar / cambiar emoji | ❌ | ✅ |
| CU-H08 | Disolver colectivo | ❌ | ✅ |
| §6.3 | Eliminación de cuenta | ❌ | ✅ |
| §5.1 | Trash restore | partial (no UI) | ✅ |
| §3.4 | Reset list from detail | partial (overview only) | ✅ |
| §6.2 | Accept-Language detection | ❌ | ✅ |
All H-series CUs and the audited completion-debt items are now covered end-to-end.