docs(plan): fases 11–14 — tags, section visibility, server admin, PWA hardening

Adds four planned-phase docs covering the next post-MVP roadmap:

- Fase 11 — item tags (collective-scoped) + drag-reorder UI for the
  existing `shopping_items.sort_order` column. Migration 021.
- Fase 12 — section visibility via JSONB `feature_flags` on `users` and
  `collectives`, with a `section_enabled()` SQL helper that resolves
  precedence (collective > user > default ON). Migration 022.
- Fase 13 — `/admin` area with a global `server_admins` role, append-only
  `admin_actions` audit log, RPCs for collective CRUD + member removal,
  and server-level section-visibility defaults that sit above Fase 12.
  Migrations 023+024. Depends on Fase 12.
- Fase 14 — PWA hardening: `onNeedRefresh` update toast, explicit offline
  indicator + sync queue badge + conflict surface, `__APP_VERSION__` and
  `__APP_COMMIT__` baked at build with a chip in `/settings`. No migration.
  Depends on Fase 9.3 (`sync_conflicts` wiring).

Also bumps CLAUDE.md project status with the planned-fases pointer, and
brings README.md status + index tables in line with Fase 7, 8, 9, 10
(previously missing) plus the four new ones.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 00:38:23 +02:00
parent d4a781ddcf
commit 6e0814169d
6 changed files with 513 additions and 3 deletions

View File

@@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**Fase 05 complete. Fase 6 complete (deploy readiness: PWA injectManifest SW + manifest + iOS meta tags + placeholder icons + Kong rate-limit on invitations + dev JWT rotation + prod env template + rls-audit.sh + Justfile lighthouse/rls-audit/test-rate-limit). Production stack deployed to ambrosio (OVH VPS) at https://erosi.limonia.net (app + Supabase, external Keycloak); edge proxy now off-stack — internal Caddy fans kong/app on host port 3000, whatever external proxy fronts the host terminates TLS and forwards plain HTTP to ambrosio:3000 (originally NetBird's self-hosted Traefik, now proxy-agnostic); PWA strategy switched to generateSW to unblock the prod build (see the "injectManifest hardcoded filename" gotcha below). Fase 7 complete: 12 new Playwright tests fill the collective-flow UI gap (O-01..O-03 onboarding, I-01..I-05 invitation acceptance, MC-02..MC-05 admin-manage) — writing them surfaced + fixed three latent bugs in the product code (atomic `create_collective()` RPC, pendingInvitationToken restore after login, manage page not reloading on late $currentCollective hydration). Plus migration 013 (auth.users role/aud default trigger) — found after the first real self-registration on prod via Keycloak→GoTrue left the user with empty `role`/`aud` columns, breaking every REST call with `role "" does not exist`. Post-fase-7 prod auth hardening (2026-04-20..21, see `docs/history/fase-8-auth-logout.md`): migration 014 extends the role/aud guard to UPDATE (GoTrue's pop-ORM resends role='' right after the INSERT and clobbered the INSERT trigger's backfill); `logout()` now does RP-initiated Keycloak end-session + lands on a new public `/logged-out` route, which both actually ends the Keycloak SSO cookie (so the next login re-prompts) and closes a PKCE-verifier race that produced "PKCE code verifier not found in storage" on prod. 259 tests green: 45 pgTAP + 140 Vitest integration + 15 Vitest unit + 59 Playwright + 1 Vitest rate-limit (gated, run via `just test-rate-limit`). 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). Push notifications + CI Docker + final production icons deliberately out of scope. ✅** **Fase 05 complete. Fase 6 complete (deploy readiness: PWA injectManifest SW + manifest + iOS meta tags + placeholder icons + Kong rate-limit on invitations + dev JWT rotation + prod env template + rls-audit.sh + Justfile lighthouse/rls-audit/test-rate-limit). Production stack deployed to ambrosio (OVH VPS) at https://erosi.limonia.net (app + Supabase, external Keycloak); edge proxy now off-stack — internal Caddy fans kong/app on host port 3000, whatever external proxy fronts the host terminates TLS and forwards plain HTTP to ambrosio:3000 (originally NetBird's self-hosted Traefik, now proxy-agnostic); PWA strategy switched to generateSW to unblock the prod build (see the "injectManifest hardcoded filename" gotcha below). Fase 7 complete: 12 new Playwright tests fill the collective-flow UI gap (O-01..O-03 onboarding, I-01..I-05 invitation acceptance, MC-02..MC-05 admin-manage) — writing them surfaced + fixed three latent bugs in the product code (atomic `create_collective()` RPC, pendingInvitationToken restore after login, manage page not reloading on late $currentCollective hydration). Plus migration 013 (auth.users role/aud default trigger) — found after the first real self-registration on prod via Keycloak→GoTrue left the user with empty `role`/`aud` columns, breaking every REST call with `role "" does not exist`. Post-fase-7 prod auth hardening (2026-04-20..21, see `docs/history/fase-8-auth-logout.md`): migration 014 extends the role/aud guard to UPDATE (GoTrue's pop-ORM resends role='' right after the INSERT and clobbered the INSERT trigger's backfill); `logout()` now does RP-initiated Keycloak end-session + lands on a new public `/logged-out` route, which both actually ends the Keycloak SSO cookie (so the next login re-prompts) and closes a PKCE-verifier race that produced "PKCE code verifier not found in storage" on prod. 259 tests green: 45 pgTAP + 140 Vitest integration + 15 Vitest unit + 59 Playwright + 1 Vitest rate-limit (gated, run via `just test-rate-limit`). 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). Push notifications + CI Docker + final production icons deliberately out of scope. ✅**
**Planned (📋):** Fase 9 polish + dark mode (`plan/fase-9-polish-dark-mode.md`) | Fase 10 spec completion — CU-H06/H07/H08, trash restore, account hard-delete, sidebar create, list reset, lang detect (`plan/fase-10-spec-completion.md`) | Fase 11 item tags + drag-reorder (`plan/fase-11-item-tags-importance.md`, migration 021) | Fase 12 section visibility — JSONB `feature_flags` on `users`+`collectives`, `section_enabled()` SQL helper (`plan/fase-12-section-visibility.md`, migration 022) | Fase 13 server administration — `/admin` area, `server_admins` table, audit log, server-level visibility defaults, depends on Fase 12 (`plan/fase-13-server-admin.md`, migrations 023+024) | Fase 14 PWA hardening — update toast, offline polish, version chip, depends on Fase 9.3 (`plan/fase-14-pwa-hardening.md`, no migration).
## Documentation Map ## Documentation Map
- `README.md` — full development plan, confirmed tech stack, Justfile reference, technical warnings - `README.md` — full development plan, confirmed tech stack, Justfile reference, technical warnings

