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:
@@ -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. ✅**
|
**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
|
## 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
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -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) | 1–2 semanas |
|
| 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 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").
|
Los mockups de referencia viven en [`design/`](design/) (organizados por pantalla — ver `design/slate_collective/DESIGN.md` para la dirección "Monolith Editorial").
|
||||||
|
|
||||||
|
|||||||
132
plan/fase-11-item-tags-importance.md
Normal file
132
plan/fase-11-item-tags-importance.md
Normal 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.
|
||||||
122
plan/fase-12-section-visibility.md
Normal file
122
plan/fase-12-section-visibility.md
Normal 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.
|
||||||
159
plan/fase-13-server-admin.md
Normal file
159
plan/fase-13-server-admin.md
Normal 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).
|
||||||
80
plan/fase-14-pwa-hardening.md
Normal file
80
plan/fase-14-pwa-hardening.md
Normal 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.
|
||||||
Reference in New Issue
Block a user