From 6e0814169db2fe0af93426fa49c7daf5f90b2f0d Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 18 May 2026 00:38:23 +0200 Subject: [PATCH] =?UTF-8?q?docs(plan):=20fases=2011=E2=80=9314=20=E2=80=94?= =?UTF-8?q?=20tags,=20section=20visibility,=20server=20admin,=20PWA=20hard?= =?UTF-8?q?ening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 2 + README.md | 21 +++- plan/fase-11-item-tags-importance.md | 132 ++++++++++++++++++++++ plan/fase-12-section-visibility.md | 122 ++++++++++++++++++++ plan/fase-13-server-admin.md | 159 +++++++++++++++++++++++++++ plan/fase-14-pwa-hardening.md | 80 ++++++++++++++ 6 files changed, 513 insertions(+), 3 deletions(-) create mode 100644 plan/fase-11-item-tags-importance.md create mode 100644 plan/fase-12-section-visibility.md create mode 100644 plan/fase-13-server-admin.md create mode 100644 plan/fase-14-pwa-hardening.md diff --git a/CLAUDE.md b/CLAUDE.md index e92dccd..fcf95d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **Fase 0–5 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 - `README.md` — full development plan, confirmed tech stack, Justfile reference, technical warnings diff --git a/README.md b/README.md index 8128f46..6750f32 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,16 @@ | 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 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. | -| 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 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. | +| 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 5 | [fase-5-mobile-ux.md](plan/fase-5-mobile-ux.md) | 1–2 semanas | | Fase 6 | [fase-6-deploy-prep.md](plan/fase-6-deploy-prep.md) | 3–5 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) | 1–2 semanas | +| Fase 10 | [fase-10-spec-completion.md](plan/fase-10-spec-completion.md) | 2–3 semanas | +| Fase 11 | [fase-11-item-tags-importance.md](plan/fase-11-item-tags-importance.md) | 1–2 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) | 2–3 semanas | +| Fase 14 | [fase-14-pwa-hardening.md](plan/fase-14-pwa-hardening.md) | 3–5 días | -**Total estimado: 11–16 semanas (con Fase 6 deploy prep)** +**Total estimado MVP (Fase 0–6): 11–16 semanas. Post-MVP (Fase 7–14): 10–14 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"). diff --git a/plan/fase-11-item-tags-importance.md b/plan/fase-11-item-tags-importance.md new file mode 100644 index 0000000..3d62cfc --- /dev/null +++ b/plan/fase-11-item-tags-importance.md @@ -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. diff --git a/plan/fase-12-section-visibility.md b/plan/fase-12-section-visibility.md new file mode 100644 index 0000000..276ad15 --- /dev/null +++ b/plan/fase-12-section-visibility.md @@ -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>`. +- [ ] **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>`. + +### 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. diff --git a/plan/fase-13-server-admin.md b/plan/fase-13-server-admin.md new file mode 100644 index 0000000..7b859ee --- /dev/null +++ b/plan/fase-13-server-admin.md @@ -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). diff --git a/plan/fase-14-pwa-hardening.md b/plan/fase-14-pwa-hardening.md new file mode 100644 index 0000000..ae65ae9 --- /dev/null +++ b/plan/fase-14-pwa-hardening.md @@ -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(), __APP_COMMIT__: JSON.stringify() }`: + - `__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.