View File

@@ -16,8 +16,16 @@
| Fase 3 — Tareas y Notas | ✅ Completa | | Fase 3 — Tareas y Notas | ✅ Completa |
| Fase 4 — Búsqueda y Pulido | ✅ Búsqueda + RLS audit completos; PWA install/rate-limit/JWT rotation cerrados en Fase 6 | | Fase 4 — Búsqueda y Pulido | ✅ Búsqueda + RLS audit completos; PWA install/rate-limit/JWT rotation cerrados en Fase 6 |
| Fase 5 — Rediseño UX Mobile | ✅ Completa en scope automatizable (shell + masthead + big-button create + row redesign + selection mode + sticky detail chrome); presence avatars en cards + M-swipe/SEL-long-press touch E2E diferidos a WebKit install | | Fase 5 — Rediseño UX Mobile | ✅ Completa en scope automatizable (shell + masthead + big-button create + row redesign + selection mode + sticky detail chrome); presence avatars en cards + M-swipe/SEL-long-press touch E2E diferidos a WebKit install |
| Fase 6 — Deploy readiness | ✅ Completa (PWA injectManifest + manifest + iconos placeholder + Kong rate-limit en invitaciones + rotación JWT dev + rotate-jwt.sh + `.env.production.example` + rls-audit.sh + `just lighthouse`). Rate-limit auth retirado — Keycloak brute-force nativa cubre el caso. Push notifications y iconos finales fuera de scope. | | Fase 6 — Deploy readiness | ✅ Completa (PWA generateSW + manifest + iconos placeholder + Kong rate-limit en invitaciones + rotación JWT dev + rotate-jwt.sh + `.env.erosi.example` + rls-audit.sh + `just lighthouse`). Rate-limit auth retirado — Keycloak brute-force nativa cubre el caso. Push notifications y iconos finales fuera de scope. |
| Suite de tests (pgTAP + Vitest + Playwright) | ✅ 236 tests verdes (34 pgTAP + 140 integración + 15 unit + 46 E2E) + 1 rate-limit gated (`just test-rate-limit`). 3 skipped en `just test-all`. | | Fase 7 — Collective-flow E2E tests | ✅ Completa (12 Playwright nuevos O-01..MC-05; 3 bugs latentes corregidos: RPC atómica `create_collective`, restore de `pendingInvitationToken` tras login, hidratación tardía de `$currentCollective` en manage; migración 013 trigger `role/aud` default). |
| Fase 8 — Prod auth hardening | ✅ Completa (migración 014 extiende `role/aud` guard a UPDATE; `logout()` RP-initiated Keycloak end-session + ruta pública `/logged-out`). |
| Fase 9 — Polish + Dark Mode | 📋 Planificada |
| Fase 10 — Spec completion | 📋 Planificada |
| Fase 11 — Item tags + importance UX | 📋 Planificada |
| Fase 12 — Section visibility | 📋 Planificada |
| Fase 13 — Server administration | 📋 Planificada (depende de Fase 12) |
| Fase 14 — PWA hardening | 📋 Planificada (depende de Fase 9.3) |
| Suite de tests (pgTAP + Vitest + Playwright) | ✅ 259 tests verdes (45 pgTAP + 140 integración + 15 unit + 59 E2E) + 1 rate-limit gated (`just test-rate-limit`). 3 skipped en `just test-all`. |
--- ---
@@ -43,8 +51,15 @@ Las fases ya cerradas (0, 1, 2a) reflejan este patrón retroactivamente: la secc
| Fase 4 | [fase-4-busqueda-pulido.md](plan/fase-4-busqueda-pulido.md) | 1 semana | | Fase 4 | [fase-4-busqueda-pulido.md](plan/fase-4-busqueda-pulido.md) | 1 semana |
| Fase 5 | [fase-5-mobile-ux.md](plan/fase-5-mobile-ux.md) | 12 semanas | | Fase 5 | [fase-5-mobile-ux.md](plan/fase-5-mobile-ux.md) | 12 semanas |
| Fase 6 | [fase-6-deploy-prep.md](plan/fase-6-deploy-prep.md) | 35 días | | Fase 6 | [fase-6-deploy-prep.md](plan/fase-6-deploy-prep.md) | 35 días |
| Fase 7 | [fase-7-collective-flow-tests.md](plan/fase-7-collective-flow-tests.md) | 1 semana |
| Fase 9 | [fase-9-polish-dark-mode.md](plan/fase-9-polish-dark-mode.md) | 12 semanas |
| Fase 10 | [fase-10-spec-completion.md](plan/fase-10-spec-completion.md) | 23 semanas |
| Fase 11 | [fase-11-item-tags-importance.md](plan/fase-11-item-tags-importance.md) | 12 semanas |
| Fase 12 | [fase-12-section-visibility.md](plan/fase-12-section-visibility.md) | 1 semana |
| Fase 13 | [fase-13-server-admin.md](plan/fase-13-server-admin.md) | 23 semanas |
| Fase 14 | [fase-14-pwa-hardening.md](plan/fase-14-pwa-hardening.md) | 35 días |
**Total estimado: 1116 semanas (con Fase 6 deploy prep)** **Total estimado MVP (Fase 06): 1116 semanas. Post-MVP (Fase 714): 1014 semanas adicionales.**
Los mockups de referencia viven en [`design/`](design/) (organizados por pantalla — ver `design/slate_collective/DESIGN.md` para la dirección "Monolith Editorial"). Los mockups de referencia viven en [`design/`](design/) (organizados por pantalla — ver `design/slate_collective/DESIGN.md` para la dirección "Monolith Editorial").

View File

