Add planning docs, design system, and Stitch mockups
- CLAUDE.md: project guidance (stack, domain model, auth, i18n, gotchas) - DESIGN.md: full design system spec derived from Stitch mockups (slate monochrome, Inter, shadcn-svelte, screen patterns for all 5 views) - README.md: development plan with Justfile commands - analysis/: functional spec (domain model, use cases, business rules) - plan/: phase-by-phase implementation plans (Fase 0–4) - .stitch/designs/: generated mockups (lists, shopping session, notes, tasks, list detail with frequency chips) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
372
plan/fase-0-infraestructura.md
Normal file
372
plan/fase-0-infraestructura.md
Normal file
@@ -0,0 +1,372 @@
|
||||
### Fase 0 — Infraestructura y Entornos
|
||||
**Duración estimada: 1–2 semanas**
|
||||
**Objetivo: tener los dos entornos funcionando antes de escribir producto.**
|
||||
|
||||
#### 0.1 Variables de entorno
|
||||
|
||||
Todas las variables viven en `.env.dev` (dev) y `.env.production` (prod, fuera del repo). El fichero `.env.example` documenta cada variable con su propósito.
|
||||
|
||||
**Keycloak:**
|
||||
```env
|
||||
# Credenciales del administrador de Keycloak
|
||||
KEYCLOAK_ADMIN=admin
|
||||
KEYCLOAK_ADMIN_PASSWORD=...
|
||||
|
||||
# Realm configurado para la app
|
||||
KEYCLOAK_REALM=colectivo
|
||||
|
||||
# Client OIDC que usa SvelteKit
|
||||
KEYCLOAK_CLIENT_ID=colectivo-web
|
||||
KEYCLOAK_CLIENT_SECRET=... # solo en prod; en dev el client es public
|
||||
|
||||
# URL base de Keycloak (usada por SvelteKit y Supabase)
|
||||
# Dev: http://localhost:8080
|
||||
# Prod: https://auth.tudominio.com
|
||||
KEYCLOAK_URL=http://localhost:8080
|
||||
KEYCLOAK_ISSUER=${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}
|
||||
```
|
||||
|
||||
**Supabase:**
|
||||
```env
|
||||
# Claves de Supabase (generadas al arrancar; rotar en prod)
|
||||
SUPABASE_URL=http://localhost:54321
|
||||
SUPABASE_ANON_KEY=...
|
||||
SUPABASE_SERVICE_ROLE_KEY=... # solo para Edge Functions y scripts de admin
|
||||
JWT_SECRET=... # debe coincidir con la firma JWT de Keycloak en prod
|
||||
|
||||
# OIDC — Keycloak como proveedor externo de Supabase Auth
|
||||
SUPABASE_AUTH_EXTERNAL_KEYCLOAK_ENABLED=true
|
||||
SUPABASE_AUTH_EXTERNAL_KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID}
|
||||
SUPABASE_AUTH_EXTERNAL_KEYCLOAK_SECRET=${KEYCLOAK_CLIENT_SECRET}
|
||||
SUPABASE_AUTH_EXTERNAL_KEYCLOAK_URL=${KEYCLOAK_ISSUER}
|
||||
```
|
||||
|
||||
**SvelteKit:**
|
||||
```env
|
||||
# Públicas (expuestas al navegador — prefijo PUBLIC_)
|
||||
PUBLIC_SUPABASE_URL=http://localhost:54321
|
||||
PUBLIC_SUPABASE_ANON_KEY=...
|
||||
PUBLIC_KEYCLOAK_URL=http://localhost:8080
|
||||
PUBLIC_KEYCLOAK_REALM=colectivo
|
||||
PUBLIC_KEYCLOAK_CLIENT_ID=colectivo-web
|
||||
|
||||
# Privadas (solo servidor SvelteKit)
|
||||
SUPABASE_SERVICE_ROLE_KEY=...
|
||||
```
|
||||
|
||||
**Email (Resend):**
|
||||
```env
|
||||
RESEND_API_KEY=...
|
||||
EMAIL_FROM=noreply@tudominio.com
|
||||
```
|
||||
|
||||
**Backups (solo prod):**
|
||||
```env
|
||||
# Volumen local donde se guardan los dumps antes/además de B2
|
||||
BACKUP_LOCAL_DIR=/mnt/backups # ruta en el host montada como volumen
|
||||
|
||||
# Política de retención — número máximo de backups de cada tipo
|
||||
BACKUP_RETENTION_DAILY=7 # 7 diarios → cubre la última semana
|
||||
BACKUP_RETENTION_WEEKLY=4 # 4 semanales → cubre el último mes
|
||||
BACKUP_RETENTION_MONTHLY=3 # 3 mensuales → configurable, por defecto 3 meses
|
||||
|
||||
# Subida a Backblaze B2 (opcional pero recomendado como segunda copia)
|
||||
BACKUP_B2_ENABLED=true
|
||||
B2_BUCKET=colectivo-backups
|
||||
B2_KEY_ID=...
|
||||
B2_APPLICATION_KEY=...
|
||||
```
|
||||
|
||||
#### 0.2 Entorno de desarrollo local
|
||||
|
||||
- [ ] `docker-compose.dev.yml` con todos los servicios:
|
||||
- **PostgreSQL 15** — puerto `5432`
|
||||
- **Supabase Auth (GoTrue)** — configurado con Keycloak como OIDC externo
|
||||
- **Supabase Realtime** — puerto `4000`
|
||||
- **Supabase Storage** — puerto `5000`
|
||||
- **Kong API Gateway** — puerto `54321` (entrada principal de Supabase)
|
||||
- **Supabase Studio** — puerto `54323`
|
||||
- **Keycloak 24** — puerto `8080`, arranca con `--import-realm keycloak/realm-export.json`
|
||||
- [ ] `keycloak/realm-export.json` — realm `colectivo` preconfigurado con:
|
||||
- Client `colectivo-web` (public, PKCE habilitado, redirect URIs para dev)
|
||||
- **5 usuarios de prueba** creados y listos (ver tabla abajo)
|
||||
- Roles de realm: `app-user` (asignado a todos por defecto)
|
||||
- [ ] SvelteKit dev server en puerto `5173`, apuntando a variables de `.env.dev`
|
||||
- [ ] `supabase/seed.sql` — crea los usuarios de prueba en `auth.users` con los mismos UUIDs que Keycloak, un colectivo de ejemplo, listas e ítems
|
||||
- [ ] Generación automática de tipos TypeScript: `supabase gen types typescript`
|
||||
- [ ] `Justfile` con `just dev`, `just db-reset`, `just db-types`, `just kc-export`, `just kc-open`
|
||||
|
||||
**Usuarios de prueba en Keycloak (dev):**
|
||||
|
||||
| Usuario | Email | Contraseña | Rol en colectivo de prueba |
|
||||
|---------|-------|-----------|---------------------------|
|
||||
| `ana` | ana@dev.local | `test1234` | Administradora |
|
||||
| `borja` | borja@dev.local | `test1234` | Miembro |
|
||||
| `carmen` | carmen@dev.local | `test1234` | Miembro |
|
||||
| `david` | david@dev.local | `test1234` | Invitado |
|
||||
| `eva` | eva@dev.local | `test1234` | Sin colectivo (prueba onboarding) |
|
||||
|
||||
> Estos usuarios están en el `realm-export.json` versionado. Al hacer `just dev` arrancan disponibles sin configuración manual.
|
||||
|
||||
#### 0.3 Entorno de producción (VPS)
|
||||
|
||||
> **El entorno de producción ya está montado y dockerizado.** Keycloak, nginx y su PostgreSQL propio ya están operativos. Esta sección cubre únicamente lo que hay que añadir.
|
||||
|
||||
- [ ] Incorporar los servicios de Supabase al `docker-compose.prod.yml` existente:
|
||||
- PostgreSQL dedicado para Supabase — contenedor independiente, volumen propio, **no compartir con Keycloak**
|
||||
- GoTrue (Auth), Realtime, Storage, Kong en la red interna Docker existente
|
||||
- Kong expuesto en un puerto interno (ej: `127.0.0.1:54321`) — nginx hace proxy hacia él, no expuesto directamente
|
||||
- [ ] Añadir el contenedor SvelteKit al compose, escuchando en un puerto interno (ej: `127.0.0.1:3000`) — nginx hace proxy hacia él
|
||||
- [ ] Configurar nginx externamente (fuera del repo) para los nuevos servicios — ver bloques de ejemplo abajo
|
||||
- [ ] Exportar el realm `colectivo` desde dev (`just kc-export`) e importarlo en el Keycloak de producción via Admin Console — sin `--import-realm` en prod
|
||||
- [ ] `.env.production` en el VPS con todas las variables (ver sección 0.1), fuera del repo
|
||||
- [ ] Scripts de backup — ver sección **0.6 Sistema de Backups** para el detalle completo
|
||||
- [ ] Script de deploy: `ssh vps "cd /app && git pull && docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d --no-deps web"`
|
||||
- `--no-deps web` para actualizar solo el contenedor SvelteKit sin reiniciar Supabase ni Keycloak
|
||||
|
||||
##### Configuración nginx de referencia
|
||||
|
||||
Tres bloques independientes: app, API/Supabase y Keycloak. Ajustar puertos y dominios según la configuración real del VPS.
|
||||
|
||||
**`/etc/nginx/sites-available/colectivo-web`** — SvelteKit PWA:
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name tudominio.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/tudominio.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/tudominio.com/privkey.pem;
|
||||
|
||||
# Headers de seguridad
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy
|
||||
"default-src 'self'; \
|
||||
script-src 'self' 'wasm-unsafe-eval'; \
|
||||
style-src 'self' 'unsafe-inline'; \
|
||||
connect-src 'self' https://api.tudominio.com wss://api.tudominio.com https://auth.tudominio.com; \
|
||||
img-src 'self' data: blob:; \
|
||||
font-src 'self'; \
|
||||
worker-src 'self' blob:;"
|
||||
always;
|
||||
|
||||
# Service Worker — nunca cacheado por nginx
|
||||
location = /service-worker.js {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# Redirección HTTP → HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name tudominio.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
**`/etc/nginx/sites-available/colectivo-api`** — Kong/Supabase con WebSocket para Realtime:
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.tudominio.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/api.tudominio.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/api.tudominio.com/privkey.pem;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
# Supabase Realtime — WebSocket (wss://api.tudominio.com/realtime/v1/websocket)
|
||||
# CRÍTICO: sin estos headers el Modo Compra no funciona en producción
|
||||
location /realtime/ {
|
||||
proxy_pass http://127.0.0.1:54321/realtime/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade; # ← habilita upgrade a WebSocket
|
||||
proxy_set_header Connection "upgrade"; # ← mantiene la conexión viva
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 3600s; # ← evita que nginx cierre conexiones WS inactivas
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Resto de la API de Supabase (REST, Auth, Storage)
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:54321;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.tudominio.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
**`/etc/nginx/sites-available/colectivo-auth`** — Keycloak (si aún no está configurado):
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name auth.tudominio.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/auth.tudominio.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/auth.tudominio.com/privkey.pem;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
# Keycloak necesita el host original para construir redirect URIs correctamente
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host; # ← crítico para Keycloak
|
||||
proxy_set_header X-Forwarded-Port 443; # ← crítico para Keycloak
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffer_size 128k; # Keycloak genera headers grandes (tokens)
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name auth.tudominio.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
> **Notas importantes:**
|
||||
> - El bloque `/realtime/` debe ir **antes** del bloque `/` en el server de API — nginx aplica la regla más específica primero.
|
||||
> - `proxy_read_timeout 3600s` en el bloque de Realtime es necesario porque las conexiones WebSocket del Modo Compra son de larga duración. Sin él, nginx cierra la conexión a los 60 segundos (valor por defecto) y el cliente tiene que reconectar continuamente.
|
||||
> - En el CSP de SvelteKit, `connect-src` debe incluir tanto `https://` como `wss://` para `api.tudominio.com` — sin el `wss://` el Service Worker no puede abrir la conexión WebSocket.
|
||||
> - Los headers `X-Forwarded-Host` y `X-Forwarded-Port` en Keycloak son obligatorios. Sin ellos, Keycloak construye las redirect URIs con el puerto interno (8080) en lugar del 443, y el flujo OIDC falla.
|
||||
|
||||
#### 0.4 CI/CD
|
||||
|
||||
- [ ] `ci.yml`: lint + typecheck + tests unitarios en cada PR
|
||||
- [ ] `deploy.yml`: build de imagen SvelteKit → GitHub Container Registry → deploy vía SSH en merge a `main`
|
||||
- [ ] Secrets de GitHub: `SSH_KEY`, `VPS_HOST`, `GHCR_TOKEN`
|
||||
|
||||
#### 0.5 Monorepo
|
||||
|
||||
- [ ] Turborepo configurado con pipeline `build → test → deploy`
|
||||
- [ ] `packages/types` como paquete interno
|
||||
- [ ] `apps/web` consumiendo `packages/types`
|
||||
|
||||
#### 0.6 Sistema de Backups
|
||||
|
||||
##### Estrategia de rotación
|
||||
|
||||
Se guardan tres tipos de backup, cada uno con su propia ventana de retención:
|
||||
|
||||
| Tipo | Frecuencia | Retención por defecto | Variable de control |
|
||||
|------|-----------|----------------------|---------------------|
|
||||
| Diario | Cada día a las 02:00 | 7 copias | `BACKUP_RETENTION_DAILY` |
|
||||
| Semanal | Lunes a las 03:00 | 4 copias | `BACKUP_RETENTION_WEEKLY` |
|
||||
| Mensual | Día 1 de cada mes a las 04:00 | 3 copias | `BACKUP_RETENTION_MONTHLY` |
|
||||
|
||||
Con los valores por defecto se conservan hasta ~3 meses de historia. Cambiando `BACKUP_RETENTION_MONTHLY=6` se extiende a 6 meses sin tocar nada más.
|
||||
|
||||
##### Volumen local
|
||||
|
||||
Los dumps se guardan en una estructura de directorios clara en el volumen montado en `BACKUP_LOCAL_DIR`:
|
||||
|
||||
```
|
||||
/mnt/backups/
|
||||
├── supabase/
|
||||
│ ├── daily/
|
||||
│ │ ├── supabase_daily_2026-04-10.sql.gz
|
||||
│ │ └── supabase_daily_2026-04-11.sql.gz
|
||||
│ ├── weekly/
|
||||
│ │ └── supabase_weekly_2026-04-07.sql.gz
|
||||
│ └── monthly/
|
||||
│ └── supabase_monthly_2026-04-01.sql.gz
|
||||
└── keycloak/
|
||||
├── daily/
|
||||
├── weekly/
|
||||
└── monthly/
|
||||
```
|
||||
|
||||
##### Scripts
|
||||
|
||||
**`infra/scripts/backup.sh`** — backup manual inmediato de una BD concreta:
|
||||
```bash
|
||||
# Uso: ./backup.sh <supabase|keycloak> [daily|weekly|monthly]
|
||||
# Sin segundo argumento, genera un dump con timestamp libre (para backups manuales pre-deploy)
|
||||
# Ejemplo: ./backup.sh supabase
|
||||
# Ejemplo: ./backup.sh keycloak monthly
|
||||
```
|
||||
- Conecta al contenedor Docker correcto según el primer argumento
|
||||
- Ejecuta `pg_dump` con formato custom (`-Fc`) para compatibilidad con `pg_restore`
|
||||
- Comprime con gzip
|
||||
- Guarda en `$BACKUP_LOCAL_DIR/<db>/<tipo>/`
|
||||
- Si `BACKUP_B2_ENABLED=true`, sube el fichero a B2 con `rclone` o `b2 cli`
|
||||
- Imprime checksum SHA256 del fichero generado
|
||||
|
||||
**`infra/scripts/backup-cron.sh`** — wrapper para cron, decide el tipo automáticamente:
|
||||
```bash
|
||||
# Lógica de decisión:
|
||||
# - Si es día 1 del mes → tipo = monthly
|
||||
# - Si es lunes → tipo = weekly
|
||||
# - En cualquier otro caso → tipo = daily
|
||||
# Llama a backup.sh para supabase y keycloak secuencialmente
|
||||
# Tras el backup, llama a la función de purga para eliminar copias antiguas
|
||||
# según BACKUP_RETENTION_DAILY/WEEKLY/MONTHLY
|
||||
# Registra resultado en /var/log/backup-colectivo.log
|
||||
```
|
||||
|
||||
**`infra/scripts/restore.sh`** — restaura un dump en una BD:
|
||||
```bash
|
||||
# Uso: ./restore.sh <supabase|keycloak> <ruta-al-fichero.sql.gz>
|
||||
# Ejemplo: ./restore.sh supabase /mnt/backups/supabase/daily/supabase_daily_2026-04-10.sql.gz
|
||||
```
|
||||
- Pide confirmación explícita antes de ejecutar (`¿Seguro? Esto sobreescribirá la BD actual [s/N]`)
|
||||
- Detiene los servicios dependientes de la BD antes de restaurar (GoTrue, Realtime, Kong para supabase)
|
||||
- Descomprime y ejecuta `pg_restore` sobre el contenedor correcto
|
||||
- Reinicia los servicios tras la restauración
|
||||
- Imprime resumen: tablas restauradas, filas por tabla
|
||||
|
||||
##### Crontab en el host
|
||||
|
||||
```cron
|
||||
# Backups de producción — colectivo
|
||||
0 2 * * * /app/infra/scripts/backup-cron.sh >> /var/log/backup-colectivo.log 2>&1
|
||||
```
|
||||
|
||||
Un solo entry en cron. El script decide internamente si es daily, weekly o monthly.
|
||||
|
||||
##### Tareas de la Fase 0
|
||||
|
||||
- [ ] Crear volumen Docker (o directorio montado) en `BACKUP_LOCAL_DIR` con permisos correctos
|
||||
- [ ] Implementar `backup.sh` con soporte para supabase y keycloak, compresión y subida opcional a B2
|
||||
- [ ] Implementar `backup-cron.sh` con lógica de rotación y purga por tipo
|
||||
- [ ] Implementar `restore.sh` con confirmación, parada de servicios y resumen post-restauración
|
||||
- [ ] Configurar crontab en el host del VPS
|
||||
- [ ] Verificar backup completo: ejecutar `backup.sh supabase`, comprobar fichero generado, ejecutar `restore.sh` en un entorno de prueba
|
||||
- [ ] Verificar purga: crear backups de prueba con fechas antiguas, ejecutar `backup-cron.sh`, confirmar que se eliminan los que exceden la retención
|
||||
|
||||
**Criterio de aceptación:** `just dev` levanta todo el stack en < 3 minutos. Los 5 usuarios de prueba pueden hacer login con Keycloak y llegar a la app. Deploy a producción funciona con un `git push`. Un backup manual de supabase y keycloak genera ficheros válidos que se restauran correctamente.
|
||||
|
||||
---
|
||||
93
plan/fase-1-auth-colectivo.md
Normal file
93
plan/fase-1-auth-colectivo.md
Normal file
@@ -0,0 +1,93 @@
|
||||
### Fase 1 — Autenticación y Colectivo
|
||||
**Duración estimada: 2–3 semanas**
|
||||
**Objetivo: la base del dominio. Sin esto no existe nada más.**
|
||||
|
||||
#### 1.1 Base de datos
|
||||
|
||||
- [ ] Migración: tabla `users` — espejo de `auth.users` de Supabase, poblada por trigger al primer login OIDC:
|
||||
- `display_name`, `email`
|
||||
- `language: enum(en | es)` — default `en`, sobrescrito por preferencia de navegador en primer login
|
||||
- `avatar_type: enum(initials | emoji | upload)` — default `initials`
|
||||
- `avatar_emoji: text | null`
|
||||
- `avatar_url: text | null` — URL firmada de Supabase Storage
|
||||
- [ ] Migración: tabla `collectives` con campos `id`, `name`, `emoji`, `created_by`, `created_at`
|
||||
- [ ] Migración: tabla `collective_members` con `role: enum(admin | member | guest)`
|
||||
- [ ] Migración: tabla `collective_invitations` con token, expiración y estado
|
||||
- [ ] **RLS completo** — políticas críticas:
|
||||
- Un usuario solo puede leer colectivos a los que pertenece
|
||||
- Solo administradores pueden insertar en `collective_invitations`
|
||||
- Solo administradores pueden actualizar roles en `collective_members`
|
||||
- Un usuario solo puede actualizar su propia fila en `users`
|
||||
- [ ] Trigger: `on_auth_user_created` — sincroniza el perfil de Keycloak (display_name, email) a `users` en cada login
|
||||
- [ ] Trigger: promoción automática al miembro más antiguo si el último admin elimina su cuenta (RN-10)
|
||||
- [ ] Función SQL: `is_active_member(collective_id, user_id)` — usada en todas las políticas RLS
|
||||
- [ ] Bucket Supabase Storage: `avatars` — privado, acceso vía signed URLs, un fichero por usuario (`{user_id}/avatar`)
|
||||
|
||||
#### 1.2 Integración Keycloak → Supabase
|
||||
|
||||
El JWT que emite Keycloak debe ser validable por Supabase RLS. Hay dos estrategias; elegir una antes de implementar:
|
||||
|
||||
**Opción A — JWT de Keycloak directo en Supabase (recomendada para self-hosted):**
|
||||
- Configurar `JWT_SECRET` de Supabase con la clave pública de Keycloak (RS256)
|
||||
- Supabase valida el token de Keycloak directamente en RLS via `auth.uid()`
|
||||
- Sin intermediarios, latencia mínima
|
||||
- Requiere que el claim `sub` de Keycloak sea el UUID que identifica al usuario en Supabase
|
||||
|
||||
**Opción B — GoTrue como proxy OIDC:**
|
||||
- Supabase Auth recibe el token de Keycloak, lo valida y emite su propio JWT
|
||||
- Más aislamiento, pero un salto extra en cada request
|
||||
|
||||
> **Decisión recomendada: Opción A.** Más simple operacionalmente en self-hosted. El `sub` de Keycloak se convierte en el `auth.uid()` de Supabase.
|
||||
|
||||
- [ ] Configurar `supabase/config.toml` con Keycloak como OIDC externo
|
||||
- [ ] Verificar que `auth.uid()` en las políticas RLS resuelve al `sub` de Keycloak
|
||||
- [ ] Test de integración: token de Keycloak → query a Supabase con RLS activo → respuesta correcta
|
||||
|
||||
#### 1.3 Edge Functions
|
||||
|
||||
- [ ] `invite-member`: genera token único, inserta invitación, envía email vía Resend
|
||||
- [ ] `accept-invitation`: valida token + expiración, une al usuario al colectivo, invalida el token
|
||||
|
||||
#### 1.4 Internacionalización (i18n)
|
||||
|
||||
Librería: **Paraglide JS** (`@inlang/paraglide-sveltekit`). Compile-time, tree-shaken por idioma, sin overhead en runtime.
|
||||
|
||||
- [ ] Instalar y configurar `@inlang/paraglide-sveltekit` con el plugin de Vite
|
||||
- [ ] Crear ficheros de mensajes: `messages/en.json` y `messages/es.json`
|
||||
- [ ] Configurar detección de idioma: `Accept-Language` header en primer load → guardado en `users.language` tras login
|
||||
- [ ] Cambio de idioma en runtime: actualizar `users.language` en Supabase + llamar a `setLanguageTag()` de Paraglide — sin recarga de página
|
||||
- [ ] Traducir todas las cadenas UI de la Fase 1 (onboarding, gestión de colectivo, settings)
|
||||
|
||||
> Las cadenas de UI van en `messages/`. Nunca hardcodear texto visible al usuario directamente en componentes.
|
||||
|
||||
#### 1.5 Frontend
|
||||
|
||||
- [ ] Integración de `keycloak-js` en SvelteKit:
|
||||
- Inicialización en `+layout.ts` con `checkLoginIframe: false` (compatibilidad PWA/Safari)
|
||||
- Store Svelte `auth` — expone `user`, `token`, `isAuthenticated`, `login()`, `logout()`
|
||||
- `login()` redirige al login page de Keycloak; el callback vuelve a la app con el token
|
||||
- Refresco silencioso del token antes de expiración (`updateToken(60)`)
|
||||
- [ ] Rutas protegidas: hook `+layout.server.ts` que verifica sesión y redirige si no hay token válido
|
||||
- [ ] Flujo de onboarding post-primer-login:
|
||||
- Detectar si el usuario no tiene colectivo → redirigir a `/onboarding`
|
||||
- Opción A: Crear nuevo colectivo (nombre + emoji)
|
||||
- Opción B: Introducir link de invitación
|
||||
- [ ] Pantalla `/collective/manage` — miembros, roles, botón invitar, expulsar
|
||||
- [ ] Pantalla `/invitation/[token]` — aceptar invitación (el usuario debe estar autenticado en Keycloak)
|
||||
- [ ] Selector de colectivo activo en la barra de navegación principal
|
||||
- [ ] Store Svelte `collective` — colectivo activo, lista de miembros, rol del usuario actual
|
||||
- [ ] Pantalla `/settings` — configuración de usuario:
|
||||
- Campo display name (input de texto, guardado automático con debounce)
|
||||
- Selector de avatar con tres modos:
|
||||
- *Initials* — preview generado en tiempo real desde el display name
|
||||
- *Emoji* — grid de ~40 emoji curados, tap para seleccionar
|
||||
- *Upload* — input de fichero; preview antes de subir; subida a Supabase Storage bucket `avatars`; soporte para recortar en proporción 1:1
|
||||
- Selector de idioma (English / Español) con efecto inmediato
|
||||
- Enlace externo a Keycloak Account Console para cambio de email/contraseña
|
||||
- [ ] Componente `Avatar.svelte` — renderiza según `avatar_type`: initials (letra sobre fondo slate), emoji, o `<img>` con fallback a initials si la URL falla
|
||||
|
||||
> **Nota sobre registro de nuevos usuarios:** Con Keycloak, el registro lo gestiona Keycloak (su propia pantalla o self-registration habilitado en el realm). No hay pantalla de registro en SvelteKit. Si un usuario llega desde un link de invitación sin cuenta, Keycloak gestiona el registro y luego redirige de vuelta al callback de invitación.
|
||||
|
||||
**Criterio de aceptación:** los 5 usuarios de prueba pueden autenticarse contra Keycloak, llegar a la app con sesión activa, y `auth.uid()` en Supabase resuelve correctamente para las políticas RLS.
|
||||
|
||||
---
|
||||
106
plan/fase-2a-lista-compra-crud.md
Normal file
106
plan/fase-2a-lista-compra-crud.md
Normal file
@@ -0,0 +1,106 @@
|
||||
### Fase 2a — Lista de la Compra (CRUD)
|
||||
**Duración estimada: 2 semanas**
|
||||
**Objetivo: funcionalidad completa de listas sin sincronización en tiempo real.**
|
||||
|
||||
#### 2a.1 Base de datos
|
||||
|
||||
- [ ] Migración: tabla `shopping_lists` con `status enum(active | completed | archived)`
|
||||
- [ ] Migración: tabla `shopping_items` con `name`, `quantity`, `unit`, `is_checked`, `checked_by`, `sort_order`
|
||||
- [ ] RLS: solo miembros del colectivo pueden leer/escribir listas e ítems
|
||||
- [ ] Índice en `shopping_items.list_id` y `shopping_items.sort_order`
|
||||
- [ ] Job `pg_cron`: cada noche, mueve a `archived` las listas completadas hace > 7 días en estado borrado
|
||||
- [ ] Migración: tabla `item_frequency` y trigger de recomendaciones (ver sección 2a.3)
|
||||
|
||||
#### 2a.2 Frontend
|
||||
|
||||
- [ ] Pantalla `/lists` — listado de listas activas del colectivo
|
||||
- [ ] Pantalla `/lists/[id]` — vista normal de lista:
|
||||
- Añadir ítem (input rápido: name + quantity + unit) con recomendaciones de ítems frecuentes
|
||||
- Editar ítem inline
|
||||
- Eliminar ítem con swipe (confirmación)
|
||||
- Reordenar con drag & drop (`svelte-dnd-action`)
|
||||
- [ ] Papelera de listas: retención 7 días, restauración, borrado definitivo
|
||||
- [ ] Store Svelte `lists` — listas del colectivo activo, optimistic updates
|
||||
|
||||
#### 2a.3 Recomendaciones de ítems frecuentes
|
||||
|
||||
Registra automáticamente la frecuencia de uso de cada ítem del colectivo y la usa como sistema de sugerencias al añadir nuevos ítems. Sin IA — solo frecuencia de uso basada en registros de la base de datos.
|
||||
|
||||
**Tabla:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE item_frequency (
|
||||
collective_id UUID NOT NULL REFERENCES collectives(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL, -- normalized: lower(trim(name))
|
||||
use_count INTEGER NOT NULL DEFAULT 1,
|
||||
last_used_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (collective_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_item_frequency_collective_count
|
||||
ON item_frequency(collective_id, use_count DESC);
|
||||
```
|
||||
|
||||
**Trigger** — se dispara en cada `INSERT` en `shopping_items`, normaliza el nombre y hace upsert:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION fn_record_item_frequency()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
v_collective_id UUID;
|
||||
BEGIN
|
||||
SELECT collective_id INTO v_collective_id
|
||||
FROM shopping_lists WHERE id = NEW.list_id;
|
||||
|
||||
INSERT INTO item_frequency (collective_id, name, use_count, last_used_at)
|
||||
VALUES (v_collective_id, lower(trim(NEW.name)), 1, now())
|
||||
ON CONFLICT (collective_id, name) DO UPDATE SET
|
||||
use_count = item_frequency.use_count + 1,
|
||||
last_used_at = now();
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_item_frequency
|
||||
AFTER INSERT ON shopping_items
|
||||
FOR EACH ROW EXECUTE FUNCTION fn_record_item_frequency();
|
||||
```
|
||||
|
||||
**Consulta de sugerencias** — llamada desde el cliente al escribir en el input de añadir ítem:
|
||||
|
||||
```sql
|
||||
-- Input vacío: top 5 más usados del colectivo (chips de acceso rápido)
|
||||
SELECT name, use_count
|
||||
FROM item_frequency
|
||||
WHERE collective_id = $1
|
||||
ORDER BY use_count DESC
|
||||
LIMIT 5;
|
||||
|
||||
-- Con prefijo: filtro por lo que el usuario va escribiendo
|
||||
SELECT name, use_count
|
||||
FROM item_frequency
|
||||
WHERE collective_id = $1
|
||||
AND name LIKE lower(trim($2)) || '%'
|
||||
ORDER BY use_count DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
**RLS:** los miembros del colectivo solo pueden leer las frecuencias de su colectivo. Escritura solo vía trigger (el rol de la app no tiene `INSERT`/`UPDATE` directo sobre esta tabla).
|
||||
|
||||
**Comportamiento en UI:**
|
||||
- Input vacío → muestra los 5 ítems más frecuentes como chips de acceso rápido bajo el input
|
||||
- Usuario escribe → sustituye los chips por resultados filtrados por prefijo (máx. 10)
|
||||
- Tap en chip/sugerencia → rellena el input con ese nombre (el usuario puede ajustar cantidad/unidad antes de añadir)
|
||||
- Al confirmar el ítem, el trigger registra la frecuencia automáticamente
|
||||
|
||||
**Tareas:**
|
||||
- [ ] Migración: tabla `item_frequency` con índice
|
||||
- [ ] Función `fn_record_item_frequency` + trigger `trg_item_frequency`
|
||||
- [ ] RLS en `item_frequency`
|
||||
- [ ] Componente `ItemSuggestions.svelte` — chips + lista filtrada
|
||||
- [ ] Integrar `ItemSuggestions` en el input de añadir ítem de `/lists/[id]`
|
||||
|
||||
**Criterio de aceptación:** crear, editar, reordenar y eliminar ítems funciona fluidamente. Al añadir un ítem, aparecen sugerencias basadas en el historial del colectivo; añadir un ítem actualiza su frecuencia automáticamente.
|
||||
|
||||
---
|
||||
40
plan/fase-2b-realtime-modo-compra.md
Normal file
40
plan/fase-2b-realtime-modo-compra.md
Normal file
@@ -0,0 +1,40 @@
|
||||
### Fase 2b — Sincronización en Tiempo Real y Modo Compra
|
||||
**Duración estimada: 2–3 semanas**
|
||||
**Objetivo: el diferencial del producto. Es la fase más compleja técnicamente.**
|
||||
|
||||
#### 2b.1 Sincronización Realtime
|
||||
|
||||
- [ ] Suscripción a `Postgres Changes` de Supabase Realtime sobre `items_compra` filtrada por `lista_id`
|
||||
- [ ] Suscripción a `Presence` de Supabase Realtime para indicador de miembros presentes en lista
|
||||
- [ ] Store Svelte `realtimeSync`:
|
||||
- Gestiona el ciclo de vida de las suscripciones (subscribe/unsubscribe al entrar/salir de la lista)
|
||||
- Aplica cambios remotos al estado local sin causar bucles
|
||||
- Detecta conflicto last-write-wins: si el `updated_at` remoto > local, aplica remoto y muestra aviso visual
|
||||
|
||||
#### 2b.2 Offline-First
|
||||
|
||||
- [ ] Instalación y configuración de `idb` (wrapper de IndexedDB)
|
||||
- [ ] Esquema local en IndexedDB: `pending_ops`, `listas_cache`, `items_cache`
|
||||
- [ ] Cola de operaciones pendientes: cada mutación se escribe en `pending_ops` antes de llamar a Supabase
|
||||
- [ ] Service Worker (`@vite-pwa/sveltekit` + Workbox):
|
||||
- Cache-first para assets estáticos
|
||||
- Network-first con fallback a caché para rutas de la app
|
||||
- Background sync para flush de `pending_ops` al recuperar conexión
|
||||
- [ ] Fallback manual de sync: al detectar `online` en el evento del navegador, flush de la cola (cubre Safari que no soporta Background Sync API)
|
||||
- [ ] Indicador de estado de sincronización en la UI: `sincronizado | sincronizando | sin conexión`
|
||||
|
||||
#### 2b.3 Modo Compra
|
||||
|
||||
- [ ] Ruta `/listas/[id]/compra` — UI dedicada al Modo Compra:
|
||||
- Ítems en formato grande (mínimo 64px de altura), optimizado para pulsar con el pulgar
|
||||
- Reordenamiento automático al marcar: pendientes arriba, marcados abajo (animación fluida con `flip`)
|
||||
- Indicador de presencia: avatares de miembros que tienen la lista abierta simultáneamente
|
||||
- Banner `sin conexión` cuando no hay red (los cambios se guardan en local)
|
||||
- Botón prominente **"Finalizar compra"** — confirmación modal → lista pasa a `completada`
|
||||
- Notificación push (si el usuario ha dado permiso) a otros miembros al finalizar
|
||||
- [ ] Rendimiento: 60fps en gama media, tap < 100ms (validar en Chrome DevTools con throttling)
|
||||
- [ ] Prueba de sincronización: dos dispositivos, misma lista, cambios visibles en < 1 segundo
|
||||
|
||||
**Criterio de aceptación:** dos personas en el mismo supermercado, con cobertura inestable, pueden marcar ítems desde sus móviles y verse los cambios mutuamente en tiempo real. Si se pierde la conexión, los cambios locales se sincronizan al recuperarla.
|
||||
|
||||
---
|
||||
34
plan/fase-3-tareas-notas.md
Normal file
34
plan/fase-3-tareas-notas.md
Normal file
@@ -0,0 +1,34 @@
|
||||
### Fase 3 — Tareas y Notas
|
||||
**Duración estimada: 1–2 semanas**
|
||||
**Objetivo: completar los módulos secundarios.**
|
||||
|
||||
#### 3.1 Tareas
|
||||
|
||||
- [ ] Migración: tablas `listas_tareas` y `tareas` con RLS
|
||||
- [ ] Pantalla `/tareas` — listado de listas de tareas
|
||||
- [ ] Vista de lista de tareas:
|
||||
- Añadir tarea (input rápido)
|
||||
- Checkbox de completado (registra `completada_por` + `completada_en`)
|
||||
- Tooltip "Completada por [Nombre] el [fecha]"
|
||||
- Edición inline del título
|
||||
- Swipe para eliminar
|
||||
- Drag & drop para reordenar
|
||||
- Tareas completadas al fondo (no se ocultan)
|
||||
- [ ] Sincronización vía polling a intervalos de 5 segundos cuando la vista está activa
|
||||
|
||||
#### 3.2 Notas
|
||||
|
||||
- [ ] Migración: tabla `notas` con `color`, `fijada`, `archivada`, `eliminada`, `eliminada_en` + RLS
|
||||
- [ ] Job `pg_cron`: borrado definitivo de notas en papelera con `eliminada_en` > 7 días
|
||||
- [ ] Pantalla `/notas` — tablero tipo masonry:
|
||||
- Notas fijadas en bloque superior
|
||||
- Notas activas en rejilla de colores
|
||||
- [ ] Editor de nota (`/notas/[id]`):
|
||||
- Editor de texto con Markdown básico (negrita, cursiva, listas, cabeceras)
|
||||
- Guardado automático con debounce de 500ms
|
||||
- Selector de color (8 opciones predefinidas)
|
||||
- Opciones: fijar, archivar, mover a papelera, duplicar
|
||||
- [ ] Archivo y papelera accesibles desde `/notas/archivo` y `/notas/papelera`
|
||||
- [ ] Sincronización vía polling a intervalos de 30 segundos
|
||||
|
||||
---
|
||||
36
plan/fase-4-busqueda-pulido.md
Normal file
36
plan/fase-4-busqueda-pulido.md
Normal file
@@ -0,0 +1,36 @@
|
||||
### Fase 4 — Búsqueda y Pulido Final
|
||||
**Duración estimada: 1 semana**
|
||||
**Objetivo: completar el MVP con calidad de producción.**
|
||||
|
||||
#### 4.1 Búsqueda global
|
||||
|
||||
- [ ] Función PostgreSQL `buscar_en_colectivo(colectivo_id, query, filtros)` usando `tsvector` + `tsquery`
|
||||
- [ ] Columnas `tsvector` en `items_compra`, `tareas`, `notas` con índice GIN
|
||||
- [ ] Trigger para mantener los vectores actualizados en cada INSERT/UPDATE
|
||||
- [ ] Pantalla `/buscar`:
|
||||
- Input de búsqueda con debounce 300ms
|
||||
- Resultados agrupados por tipo (listas, tareas, notas)
|
||||
- Filtros: tipo de contenido, miembro creador, rango de fechas, estado
|
||||
- La búsqueda no opera sobre papelera (RN-08)
|
||||
|
||||
#### 4.2 Perfil de usuario
|
||||
|
||||
- [ ] Pantalla `/perfil`:
|
||||
- Editar nombre y avatar (Supabase Storage)
|
||||
- Lista de colectivos a los que pertenece con rol
|
||||
- Opción de abandonar colectivo
|
||||
- Eliminar cuenta (con aviso sobre promoción de admin si aplica)
|
||||
|
||||
#### 4.3 PWA — Validación final
|
||||
|
||||
- [ ] Lighthouse PWA score ≥ 90 en mobile
|
||||
- [ ] Instalable en iOS (Safari → Añadir a pantalla de inicio) y Android (Chrome → Instalar app)
|
||||
- [ ] Notificaciones push configuradas para iOS 16.4+ (requiere PWA instalada)
|
||||
- [ ] Offline completo verificado: desconectar red, operar en todos los módulos, reconectar y verificar sync
|
||||
|
||||
#### 4.4 Seguridad y hardening
|
||||
|
||||
- [ ] Auditoría de políticas RLS: verificar aislamiento total entre colectivos con tests de integración
|
||||
- [ ] Rate limiting en Kong para endpoints de auth y invitaciones
|
||||
- [ ] Headers de seguridad en nginx: CSP, HSTS, X-Frame-Options — gestionados externamente en la config de nginx del VPS
|
||||
- [ ] Rotación de `JWT_SECRET` y `ANON_KEY` para producción (no usar los valores por defecto de Supabase)
|
||||
Reference in New Issue
Block a user