@@ -0,0 +1,132 @@
### Fase 11 — Item tags + importance UX
**Estado: 📋 Planificada. Cierra dos huecos en la experiencia del item de compra: clasificación libre por tags (scope colectivo) y reordenación explícita por arrastrar (importance).**
**Objetivo: enriquecer el item sin tocar el dominio core. La columna `sort_order` ya existe en `shopping_items` (migración 005) y el store ya escribe `update sort_order` en batch al reordenar (`apps/web/src/lib/stores/lists.ts:224`); falta el handle de drag visible en la fila de item. Los tags son nuevos — modelo + UI + filtros.**
**Decisiones clave:**
- Tags **scoped al colectivo**, no globales. Un mismo string ("vegano") en dos colectivos = dos filas distintas. Justifica la FK directa a `collectives`.
- Sin jerarquía. Tag plano. Color opcional (8 presets, sin custom picker en MVP).
- Filtro por tag aplica intersección (item con todos los tags seleccionados), no unión.
- Reordenación: drag handle solo visible en desktop o tras long-press en mobile (evita interferir con swipe-delete).
---
#### 11.1 Modelo de tags
**Fichero:** `supabase/migrations/021_item_tags.sql`.
```sql
create table public.item_tags (
id uuid primary key default gen_random_uuid(),
collective_id uuid not null references public.collectives(id) on delete cascade,
name text not null,
color text not null default 'slate' check (color in ('slate','red','amber','green','sky','indigo','pink','stone')),
created_at timestamptz not null default now(),
unique (collective_id, name)
);
alter table public.item_tags enable row level security;
create policy "select_member" on public.item_tags
for select using (public.is_member(collective_id));
create policy "mutate_member" on public.item_tags
for all using (public.is_member(collective_id))
with check (public.is_member(collective_id));
create table public.shopping_item_tags (
item_id uuid not null references public.shopping_items(id) on delete cascade,
tag_id uuid not null references public.item_tags(id) on delete cascade,
primary key (item_id, tag_id)
);
alter table public.shopping_item_tags enable row level security;
create policy "select_via_item" on public.shopping_item_tags
for select using (public.is_member(public.list_collective_id((select list_id from public.shopping_items where id = item_id))));
create policy "mutate_via_item" on public.shopping_item_tags
for all using (public.is_member(public.list_collective_id((select list_id from public.shopping_items where id = item_id))))
with check (public.is_member(public.list_collective_id((select list_id from public.shopping_items where id = item_id))));
create index shopping_item_tags_tag_idx on public.shopping_item_tags (tag_id);
```
**Tareas:**
- [ ] **11.1.1** Migración 021 (arriba).
- [ ] **11.1.2** Añadir tabla al publication realtime (`alter publication supabase_realtime add table public.item_tags, public.shopping_item_tags`).
- [ ] **11.1.3** Tests pgTAP `supabase/tests/011_item_tags.sql`: insert tag como miembro OK; insert como no-miembro denegado; unique (collective_id, name) enforce; join tag de otro colectivo a item denegado por RLS; cascade delete del colectivo limpia ambas tablas.
- [ ] **11.1.4** `just db-types` para regenerar `packages/types/src/database.ts`.
---
#### 11.2 Stores + tipos de dominio
**Ficheros:** `apps/web/src/lib/stores/tags.ts` (nuevo), `packages/types/src/domain.ts`.
- [ ] **11.2.1** Tipo `ItemTag = { id; collective_id; name; color }` + helper `ItemWithTags = ShoppingItem & { tags: ItemTag[] }`.
- [ ] **11.2.2** Store `tagsStore` con `loadTags(collectiveId)`, `createTag(...)`, `deleteTag(id)`, suscripción realtime por colectivo activo.
- [ ] **11.2.3** Extender `loadListItems()` en `lists.ts` para join lateral de tags (una sola query con `select *, tags:item_tags(*)` via PostgREST embedding sobre `shopping_item_tags`).
- [ ] **11.2.4** Funciones `attachTag(itemId, tagId)` / `detachTag(itemId, tagId)` — escriben optimistic en el item local + insert/delete en `shopping_item_tags`.
---
#### 11.3 UI — chips, editor, filtro
**Ficheros:** `apps/web/src/lib/components/TagChip.svelte` (nuevo), `apps/web/src/lib/components/TagPicker.svelte` (nuevo), `apps/web/src/routes/(app)/lists/[id]/+page.svelte`.
- [ ] **11.3.1** `TagChip` — pill pequeño (text-xs px-2 py-0.5 rounded-full) con `bg-{color}-100 text-{color}-700` y variante dark. No interactivo por defecto; prop `onRemove` opcional añade ícono X.
- [ ] **11.3.2** `TagPicker` — combobox: input filtra tags existentes del colectivo + opción "Crear `{query}`" si no coincide. Selección múltiple. Re-usable desde item modal y filtro de lista.
- [ ] **11.3.3** Item row: chips renderizan inline al lado del nombre, líneas en wrap. Tap en chip filtra la lista por ese tag.
- [ ] **11.3.4** Item modal (existing): nueva sección "Etiquetas" con `TagPicker`.
- [ ] **11.3.5** Header de `/lists/[id]`: fila de tags activos como filtro. Selección persiste en query string (`?tags=foo,bar`) para deep-linking. Filtro es intersección.
- [ ] **11.3.6** Settings → gestión de tags del colectivo: `/settings` nueva subsección "Etiquetas" — lista con rename + borrar + cambio de color. Sin pantalla nueva, todo inline.
---
#### 11.4 Importance — drag-reorder UI
**Ficheros:** `apps/web/src/routes/(app)/lists/[id]/+page.svelte`, `apps/web/src/lib/utils/drag.ts` (nuevo o existente — verificar tareas; ver `apps/web/src/routes/(app)/tasks/[id]/+page.svelte` que ya reordena).
**Auditoría previa:** confirmar si tasks reordena con HTML5 DnD o con un helper Pointer/Touch. Replicar el mismo patrón en items para consistencia.
- [ ] **11.4.1** Drag handle (icono `GripVertical` de lucide) en cada item row. Visible siempre en `md:` y up; en mobile aparece tras long-press (300ms) en cualquier zona del item que no sea checkbox/nombre.
- [ ] **11.4.2** Reordenación local optimista; al soltar, batch update via `reorderItems(list)` ya existente en `lists.ts:224`.
- [ ] **11.4.3** Sincronización realtime: si llega un UPDATE de `sort_order` desde otro cliente, reaplicar orden sin animación destructiva (FLIP transition opcional).
- [ ] **11.4.4** Mantener compatibilidad con swipe-delete en mobile — long-press debe ganar sobre swipe horizontal solo si el dedo se mueve <8px en los primeros 300ms.
---
#### 11.5 Tests
- [ ] **11.5.1** Vitest integración `tests/integration/item-tags.test.ts`: crear tag, asociar a item, verificar select; intentar asociar tag de colectivo X a item del colectivo Y → RLS error.
- [ ] **11.5.2** Vitest unit `TagPicker.test.ts`: filtrado por substring, "Crear nuevo" aparece cuando no hay match exacto, selección múltiple.
- [ ] **11.5.3** Playwright `tests/e2e/tags.test.ts`:
- TG-01 Ana crea tag "vegano", lo asigna a 3 items, filtra lista por "vegano" → 3 items visibles.
- TG-02 query string `?tags=vegano,oferta` filtra por intersección.
- TG-03 Borja (otro colectivo) no ve los tags de Ana.
- [ ] **11.5.4** Playwright `tests/e2e/reorder-items.test.ts`:
- RO-01 Ana arrastra item del puesto 3 al 1 → recarga preserva orden.
- RO-02 Realtime: Ana reordena, Borja (mismo colectivo) ve el orden nuevo sin reload.
---
#### 11.Z Verificación + cierre
- [ ] **11.Z.1** `just test-all` verde (suma esperada: 259 + 5 pgTAP + ~6 vitest + 5 playwright ≈ 275).
- [ ] **11.Z.2** Lighthouse sobre `/lists/[id]` con 50 items + 8 tags activos: Performance ≥ 85, no degradación vs baseline pre-fase-11.
- [ ] **11.Z.3** Actualizar `messages/en.json` + `messages/es.json` con strings nuevos (placeholder, "Crear tag", "Filtrar por etiqueta", etc.).
- [ ] **11.Z.4** Documentar en `docs/history/fase-11-item-tags-importance.md`.
---
### Riesgos / notas
- **`is_member` en policy de `shopping_item_tags` hace subquery doble** (item → list → collective). Si el plan EXPLAIN muestra coste alto, materializar via función `item_collective_id(uuid) returns uuid stable`.
- **Long-press en iOS Safari** dispara el menú nativo de selección de texto. Necesario `touch-action: none` + `user-select: none` en el item row mientras drag está armado.
- **Realtime con embedding (`select *, tags:...`)** — los eventos llegan por tabla individual. Hay que escuchar `shopping_item_tags` y refrescar el item afectado, no asumir que el embedding se actualiza solo.
### Scope explícito fuera
- Tags "system" sugeridos automáticamente. Solo creación manual.
- Color picker custom. Solo 8 presets.
- Tags en notas o tareas. Solo en `shopping_items`.
- Importar tags de un colectivo a otro.

View File

@@ -0,0 +1,122 @@
### Fase 12 — Section visibility (feature toggles user + colectivo)
**Estado: 📋 Planificada. Permite a cada usuario ocultar secciones top-level que no usa, y a admins de colectivo ocultarlas para todo el colectivo. Pre-requisito para Fase 13 (server admin reusa el mismo modelo a nivel servidor).**
**Objetivo: dar control granular sobre las cuatro secciones top-level actuales (Lists, Tasks, Notes, Search) sin construir un sistema de plugins. Cierra dos peticiones: usuarios que solo usan compra, y colectivos chiquitos que no necesitan tareas/notas.**
**Decisiones clave:**
- Tres capas con precedencia: **servidor** (Fase 13) → **colectivo** (admin) → **usuario**. La capa más restrictiva gana. Si servidor dice off → off; si colectivo dice off → off para todos; si usuario dice off → off solo para él.
- Modelo de datos: JSONB `feature_flags` en `users` y en `collectives`, con un schema típico-rígido (claves conocidas, no string libre).
- Default: todo ON. Toggle off es opt-out.
- **Regla del repo:** toda sección top-level nueva requiere clave en `feature_flags` + checkbox en `/settings` y en `/collective/manage`. Documentado en `CLAUDE.md`.
---
#### 12.1 Modelo de datos
**Fichero:** `supabase/migrations/022_section_visibility.sql`.
```sql
alter table public.users
add column feature_flags jsonb not null default '{}'::jsonb;
alter table public.collectives
add column feature_flags jsonb not null default '{}'::jsonb;
-- Helper inmutable: secciones conocidas. Cambiar lista = nueva migración.
create or replace function public.known_sections()
returns text[] language sql immutable as $$
select array['lists','tasks','notes','search']
$$;
-- Helper: ¿está la sección activa para (user, collective)?
-- Precedencia: collective override > user override > default ON.
create or replace function public.section_enabled(
p_section text, p_user uuid, p_collective uuid
) returns boolean language sql stable as $$
select coalesce(
(select (feature_flags ->> p_section)::boolean
from public.collectives where id = p_collective),
(select (feature_flags ->> p_section)::boolean
from public.users where id = p_user),
true
)
$$;
```
**Tareas:**
- [ ] **12.1.1** Migración 022.
- [ ] **12.1.2** Tests pgTAP `supabase/tests/012_section_visibility.sql`: default true; user off → false; collective off → false aunque user on; user on + collective on → true; sección desconocida (`'foo'`) → true (default).
- [ ] **12.1.3** `just db-types`.
---
#### 12.2 Stores + tipos
**Ficheros:** `apps/web/src/lib/stores/features.ts` (nuevo), `packages/types/src/domain.ts`.
- [ ] **12.2.1** Tipo `SectionKey = 'lists' | 'tasks' | 'notes' | 'search'` + `FeatureFlags = Partial<Record<SectionKey, boolean>>`.
- [ ] **12.2.2** Store `enabledSections` derivado de `currentUser.feature_flags` + `currentCollective.feature_flags`, aplicando la misma precedencia que `section_enabled` para evitar viaje al servidor en cada render.
- [ ] **12.2.3** Helpers `setUserFeature(key, value)` y `setCollectiveFeature(key, value)` que escriben a la columna JSONB (RPC dedicada o `update` directo con RLS — verificar coste de policy).
- [ ] **12.2.4** Subscripción realtime a `users` (propia fila) y `collectives` (active) para reaccionar a cambios sin reload.
---
#### 12.3 Gating de UI
**Ficheros:** `apps/web/src/lib/components/layout/DesktopSidebar.svelte`, `MobileDrawer.svelte`, `BottomTabBar.svelte`, todas las rutas `(app)/lists`, `(app)/tasks`, `(app)/notes`, `(app)/search`.
- [ ] **12.3.1** Filtrar entradas de navegación (sidebar + drawer + tab bar) por `$enabledSections`. Sin animación al ocultar; cambio instantáneo.
- [ ] **12.3.2** Guard en `+layout.ts` (o `+page.ts`) de cada sección: si la sección está off, redirect a `/` con toast `"$section_disabled_for_collective"`.
- [ ] **12.3.3** Tab bar mobile recalcula columnas según número de secciones visibles (CSS grid `grid-template-columns: repeat(n, 1fr)`).
- [ ] **12.3.4** Si todas las secciones están off (edge case absurdo), forzar al menos "lists" siempre visible — único override hard en cliente.
---
#### 12.4 UI de configuración
**Ficheros:** `apps/web/src/routes/(app)/settings/+page.svelte`, `apps/web/src/routes/(app)/collective/manage/+page.svelte`.
- [ ] **12.4.1** `/settings` → nueva sección "Secciones visibles" con un toggle por `SectionKey`. Estado visual de "Override del colectivo" si el colectivo lo tiene off (toggle deshabilitado con tooltip).
- [ ] **12.4.2** `/collective/manage` (solo admin) → bloque "Secciones del colectivo" con los mismos toggles. Cambios afectan a todos los miembros en tiempo real (realtime feed).
- [ ] **12.4.3** Mensajes Paraglide: `settings_section_visibility_title`, `collective_section_visibility_title`, `section_disabled_for_collective`, etiquetas por sección.
---
#### 12.5 Convención y guía para el repo
- [ ] **12.5.1** Añadir bloque a `CLAUDE.md` bajo "Domain Model": **"Toda sección top-level nueva requiere (a) entrada en `public.known_sections()`, (b) toggle por defecto en `/settings`, (c) toggle por defecto en `/collective/manage`, (d) test pgTAP que verifique `section_enabled('newkey', ...)` por defecto true."**
- [ ] **12.5.2** Plantilla de PR (si existe) o `docs/development.md` → checklist "nueva sección".
---
#### 12.6 Tests
- [ ] **12.6.1** Vitest integración `tests/integration/section-visibility.test.ts`: precedencia capa por capa; toggle de usuario no afecta a otro usuario; toggle de admin afecta a todos los miembros del colectivo.
- [ ] **12.6.2** Playwright `tests/e2e/section-visibility.test.ts`:
- SV-01 Ana esconde "Tasks" en `/settings` → tab bar móvil y sidebar dejan de mostrarlo; navegar a `/tasks` redirige a `/`.
- SV-02 Ana (admin) esconde "Notes" en `/collective/manage` → Borja (member, mismo colectivo) lo ve desaparecer sin reload.
- SV-03 Borja apaga "Tasks" como usuario; cambia a otro colectivo donde sigue ON → "Tasks" vuelve a aparecer porque el override es por usuario, evaluado contra el colectivo activo.
---
#### 12.Z Verificación + cierre
- [ ] **12.Z.1** `just test-all` verde. Suma esperada: +4 pgTAP + ~3 vitest + 3 playwright.
- [ ] **12.Z.2** Auditoría manual: rotar las 4 secciones encendido/apagado en cada combinación user/collective, verificar que nada se rompe (rutas hijas como `/lists/[id]` siguen accesibles si "lists" off por user-override pero on por colectivo — porque el deep link no debería romperse si el dueño tiene acceso al colectivo).
- [ ] **12.Z.3** Documentar en `docs/history/fase-12-section-visibility.md`.
---
### Riesgos / notas
- **Deep linking a sección oculta**: decisión a tomar — `/lists/abc` con "lists" off ¿muestra 404, redirige al home, o respeta el deep link? Propuesta MVP: redirigir al home con toast. Si molesta, abrir issue.
- **RLS en `users.feature_flags`** — escribir solo la propia fila; existe ya una policy `users_select_self` / similar, comprobar que `update` está cubierto por `user_id = auth.uid()`.
- **JSONB vs columnas explícitas**: se elige JSONB para no requerir migración por cada sección nueva. Coste: tipado más débil en cliente; mitigado con el tipo `FeatureFlags = Partial<Record<SectionKey, boolean>>`.
### Scope explícito fuera
- Roles de feature (member vs guest vs admin con sets distintos). Solo dos planos: user + collective.
- Toggles a nivel de sub-feature dentro de una sección. Granularidad = sección top-level.
- UI para que un guest fuerce features de un colectivo. Guest sigue siendo read-only en config.

View File

@@ -0,0 +1,159 @@
### Fase 13 — Server administration
**Estado: 📋 Planificada. Mayor salto de scope hasta ahora — introduce un rol global ortogonal a los roles de colectivo, un área `/admin` nueva, RPCs con privilegios elevados, y un audit log. Requiere Fase 12 para los toggles de visibilidad a nivel servidor.**
**Objetivo: dar al operador del servidor herramientas para gestionar la salud de la instancia sin tocar SQL en producción — listar y borrar colectivos huérfanos, expulsar miembros problemáticos, fijar visibilidad por defecto de secciones a nivel servidor, y dejar trazabilidad de cada acción.**
**Decisiones clave:**
- **Rol global aparte del modelo de colectivo.** Un `server_admin` puede no pertenecer a ningún colectivo. Tabla `server_admins (user_id pk)`. No flag en `users` para evitar tener que regrabar JWT al promote/demote — la verificación es una query a esta tabla.
- **Bootstrap por seed o ENV.** En arranque, si la tabla está vacía y `SERVER_ADMIN_EMAIL` está seteado, promover ese email a admin (idempotente).
- **RPCs SECURITY DEFINER con check explícito** `if not is_server_admin(auth.uid()) then raise exception 'forbidden'`. No se exponen tablas con RLS open para admins.
- **Audit log inmutable**. Tabla `admin_actions` append-only (no UPDATE/DELETE policy). Cada RPC admin escribe una fila.
- **UI separada** en `/admin/*`. Layout propio. No se cuela con el shell del usuario normal (cambio visual evidente — banner rojo).
---
#### 13.1 Modelo: rol + audit log
**Fichero:** `supabase/migrations/023_server_admin.sql`.
```sql
create table public.server_admins (
user_id uuid primary key references public.users(id) on delete cascade,
granted_by uuid null references public.users(id) on delete set null,
granted_at timestamptz not null default now()
);
alter table public.server_admins enable row level security;
create policy "select_self_or_admin" on public.server_admins
for select using (
user_id = auth.uid()
or exists (select 1 from public.server_admins where user_id = auth.uid())
);
-- Sin INSERT/UPDATE/DELETE policy: solo SECURITY DEFINER RPCs pueden tocarla.
create or replace function public.is_server_admin(p_user uuid default auth.uid())
returns boolean language sql stable security definer
set search_path = public, auth
as $$ select exists (select 1 from public.server_admins where user_id = p_user) $$;
create table public.admin_actions (
id uuid primary key default gen_random_uuid(),
actor_id uuid not null references public.users(id) on delete restrict,
action text not null,
target_type text not null,
target_id uuid null,
payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
alter table public.admin_actions enable row level security;
create policy "select_admin_only" on public.admin_actions
for select using (public.is_server_admin());
-- Append-only: ningún UPDATE/DELETE policy. Solo INSERT desde RPCs SECURITY DEFINER.
create index admin_actions_actor_idx on public.admin_actions (actor_id, created_at desc);
create index admin_actions_target_idx on public.admin_actions (target_type, target_id);
```
**Tareas:**
- [ ] **13.1.1** Migración 023.
- [ ] **13.1.2** Bootstrap: script `infra/db-init/10-server-admin-seed.sh` que lee `SERVER_ADMIN_EMAIL` y, si la tabla está vacía, inserta la fila correspondiente vía join a `auth.users`. Idempotente.
- [ ] **13.1.3** Añadir `SERVER_ADMIN_EMAIL` a `.env.erosi.example` con comentario explicativo.
- [ ] **13.1.4** Tests pgTAP `supabase/tests/013_server_admin.sql`: `is_server_admin()` true/false; non-admin no puede SELECT `server_admins` ajeno; non-admin no puede ver `admin_actions`.
---
#### 13.2 RPCs admin
**Fichero:** `supabase/migrations/024_admin_rpcs.sql`.
Funciones (todas SECURITY DEFINER con check `if not is_server_admin() then raise exception 'forbidden' using errcode = 'P0001'`):
```sql
-- Gestión de admins (solo otro admin puede promote/demote; el último admin no puede demoteárse).
create function public.grant_server_admin(p_user uuid) returns void ...
create function public.revoke_server_admin(p_user uuid) returns void ...
-- Colectivos
create function public.admin_list_collectives(p_search text default null, p_limit int default 50)
returns table (id uuid, name text, member_count int, created_at timestamptz, deleted_at timestamptz) ...
create function public.admin_soft_delete_collective(p_collective_id uuid, p_reason text) returns void ...
create function public.admin_restore_collective(p_collective_id uuid) returns void ...
create function public.admin_hard_delete_collective(p_collective_id uuid) returns void ...
-- Solo si deleted_at not null y han pasado >= 30 días, o force flag explícito.
-- Miembros
create function public.admin_remove_member(p_collective_id uuid, p_user uuid, p_reason text) returns void ...
-- Section visibility por defecto a nivel servidor (Fase 12 dependency)
create function public.admin_set_default_section(p_section text, p_enabled boolean) returns void ...
-- Escribe en una tabla nueva server_settings (key, value jsonb) — se crea aquí.
```
**Tareas:**
- [ ] **13.2.1** Migración 024 con todas las RPCs + tabla `server_settings (key text primary key, value jsonb)`.
- [ ] **13.2.2** Cada RPC: chequeo de admin → inserción en `admin_actions` → ejecución. Wrap en transacción.
- [ ] **13.2.3** Guard "último admin": `revoke_server_admin` falla si el target es el único en `server_admins`.
- [ ] **13.2.4** Integrar `server_settings.default_sections` en `public.section_enabled(...)` de Fase 12 — añade una capa por encima del colectivo: precedencia final **server → collective → user → default true**.
- [ ] **13.2.5** Tests pgTAP `supabase/tests/014_admin_rpcs.sql`: cada RPC denegada para non-admin; soft-delete escribe audit; hard-delete bloqueado antes de los 30 días sin force; promote/demote audit; último admin no puede demoteárse.
---
#### 13.3 UI `/admin/*`
**Ficheros:** nueva ruta `apps/web/src/routes/(admin)/`. Layout propio.
- [ ] **13.3.1** `(admin)/+layout.ts`: `load` redirige a `/` si `is_server_admin()` (vía RPC) devuelve false. Sin SSR — `ssr: false`.
- [ ] **13.3.2** `(admin)/+layout.svelte`: banner rojo `bg-red-600 text-white` con texto "Admin mode — actions are logged". Sidebar simple: Colectivos, Admins, Audit log, Servidor.
- [ ] **13.3.3** `/admin/collectives` — tabla paginada con search por nombre, columnas: nombre, miembros, creado, estado (activo / soft-deleted). Acciones por fila: ver, soft-delete (modal con reason obligatorio), restore, hard-delete (modal de confirmación + checkbox "Entiendo que es irreversible").
- [ ] **13.3.4** `/admin/collectives/[id]` — detalle: miembros con rol, botón "Expulsar" por miembro (modal con reason). Sección "Acciones recientes" filtrando `admin_actions` por este `target_id`.
- [ ] **13.3.5** `/admin/admins` — lista de `server_admins`. Botón "Promote user" abre modal con email lookup (`auth.users where email = ...`). Botón "Revoke" por fila (deshabilitado si es uno mismo o si es el único).
- [ ] **13.3.6** `/admin/audit` — feed reverse-chronological de `admin_actions`, filtros por actor, acción, fecha. Paginación a 50.
- [ ] **13.3.7** `/admin/server` — defaults de sección (Fase 12): 4 toggles + servicio info (versión, uptime — usar `select pg_postmaster_start_time(), version()` vía RPC).
---
#### 13.4 Acceso al menú
- [ ] **13.4.1** Si `$isServerAdmin` (store derivado de un `is_server_admin()` cacheado al login), añadir entrada "Admin" en el menú de usuario del sidebar/drawer (separador + ícono `Shield` rojo).
- [ ] **13.4.2** Refrescar el flag al `INITIAL_SESSION` / `SIGNED_IN` / `TOKEN_REFRESHED`.
---
#### 13.5 Tests
- [ ] **13.5.1** Vitest integración `tests/integration/server-admin.test.ts`: precedencia de section-visibility con la capa server activa; RPCs denegadas para no-admin; audit log se escribe; hard-delete cascade no rompe FK fuera del colectivo.
- [ ] **13.5.2** Playwright `tests/e2e/admin.test.ts` (nuevo helper `loginAsAdmin`):
- SA-01 admin promovido vía seed accede a `/admin`; usuario normal redirigido a `/`.
- SA-02 admin soft-deletea un colectivo → miembros no pueden entrar (redirect a `/onboarding`); admin restore lo recupera.
- SA-03 admin apaga "Notes" a nivel servidor → todos los colectivos lo ven OFF aunque el admin de colectivo lo ponga ON localmente.
- SA-04 admin promueve a otro usuario; el segundo accede a `/admin`; el primero intenta revocarse a sí mismo siendo el único restante → bloqueado.
---
#### 13.Z Verificación + cierre
- [ ] **13.Z.1** `just test-all` verde. Suma esperada: +6 pgTAP + ~4 vitest + 4 playwright.
- [ ] **13.Z.2** Revisión manual de seguridad: `select * from pg_proc where proname like 'admin\_%' and prosecdef` debe coincidir con la lista esperada de RPCs SECURITY DEFINER (todas y solo las nuestras).
- [ ] **13.Z.3** Smoke en prod: crear un server_admin vía bootstrap, listar colectivos, ver audit log. No tocar datos reales.
- [ ] **13.Z.4** Documentar en `docs/history/fase-13-server-admin.md` + sección "Server administration" en `docs/deployment.md` (cómo promover el primer admin via `SERVER_ADMIN_EMAIL`).
---
### Riesgos / notas
- **Hard delete cascade**. Borrar un colectivo borra `shopping_lists``shopping_items``shopping_item_tags` → (Fase 11) `item_tags`. Todas las FK ya tienen `on delete cascade`; verificar en `EXPLAIN`. Confirmar también que `users` no se ve afectado.
- **JWT no contiene el flag admin** (el rol se chequea por query, no por claim). Compromiso: una query extra al login + cada vez que el store se hidrata. Beneficio: promote/revoke instantáneo sin re-login. Si esto se vuelve caliente, mover a custom claim vía hook `auth.users``raw_app_meta_data`.
- **Audit append-only sin policy DELETE** funciona en runtime, pero `postgres` superuser puede borrar. Aceptable: el modelo de amenaza es "admin malicioso del producto", no "operador con shell en el servidor".
- **Bootstrap idempotente** importante — si ya hay admins, el script no debe degradar nada. Test con `pgTAP`: segunda invocación con `SERVER_ADMIN_EMAIL` distinto no toca filas previas.
### Scope explícito fuera
- 2FA obligatorio para admins. Bonito, no MVP.
- Roles intermedios (read-only admin). Solo `server_admin` binario.
- Webhooks o notificaciones de acciones admin. Solo audit table.
- Importar/exportar colectivos como JSON. Solo CRUD destructivo + creación de usuario normal.
- Admin sobre `auth.users` directamente. Solo a nivel `public` (colectivos, miembros, settings).

View File

@@ -0,0 +1,80 @@
### Fase 14 — PWA hardening (auto-update + offline polish + version chip)
**Estado: 📋 Planificada. Pulido del PWA que Fase 6 dejó funcional pero crudo. Sin migraciones de DB. Depende de Fase 9.3 (wiring de `sync_conflicts`) para el badge de conflictos.**
**Objetivo: convertir el PWA de "técnicamente instalable" a "se actualiza solo, avisa cuando estás offline, y muestra qué versión corres". Tres asks pequeños, una fase pequeña.**
**Estado actual (auditado el 2026-05-18):**
- `apps/web/src/sw.ts` ya llama `self.skipWaiting()` y `clientsClaim()` — el SW nuevo toma control en cuanto entra (no requiere cerrar todas las pestañas).
- `apps/web/src/routes/+layout.svelte:15` registra con `registerSW({ immediate: true })` sin callbacks. No hay UX para avisar al usuario de update.
- Estrategia `generateSW` activa (ver gotcha "injectManifest hardcoded filename" en CLAUDE.md). Workbox precachea assets.
- IndexedDB offline-first existe (CLAUDE.md "Sync Strategy"). `sync_conflicts` se llena pero no se enseña.
---
#### 14.1 Auto-update con toast
**Ficheros:** `apps/web/src/routes/+layout.svelte`, nuevo `apps/web/src/lib/components/UpdateToast.svelte`.
- [ ] **14.1.1** Migrar de `registerSW({ immediate: true })` a `useRegisterSW({ onNeedRefresh, onOfflineReady })` desde `virtual:pwa-register/svelte`.
- [ ] **14.1.2** `UpdateToast.svelte` — pill flotante bottom-center: "Nueva versión disponible — toca para actualizar" + botón "Recargar". Click invoca `updateSW(true)` (force reload).
- [ ] **14.1.3** Cuando `onOfflineReady` dispara la primera vez (instalación inicial), toast distinto: "Lista para usar offline". Auto-dismiss a 4s.
- [ ] **14.1.4** Telemetría mínima (`console.info` con prefijo `[pwa]`): registro, update detectado, update aplicado. Sin externalizar — solo facilitar debug de campo.
- [ ] **14.1.5** Test Playwright `tests/e2e/pwa-update.test.ts` PU-01: deploy mock con nuevo SW → toast aparece → click recarga → app sirve nuevo bundle. Stubs vía `addInitScript` para simular `onNeedRefresh`.
---
#### 14.2 Offline polish
**Ficheros:** `apps/web/src/lib/components/SyncBanner.svelte` (existe), `apps/web/src/lib/stores/network.ts` (verificar; crear si no existe), `apps/web/src/routes/(app)/+layout.svelte`.
**Auditoría previa:** `SyncBanner` ya existe. Verificar si maneja `navigator.onLine` + listener `online`/`offline`. Reusar lo que haya.
- [ ] **14.2.1** Indicador explícito de estado offline: chip pequeño persistente en `MobileTopBar` y `DesktopSidebar` cuando `!navigator.onLine`. Color `bg-amber-500/10 text-amber-700`. Tooltip: "Sin conexión — los cambios se sincronizarán al volver".
- [ ] **14.2.2** Badge con número de operaciones encoladas. Lee del store de sync existente (verificar nombre en `apps/web/src/lib/stores/`). 0 → sin badge; >0 → número junto al chip offline.
- [ ] **14.2.3** Surface de conflictos (depende de Fase 9.3): si hay filas en `sync_conflicts` para el usuario, banner amarillo en `(app)/+layout.svelte` con link a `/settings/sync-conflicts` (nueva ruta — tabla de conflictos con opción "descartar local" / "descartar remoto").
- [ ] **14.2.4** Fallback de Background Sync API en Safari (gotcha en CLAUDE.md): listener `window.addEventListener('online', flushQueue)`. Verificar que está implementado; si no, añadir.
- [ ] **14.2.5** Tests Playwright `tests/e2e/offline.test.ts`:
- OF-01 `context.setOffline(true)`, añadir item → chip offline visible + badge "1" → `setOffline(false)` → badge desaparece + item sincronizado en otro contexto.
- OF-02 Forzar conflicto (escribir local + escribir remoto antes de flush) → banner conflicto aparece → `/settings/sync-conflicts` lista la fila → "descartar local" la elimina.
---
#### 14.3 Server version en perfil
**Ficheros:** `apps/web/vite.config.ts`, `apps/web/src/routes/(app)/settings/+page.svelte`, `apps/web/src/lib/version.ts` (nuevo).
- [ ] **14.3.1** En `vite.config.ts`, inyectar `define: { __APP_VERSION__: JSON.stringify(<value>), __APP_COMMIT__: JSON.stringify(<value>) }`:
- `__APP_VERSION__` = `package.json` version del root.
- `__APP_COMMIT__` = `process.env.GIT_SHA || execSync('git rev-parse --short HEAD')` con fallback a `'dev'` si no hay git.
- [ ] **14.3.2** `apps/web/src/lib/version.ts` exporta `{ version: __APP_VERSION__, commit: __APP_COMMIT__, builtAt: __BUILD_TIME__ }`. Añadir `__BUILD_TIME__ = new Date().toISOString()` al `define`.
- [ ] **14.3.3** Declarar los tres símbolos en `apps/web/src/global.d.ts`.
- [ ] **14.3.4** Sección "Acerca de" en `/settings/+page.svelte` al final: chip pequeño `text-xs text-muted` con `v{version} · {commit} · {builtAt.slice(0,10)}`. No clicable, solo info.
- [ ] **14.3.5** Bake durante el build de Docker (`apps/web/Dockerfile`): pasar `GIT_SHA` como build arg, y desde `infra/scripts/deploy-erosi.sh` capturar `git rev-parse --short HEAD` localmente y reenviar como `--build-arg GIT_SHA=$(git rev-parse --short HEAD)`.
- [ ] **14.3.6** Test unit `apps/web/src/lib/version.test.ts`: en entorno de test (Vitest sin define) usa stubs vía `vi.stubGlobal`; verifica que `version` no sea `undefined` ni `'dev'` cuando el define está activo.
---
#### 14.Z Verificación + cierre
- [ ] **14.Z.1** `just test-all` verde. Suma esperada: +1 vitest + 3 playwright.
- [ ] **14.Z.2** Smoke manual del flujo update: build, deploy, instalar PWA en móvil, redeploy con versión bumpeada, esperar el toast (≤ 60s con `updateViaCache: 'none'`), aplicar, verificar versión nueva en `/settings`.
- [ ] **14.Z.3** Lighthouse PWA score en `https://erosi.limonia.net`: objetivo ≥ 90 (installable + offline + correct manifest).
- [ ] **14.Z.4** Documentar en `docs/history/fase-14-pwa-hardening.md`.
---
### Riesgos / notas
- **`skipWaiting` agresivo puede romper sesiones activas con cambios optimistas en vuelo**. Mitigación: el toast pide confirmación del usuario antes de `updateSW(true)`. Si el usuario ignora, el SW nuevo entra de todos modos en la siguiente navegación — aceptable.
- **`onNeedRefresh` no se dispara si Workbox no detecta cambios en el manifest**. El build debe alterar al menos un asset hasheado o el `revision` del precache no cambia. `generateSW` lo hace por defecto, pero un deploy que solo cambia env runtime no fuerza update. Documentar en el runbook.
- **Background Sync en iOS Safari sigue sin existir** (gotcha histórica). El fallback `online` listener es la única solución. No degradar la UX confiando en `sync` tag.
- **`__APP_VERSION__` y los define-symbols** rompen si el bundler hace tree-shake del fichero. Usar siempre los símbolos a través de `version.ts`, nunca inline.
- **`updateViaCache: 'none'`** en el registro del SW evita que el navegador cachee el SW mismo agresivamente. Sin esto, el toast puede tardar hasta 24h en aparecer en algunos navegadores.
### Scope explícito fuera
- Push notifications (parked en MVP; iOS 16.4+ requiere instalación a home screen — fuera).
- Resolución de conflictos con CRDT. Sigue siendo last-write-wins; solo se hace visible.
- Migración a `injectManifest` (gotcha bloquea; sigue en `generateSW`).
- Versionado semántico automático. La versión del `package.json` se bumpea a mano.