diff --git a/.env.example b/.env.example
index 7b7c3d6..7adfb76 100644
--- a/.env.example
+++ b/.env.example
@@ -16,6 +16,25 @@ PORT=3007
# Entorno
NODE_ENV="production" # production | development | test
TZ="Europe/Madrid" # Zona horaria usada para "hoy/mañana" y render de fechas
+WEB_BASE_URL="https://taskbot.server.brobert.net" # Host público de la web (p.ej. https://wtask.org en producción)
+# DB_PATH="./data/tasks.db" # Si se define, ignora DATA_DIR y usa esta ruta exacta
+ONBOARDING_FALLBACK_MIN_DIGITS=8 # A2: longitud mínima para conservar números en menciones/tokens; por defecto 8
+
+# Onboarding A3 (prompts únicos por grupo)
+# Habilita/deshabilita la publicación (por defecto true fuera de test)
+# ONBOARDING_PROMPTS_ENABLED=true
+# Permite publicación durante tests específicos
+# ONBOARDING_ENABLE_IN_TEST=false
+#
+# Onboarding A4 (DM JIT y palabra clave)
+# Palabra clave de alta por DM: activar
+# En tests, los prompts JIT (A4) y los mensajes al grupo (A3) solo se envían si ONBOARDING_ENABLE_IN_TEST=true
+# Umbral de cobertura (publica si coverage < threshold). Por defecto 1.0
+# ONBOARDING_COVERAGE_THRESHOLD=1
+# Periodo de gracia tras la última verificación de miembros (segundos). Por defecto 90
+# ONBOARDING_GRACE_SECONDS=90
+# Cooldown entre publicaciones (días). Por defecto 7
+# ONBOARDING_COOLDOWN_DAYS=7
# Sincronización de grupos (opcional)
# Intervalo en milisegundos; por defecto 86400000 (24h). En desarrollo puede bajarse (mínimo recomendable 10000ms).
@@ -69,6 +88,9 @@ TZ="Europe/Madrid" # Zona horaria usada para "hoy/mañana" y render de fe
# METRICS_ENABLED=true
# METRICS_FORMAT=prom # prom|json
+# Migrador (opcional)
+# MIGRATIONS_LOG_LEVEL="silent" # Silencia logs del migrador (en test ya se silencian automáticamente)
+
# Control de acceso por grupos (multicomunidad)
# Modo: off|discover|enforce (por defecto off)
# GROUP_GATING_MODE=discover
diff --git a/.gitignore b/.gitignore
index 9020d2c..388708f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
# dependencies (bun install)
+#
node_modules
# output
@@ -41,7 +42,5 @@ docs/evolution-api.envs
/data/*
!data/.gitkeep
-#sveltekit
-.sveltekit
-apps
-tmp
+tmp/
+apps/web/tmp/
diff --git a/Dockerfile b/Dockerfile
index 5fd2580..dfa162b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,27 +1,47 @@
# Use standard Bun image for debugging (switch back to alpine later)
-FROM oven/bun:1.1 as base
+FROM oven/bun:debian as base
# Install basic debugging tools
-RUN apt-get update && apt-get install -y curl netcat sqlite3
+RUN apt-get update && apt-get install -y sqlite3
WORKDIR /app
+# Default data dir for SQLite; can be overridden by DB_PATH at runtime (DB_PATH has priority)
+ENV DATA_DIR=/app/data
# Create data directory with proper permissions
RUN mkdir -p /app/data && chown -R bun:bun /app/data
-# Install dependencies first (better layer caching)
+# Install bot dependencies first (better layer caching)
COPY package.json bun.lock ./
RUN bun install
-# Copy only necessary files
+# Prepare and install web dependencies
+COPY apps/web/package.json apps/web/
+WORKDIR /app/apps/web
+# No instalar optionalDependencies (better-sqlite3) en build de producción
+RUN bun install --no-optional
+
+# Copy sources
+WORKDIR /app
COPY src/ ./src/
COPY index.ts ./
+COPY apps/web/ /app/apps/web/
+COPY proxy.ts ./
+
+# Build the web app
+WORKDIR /app/apps/web
+RUN bunx svelte-kit sync && bun run build
+
+# Return to root workdir
+WORKDIR /app
-# More forgiving health check during debugging
+# More forgiving health check during debugging (router en 3000)
HEALTHCHECK --start-period=30s --interval=30s --timeout=3s --retries=3 \
- CMD curl -f http://localhost:${PORT:-3007}/health || exit 0
+ CMD curl -f http://localhost:3000/health || exit 0
-# Server runs on port from environment variable
-EXPOSE ${PORT:-3007}
+# Expose router port
+EXPOSE 3000
+# Declare volume for persistent data by default
+VOLUME ["/app/data"]
# Make script executable
COPY startup.sh ./
diff --git a/README.md b/README.md
index 3194a9b..1225a5b 100644
--- a/README.md
+++ b/README.md
@@ -13,10 +13,12 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen
## Características
- Gestión de tareas: crear, asignar, reclamar/soltar, fechas límite y código corto de referencia.
+- Edición desde la web: reclamar/soltar, editar descripción y actualizar fecha de vencimiento desde /app; completar tareas y ver “Completadas (24 h)”.
- Recordatorios configurables por usuario (frecuencia y hora, respetando zona horaria).
- Control de acceso por grupos: modos off, discover y enforce; aprobación y bloqueo por admins.
- Sincronización de grupos y miembros con cachés y schedulers configurables.
- Alias de identidad con normalización de IDs.
+- Acceso web por token mágico (/t web) con página intermedia anti-preview y sesión por cookie (idle 2h); tokens de 10 min de un solo uso.
- Métricas listas para Prometheus en el endpoint /metrics.
- Rate limiting por usuario para evitar abuso.
- Persistencia simple con SQLite, migraciones automáticas y PRAGMAs seguros (WAL, FK, etc.).
@@ -26,7 +28,7 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen
- No es un framework general de bots ni un CRM.
- No conecta directamente con WhatsApp: requiere Evolution API.
- No gestiona flujos conversacionales complejos ni multimedia avanzada.
-- No incluye panel web; la interacción es vía WhatsApp.
+- Panel web: login operativo, lista de tareas con acciones básicas (reclamar/soltar, editar texto y fecha, completar; sección “Completadas (24 h)”), vista de grupos (contadores "abiertas" y "sin responsable" con lista sin límite y botón “Reclamar”; tarjetas ordenadas por cantidad de “sin responsable”) y página de preferencias de recordatorios; la interacción principal sigue siendo WhatsApp.
- Está optimizado para un despliegue por comunidad/instancia (no multi-tenant masivo).
## Cómo funciona (alto nivel)
@@ -37,6 +39,7 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen
4. Las respuestas se encolan y envían a través de Evolution API.
5. Schedulers ejecutan sincronización de grupos/miembros, recordatorios y tareas de mantenimiento.
6. Las métricas se exponen en /metrics (Prometheus o JSON).
+7. Un proxy interno en Bun sirve web y bot bajo el mismo dominio: /webhook y /metrics → bot; el resto → web. Actualmente, la compresión HTTP está desactivada temporalmente (sin Content-Encoding).
## Uso básico
@@ -71,8 +74,11 @@ Variables clave:
- REMINDERS_GRACE_MINUTES (ventana de gracia tras la hora; por defecto 60).
- ALLOWED_GROUPS (semilla inicial), NOTIFY_ADMINS_ON_DISCOVERY.
- METRICS_ENABLED, PORT.
+- WEB_BASE_URL (host público de la web para generar enlaces absolutos; usado por /t web).
- Rate limit: RATE_LIMIT_PER_MIN, RATE_LIMIT_BURST.
- Intervalos y retención: GROUP_SYNC_INTERVAL_MS, GROUP_MEMBERS_SYNC_INTERVAL_MS, GROUP_MEMBERS_INACTIVE_RETENTION_DAYS.
+- DB_PATH: ruta al archivo SQLite. Tiene prioridad sobre DATA_DIR y permite aislar BD por rama/entorno. Ej.: DB_PATH='./data/tasks.db'
+- DATA_DIR: directorio raíz para la base de datos SQLite compartida (por defecto ./data).
Consulta:
- docs/operations.md para operación, endpoints y variables de entorno.
@@ -85,10 +91,20 @@ Consulta:
- Migraciones up-only al arranque; logging de eventos de migración.
- Copias de seguridad: respaldar el directorio data/ y planificar retención.
+## Pruebas (bun:test)
+- Suite web implementada con build programático: los tests construyen apps/web (adapter-node) una única vez, arrancan el servidor en un puerto efímero y hacen peticiones HTTP reales.
+- Sin dependencias externas: bun:test, bun:sqlite y helpers propios.
+- Cobertura actual: endpoints /api/me/tasks (gating, orden, búsqueda con ESCAPE, soonDays y paginación), /api/me/preferences (GET y POST) y página /app/preferences; además de helpers de servidor para build/arranque.
+- Ejecución: bun test tests/web
+
## Estado y licencia
- Nombre provisional: “Taskbot”.
- Licencia por definir (software libre; se evaluará GPLv3/AGPL/MIT/Apache-2.0).
+- Etapa 1 (autenticación web): completada. /login (GET intermedio + POST), sesión con idle 2h, logout y ruta /app protegida; desplegado con proxy interno en Bun.
+- Etapa 2 (lectura de datos - MVP): completada. GET /api/me/tasks (orden por due_date asc con NULL al final, búsqueda con ESCAPE, filtros soonDays/dueBefore, paginación page/limit), GET /api/me/groups (contadores open/unassigned) y GET /api/groups/:id/tasks (unassignedFirst, onlyUnassigned, limit). UI: /app (Mis tareas, filtros/búsqueda/paginación) y /app/groups (bloque “sin responsable” con prefetch).
+- Etapa 3 (preferencias): completada. GET/POST /api/me/preferences y página /app/preferences con cálculo de “próximo recordatorio” coherente con la TZ y semántica del bot.
+- Edición de tareas en web: completada. Reclamar/soltar, editar fecha y descripción desde /app; completar tareas y mostrar “Completadas (24 h)”; reclamar desde /app/groups; lista "sin responsable" sin límite y fichas ordenadas por cantidad de "sin responsable" (con gating y validación).
- Roadmap y contribuciones: pendientes de publicación.
## Enlaces
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
new file mode 100644
index 0000000..3b462cb
--- /dev/null
+++ b/apps/web/.gitignore
@@ -0,0 +1,23 @@
+node_modules
+
+# Output
+.output
+.vercel
+.netlify
+.wrangler
+/.svelte-kit
+/build
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Env
+.env
+.env.*
+!.env.example
+!.env.test
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
diff --git a/apps/web/.npmrc b/apps/web/.npmrc
new file mode 100644
index 0000000..b6f27f1
--- /dev/null
+++ b/apps/web/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/apps/web/README.md b/apps/web/README.md
new file mode 100644
index 0000000..75842c4
--- /dev/null
+++ b/apps/web/README.md
@@ -0,0 +1,38 @@
+# sv
+
+Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
+
+## Creating a project
+
+If you're seeing this, you've probably already done this step. Congrats!
+
+```sh
+# create a new project in the current directory
+npx sv create
+
+# create a new project in my-app
+npx sv create my-app
+```
+
+## Developing
+
+Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
+
+```sh
+npm run dev
+
+# or start the server and open the app in a new browser tab
+npm run dev -- --open
+```
+
+## Building
+
+To create a production version of your app:
+
+```sh
+npm run build
+```
+
+You can preview the production build with `npm run preview`.
+
+> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
diff --git a/apps/web/bun.lock b/apps/web/bun.lock
new file mode 100644
index 0000000..b3ffa84
--- /dev/null
+++ b/apps/web/bun.lock
@@ -0,0 +1,345 @@
+{
+ "lockfileVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "web",
+ "dependencies": {
+ "better-sqlite3": "^12.4.1",
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^6.1.0",
+ "@sveltejs/adapter-node": "^5.3.3",
+ "@sveltejs/kit": "^2.43.2",
+ "@sveltejs/vite-plugin-svelte": "^6.2.0",
+ "@types/bun": "^1.3.0",
+ "svelte": "^5.39.5",
+ "svelte-check": "^4.3.2",
+ "typescript": "^5.9.2",
+ "vite": "^7.1.7",
+ },
+ },
+ },
+ "packages": {
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.10", "", { "os": "android", "cpu": "arm64" }, "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.25.10", "", { "os": "android", "cpu": "x64" }, "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.10", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.10", "", { "os": "linux", "cpu": "arm" }, "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.10", "", { "os": "linux", "cpu": "ia32" }, "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.10", "", { "os": "none", "cpu": "x64" }, "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.10", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.10", "", { "os": "openbsd", "cpu": "x64" }, "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.10", "", { "os": "sunos", "cpu": "x64" }, "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
+
+ "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.6", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw=="],
+
+ "@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
+
+ "@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.2", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tCtHJ2BlhSoK4cCs25NMXfV7EALKr0jyasmqVCq3y9cBrKdmJhtsy1iTz36Xhk/O+pDJbzawxF4K6ZblqCnITQ=="],
+
+ "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
+
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.4", "", { "os": "android", "cpu": "arm" }, "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.4", "", { "os": "android", "cpu": "arm64" }, "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw=="],
+
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ=="],
+
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g=="],
+
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ=="],
+
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg=="],
+
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw=="],
+
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.4", "", { "os": "none", "cpu": "arm64" }, "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw=="],
+
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w=="],
+
+ "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
+
+ "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.6", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ=="],
+
+ "@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@6.1.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-cBNt4jgH4KuaNO5gRSB2CZKkGtz+OCZ8lPjRQGjhvVUD4akotnj2weUia6imLl2v07K3IgsQRyM36909miSwoQ=="],
+
+ "@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.3.3", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-SRDVuFBkmpKGsA9b0wYaCrrSChq2Yv5Dv8g7WiZcs8E69vdQNRamN0DzQV9/rEixvuRkojATLADNeQ+6FeyVNQ=="],
+
+ "@sveltejs/kit": ["@sveltejs/kit@2.46.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-J1fd80WokLzIm6EAV7z7C2+/C02qVAX645LZomARARTRJkbbJSY1Jln3wtBZYibUB8c9/5Z6xqLAV39VdbtWCQ=="],
+
+ "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", "deepmerge": "^4.3.1", "magic-string": "^0.30.17", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ=="],
+
+ "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
+
+ "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
+
+ "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
+
+ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+
+ "@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
+
+ "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
+
+ "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
+
+ "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
+
+ "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
+
+ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
+
+ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
+
+ "better-sqlite3": ["better-sqlite3@12.4.1", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ=="],
+
+ "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
+
+ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
+
+ "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
+
+ "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
+
+ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+
+ "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
+
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
+ "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
+
+ "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
+
+ "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
+
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
+
+ "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
+
+ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "devalue": ["devalue@5.3.2", "", {}, "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw=="],
+
+ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
+
+ "esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
+
+ "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
+
+ "esrap": ["esrap@2.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA=="],
+
+ "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
+
+ "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
+
+ "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
+ "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
+
+ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+
+ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
+
+ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
+ "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
+
+ "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
+
+ "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
+
+ "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
+
+ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
+
+ "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
+
+ "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
+
+ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
+
+ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
+
+ "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
+
+ "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
+
+ "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+ "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
+
+ "node-abi": ["node-abi@3.78.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ=="],
+
+ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
+
+ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+
+ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
+
+ "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
+
+ "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
+
+ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
+
+ "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
+
+ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
+
+ "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
+
+ "rollup": ["rollup@4.52.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.4", "@rollup/rollup-android-arm64": "4.52.4", "@rollup/rollup-darwin-arm64": "4.52.4", "@rollup/rollup-darwin-x64": "4.52.4", "@rollup/rollup-freebsd-arm64": "4.52.4", "@rollup/rollup-freebsd-x64": "4.52.4", "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", "@rollup/rollup-linux-arm-musleabihf": "4.52.4", "@rollup/rollup-linux-arm64-gnu": "4.52.4", "@rollup/rollup-linux-arm64-musl": "4.52.4", "@rollup/rollup-linux-loong64-gnu": "4.52.4", "@rollup/rollup-linux-ppc64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-musl": "4.52.4", "@rollup/rollup-linux-s390x-gnu": "4.52.4", "@rollup/rollup-linux-x64-gnu": "4.52.4", "@rollup/rollup-linux-x64-musl": "4.52.4", "@rollup/rollup-openharmony-arm64": "4.52.4", "@rollup/rollup-win32-arm64-msvc": "4.52.4", "@rollup/rollup-win32-ia32-msvc": "4.52.4", "@rollup/rollup-win32-x64-gnu": "4.52.4", "@rollup/rollup-win32-x64-msvc": "4.52.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ=="],
+
+ "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
+
+ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
+
+ "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
+
+ "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
+
+ "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
+
+ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
+
+ "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
+
+ "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
+
+ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
+
+ "svelte": ["svelte@5.39.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-8MxWVm2+3YwrFbPaxOlT1bbMi6OTenrAgks6soZfiaS8Fptk4EVyRIFhJc3RpO264EeSNwgjWAdki0ufg4zkGw=="],
+
+ "svelte-check": ["svelte-check@4.3.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg=="],
+
+ "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
+
+ "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
+
+ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+
+ "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
+
+ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
+
+ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+
+ "vite": ["vite@7.1.9", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg=="],
+
+ "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
+
+ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
+
+ "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
+
+ "@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
+ }
+}
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..8ade05d
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "web",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "start": "bun ./build/index.js",
+ "prepare": "svelte-kit sync || echo ''",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^6.1.0",
+ "@sveltejs/adapter-node": "^5.3.3",
+ "@sveltejs/kit": "^2.43.2",
+ "@sveltejs/vite-plugin-svelte": "^6.2.0",
+ "@types/bun": "^1.3.0",
+ "svelte": "^5.39.5",
+ "svelte-check": "^4.3.2",
+ "typescript": "^5.9.2",
+ "vite": "^7.1.7"
+ },
+ "optionalDependencies": {
+ "better-sqlite3": "^12.4.1"
+ }
+}
diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts
new file mode 100644
index 0000000..4bc652b
--- /dev/null
+++ b/apps/web/src/app.d.ts
@@ -0,0 +1,14 @@
+// See https://svelte.dev/docs/kit/types#app.d.ts
+/* See https://svelte.dev/docs/kit/types#app.d.ts */
+declare global {
+ namespace App {
+ interface Locals {
+ userId?: string | null;
+ }
+ // interface Error {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+export {};
diff --git a/apps/web/src/app.html b/apps/web/src/app.html
new file mode 100644
index 0000000..f273cc5
--- /dev/null
+++ b/apps/web/src/app.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts
new file mode 100644
index 0000000..a282ddd
--- /dev/null
+++ b/apps/web/src/hooks.server.ts
@@ -0,0 +1,124 @@
+import type { Handle } from '@sveltejs/kit';
+import { getDb } from '$lib/server/db';
+import { sha256Hex } from '$lib/server/crypto';
+import { isProd, sessionIdleTtlMs, isDev, DEV_BYPASS_AUTH, DEV_DEFAULT_USER } from '$lib/server/env';
+
+function toIsoSql(d: Date): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+export const handle: Handle = async ({ event, resolve }) => {
+ // Bypass de auth en desarrollo (siempre activo en dev para entorno de pruebas)
+ const bypass = isDev();
+ if (bypass) {
+ const qp = event.url.searchParams.get('__as')?.trim();
+ const current = event.cookies.get('dev_as') || '';
+ const user = qp && qp.length ? qp : (current || DEV_DEFAULT_USER);
+ if (qp && qp.length && qp !== current) {
+ event.cookies.set('dev_as', user, {
+ path: '/',
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: isProd(),
+ maxAge: 60 * 60 * 24 * 30 // 30 días
+ });
+ }
+ event.locals.userId = user;
+ }
+ // Sesión por cookie 'sid'
+ const isLogout = event.url.pathname === '/api/logout' || event.url.pathname.startsWith('/api/logout/');
+ const sid = event.cookies.get('sid');
+ if (!bypass && sid) {
+ try {
+ const db = await getDb();
+ const hash = await sha256Hex(sid);
+
+ // Validar sesión vigente
+ const row = db
+ .prepare(
+ `SELECT user_id FROM web_sessions
+ WHERE session_hash = ?
+ AND expires_at > strftime('%Y-%m-%d %H:%M:%f','now')
+ LIMIT 1`
+ )
+ .get(hash) as { user_id: string } | undefined;
+
+ if (row?.user_id) {
+ event.locals.userId = row.user_id;
+
+ // Renovar expiración por inactividad y last_seen_at
+ const newExpIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs));
+ try {
+ db.prepare(
+ `UPDATE web_sessions
+ SET last_seen_at = strftime('%Y-%m-%d %H:%M:%f','now'),
+ expires_at = ?
+ WHERE session_hash = ?`
+ ).run(newExpIso, hash);
+ } catch {
+ // Si no existe last_seen_at en el esquema, al menos renovar expires_at
+ try {
+ db.prepare(
+ `UPDATE web_sessions
+ SET expires_at = ?
+ WHERE session_hash = ?`
+ ).run(newExpIso, hash);
+ } catch {}
+ }
+
+ // Refrescar cookie (idle) excepto durante /api/logout
+ if (!isLogout) {
+ event.cookies.set('sid', sid, {
+ path: '/',
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: isProd(),
+ maxAge: Math.floor(sessionIdleTtlMs / 1000)
+ });
+ }
+ } else {
+ // Sesión inválida/expirada
+ event.cookies.delete('sid', { path: '/', httpOnly: true, sameSite: 'lax', secure: isProd() });
+ }
+ } catch {
+ // En caso de error de DB, no romper la request; continuar sin sesión
+ }
+ }
+
+ const response = await resolve(event);
+
+ // Cabeceras de seguridad y caché: solo para HTML
+ try {
+ const ct = response.headers.get('content-type') || '';
+ if (ct.includes('text/html')) {
+ response.headers.set('cache-control', 'no-store');
+ response.headers.set('X-Frame-Options', 'DENY');
+ response.headers.set('Referrer-Policy', 'no-referrer');
+ response.headers.set('X-Content-Type-Options', 'nosniff');
+
+ // Mitigar aviso de “preload no usado” en CSS:
+ // Filtrar del header Link los preloads con as=style (dejamos modulepreload para JS).
+ const link = response.headers.get('Link') || response.headers.get('link');
+ if (link) {
+ const filtered = link
+ .split(',')
+ .map((s) => s.trim())
+ .filter((seg) => !/;\s*as=style\b/i.test(seg));
+ if (filtered.length > 0) {
+ response.headers.set('Link', filtered.join(', '));
+ } else {
+ response.headers.delete('Link');
+ }
+ }
+ }
+ } catch {
+ // Ignorar si la implementación de Response no permite set()
+ }
+ // Indicador de bypass en respuestas (útil en dev)
+ try {
+ if (bypass) {
+ response.headers.set('X-Dev-Auth', 'bypass');
+ }
+ } catch {}
+ return response;
+};
diff --git a/apps/web/src/lib/assets/delay-icon.svg b/apps/web/src/lib/assets/delay-icon.svg
new file mode 100644
index 0000000..7888f41
--- /dev/null
+++ b/apps/web/src/lib/assets/delay-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/lib/assets/edit-list-icon.svg b/apps/web/src/lib/assets/edit-list-icon.svg
new file mode 100644
index 0000000..56d9fc0
--- /dev/null
+++ b/apps/web/src/lib/assets/edit-list-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/lib/assets/emergency-exit-icon.svg b/apps/web/src/lib/assets/emergency-exit-icon.svg
new file mode 100644
index 0000000..a9e69a8
--- /dev/null
+++ b/apps/web/src/lib/assets/emergency-exit-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/lib/assets/favicon.svg b/apps/web/src/lib/assets/favicon.svg
new file mode 100644
index 0000000..cc5dc66
--- /dev/null
+++ b/apps/web/src/lib/assets/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/lib/assets/friends-icon.svg b/apps/web/src/lib/assets/friends-icon.svg
new file mode 100644
index 0000000..0e2d7be
--- /dev/null
+++ b/apps/web/src/lib/assets/friends-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/lib/assets/green-checkmark-icon.svg b/apps/web/src/lib/assets/green-checkmark-icon.svg
new file mode 100644
index 0000000..5227b2a
--- /dev/null
+++ b/apps/web/src/lib/assets/green-checkmark-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/lib/assets/mining-icon.svg b/apps/web/src/lib/assets/mining-icon.svg
new file mode 100644
index 0000000..9b58fab
--- /dev/null
+++ b/apps/web/src/lib/assets/mining-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/lib/assets/on-time-icon.svg b/apps/web/src/lib/assets/on-time-icon.svg
new file mode 100644
index 0000000..d769744
--- /dev/null
+++ b/apps/web/src/lib/assets/on-time-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/lib/assets/remove-date-calendar-icon.svg b/apps/web/src/lib/assets/remove-date-calendar-icon.svg
new file mode 100644
index 0000000..f865919
--- /dev/null
+++ b/apps/web/src/lib/assets/remove-date-calendar-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/lib/assets/robots.txt b/apps/web/src/lib/assets/robots.txt
new file mode 100644
index 0000000..b6dd667
--- /dev/null
+++ b/apps/web/src/lib/assets/robots.txt
@@ -0,0 +1,3 @@
+# allow crawling everything by default
+User-agent: *
+Disallow:
diff --git a/apps/web/src/lib/assets/sand-clock-half-icon.svg b/apps/web/src/lib/assets/sand-clock-half-icon.svg
new file mode 100644
index 0000000..37754a1
--- /dev/null
+++ b/apps/web/src/lib/assets/sand-clock-half-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/lib/assets/time-expire-icon.svg b/apps/web/src/lib/assets/time-expire-icon.svg
new file mode 100644
index 0000000..06a1bb8
--- /dev/null
+++ b/apps/web/src/lib/assets/time-expire-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/lib/assets/time-period-icon.svg b/apps/web/src/lib/assets/time-period-icon.svg
new file mode 100644
index 0000000..382e449
--- /dev/null
+++ b/apps/web/src/lib/assets/time-period-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/lib/index.ts b/apps/web/src/lib/index.ts
new file mode 100644
index 0000000..856f2b6
--- /dev/null
+++ b/apps/web/src/lib/index.ts
@@ -0,0 +1 @@
+// place files you want to import through the `$lib` alias in this folder.
diff --git a/apps/web/src/lib/server/calendar-tokens.ts b/apps/web/src/lib/server/calendar-tokens.ts
new file mode 100644
index 0000000..6a7282b
--- /dev/null
+++ b/apps/web/src/lib/server/calendar-tokens.ts
@@ -0,0 +1,111 @@
+import { getDb } from './db';
+import { randomTokenBase64Url, sha256Hex } from './crypto';
+import { WEB_BASE_URL } from './env';
+
+export type CalendarTokenType = 'personal' | 'group' | 'aggregate';
+
+function toIsoSql(d: Date = new Date()): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+function requireBaseUrl(): string {
+ const base = (WEB_BASE_URL || '').trim();
+ if (!base) {
+ throw new Error('[calendar-tokens] WEB_BASE_URL no está configurado');
+ }
+ return base.replace(/\/+$/, '');
+}
+
+export function buildCalendarIcsUrl(type: CalendarTokenType, token: string): string {
+ const base = requireBaseUrl();
+ const segment = type === 'personal' ? 'personal' : type === 'group' ? 'group' : 'aggregate';
+ return `${base}/ics/${segment}/${token}.ics`;
+}
+
+export async function findActiveToken(
+ type: CalendarTokenType,
+ userId: string,
+ groupId?: string | null
+): Promise<{
+ id: number;
+ type: CalendarTokenType;
+ user_id: string;
+ group_id: string | null;
+ token_hash: string;
+ token_plain: string | null;
+ created_at: string;
+ revoked_at: string | null;
+ last_used_at: string | null;
+} | null> {
+ const db = await getDb();
+ const sql = groupId
+ ? `
+ SELECT id, type, user_id, group_id, token_hash, token_plain, created_at, revoked_at, last_used_at
+ FROM calendar_tokens
+ WHERE type = ? AND user_id = ? AND group_id = ? AND revoked_at IS NULL
+ ORDER BY id DESC
+ LIMIT 1
+ `
+ : `
+ SELECT id, type, user_id, group_id, token_hash, token_plain, created_at, revoked_at, last_used_at
+ FROM calendar_tokens
+ WHERE type = ? AND user_id = ? AND group_id IS NULL AND revoked_at IS NULL
+ ORDER BY id DESC
+ LIMIT 1
+ `;
+ const stmt = db.prepare(sql);
+ const row = groupId
+ ? stmt.get(type, userId, groupId)
+ : stmt.get(type, userId);
+ return (row as any) || null;
+}
+
+/**
+ * Crea un nuevo token ICS y devuelve la URL completa (no se guarda el token en claro).
+ * Lanza si existe una entrada activa y se viola la unicidad; usar findActiveToken antes si quieres evitar error.
+ */
+export async function createCalendarTokenUrl(
+ type: CalendarTokenType,
+ userId: string,
+ groupId?: string | null
+): Promise<{ url: string; token: string; id: number }> {
+ const db = await getDb();
+
+ const token = randomTokenBase64Url(32);
+ const tokenHash = await sha256Hex(token);
+ const createdAt = toIsoSql(new Date());
+
+ const insert = db.prepare(`
+ INSERT INTO calendar_tokens (type, user_id, group_id, token_hash, token_plain, created_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `);
+ const res = insert.run(type, userId, groupId ?? null, tokenHash, token, createdAt);
+ const id = Number(res.lastInsertRowid || 0);
+
+ return { url: buildCalendarIcsUrl(type, token), token, id };
+}
+
+/**
+ * Revoca el token activo (si existe) y crea uno nuevo. Devuelve la nueva URL completa.
+ */
+export async function rotateCalendarTokenUrl(
+ type: CalendarTokenType,
+ userId: string,
+ groupId?: string | null
+): Promise<{ url: string; token: string; id: number; revoked: number | null }> {
+ const db = await getDb();
+ const now = toIsoSql(new Date());
+
+ const existing = await findActiveToken(type, userId, groupId ?? null);
+ let revoked: number | null = null;
+ if (existing) {
+ db.prepare(`UPDATE calendar_tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`).run(
+ now,
+ existing.id
+ );
+ revoked = existing.id;
+ }
+
+ const created = await createCalendarTokenUrl(type, userId, groupId ?? null);
+ return { ...created, revoked };
+}
diff --git a/apps/web/src/lib/server/crypto.ts b/apps/web/src/lib/server/crypto.ts
new file mode 100644
index 0000000..98dd094
--- /dev/null
+++ b/apps/web/src/lib/server/crypto.ts
@@ -0,0 +1 @@
+export { randomTokenBase64Url, sha256Hex } from '../../../../../src/utils/crypto';
diff --git a/apps/web/src/lib/server/db.ts b/apps/web/src/lib/server/db.ts
new file mode 100644
index 0000000..2b03d0b
--- /dev/null
+++ b/apps/web/src/lib/server/db.ts
@@ -0,0 +1,165 @@
+import { mkdirSync, existsSync } from 'fs';
+import { dirname } from 'path';
+import { resolveDbAbsolutePath, isDev, DEV_AUTOSEED_DB, DEV_DEFAULT_USER } from './env';
+
+function applyDefaultPragmas(instance: any): void {
+ try {
+ instance.exec(`PRAGMA busy_timeout = 5000;`);
+ // Intentar activar WAL (si no es soportado, SQLite devolverá 'memory' u otro modo)
+ try {
+ if (typeof instance.query === 'function') {
+ instance.query(`PRAGMA journal_mode = WAL`)?.get?.();
+ } else {
+ instance.prepare?.(`PRAGMA journal_mode = WAL`)?.get?.();
+ }
+ } catch {}
+ instance.exec(`PRAGMA synchronous = NORMAL;`);
+ instance.exec(`PRAGMA wal_autocheckpoint = 1000;`);
+ // Asegurar claves foráneas siempre activas
+ instance.exec(`PRAGMA foreign_keys = ON;`);
+ } catch (e) {
+ console.warn('[web/db] No se pudieron aplicar PRAGMAs (WAL, busy_timeout...):', e);
+ }
+}
+
+/**
+ * Intenta cargar un constructor de Database compatible:
+ * - En Bun (SSR nativo): bun:sqlite
+ * - En Node (Vite dev SSR): better-sqlite3
+ */
+async function importSqliteDatabase(): Promise {
+ // En desarrollo (Vite SSR), cargar better-sqlite3 vía require de Node para mantener el contexto CJS
+ if (import.meta.env.DEV) {
+ const modModule: any = await import('node:module');
+ const require = modModule.createRequire(import.meta.url);
+ const mod = require('better-sqlite3');
+ return (mod as any).default || (mod as any).Database || mod;
+ }
+ // En producción (Bun en runtime), usar bun:sqlite nativo
+ const mod: any = await import('bun:sqlite');
+ return (mod as any).Database || (mod as any).default || mod;
+}
+
+/**
+ * Abre la BD compartida. En desarrollo, si el archivo no existe y DEV_AUTOSEED_DB=true,
+ * inicializa el esquema (migraciones) y siembra datos de demo.
+ * Nota: usa bun:sqlite si está disponible; en SSR Node usa better-sqlite3.
+ */
+async function openDb(filename: string = 'tasks.db'): Promise {
+ const absolutePath = resolveDbAbsolutePath(filename);
+ const firstCreate = !existsSync(absolutePath);
+
+ // Crear directorio padre si no existe
+ try {
+ mkdirSync(dirname(absolutePath), { recursive: true });
+ } catch (err: any) {
+ if (err?.code !== 'EEXIST') throw err;
+ }
+
+ const DatabaseCtor = await importSqliteDatabase();
+ const instance = new DatabaseCtor(absolutePath);
+ applyDefaultPragmas(instance);
+
+ // Auto-inicialización de esquema en desarrollo si falta y seed opcional
+ if (isDev()) {
+ // ¿Existe la tabla principal?
+ let hasTasksTable = false;
+ try {
+ instance.prepare(`SELECT 1 FROM tasks LIMIT 1`).get();
+ hasTasksTable = true;
+ } catch {}
+
+ // Si no existe el esquema, aplicar inicialización/migraciones
+ if (!hasTasksTable) {
+ const isBun = typeof (globalThis as any).Bun !== 'undefined';
+
+ if (isBun) {
+ // En Bun podemos reutilizar initializeDatabase del repo principal
+ try {
+ const dbModule = await import('../../../../../src/db');
+ if (typeof (dbModule as any).initializeDatabase === 'function') {
+ (dbModule as any).initializeDatabase(instance);
+ hasTasksTable = true;
+ console.info('[web/db] DEV: esquema inicializado (Bun initializeDatabase).');
+ }
+ } catch (e) {
+ console.warn('[web/db] No se pudo ejecutar initializeDatabase en dev (Bun):', e);
+ }
+ } else {
+ // En SSR Node: aplicar migraciones directamente con compat para .query
+ try {
+ const mod = await import('../../../../../src/db/migrations/index.ts');
+ const list = (mod as any).migrations as any[];
+ const compat: any = instance;
+ if (typeof compat.query !== 'function') {
+ compat.query = (sql: string) => ({
+ all: () => compat.prepare(sql).all(),
+ get: () => compat.prepare(sql).get()
+ });
+ }
+ try { compat.exec?.(`PRAGMA foreign_keys = ON;`); } catch {}
+ for (const m of list) {
+ try {
+ await (m.up as any)(compat);
+ } catch (e) {
+ console.warn('[web/db] Error aplicando migración en dev (Node):', (m as any)?.name ?? '(sin nombre)', e);
+ }
+ }
+ // Verificar de nuevo
+ try {
+ compat.prepare(`SELECT 1 FROM tasks LIMIT 1`).get();
+ hasTasksTable = true;
+ console.info('[web/db] DEV: esquema inicializado (migraciones aplicadas en Node).');
+ } catch {}
+ } catch (e) {
+ console.warn('[web/db] No se pudieron aplicar migraciones en dev (Node):', e);
+ }
+ }
+ }
+
+ // Seed de datos de demo si la tabla está vacía (por defecto habilitado en dev)
+ try {
+ let count = 0;
+ try {
+ const row = instance.prepare(`SELECT COUNT(1) AS c FROM tasks`).get() as any;
+ count = Number(row?.c ?? 0);
+ } catch {
+ // Si aún no existe la tabla, no seedear
+ count = 0;
+ }
+
+ const shouldSeed = (typeof DEV_AUTOSEED_DB === 'boolean' ? DEV_AUTOSEED_DB : true);
+ if (count === 0 && shouldSeed) {
+ console.info('[web/db] DEV: tabla tasks vacía; iniciando seed de demo...');
+ try {
+ const seed = await import('./dev-seed');
+ if (typeof (seed as any).seedDev === 'function') {
+ await (seed as any).seedDev(instance, DEV_DEFAULT_USER);
+ console.info('[web/db] DEV: seed de demo completado.');
+ } else {
+ console.warn('[web/db] DEV: módulo dev-seed sin función seedDev; omitiendo seed.');
+ }
+ } catch (e) {
+ console.warn('[web/db] DEV: no se pudo cargar/ejecutar dev-seed; omitiendo seed. Error:', e);
+ }
+ } else {
+ console.info(`[web/db] DEV: seed no aplicado (count=${count}, DEV_AUTOSEED_DB=${shouldSeed}).`);
+ }
+ } catch (e) {
+ console.warn('[web/db] DEV: error al evaluar seed de demo:', e);
+ }
+ }
+
+ return instance;
+}
+
+let _db: any | null = null;
+
+/**
+ * Devuelve una única instancia compartida (lazy) de la BD.
+ */
+export async function getDb(filename: string = 'tasks.db'): Promise {
+ if (_db) return _db;
+ _db = await openDb(filename);
+ return _db;
+}
diff --git a/apps/web/src/lib/server/dev-seed.ts b/apps/web/src/lib/server/dev-seed.ts
new file mode 100644
index 0000000..43965a9
--- /dev/null
+++ b/apps/web/src/lib/server/dev-seed.ts
@@ -0,0 +1,201 @@
+/**
+ * Semilla enriquecida de datos de demo para desarrollo.
+ * Inserta usuarios, grupos (allowed/pending), membresías, preferencias y un set amplio de tareas.
+ * Idempotente: solo ejecuta si la tabla tasks está vacía.
+ */
+function toIsoYmd(d: Date): string {
+ const yyyy = d.getUTCFullYear();
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
+ const dd = String(d.getUTCDate()).padStart(2, '0');
+ return `${yyyy}-${mm}-${dd}`;
+}
+function addDays(base: Date, days: number): Date {
+ return new Date(base.getTime() + days * 24 * 3600 * 1000);
+}
+function isoSql(dt: Date): string {
+ return dt.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+export async function seedDev(db: any, defaultUser: string): Promise {
+ try { db.exec(`PRAGMA foreign_keys = ON;`); } catch {}
+ // Si ya hay tareas, asumimos BD poblada
+ try {
+ const row = db.prepare(`SELECT COUNT(*) AS c FROM tasks`).get() as any;
+ if (row && Number(row.c || 0) > 0) return;
+ } catch {}
+
+ const now = new Date();
+ const today = toIsoYmd(now);
+ const tomorrow = toIsoYmd(addDays(now, 1));
+ const nextWeek = toIsoYmd(addDays(now, 7));
+ const overdue = toIsoYmd(addDays(now, -1));
+ const inTwoDays = toIsoYmd(addDays(now, 2));
+ const noDate = null;
+
+ // Usuarios (incluye el de desarrollo aunque no sea numérico)
+ const DEV = (defaultUser || '').trim();
+ const U1 = DEV; // usuario de desarrollo (puede no ser numérico; lo insertamos igualmente)
+ const U2 = '34600123456';
+ const U3 = '5550001111';
+ const U4 = '600123456';
+ const U5 = '34987654321';
+ const users = [U1, U2, U3, U4, U5].filter(Boolean);
+
+ const insertUser = db.prepare(`
+ INSERT OR IGNORE INTO users (id, first_seen, last_seen)
+ VALUES (?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
+ `);
+ for (const u of users) insertUser.run(u);
+
+ // Grupos (IDs tipo JID) y estados
+ const G_FAM = 'g-familia@g.us';
+ const G_TRA = 'g-trabajo@g.us';
+ const G_VOL = 'g-voluntariado@g.us';
+ const G_COM = 'g-compras@g.us';
+ const G_VAR = 'g-varios@g.us';
+ const groups: Array<{ id: string; name: string; allowed: 'allowed' | 'pending' | 'blocked' }> = [
+ { id: G_FAM, name: 'Familia', allowed: 'allowed' },
+ { id: G_TRA, name: 'Trabajo', allowed: 'allowed' },
+ { id: G_VOL, name: 'Voluntariado', allowed: 'allowed' },
+ { id: G_COM, name: 'Compras', allowed: 'allowed' }, // allowed pero sin membresía del usuario DEV
+ { id: G_VAR, name: 'Varios', allowed: 'pending' } // pendiente/bloqueado para validar gating
+ ];
+
+ const insertGroup = db.prepare(`
+ INSERT OR IGNORE INTO groups (id, community_id, name, active, last_verified)
+ VALUES (?, 'comm-dev', ?, 1, strftime('%Y-%m-%d %H:%M:%f','now'))
+ `);
+ const insertAllowed = db.prepare(`
+ INSERT OR IGNORE INTO allowed_groups (group_id, label, status, discovered_at, updated_at, discovered_by)
+ VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'), NULL)
+ `);
+ for (const g of groups) {
+ insertGroup.run(g.id, g.name);
+ try { insertAllowed.run(g.id, g.name, g.allowed); } catch {}
+ }
+
+ // Membresías activas: el usuario DEV en Familia, Trabajo, Voluntariado; otros usuarios repartidos
+ const insertMember = db.prepare(`
+ INSERT OR REPLACE INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
+ VALUES (?, ?, 0, 1, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
+ `);
+ for (const gid of [G_FAM, G_TRA, G_VOL]) insertMember.run(gid, U1);
+ // Otros usuarios en distintos grupos para facilitar múltiples responsables
+ insertMember.run(G_FAM, U2);
+ insertMember.run(G_FAM, U3);
+ insertMember.run(G_TRA, U3);
+ insertMember.run(G_TRA, U4);
+ insertMember.run(G_VOL, U5);
+ // Compras: allowed pero sin membresía del usuario DEV (solo U2), para validar gating
+ insertMember.run(G_COM, U2);
+
+ // Preferencias del usuario DEV
+ try {
+ db.prepare(`
+ INSERT OR REPLACE INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
+ VALUES (?, 'daily', '08:30', NULL, strftime('%Y-%m-%d %H:%M:%f','now'))
+ `).run(U1);
+ } catch {}
+
+ // Insertadores
+ const insertTask = db.prepare(`
+ INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at, completed_by)
+ VALUES (?, ?, ?, ?, COALESCE(?, 0), ?, ?)
+ `);
+ const assignStmt = db.prepare(`
+ INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by)
+ VALUES (?, ?, ?)
+ `);
+
+ // Helpers para completadas
+ const completedRecent = isoSql(addDays(now, 0)); // ahora
+ const completed2hAgo = isoSql(new Date(Date.now() - 2 * 3600 * 1000));
+ const completed12hAgo = isoSql(new Date(Date.now() - 12 * 3600 * 1000));
+ const completed48hAgo = isoSql(new Date(Date.now() - 48 * 3600 * 1000));
+ const completed72hAgo = isoSql(new Date(Date.now() - 72 * 3600 * 1000));
+
+ type Spec = {
+ desc: string;
+ due: string | null;
+ group: string | null;
+ createdBy: string;
+ completed?: 0 | 1;
+ completedAt?: string | null;
+ completedBy?: string | null;
+ assignees?: string[]; // responsables
+ };
+
+ const specs: Spec[] = [
+ // Familia (mezcla: sin responsables, 1 responsable, múltiples, completadas reciente/antigua)
+ { desc: 'Compra semanal para la casa', due: today, group: G_FAM, createdBy: U1, assignees: [] },
+ { desc: 'Llevar coche al taller', due: tomorrow, group: G_FAM, createdBy: U1, assignees: [U1] },
+ { desc: 'Organizar cumpleaños (lista de invitados y tarta)', due: nextWeek, group: G_FAM, createdBy: U2, assignees: [U1, U2] },
+ { desc: 'Revisar facturas de luz y gas', due: overdue, group: G_FAM, createdBy: U1, assignees: [], completed: 0 },
+ { desc: 'Pedir cita pediatra', due: inTwoDays, group: G_FAM, createdBy: U1, assignees: [U3] },
+ { desc: 'Sacar basura orgánica', due: noDate, group: G_FAM, createdBy: U3, assignees: [], completed: 1, completedAt: completed2hAgo, completedBy: U1 },
+ { desc: 'Regar plantas del balcón (abundante agua)', due: today, group: G_FAM, createdBy: U1, assignees: [U1, U3] },
+
+ // Trabajo
+ { desc: 'Preparar informe trimestral de ventas', due: nextWeek, group: G_TRA, createdBy: U3, assignees: [U1] },
+ { desc: 'Reunión con proveedor clave', due: tomorrow, group: G_TRA, createdBy: U4, assignees: [], completed: 1, completedAt: completed12hAgo, completedBy: U1 },
+ { desc: 'Actualizar tablero de tareas del sprint', due: today, group: G_TRA, createdBy: U1, assignees: [U1, U4] },
+ { desc: 'Revisión de PRs acumuladas', due: overdue, group: G_TRA, createdBy: U1, assignees: [U3] },
+ { desc: 'Definir OKRs del próximo trimestre', due: noDate, group: G_TRA, createdBy: U1, assignees: [] },
+ { desc: 'Publicar release menor (v1.0.1)', due: inTwoDays, group: G_TRA, createdBy: U2, assignees: [U1], completed: 1, completedAt: completed48hAgo, completedBy: U2 },
+
+ // Voluntariado
+ { desc: 'Clasificar donaciones de ropa', due: today, group: G_VOL, createdBy: U5, assignees: [] },
+ { desc: 'Coordinar recogida de alimentos', due: tomorrow, group: G_VOL, createdBy: U1, assignees: [U1, U5] },
+ { desc: 'Llamar a nuevos voluntarios (lista A–M)', due: nextWeek, group: G_VOL, createdBy: U1, assignees: [U1] },
+ { desc: 'Actualizar listado de familias beneficiarias', due: overdue, group: G_VOL, createdBy: U5, assignees: [], completed: 1, completedAt: completed72hAgo, completedBy: U5 },
+ { desc: 'Solicitar permiso para evento solidario', due: noDate, group: G_VOL, createdBy: U1, assignees: [] },
+
+ // Compras (allowed pero sin membresía del usuario DEV)
+ { desc: 'Comprar detergente y suavizante', due: today, group: G_COM, createdBy: U2, assignees: [U2] },
+ { desc: 'Reponer comida para mascotas', due: tomorrow, group: G_COM, createdBy: U2, assignees: [] },
+ { desc: 'Comparar precios de frutas y verduras', due: nextWeek, group: G_COM, createdBy: U2, assignees: [U2], completed: 1, completedAt: completed2hAgo, completedBy: U2 },
+ { desc: 'Planificar compra mensual a granel', due: noDate, group: G_COM, createdBy: U2, assignees: [] },
+
+ // Personales (group_id NULL)
+ { desc: 'Pagar recibo del móvil', due: overdue, group: null, createdBy: U1, assignees: [U1] },
+ { desc: 'Hacer copia de seguridad del portátil', due: today, group: null, createdBy: U1, assignees: [], completed: 1, completedAt: completed2hAgo, completedBy: U1 },
+ { desc: 'Llamar al banco para aclarar comisión', due: tomorrow, group: null, createdBy: U1, assignees: [U1] },
+ { desc: 'Leer artículo técnico pendiente', due: noDate, group: null, createdBy: U1, assignees: [] },
+ { desc: 'Renovar DNI (pedir cita)', due: inTwoDays, group: null, createdBy: U1, assignees: [U1] },
+ { desc: 'Terminar curso online de accesibilidad', due: nextWeek, group: null, createdBy: U1, assignees: [U1, U3] },
+ { desc: 'Ordenar fotos antiguas en la nube (muchas carpetas)', due: noDate, group: null, createdBy: U1, assignees: [], completed: 1, completedAt: completed48hAgo, completedBy: U1 },
+
+ // Más casos para densidad y mezcla (≈ 30–35 total)
+ { desc: 'Preparar lista de la compra grande', due: tomorrow, group: G_FAM, createdBy: U1, assignees: [U1, U2] },
+ { desc: 'Pintar habitación pequeña', due: nextWeek, group: G_FAM, createdBy: U2, assignees: [] },
+ { desc: 'Plan de pruebas regresivas', due: today, group: G_TRA, createdBy: U1, assignees: [U4] },
+ { desc: 'Reunión semanal con equipo', due: today, group: G_TRA, createdBy: U3, assignees: [U1, U3] },
+ { desc: 'Retirar material donado del almacén', due: tomorrow, group: G_VOL, createdBy: U5, assignees: [] },
+ { desc: 'Preparar carteles del evento solidario (texto largo y revisión)', due: noDate, group: G_VOL, createdBy: U1, assignees: [U1] }
+ ];
+
+ // Transacción para insertar todo
+ db.transaction(() => {
+ for (const t of specs) {
+ const res = insertTask.run(
+ t.desc,
+ t.due ?? null,
+ t.group ?? null,
+ t.createdBy,
+ t.completed ?? 0,
+ t.completed ? (t.completedAt ?? completedRecent) : null,
+ t.completed ? (t.completedBy ?? t.createdBy) : null
+ );
+ const id = Number((res as any)?.lastInsertRowid ?? 0);
+ if (id > 0 && Array.isArray(t.assignees)) {
+ const seen = new Set();
+ for (const uid of t.assignees) {
+ if (uid && !seen.has(uid)) {
+ seen.add(uid);
+ assignStmt.run(id, uid, t.createdBy);
+ }
+ }
+ }
+ }
+ })();
+}
diff --git a/apps/web/src/lib/server/env.ts b/apps/web/src/lib/server/env.ts
new file mode 100644
index 0000000..dd99706
--- /dev/null
+++ b/apps/web/src/lib/server/env.ts
@@ -0,0 +1,46 @@
+import { join, resolve } from 'path';
+import { env } from '$env/dynamic/private';
+
+/**
+ * Resuelve la ruta absoluta al archivo de la base de datos SQLite compartida.
+ * Prioridad:
+ * 1) DB_PATH (ruta completa al archivo)
+ * 2) DATA_DIR + filename (en prod por defecto /app/data; en dev por defecto ./tmp)
+ */
+export function resolveDbAbsolutePath(filename: string = 'tasks.db'): string {
+ const dbPathEnv = (env.DB_PATH || '').trim();
+ if (dbPathEnv) {
+ return resolve(dbPathEnv);
+ }
+ const isProdEnv = String(env.NODE_ENV || 'development').trim().toLowerCase() === 'production';
+ const dataDir = env.DATA_DIR ? String(env.DATA_DIR) : (isProdEnv ? '/app/data' : 'tmp');
+ return resolve(join(dataDir, filename));
+}
+
+export const WEB_BASE_URL = (env.WEB_BASE_URL || '').trim();
+export const COOKIE_SECRET = (env.COOKIE_SECRET || '').trim();
+
+const SESSION_IDLE_TTL_MIN = Number(env.SESSION_IDLE_TTL_MIN || 120);
+export const sessionIdleTtlMs = Math.max(1, Math.floor(SESSION_IDLE_TTL_MIN)) * 60 * 1000;
+
+export const NODE_ENV = (env.NODE_ENV || 'development').trim().toLowerCase();
+export const isProd = () => NODE_ENV === 'production';
+export const isDev = () => NODE_ENV === 'development';
+
+// Flags de desarrollo (solo en entornos no productivos)
+const toBool = (v: string) => ['1', 'true', 'yes', 'on'].includes(String(v || '').trim().toLowerCase());
+export const DEV_BYPASS_AUTH = toBool(env.DEV_BYPASS_AUTH || '');
+export const DEV_DEFAULT_USER = (env.DEV_DEFAULT_USER || 'demo').trim();
+export const DEV_AUTOSEED_DB = toBool(env.DEV_AUTOSEED_DB || '');
+
+// ICS: horizonte en meses y rate limit (por minuto, 0 = desactivado)
+const ICS_HORIZON_MONTHS = Number(env.ICS_HORIZON_MONTHS || 12);
+export const icsHorizonMonths = Math.max(1, Math.floor(ICS_HORIZON_MONTHS));
+
+const ICS_RATE_LIMIT_PER_MIN = Number(env.ICS_RATE_LIMIT_PER_MIN || 0);
+export const icsRateLimitPerMin = Math.max(0, Math.floor(ICS_RATE_LIMIT_PER_MIN));
+
+// Uncomplete window (minutos; por defecto 1440 = 24h)
+const UNCOMPLETE_WINDOW_MIN_RAW = Number(env.UNCOMPLETE_WINDOW_MIN || 1440);
+export const UNCOMPLETE_WINDOW_MIN = Math.max(1, Math.floor(UNCOMPLETE_WINDOW_MIN_RAW));
+export const uncompleteWindowMs = UNCOMPLETE_WINDOW_MIN * 60 * 1000;
diff --git a/apps/web/src/lib/server/ics.ts b/apps/web/src/lib/server/ics.ts
new file mode 100644
index 0000000..dcff319
--- /dev/null
+++ b/apps/web/src/lib/server/ics.ts
@@ -0,0 +1,91 @@
+import { sha256Hex } from './crypto';
+
+function escapeIcsText(s: string): string {
+ return String(s)
+ .replace(/\\/g, '\\\\')
+ .replace(/\n/g, '\\n')
+ .replace(/\r/g, '')
+ .replace(/,/g, '\\,')
+ .replace(/;/g, '\\;');
+}
+
+function foldIcsLine(line: string): string {
+ // 75 octetos; para simplicidad contamos caracteres (UTF-8 simple en nuestro caso)
+ const max = 75;
+ if (line.length <= max) return line;
+ const parts: string[] = [];
+ let i = 0;
+ while (i < line.length) {
+ const chunk = line.slice(i, i + max);
+ parts.push(i === 0 ? chunk : ' ' + chunk);
+ i += max;
+ }
+ return parts.join('\r\n');
+}
+
+function padTaskId(id: number, width: number = 4): string {
+ const s = String(Math.max(0, Math.floor(id)));
+ if (s.length >= width) return s;
+ return '0'.repeat(width - s.length) + s;
+}
+
+function ymdToBasic(ymd: string): string {
+ // Espera YYYY-MM-DD
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
+ if (!m) return '';
+ return `${m[1]}${m[2]}${m[3]}`;
+}
+
+function addDays(ymd: string, days: number): string {
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
+ if (!m) return ymd;
+ const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
+ d.setUTCDate(d.getUTCDate() + days);
+ const yyyy = String(d.getUTCFullYear()).padStart(4, '0');
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
+ const dd = String(d.getUTCDate()).padStart(2, '0');
+ return `${yyyy}-${mm}-${dd}`;
+}
+
+export type IcsEvent = {
+ id: number;
+ description: string;
+ due_date: string; // YYYY-MM-DD
+ group_name?: string | null;
+ prefix?: string; // ej: "T" para [T0123]
+};
+
+export async function buildIcsCalendar(title: string, events: IcsEvent[]): Promise<{ body: string; etag: string }> {
+ const lines: string[] = [];
+ lines.push('BEGIN:VCALENDAR');
+ lines.push('VERSION:2.0');
+ lines.push('PRODID:-//TaskWhatsApp//Calendar//ES');
+ lines.push('CALSCALE:GREGORIAN');
+ lines.push('METHOD:PUBLISH');
+ lines.push(`X-WR-CALNAME:${escapeIcsText(title)}`);
+ lines.push('X-WR-TIMEZONE:UTC');
+
+ for (const ev of events) {
+ const idPad = padTaskId(ev.id);
+ const summary = `[${ev.prefix || 'T'}${idPad}] ${ev.description}`;
+ const dtStart = ymdToBasic(ev.due_date);
+ const dtEnd = ymdToBasic(addDays(ev.due_date, 1));
+ const uid = `task-${ev.id}@tw`;
+
+ lines.push('BEGIN:VEVENT');
+ lines.push(foldIcsLine(`UID:${uid}`));
+ lines.push(foldIcsLine(`SUMMARY:${escapeIcsText(summary)}`));
+ lines.push(`DTSTART;VALUE=DATE:${dtStart}`);
+ lines.push(`DTEND;VALUE=DATE:${dtEnd}`);
+ if (ev.group_name) {
+ lines.push(foldIcsLine(`CATEGORIES:${escapeIcsText(ev.group_name || '')}`));
+ }
+ lines.push('END:VEVENT');
+ }
+
+ lines.push('END:VCALENDAR');
+
+ const body = lines.join('\r\n') + '\r\n';
+ const etag = await sha256Hex(body);
+ return { body, etag: `W/"${etag}"` };
+}
diff --git a/apps/web/src/lib/stores/toasts.ts b/apps/web/src/lib/stores/toasts.ts
new file mode 100644
index 0000000..0e8d06b
--- /dev/null
+++ b/apps/web/src/lib/stores/toasts.ts
@@ -0,0 +1,41 @@
+import { writable } from 'svelte/store';
+
+export type ToastType = 'info' | 'success' | 'error';
+
+export type ToastItem = {
+ id: string;
+ type: ToastType;
+ message: string;
+ timeout?: number;
+};
+
+export const toasts = writable([]);
+
+function uid(): string {
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
+}
+
+export function show(message: string, type: ToastType = 'info', timeout = 2500): string {
+ const id = uid();
+ toasts.update((list) => [...list, { id, type, message, timeout }]);
+ if (timeout > 0) {
+ setTimeout(() => dismiss(id), timeout);
+ }
+ return id;
+}
+
+export function success(message: string, timeout = 2500): string {
+ return show(message, 'success', timeout);
+}
+
+export function error(message: string, timeout = 3500): string {
+ return show(message, 'error', timeout);
+}
+
+export function info(message: string, timeout = 2500): string {
+ return show(message, 'info', timeout);
+}
+
+export function dismiss(id: string): void {
+ toasts.update((list) => list.filter((t) => t.id !== id));
+}
diff --git a/apps/web/src/lib/styles/base.css b/apps/web/src/lib/styles/base.css
new file mode 100644
index 0000000..eb61b9d
--- /dev/null
+++ b/apps/web/src/lib/styles/base.css
@@ -0,0 +1,104 @@
+/* Reset/normalización ligera */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Noto Sans, Ubuntu, Cantarell, Helvetica Neue, Arial, "Apple Color Emoji", "Segoe UI Emoji";
+ font-size: 16px;
+ line-height: 1.5;
+ background: var(--color-bg);
+ color: var(--color-text);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+img,
+svg,
+video {
+ display: block;
+ max-width: 100%;
+ height: 0.8rem;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+button {
+ font: inherit;
+}
+
+/* Accesibilidad: foco visible */
+:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
+}
+
+/* Utilidades */
+.container {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 0 var(--space-4);
+}
+
+.sr-only {
+ position: absolute !important;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* Controles base */
+button,
+input[type="submit"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 36px;
+ padding: 0 12px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface);
+ color: var(--color-text);
+ cursor: pointer;
+ box-shadow: var(--shadow-sm);
+ transition: box-shadow 0.15s ease, transform 0.05s ease;
+}
+
+button:hover,
+button:focus-visible {
+ box-shadow: var(--shadow-md);
+}
+button:active {
+ transform: translateY(0.5px) scale(0.99);
+ box-shadow: var(--shadow-sm);
+}
+button.primary {
+ background: var(--color-primary);
+ border-color: var(--color-primary);
+ color: white;
+}
+
+button:disabled,
+button:disabled:hover,
+button:disabled:focus-visible {
+ opacity: 0.6;
+ cursor: not-allowed;
+ box-shadow: none;
+ transform: none;
+}
diff --git a/apps/web/src/lib/styles/tokens.css b/apps/web/src/lib/styles/tokens.css
new file mode 100644
index 0000000..ae3e2ee
--- /dev/null
+++ b/apps/web/src/lib/styles/tokens.css
@@ -0,0 +1,41 @@
+:root {
+ --color-bg: #ffffff;
+ --color-surface: #f7f7f8;
+ --color-text: #111111;
+ --color-text-muted: #555555;
+ --color-border: #e5e7eb;
+
+ --color-primary: #2563eb; /* azul */
+ --color-danger: #dc2626; /* rojo */
+ --color-warning: #d97706; /* ámbar */
+ --color-success: #16a34a; /* verde */
+ --color-primary-muted: #60a5fa55;
+
+ --radius-sm: 6px;
+ --radius-md: 8px;
+
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.06);
+ --shadow-md: 0 4px 12px rgba(0,0,0,0.08);
+
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 24px;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --color-bg: #0b0c0f;
+ --color-surface: #14161a;
+ --color-text: #e6e7eb;
+ --color-text-muted: #a1a1aa;
+ --color-border: #26272b;
+
+ --color-primary: #60a5fa;
+ --color-danger: #f87171;
+ --color-warning: #fbbf24;
+ --color-success: #34d399;
+ --color-primary-muted: #60a5fa55;
+ }
+}
diff --git a/apps/web/src/lib/ui/atoms/Badge.svelte b/apps/web/src/lib/ui/atoms/Badge.svelte
new file mode 100644
index 0000000..a405089
--- /dev/null
+++ b/apps/web/src/lib/ui/atoms/Badge.svelte
@@ -0,0 +1,35 @@
+
+
+
+
+
diff --git a/apps/web/src/lib/ui/atoms/Button.svelte b/apps/web/src/lib/ui/atoms/Button.svelte
new file mode 100644
index 0000000..dc35ff7
--- /dev/null
+++ b/apps/web/src/lib/ui/atoms/Button.svelte
@@ -0,0 +1,53 @@
+
+
+
+
+
diff --git a/apps/web/src/lib/ui/atoms/Skeleton.svelte b/apps/web/src/lib/ui/atoms/Skeleton.svelte
new file mode 100644
index 0000000..697b046
--- /dev/null
+++ b/apps/web/src/lib/ui/atoms/Skeleton.svelte
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/apps/web/src/lib/ui/atoms/VisuallyHidden.svelte b/apps/web/src/lib/ui/atoms/VisuallyHidden.svelte
new file mode 100644
index 0000000..72a51a9
--- /dev/null
+++ b/apps/web/src/lib/ui/atoms/VisuallyHidden.svelte
@@ -0,0 +1,11 @@
+
+
+
diff --git a/apps/web/src/lib/ui/data/FeedCard.svelte b/apps/web/src/lib/ui/data/FeedCard.svelte
new file mode 100644
index 0000000..c6aea42
--- /dev/null
+++ b/apps/web/src/lib/ui/data/FeedCard.svelte
@@ -0,0 +1,82 @@
+
+
+
+
+
+
{title}
+ {#if description}
{description}
{/if}
+ {#if url}
+
{url}
+ {:else}
+
Por seguridad, la URL no se muestra. Pulsa “Rotar” para generar una nueva.
+ {/if}
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/lib/ui/data/GroupCard.svelte b/apps/web/src/lib/ui/data/GroupCard.svelte
new file mode 100644
index 0000000..40b203c
--- /dev/null
+++ b/apps/web/src/lib/ui/data/GroupCard.svelte
@@ -0,0 +1,108 @@
+
+
+
+
+
+ {#if previews?.length}
+
+ {/if}
+
+
+
diff --git a/apps/web/src/lib/ui/data/TaskItem.svelte b/apps/web/src/lib/ui/data/TaskItem.svelte
new file mode 100644
index 0000000..0f8b9ba
--- /dev/null
+++ b/apps/web/src/lib/ui/data/TaskItem.svelte
@@ -0,0 +1,911 @@
+
+
+
+ {codeStr}
+ {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ cancelText();
+ } else if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
+ e.preventDefault();
+ saveText();
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ }
+ }}
+ >
+ {description}
+
+
+
+
+ {#if completed}
+
+ {:else}
+
+ {/if}
+
+
+ {#if assigneesCount === 0}
+
+ {:else}
+
+ {/if}
+
+
+ {#if !completed}
+ {#if !isAssigned}
+
+ {:else}
+
+ {/if}
+
+ {#if !editingText}
+
+ {:else}
+
+
+ {/if}
+
+ {#if !editing}
+
+ {:else}
+
+
+
+
+ {/if}
+ {/if}
+
+
+ Responsables
+ {#if assigneesCount === 0}
+ No hay responsables asignados.
+ {:else}
+
+ {#each assignees as a}
+ -
+
+ {normalizeDigits(a)}
+
+ {#if currentUserId && normalizeDigits(a) === normalizeDigits(currentUserId)}
+ tú
+ {/if}
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+
diff --git a/apps/web/src/lib/ui/feedback/EmptyState.svelte b/apps/web/src/lib/ui/feedback/EmptyState.svelte
new file mode 100644
index 0000000..2a15e76
--- /dev/null
+++ b/apps/web/src/lib/ui/feedback/EmptyState.svelte
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/apps/web/src/lib/ui/feedback/ErrorBanner.svelte b/apps/web/src/lib/ui/feedback/ErrorBanner.svelte
new file mode 100644
index 0000000..af0cfa3
--- /dev/null
+++ b/apps/web/src/lib/ui/feedback/ErrorBanner.svelte
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/apps/web/src/lib/ui/feedback/Popover.svelte b/apps/web/src/lib/ui/feedback/Popover.svelte
new file mode 100644
index 0000000..9d45811
--- /dev/null
+++ b/apps/web/src/lib/ui/feedback/Popover.svelte
@@ -0,0 +1,125 @@
+
+
+{#if open}
+ { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); close(); } }}
+ >
+
+
+
+{/if}
+
+
diff --git a/apps/web/src/lib/ui/feedback/Toast.svelte b/apps/web/src/lib/ui/feedback/Toast.svelte
new file mode 100644
index 0000000..d55934b
--- /dev/null
+++ b/apps/web/src/lib/ui/feedback/Toast.svelte
@@ -0,0 +1,65 @@
+
+
+
+ {#each $toasts as t (t.id)}
+
+
{t.message}
+
+
+ {/each}
+
+
+
diff --git a/apps/web/src/lib/ui/icons/Hourglass.svelte b/apps/web/src/lib/ui/icons/Hourglass.svelte
new file mode 100644
index 0000000..d5abc26
--- /dev/null
+++ b/apps/web/src/lib/ui/icons/Hourglass.svelte
@@ -0,0 +1,27 @@
+
+
+
diff --git a/apps/web/src/lib/ui/inputs/SegmentedControl.svelte b/apps/web/src/lib/ui/inputs/SegmentedControl.svelte
new file mode 100644
index 0000000..7e1780f
--- /dev/null
+++ b/apps/web/src/lib/ui/inputs/SegmentedControl.svelte
@@ -0,0 +1,45 @@
+
+
+
+ {#each options as opt}
+
+ {/each}
+
+
+
diff --git a/apps/web/src/lib/ui/inputs/TextField.svelte b/apps/web/src/lib/ui/inputs/TextField.svelte
new file mode 100644
index 0000000..aadcb58
--- /dev/null
+++ b/apps/web/src/lib/ui/inputs/TextField.svelte
@@ -0,0 +1,32 @@
+
+
+
+
+
diff --git a/apps/web/src/lib/ui/layout/AppShell.svelte b/apps/web/src/lib/ui/layout/AppShell.svelte
new file mode 100644
index 0000000..a4976e5
--- /dev/null
+++ b/apps/web/src/lib/ui/layout/AppShell.svelte
@@ -0,0 +1,326 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/lib/ui/layout/Card.svelte b/apps/web/src/lib/ui/layout/Card.svelte
new file mode 100644
index 0000000..3dfcc19
--- /dev/null
+++ b/apps/web/src/lib/ui/layout/Card.svelte
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/apps/web/src/lib/ui/layout/Pagination.svelte b/apps/web/src/lib/ui/layout/Pagination.svelte
new file mode 100644
index 0000000..c1d1aed
--- /dev/null
+++ b/apps/web/src/lib/ui/layout/Pagination.svelte
@@ -0,0 +1,32 @@
+
+
+
+
+
diff --git a/apps/web/src/lib/utils/copy.ts b/apps/web/src/lib/utils/copy.ts
new file mode 100644
index 0000000..346dda0
--- /dev/null
+++ b/apps/web/src/lib/utils/copy.ts
@@ -0,0 +1,22 @@
+export async function copyToClipboard(text: string): Promise {
+ try {
+ if (navigator?.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+ } catch {}
+ try {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.setAttribute('readonly', '');
+ ta.style.position = 'absolute';
+ ta.style.left = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ const ok = document.execCommand('copy');
+ document.body.removeChild(ta);
+ return ok;
+ } catch {
+ return false;
+ }
+}
diff --git a/apps/web/src/lib/utils/date.ts b/apps/web/src/lib/utils/date.ts
new file mode 100644
index 0000000..141b25e
--- /dev/null
+++ b/apps/web/src/lib/utils/date.ts
@@ -0,0 +1,45 @@
+export function todayYmdUTC(): string {
+ const d = new Date();
+ const y = d.getUTCFullYear();
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0');
+ const day = String(d.getUTCDate()).padStart(2, '0');
+ return `${y}-${m}-${day}`;
+}
+
+export function compareYmd(a: string, b: string): number {
+ // returns -1 if ab
+ if (a === b) return 0;
+ return a < b ? -1 : 1;
+}
+
+export function addDaysYmd(ymd: string, days: number): string {
+ const d = new Date(`${ymd}T00:00:00Z`);
+ d.setUTCDate(d.getUTCDate() + days);
+ const y = d.getUTCFullYear();
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0');
+ const day = String(d.getUTCDate()).padStart(2, '0');
+ return `${y}-${m}-${day}`;
+}
+
+export function dueStatus(ymd: string | null, soonDays: number = 3): 'none' | 'overdue' | 'soon' {
+ if (!ymd) return 'none';
+ const today = todayYmdUTC();
+ if (compareYmd(ymd, today) < 0) return 'overdue';
+ const soonCut = addDaysYmd(today, soonDays);
+ if (compareYmd(ymd, soonCut) <= 0) return 'soon';
+ return 'none';
+}
+
+export function ymdToDmy(ymd: string): string {
+ const m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(ymd || '');
+ if (!m) return ymd;
+ return `${m[3]}/${m[2]}/${m[1]}`;
+}
+
+export function isToday(ymd: string): boolean {
+ return ymd === todayYmdUTC();
+}
+
+export function isTomorrow(ymd: string): boolean {
+ return ymd === addDaysYmd(todayYmdUTC(), 1);
+}
diff --git a/apps/web/src/lib/utils/groupColor.ts b/apps/web/src/lib/utils/groupColor.ts
new file mode 100644
index 0000000..d24c075
--- /dev/null
+++ b/apps/web/src/lib/utils/groupColor.ts
@@ -0,0 +1,53 @@
+export type GroupColor = {
+ border: string;
+ bg: string;
+ text: string;
+};
+
+const PALETTE: GroupColor[] = [
+ // 1) Blue
+ { border: '#2563EB', bg: '#DBEAFE', text: '#1E3A8A' },
+ // 2) Indigo
+ { border: '#4F46E5', bg: '#E0E7FF', text: '#312E81' },
+ // 3) Violet
+ { border: '#7C3AED', bg: '#EDE9FE', text: '#4C1D95' },
+ // 4) Purple
+ { border: '#9333EA', bg: '#F3E8FF', text: '#581C87' },
+ // 5) Fuchsia
+ { border: '#C026D3', bg: '#FAE8FF', text: '#701A75' },
+ // 6) Pink
+ { border: '#DB2777', bg: '#FCE7F3', text: '#831843' },
+ // 7) Rose
+ { border: '#E11D48', bg: '#FFE4E6', text: '#881337' },
+ // 8) Red
+ { border: '#DC2626', bg: '#FEE2E2', text: '#7F1D1D' },
+ // 9) Orange
+ { border: '#EA580C', bg: '#FFE7D1', text: '#7C2D12' },
+ // 10) Amber
+ { border: '#D97706', bg: '#FEF3C7', text: '#78350F' },
+ // 11) Green
+ { border: '#16A34A', bg: '#DCFCE7', text: '#14532D' },
+ // 12) Teal
+ { border: '#0D9488', bg: '#CCFBF1', text: '#134E4A' }
+];
+
+function hashString(input: string): number {
+ // Hash sencillo y rápido (similar a multiplicador 31)
+ let h = 0;
+ for (let i = 0; i < input.length; i++) {
+ h = (h * 31 + input.charCodeAt(i)) | 0;
+ }
+ // Convertir a entero positivo de 32 bits
+ return h >>> 0;
+}
+
+/**
+ * Devuelve un esquema de color determinista para un groupId dado.
+ * - Si groupId es falsy o vacío, devuelve null (usar estilos neutros por defecto).
+ */
+export function colorForGroup(groupId: string | null | undefined): GroupColor | null {
+ const s = String(groupId || '').trim();
+ if (!s) return null;
+ const idx = hashString(s) % PALETTE.length;
+ return PALETTE[idx];
+}
diff --git a/apps/web/src/lib/utils/phone.ts b/apps/web/src/lib/utils/phone.ts
new file mode 100644
index 0000000..94de330
--- /dev/null
+++ b/apps/web/src/lib/utils/phone.ts
@@ -0,0 +1,8 @@
+export function normalizeDigits(input: string | null | undefined): string {
+ return String(input ?? '').replace(/\D+/g, '');
+}
+
+export function buildWaMeUrl(input: string): string {
+ const digits = normalizeDigits(input);
+ return `https://wa.me/${digits}`;
+}
diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte
new file mode 100644
index 0000000..1619f25
--- /dev/null
+++ b/apps/web/src/routes/+layout.svelte
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte
new file mode 100644
index 0000000..1744fbe
--- /dev/null
+++ b/apps/web/src/routes/+page.svelte
@@ -0,0 +1,4 @@
+Welcome to SvelteKit
+Visit svelte.dev/docs/kit to read the documentation
+
+Ir al panel
diff --git a/apps/web/src/routes/api/groups/[id]/tasks/+server.ts b/apps/web/src/routes/api/groups/[id]/tasks/+server.ts
new file mode 100644
index 0000000..ddacd86
--- /dev/null
+++ b/apps/web/src/routes/api/groups/[id]/tasks/+server.ts
@@ -0,0 +1,114 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+
+export const GET: RequestHandler = async (event) => {
+ // Requiere sesión
+ const userId = event.locals.userId ?? null;
+ if (!userId) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const groupId = event.params.id;
+ if (!groupId) {
+ return new Response('Bad Request', { status: 400 });
+ }
+
+ const url = new URL(event.request.url);
+ const unassignedFirst =
+ (url.searchParams.get('unassignedFirst') || '').trim().toLowerCase() === 'true';
+ const onlyUnassigned =
+ (url.searchParams.get('onlyUnassigned') || '').trim().toLowerCase() === 'true';
+ let limit = parseInt(url.searchParams.get('limit') || '', 10);
+ if (!Number.isFinite(limit) || limit <= 0) limit = 0;
+ if (limit > 100) limit = 100;
+
+ const db = await getDb();
+
+ // Gating: grupo permitido + usuario es miembro activo
+ const allowed = db
+ .prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
+ .get(groupId);
+ const active = db
+ .prepare(
+ `SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
+ )
+ .get(groupId, userId);
+
+ if (!allowed || !active) {
+ return new Response('Forbidden', { status: 403 });
+ }
+
+ const orderParts: string[] = [];
+ if (unassignedFirst) {
+ orderParts.push(
+ `CASE WHEN EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id) THEN 1 ELSE 0 END ASC`
+ );
+ }
+ orderParts.push(
+ `CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END`,
+ `t.due_date ASC`,
+ `t.id ASC`
+ );
+
+ const whereParts = [
+ `t.group_id = ?`,
+ `COALESCE(t.completed, 0) = 0`,
+ `t.completed_at IS NULL`
+ ];
+ if (onlyUnassigned) {
+ whereParts.push(
+ `NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)`
+ );
+ }
+
+ const params: any[] = [groupId];
+
+ const sql = `
+ SELECT t.id, t.description, t.due_date, t.group_id, t.display_code
+ FROM tasks t
+ WHERE ${whereParts.join(' AND ')}
+ ORDER BY ${orderParts.join(', ')}${limit > 0 ? ' LIMIT ?' : ''}`;
+
+ if (limit > 0) params.push(limit);
+
+ const rows = db.prepare(sql).all(...params) as any[];
+
+ let items = rows.map((r) => ({
+ id: Number(r.id),
+ description: String(r.description || ''),
+ due_date: r.due_date ? String(r.due_date) : null,
+ group_id: r.group_id ? String(r.group_id) : null,
+ display_code: r.display_code != null ? Number(r.display_code) : null,
+ assignees: [] as string[]
+ }));
+
+ // Cargar asignados
+ if (items.length > 0 && !onlyUnassigned) {
+ const ids = items.map((it) => it.id);
+ const placeholders = ids.map(() => '?').join(',');
+ const assignRows = db
+ .prepare(
+ `SELECT task_id, user_id
+ FROM task_assignments
+ WHERE task_id IN (${placeholders})
+ ORDER BY assigned_at ASC`
+ )
+ .all(...ids) as any[];
+
+ const map = new Map();
+ for (const row of assignRows) {
+ const tid = Number(row.task_id);
+ const uid = String(row.user_id);
+ if (!map.has(tid)) map.set(tid, []);
+ map.get(tid)!.push(uid);
+ }
+ for (const it of items) {
+ it.assignees = map.get(it.id) || [];
+ }
+ }
+
+ return new Response(JSON.stringify({ items }), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+};
diff --git a/apps/web/src/routes/api/integrations/feeds/+server.ts b/apps/web/src/routes/api/integrations/feeds/+server.ts
new file mode 100644
index 0000000..8cbcd2f
--- /dev/null
+++ b/apps/web/src/routes/api/integrations/feeds/+server.ts
@@ -0,0 +1,87 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+import { findActiveToken, createCalendarTokenUrl, buildCalendarIcsUrl, rotateCalendarTokenUrl } from '$lib/server/calendar-tokens';
+
+export const GET: RequestHandler = async (event) => {
+ // Requiere sesión
+ const userId = event.locals.userId ?? null;
+ if (!userId) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const db = await getDb();
+
+ // Listar solo grupos permitidos donde el usuario está activo (mismo gating que /api/me/groups)
+ const groups = db
+ .prepare(
+ `SELECT g.id, g.name
+ FROM groups g
+ INNER JOIN group_members gm
+ ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1
+ INNER JOIN allowed_groups ag
+ ON ag.group_id = g.id AND ag.status = 'allowed'
+ WHERE COALESCE(g.active, 1) = 1 AND COALESCE(g.is_community, 0) = 0 AND COALESCE(g.archived, 0) = 0
+ ORDER BY (g.name IS NULL) ASC, g.name ASC, g.id ASC`
+ )
+ .all(userId) as Array<{ id: string; name: string | null }>;
+
+ // Personal
+ const personalExisting = await findActiveToken('personal', userId, null);
+ const personal = await (async () => {
+ if (personalExisting) {
+ if (personalExisting.token_plain) {
+ return { url: buildCalendarIcsUrl('personal', personalExisting.token_plain) };
+ }
+ const rotated = await rotateCalendarTokenUrl('personal', userId, null);
+ return { url: rotated.url };
+ } else {
+ const created = await createCalendarTokenUrl('personal', userId, null);
+ return { url: created.url };
+ }
+ })();
+
+ // Aggregate (multigrupo)
+ const aggregateExisting = await findActiveToken('aggregate', userId, null);
+ const aggregate = await (async () => {
+ if (aggregateExisting) {
+ if (aggregateExisting.token_plain) {
+ return { url: buildCalendarIcsUrl('aggregate', aggregateExisting.token_plain) };
+ }
+ const rotated = await rotateCalendarTokenUrl('aggregate', userId, null);
+ return { url: rotated.url };
+ } else {
+ const created = await createCalendarTokenUrl('aggregate', userId, null);
+ return { url: created.url };
+ }
+ })();
+
+ // Por grupo (B): autogenerar si falta
+ const groupFeeds: Array<{ groupId: string; groupName: string | null; url: string | null }> = [];
+ for (const g of groups) {
+ const ex = await findActiveToken('group', userId, g.id);
+ if (ex) {
+ let url: string | null;
+ if (ex.token_plain) {
+ url = buildCalendarIcsUrl('group', ex.token_plain);
+ } else {
+ const rotated = await rotateCalendarTokenUrl('group', userId, g.id);
+ url = rotated.url;
+ }
+ groupFeeds.push({ groupId: g.id, groupName: g.name ?? null, url });
+ } else {
+ const created = await createCalendarTokenUrl('group', userId, g.id);
+ groupFeeds.push({ groupId: g.id, groupName: g.name ?? null, url: created.url });
+ }
+ }
+
+ const body = {
+ personal,
+ groups: groupFeeds,
+ aggregate
+ };
+
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+};
diff --git a/apps/web/src/routes/api/integrations/feeds/rotate/+server.ts b/apps/web/src/routes/api/integrations/feeds/rotate/+server.ts
new file mode 100644
index 0000000..f8f5c06
--- /dev/null
+++ b/apps/web/src/routes/api/integrations/feeds/rotate/+server.ts
@@ -0,0 +1,63 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+import { rotateCalendarTokenUrl } from '$lib/server/calendar-tokens';
+
+export const POST: RequestHandler = async (event) => {
+ // Requiere sesión
+ const userId = event.locals.userId ?? null;
+ if (!userId) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ let payload: any = null;
+ try {
+ payload = await event.request.json();
+ } catch {
+ return new Response('Bad Request', { status: 400 });
+ }
+
+ const type = String(payload?.type || '').trim().toLowerCase();
+ const groupId = payload?.groupId ? String(payload.groupId).trim() : null;
+
+ if (!['personal', 'group', 'aggregate'].includes(type)) {
+ return new Response(JSON.stringify({ error: 'type inválido' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ // Validación de gating/membresía si es group
+ if (type === 'group') {
+ if (!groupId) {
+ return new Response(JSON.stringify({ error: 'groupId requerido para type=group' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+ const db = await getDb();
+ const row = db
+ .prepare(
+ `SELECT 1
+ FROM groups g
+ INNER JOIN group_members gm
+ ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1
+ INNER JOIN allowed_groups ag
+ ON ag.group_id = g.id AND ag.status = 'allowed'
+ WHERE g.id = ? AND COALESCE(g.active, 1) = 1 AND COALESCE(g.is_community, 0) = 0 AND COALESCE(g.archived, 0) = 0
+ LIMIT 1`
+ )
+ .get(userId, groupId) as any;
+ if (!row) {
+ return new Response(JSON.stringify({ error: 'forbidden' }), {
+ status: 403,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+ }
+
+ const rotated = await rotateCalendarTokenUrl(type as any, userId, groupId);
+ return new Response(JSON.stringify({ url: rotated.url }), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+};
diff --git a/apps/web/src/routes/api/logout/+server.ts b/apps/web/src/routes/api/logout/+server.ts
new file mode 100644
index 0000000..abd444e
--- /dev/null
+++ b/apps/web/src/routes/api/logout/+server.ts
@@ -0,0 +1,32 @@
+import type { RequestHandler } from './$types';
+import { redirect } from '@sveltejs/kit';
+import { getDb } from '$lib/server/db';
+import { sha256Hex } from '$lib/server/crypto';
+import { isProd } from '$lib/server/env';
+
+export const POST: RequestHandler = async (event) => {
+ const sid = event.cookies.get('sid');
+ if (sid) {
+ try {
+ const db = await getDb();
+ const hash = await sha256Hex(sid);
+ // Intentar borrar; si falla, expirar
+ try {
+ db.prepare(`DELETE FROM web_sessions WHERE session_hash = ?`).run(hash);
+ } catch {
+ db.prepare(
+ `UPDATE web_sessions
+ SET expires_at = strftime('%Y-%m-%d %H:%M:%f','now')
+ WHERE session_hash = ?`
+ ).run(hash);
+ }
+ } catch {
+ // Ignorar errores de DB en logout
+ }
+ }
+ // Limpiar cookie (asegurar mismos atributos que al crearla)
+ event.cookies.delete('sid', { path: '/', httpOnly: true, sameSite: 'lax', secure: isProd() });
+
+ // Redirigir a home para que el navegador navegue sin depender de JS
+ throw redirect(303, '/');
+};
diff --git a/apps/web/src/routes/api/me/groups/+server.ts b/apps/web/src/routes/api/me/groups/+server.ts
new file mode 100644
index 0000000..489c08a
--- /dev/null
+++ b/apps/web/src/routes/api/me/groups/+server.ts
@@ -0,0 +1,61 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+
+export const GET: RequestHandler = async (event) => {
+ // Requiere sesión
+ const userId = event.locals.userId ?? null;
+ if (!userId) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const db = await getDb();
+
+ // Listar solo grupos permitidos donde el usuario está activo
+ const groups = db
+ .prepare(
+ `SELECT g.id, g.name
+ FROM groups g
+ INNER JOIN group_members gm
+ ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1
+ INNER JOIN allowed_groups ag
+ ON ag.group_id = g.id AND ag.status = 'allowed'
+ WHERE COALESCE(g.active, 1) = 1
+ ORDER BY (g.name IS NULL) ASC, g.name ASC, g.id ASC`
+ )
+ .all(userId) as any[];
+
+ // Preparar statements para contadores
+ const countOpenStmt = db.prepare(
+ `SELECT COUNT(*) AS cnt
+ FROM tasks t
+ WHERE t.group_id = ?
+ AND COALESCE(t.completed, 0) = 0
+ AND t.completed_at IS NULL`
+ );
+ const countUnassignedStmt = db.prepare(
+ `SELECT COUNT(*) AS cnt
+ FROM tasks t
+ WHERE t.group_id = ?
+ AND COALESCE(t.completed, 0) = 0
+ AND t.completed_at IS NULL
+ AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)`
+ );
+
+ const items = groups.map((g) => {
+ const open = countOpenStmt.get(g.id) as any;
+ const unassigned = countUnassignedStmt.get(g.id) as any;
+ return {
+ id: String(g.id),
+ name: g.name != null ? String(g.name) : null,
+ counts: {
+ open: Number(open?.cnt || 0),
+ unassigned: Number(unassigned?.cnt || 0)
+ }
+ };
+ });
+
+ return new Response(JSON.stringify({ items }), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+};
diff --git a/apps/web/src/routes/api/me/preferences/+server.ts b/apps/web/src/routes/api/me/preferences/+server.ts
new file mode 100644
index 0000000..1121764
--- /dev/null
+++ b/apps/web/src/routes/api/me/preferences/+server.ts
@@ -0,0 +1,127 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+
+export const GET: RequestHandler = async (event) => {
+ // Requiere sesión
+ const userId = event.locals.userId ?? null;
+ if (!userId) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const db = await getDb();
+ const row = db
+ .prepare(
+ `SELECT reminder_freq AS freq, reminder_time AS time
+ FROM user_preferences
+ WHERE user_id = ?
+ LIMIT 1`
+ )
+ .get(userId) as any;
+
+ const body =
+ row && row.freq
+ ? { freq: String(row.freq), time: row.time ? String(row.time) : null }
+ : { freq: 'off', time: '08:30' };
+
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+};
+
+export const POST: RequestHandler = async (event) => {
+ // Requiere sesión
+ const userId = event.locals.userId ?? null;
+ if (!userId) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ let payload: any = null;
+ try {
+ payload = await event.request.json();
+ } catch {
+ return new Response('Bad Request', { status: 400 });
+ }
+
+ const freqRaw = String(payload?.freq || '').trim().toLowerCase();
+ const timeRaw = payload?.time == null ? null : String(payload.time).trim();
+
+ const allowed = new Set(['off', 'daily', 'weekly', 'weekdays']);
+ if (!allowed.has(freqRaw)) {
+ return new Response(JSON.stringify({ error: 'freq inválida' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ function normalizeTime(input: string): string | null {
+ const m = /^\s*(\d{1,2}):(\d{1,2})\s*$/.exec(input || '');
+ if (!m) return null;
+ const h = Number(m[1]);
+ const min = Number(m[2]);
+ if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
+ if (h < 0 || h > 23 || min < 0 || min > 59) return null;
+ const hh = String(h).padStart(2, '0');
+ const mm = String(min).padStart(2, '0');
+ return `${hh}:${mm}`;
+ }
+
+ const db = await getDb();
+
+ let timeToSave: string | null = null;
+
+ if (freqRaw === 'off') {
+ // Hora opcional: si viene, validar/normalizar; si no, conservar la actual o usar '08:30'
+ if (timeRaw && timeRaw.length > 0) {
+ const norm = normalizeTime(timeRaw);
+ if (!norm) {
+ return new Response(JSON.stringify({ error: 'hora inválida' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+ timeToSave = norm;
+ } else {
+ const row = db
+ .prepare(
+ `SELECT reminder_time AS time
+ FROM user_preferences
+ WHERE user_id = ?
+ LIMIT 1`
+ )
+ .get(userId) as any;
+ timeToSave = row?.time ? String(row.time) : '08:30';
+ }
+ } else {
+ // daily/weekly/weekdays: si no se especifica hora, usar '08:30'
+ if (!timeRaw || timeRaw.length === 0) {
+ timeToSave = '08:30';
+ } else {
+ const norm = normalizeTime(timeRaw);
+ if (!norm) {
+ return new Response(JSON.stringify({ error: 'hora inválida' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+ timeToSave = norm;
+ }
+ }
+
+ // Upsert preferencia (mantener last_reminded_on intacto)
+ db.prepare(
+ `INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
+ VALUES (?, ?, ?, (SELECT last_reminded_on FROM user_preferences WHERE user_id = ?), strftime('%Y-%m-%d %H:%M:%f','now'))
+ ON CONFLICT(user_id) DO UPDATE SET
+ reminder_freq = excluded.reminder_freq,
+ reminder_time = excluded.reminder_time,
+ updated_at = excluded.updated_at`
+ ).run(userId, freqRaw, timeToSave, userId);
+
+ const responseBody = { freq: freqRaw, time: timeToSave };
+
+ return new Response(JSON.stringify(responseBody), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+};
diff --git a/apps/web/src/routes/api/me/tasks/+server.ts b/apps/web/src/routes/api/me/tasks/+server.ts
new file mode 100644
index 0000000..1204514
--- /dev/null
+++ b/apps/web/src/routes/api/me/tasks/+server.ts
@@ -0,0 +1,235 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+
+function clamp(n: number, min: number, max: number) {
+ return Math.max(min, Math.min(max, n));
+}
+
+export const GET: RequestHandler = async (event) => {
+ // Requiere sesión
+ const userId = event.locals.userId ?? null;
+ if (!userId) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const url = new URL(event.request.url);
+ const search = (url.searchParams.get('search') || '').trim();
+ const status = (url.searchParams.get('status') || 'open').trim().toLowerCase();
+ const page = clamp(parseInt(url.searchParams.get('page') || '1', 10) || 1, 1, 100000);
+ const limit = clamp(parseInt(url.searchParams.get('limit') || '20', 10) || 20, 1, 100);
+ const orderParam = (url.searchParams.get('order') || 'due').trim().toLowerCase();
+ const order = orderParam === 'group_then_due' ? 'group_then_due' : 'due';
+ const dueBeforeParam = (url.searchParams.get('dueBefore') || '').trim();
+ const soonDaysParam = parseInt(url.searchParams.get('soonDays') || '', 10);
+ const soonDays = Number.isFinite(soonDaysParam) && soonDaysParam >= 0 ? Math.min(soonDaysParam, 365) : null;
+ let dueCutoff: string | null = dueBeforeParam || null;
+ if (!dueCutoff && soonDays != null) {
+ const d = new Date();
+ d.setUTCDate(d.getUTCDate() + soonDays);
+ dueCutoff = d.toISOString().slice(0, 10);
+ }
+
+ // Acepta "open" (por defecto) o "recent" (completadas <24h)
+ if (status !== 'open' && status !== 'recent') {
+ return new Response('Bad Request', { status: 400 });
+ }
+
+ const offset = (page - 1) * limit;
+
+ const db = await getDb();
+
+ if (status === 'recent') {
+ // Construir filtros para tareas completadas en <24h asignadas al usuario.
+ const whereParts = [
+ `a.user_id = ?`,
+ `(COALESCE(t.completed, 0) = 1 OR t.completed_at IS NOT NULL)`,
+ `t.completed_at IS NOT NULL AND t.completed_at >= strftime('%Y-%m-%d %H:%M:%f','now','-24 hours')`,
+ `(t.group_id IS NULL OR (EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed') AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1) AND EXISTS (SELECT 1 FROM groups g WHERE g.id = t.group_id AND COALESCE(g.active,1)=1 AND COALESCE(g.archived,0)=0)))`
+ ];
+ const params: any[] = [userId, userId];
+
+ if (search) {
+ whereParts.push(`t.description LIKE ? ESCAPE '\\'`);
+ params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`);
+ }
+
+ // Total
+ const totalRow = db
+ .prepare(
+ `SELECT COUNT(*) AS cnt
+ FROM tasks t
+ INNER JOIN task_assignments a ON a.task_id = t.id
+ WHERE ${whereParts.join(' AND ')}`
+ )
+ .get(...params) as any;
+ const total = Number(totalRow?.cnt || 0);
+
+ // Items (order by completed_at DESC)
+ const itemsRows = db
+ .prepare(
+ `SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, COALESCE(t.completed,0) as completed, t.completed_at
+ FROM tasks t
+ INNER JOIN task_assignments a ON a.task_id = t.id
+ WHERE ${whereParts.join(' AND ')}
+ ORDER BY t.completed_at DESC, t.id DESC
+ LIMIT ? OFFSET ?`
+ )
+ .all(...params, limit, offset) as any[];
+
+ const items = itemsRows.map((r) => ({
+ id: Number(r.id),
+ description: String(r.description || ''),
+ due_date: r.due_date ? String(r.due_date) : null,
+ group_id: r.group_id ? String(r.group_id) : null,
+ display_code: r.display_code != null ? Number(r.display_code) : null,
+ completed: Number(r.completed || 0) === 1,
+ completed_at: r.completed_at ? String(r.completed_at) : null,
+ assignees: [] as string[]
+ }));
+
+ // Cargar asignados
+ if (items.length > 0) {
+ const ids = items.map((it) => it.id);
+ const placeholders = ids.map(() => '?').join(',');
+ const assignRows = db
+ .prepare(
+ `SELECT task_id, user_id
+ FROM task_assignments
+ WHERE task_id IN (${placeholders})
+ ORDER BY assigned_at ASC`
+ )
+ .all(...ids) as any[];
+
+ const map = new Map();
+ for (const row of assignRows) {
+ const tid = Number(row.task_id);
+ const uid = String(row.user_id);
+ if (!map.has(tid)) map.set(tid, []);
+ map.get(tid)!.push(uid);
+ }
+ for (const it of items) {
+ it.assignees = map.get(it.id) || [];
+ const personal = it.group_id == null;
+ const cnt = Array.isArray(it.assignees) ? it.assignees.length : 0;
+ const mine = (it.assignees || []).some((uid) => uid === userId);
+ (it as any).can_unassign = !(personal && cnt === 1 && mine);
+ }
+ }
+
+ const body = {
+ items,
+ page,
+ limit,
+ total,
+ hasMore: offset + items.length < total
+ };
+
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ // OPEN (comportamiento existente)
+ // Construir filtros dinámicos (con gating por grupo permitido y membresía activa)
+ const whereParts = [
+ `a.user_id = ?`,
+ `COALESCE(t.completed, 0) = 0`,
+ `t.completed_at IS NULL`,
+ `(t.group_id IS NULL OR (EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed') AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1) AND EXISTS (SELECT 1 FROM groups g WHERE g.id = t.group_id AND COALESCE(g.active,1)=1 AND COALESCE(g.archived,0)=0)))`
+ ];
+ const params: any[] = [userId];
+
+ // Añadir userId para el chequeo de membresía en el filtro de gating
+ params.push(userId);
+
+ if (search) {
+ whereParts.push(`t.description LIKE ? ESCAPE '\\'`);
+ params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`);
+ }
+
+ if (dueCutoff) {
+ whereParts.push(`t.due_date IS NOT NULL AND t.due_date <= ?`);
+ params.push(dueCutoff);
+ }
+
+ // Total
+ const totalRow = db
+ .prepare(
+ `SELECT COUNT(*) AS cnt
+ FROM tasks t
+ INNER JOIN task_assignments a ON a.task_id = t.id
+ WHERE ${whereParts.join(' AND ')}`
+ )
+ .get(...params) as any;
+ const total = Number(totalRow?.cnt || 0);
+
+ // Items
+ const orderBy =
+ order === 'group_then_due'
+ ? `(t.group_id IS NULL) ASC, g.name COLLATE NOCASE ASC, CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`
+ : `CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`;
+
+ const itemsRows = db
+ .prepare(
+ `SELECT t.id, t.description, t.due_date, t.group_id, t.display_code
+ FROM tasks t
+ LEFT JOIN groups g ON g.id = t.group_id
+ INNER JOIN task_assignments a ON a.task_id = t.id
+ WHERE ${whereParts.join(' AND ')}
+ ORDER BY ${orderBy}
+ LIMIT ? OFFSET ?`
+ )
+ .all(...params, limit, offset) as any[];
+
+ const items = itemsRows.map((r) => ({
+ id: Number(r.id),
+ description: String(r.description || ''),
+ due_date: r.due_date ? String(r.due_date) : null,
+ group_id: r.group_id ? String(r.group_id) : null,
+ display_code: r.display_code != null ? Number(r.display_code) : null,
+ assignees: [] as string[]
+ }));
+
+ // Cargar asignados de todas las tareas recuperadas (si hay)
+ if (items.length > 0) {
+ const ids = items.map((it) => it.id);
+ const placeholders = ids.map(() => '?').join(',');
+ const assignRows = db
+ .prepare(
+ `SELECT task_id, user_id
+ FROM task_assignments
+ WHERE task_id IN (${placeholders})
+ ORDER BY assigned_at ASC`
+ )
+ .all(...ids) as any[];
+
+ const map = new Map();
+ for (const row of assignRows) {
+ const tid = Number(row.task_id);
+ const uid = String(row.user_id);
+ if (!map.has(tid)) map.set(tid, []);
+ map.get(tid)!.push(uid);
+ }
+ for (const it of items) {
+ it.assignees = map.get(it.id) || [];
+ const personal = it.group_id == null;
+ const cnt = Array.isArray(it.assignees) ? it.assignees.length : 0;
+ const mine = (it.assignees || []).some((uid) => uid === userId);
+ (it as any).can_unassign = !(personal && cnt === 1 && mine);
+ }
+ }
+
+ const body = {
+ items,
+ page,
+ limit,
+ total,
+ hasMore: offset + items.length < total
+ };
+
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+};
diff --git a/apps/web/src/routes/api/me/tasks/overview/+server.ts b/apps/web/src/routes/api/me/tasks/overview/+server.ts
new file mode 100644
index 0000000..191d7f6
--- /dev/null
+++ b/apps/web/src/routes/api/me/tasks/overview/+server.ts
@@ -0,0 +1,113 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+
+export const GET: RequestHandler = async (event) => {
+ // Requiere sesión
+ const userId = event.locals.userId ?? null;
+ if (!userId) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const url = new URL(event.request.url);
+ const orderParam = (url.searchParams.get('order') || 'due').trim().toLowerCase();
+ const order = orderParam === 'group_then_due' ? 'group_then_due' : 'due';
+
+ const db = await getDb();
+
+ // Orden para "assigned"
+ const assignedOrder =
+ order === 'group_then_due'
+ ? `(t.group_id IS NULL) ASC, g.name COLLATE NOCASE ASC, CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`
+ : `CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`;
+
+ // Tareas asignadas al usuario (abiertas)
+ const assignedRows = db
+ .prepare(
+ `SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, g.name AS group_name
+ FROM tasks t
+ LEFT JOIN groups g ON g.id = t.group_id
+ INNER JOIN task_assignments a ON a.task_id = t.id
+ WHERE a.user_id = ?
+ AND COALESCE(t.completed, 0) = 0
+ AND t.completed_at IS NULL
+ AND (
+ t.group_id IS NULL OR (
+ EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed')
+ AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1)
+ )
+ )
+ ORDER BY ${assignedOrder}`
+ )
+ .all(userId, userId) as any[];
+
+ const assigned = assignedRows.map((r) => ({
+ id: Number(r.id),
+ description: String(r.description || ''),
+ due_date: r.due_date ? String(r.due_date) : null,
+ group_id: r.group_id ? String(r.group_id) : null,
+ group_name: r.group_name != null ? String(r.group_name) : null, // personales => null
+ display_code: r.display_code != null ? Number(r.display_code) : null,
+ assignees: [] as string[]
+ }));
+
+ // Cargar asignados completos para "assigned"
+ if (assigned.length > 0) {
+ const ids = assigned.map((it) => it.id);
+ const placeholders = ids.map(() => '?').join(',');
+ const assignRows = db
+ .prepare(
+ `SELECT task_id, user_id
+ FROM task_assignments
+ WHERE task_id IN (${placeholders})
+ ORDER BY assigned_at ASC`
+ )
+ .all(...ids) as any[];
+ const map = new Map();
+ for (const row of assignRows) {
+ const tid = Number(row.task_id);
+ const uid = String(row.user_id);
+ if (!map.has(tid)) map.set(tid, []);
+ map.get(tid)!.push(uid);
+ }
+ for (const it of assigned) {
+ it.assignees = map.get(it.id) || [];
+ }
+ }
+
+ // Orden para "unassigned"
+ const unassignedOrder =
+ order === 'group_then_due'
+ ? `(t.group_id IS NULL) ASC, g.name COLLATE NOCASE ASC, CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`
+ : `CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`;
+
+ // Tareas sin responsable (solo de grupos permitidos donde soy miembro activo)
+ const unassignedRows = db
+ .prepare(
+ `SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, g.name AS group_name
+ FROM tasks t
+ LEFT JOIN groups g ON g.id = t.group_id
+ WHERE t.group_id IS NOT NULL
+ AND COALESCE(t.completed, 0) = 0
+ AND t.completed_at IS NULL
+ AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)
+ AND EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed')
+ AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1)
+ ORDER BY ${unassignedOrder}`
+ )
+ .all(userId) as any[];
+
+ const unassigned = unassignedRows.map((r) => ({
+ id: Number(r.id),
+ description: String(r.description || ''),
+ due_date: r.due_date ? String(r.due_date) : null,
+ group_id: r.group_id ? String(r.group_id) : null,
+ group_name: r.group_name != null ? String(r.group_name) : null,
+ display_code: r.display_code != null ? Number(r.display_code) : null,
+ assignees: [] as string[] // por definición, vacío
+ }));
+
+ return new Response(JSON.stringify({ assigned, unassigned }), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+};
diff --git a/apps/web/src/routes/api/tasks/[id]/+server.ts b/apps/web/src/routes/api/tasks/[id]/+server.ts
new file mode 100644
index 0000000..92900a7
--- /dev/null
+++ b/apps/web/src/routes/api/tasks/[id]/+server.ts
@@ -0,0 +1,165 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+
+function isValidYmd(input: string): boolean {
+ const m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(input || '');
+ if (!m) return false;
+ const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
+ if (!Number.isFinite(y) || !Number.isFinite(mo) || !Number.isFinite(d)) return false;
+ if (mo < 1 || mo > 12 || d < 1 || d > 31) return false;
+ const dt = new Date(`${String(y).padStart(4, '0')}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}T00:00:00Z`);
+ // Comprobar que el Date resultante coincide (evita 2025-02-31)
+ return dt.getUTCFullYear() === y && (dt.getUTCMonth() + 1) === mo && dt.getUTCDate() === d;
+}
+
+export const PATCH: RequestHandler = async (event) => {
+ const userId = event.locals.userId ?? null;
+ if (!userId) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const idStr = event.params.id || '';
+ const taskId = parseInt(idStr, 10);
+ if (!Number.isFinite(taskId) || taskId <= 0) {
+ return new Response('Bad Request', { status: 400 });
+ }
+
+ let payload: any = null;
+ try {
+ payload = await event.request.json();
+ } catch {
+ return new Response('Bad Request', { status: 400 });
+ }
+
+ // Validar que al menos se envíe algún campo editable
+ const hasDueField = Object.prototype.hasOwnProperty.call(payload ?? {}, 'due_date');
+ const hasDescField = Object.prototype.hasOwnProperty.call(payload ?? {}, 'description');
+ if (!hasDueField && !hasDescField) {
+ return new Response('Bad Request', { status: 400 });
+ }
+
+ // due_date (opcional)
+ const due_date_raw = payload?.due_date;
+ if (hasDueField && due_date_raw !== null && due_date_raw !== undefined && typeof due_date_raw !== 'string') {
+ return new Response('Bad Request', { status: 400 });
+ }
+ const due_date =
+ !hasDueField || due_date_raw == null || String(due_date_raw).trim() === ''
+ ? null
+ : String(due_date_raw).trim();
+
+ if (hasDueField && due_date !== null && !isValidYmd(due_date)) {
+ return new Response(JSON.stringify({ error: 'invalid_due_date' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ // description (opcional)
+ let description: string | undefined = undefined;
+ if (hasDescField) {
+ const descRaw = payload?.description;
+ if (descRaw !== null && descRaw !== undefined && typeof descRaw !== 'string') {
+ return new Response('Bad Request', { status: 400 });
+ }
+ if (descRaw == null) {
+ // No permitimos null en description (columna NOT NULL)
+ return new Response(JSON.stringify({ error: 'invalid_description' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+ const normalized = String(descRaw).replace(/\s+/g, ' ').trim();
+ if (normalized.length < 1 || normalized.length > 1000) {
+ return new Response(JSON.stringify({ error: 'invalid_description' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+ description = normalized;
+ }
+
+ const db = await getDb();
+
+ // Cargar tarea y validar abierta
+ const task = db
+ .prepare(
+ `SELECT id, description, due_date, group_id, created_by, COALESCE(completed, 0) AS completed, completed_at, display_code
+ FROM tasks
+ WHERE id = ?`
+ )
+ .get(taskId) as any;
+
+ if (!task) {
+ return new Response(JSON.stringify({ status: 'not_found' }), {
+ status: 404,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+ if (Number(task.completed) !== 0 || task.completed_at) {
+ return new Response(JSON.stringify({ status: 'completed' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ // Gating: grupo permitido + usuario miembro activo (si tiene group_id)
+ const groupId: string | null = task.group_id ? String(task.group_id) : null;
+ if (groupId) {
+ const allowed = db
+ .prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
+ .get(groupId);
+ const active = db
+ .prepare(
+ `SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
+ )
+ .get(groupId, userId);
+ const gstatus = db
+ .prepare(
+ `SELECT 1 FROM groups WHERE id = ? AND COALESCE(active,1)=1 AND COALESCE(archived,0)=0 LIMIT 1`
+ )
+ .get(groupId);
+
+ if (!allowed || !active || !gstatus) {
+ return new Response('Forbidden', { status: 403 });
+ }
+ } else {
+ // Tarea sin grupo: permitir si el usuario está asignado o es el creador
+ const isAssigned = db
+ .prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
+ .get(taskId, userId);
+ const isCreator = String(task.created_by || '') === String(userId);
+
+ if (!isAssigned && !isCreator) {
+ return new Response('Forbidden', { status: 403 });
+ }
+ }
+
+ // Aplicar actualización
+ if (hasDescField && hasDueField) {
+ db.prepare(`UPDATE tasks SET description = ?, due_date = ? WHERE id = ?`).run(description!, due_date, taskId);
+ } else if (hasDescField) {
+ db.prepare(`UPDATE tasks SET description = ? WHERE id = ?`).run(description!, taskId);
+ } else if (hasDueField) {
+ db.prepare(`UPDATE tasks SET due_date = ? WHERE id = ?`).run(due_date, taskId);
+ }
+
+ const updated = db
+ .prepare(`SELECT id, description, due_date, display_code FROM tasks WHERE id = ?`)
+ .get(taskId) as any;
+
+ const body = {
+ status: 'updated',
+ task: {
+ id: Number(updated.id),
+ description: String(updated.description || ''),
+ due_date: updated.due_date ? String(updated.due_date) : null,
+ display_code: updated.display_code != null ? Number(updated.display_code) : null
+ }
+ };
+
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+};
diff --git a/apps/web/src/routes/api/tasks/[id]/claim/+server.ts b/apps/web/src/routes/api/tasks/[id]/claim/+server.ts
new file mode 100644
index 0000000..06996e0
--- /dev/null
+++ b/apps/web/src/routes/api/tasks/[id]/claim/+server.ts
@@ -0,0 +1,99 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+
+function toIsoSql(d: Date): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+export const POST: RequestHandler = async (event) => {
+ const userId = event.locals.userId ?? null;
+ if (!userId) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const idStr = event.params.id || '';
+ const taskId = parseInt(idStr, 10);
+ if (!Number.isFinite(taskId) || taskId <= 0) {
+ return new Response('Bad Request', { status: 400 });
+ }
+
+ const db = await getDb();
+
+ // Cargar tarea y validar abierta
+ const task = db
+ .prepare(
+ `SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
+ FROM tasks
+ WHERE id = ?`
+ )
+ .get(taskId) as any;
+
+ if (!task) {
+ return new Response(JSON.stringify({ status: 'not_found' }), {
+ status: 404,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+ if (Number(task.completed) !== 0 || task.completed_at) {
+ return new Response(JSON.stringify({ status: 'completed' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ // Gating: grupo permitido + usuario miembro activo (si tiene group_id)
+ const groupId: string | null = task.group_id ? String(task.group_id) : null;
+ if (groupId) {
+ const allowed = db
+ .prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
+ .get(groupId);
+ const active = db
+ .prepare(
+ `SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
+ )
+ .get(groupId, userId);
+
+ if (!allowed || !active) {
+ return new Response('Forbidden', { status: 403 });
+ }
+ }
+
+ // Asegurar existencia del usuario (best-effort)
+ try {
+ db.transaction(() => {
+ db.prepare(
+ `INSERT INTO users (id, first_seen, last_seen)
+ VALUES (?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
+ ON CONFLICT(id) DO NOTHING`
+ ).run(userId);
+ db.prepare(
+ `UPDATE users SET last_seen = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`
+ ).run(userId);
+ })();
+ } catch {}
+
+ // Reclamar (idempotente)
+ const res = db
+ .prepare(
+ `INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by)
+ VALUES (?, ?, ?)`
+ )
+ .run(taskId, userId, userId) as any;
+
+ const status = Number(res?.changes || 0) > 0 ? 'claimed' : 'already';
+
+ const body = {
+ status,
+ task: {
+ id: Number(task.id),
+ description: String(task.description || ''),
+ due_date: task.due_date ? String(task.due_date) : null,
+ display_code: task.display_code != null ? Number(task.display_code) : null
+ }
+ };
+
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+};
diff --git a/apps/web/src/routes/api/tasks/[id]/complete/+server.ts b/apps/web/src/routes/api/tasks/[id]/complete/+server.ts
new file mode 100644
index 0000000..ead73aa
--- /dev/null
+++ b/apps/web/src/routes/api/tasks/[id]/complete/+server.ts
@@ -0,0 +1,120 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+
+export const POST: RequestHandler = async (event) => {
+ const userId = event.locals.userId ?? null;
+ if (!userId) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const idStr = event.params.id || '';
+ const taskId = parseInt(idStr, 10);
+ if (!Number.isFinite(taskId) || taskId <= 0) {
+ return new Response('Bad Request', { status: 400 });
+ }
+
+ const db = await getDb();
+
+ const task = db.prepare(`
+ SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
+ FROM tasks
+ WHERE id = ?
+ `).get(taskId) as any;
+
+ if (!task) {
+ return new Response(JSON.stringify({ status: 'not_found' }), {
+ status: 404,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ // Gating:
+ // - Si tiene group_id: grupo allowed y miembro activo
+ // - Si NO tiene group_id: debe estar asignada al usuario
+ const groupId: string | null = task.group_id ? String(task.group_id) : null;
+ if (groupId) {
+ const allowed = db
+ .prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
+ .get(groupId);
+ const active = db
+ .prepare(`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`)
+ .get(groupId, userId);
+ if (!allowed || !active) {
+ return new Response('Forbidden', { status: 403 });
+ }
+ } else {
+ const isAssigned = db
+ .prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
+ .get(taskId, userId);
+ if (!isAssigned) {
+ return new Response('Forbidden', { status: 403 });
+ }
+ }
+
+ if (Number(task.completed) !== 0 || task.completed_at) {
+ const body = {
+ status: 'already',
+ task: {
+ id: Number(task.id),
+ description: String(task.description || ''),
+ due_date: task.due_date ? String(task.due_date) : null,
+ display_code: task.display_code != null ? Number(task.display_code) : null,
+ completed: 1,
+ completed_at: task.completed_at ? String(task.completed_at) : null
+ }
+ };
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ // Transacción: auto-asignar si no hay responsables y completar
+ const tx = db.transaction(() => {
+ const cntRow = db
+ .prepare(`SELECT COUNT(*) AS cnt FROM task_assignments WHERE task_id = ?`)
+ .get(taskId) as any;
+ const cnt = Number(cntRow?.cnt || 0);
+ if (cnt === 0) {
+ db.prepare(`
+ INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by)
+ VALUES (?, ?, ?)
+ `).run(taskId, userId, userId);
+ }
+ db.prepare(`
+ UPDATE tasks
+ SET completed = 1,
+ completed_at = strftime('%Y-%m-%d %H:%M:%f', 'now'),
+ completed_by = ?
+ WHERE id = ?
+ AND COALESCE(completed, 0) = 0
+ AND completed_at IS NULL
+ `).run(userId, taskId);
+ });
+ tx();
+
+ const updated = db.prepare(`
+ SELECT id, description, due_date, display_code, COALESCE(completed, 0) AS completed, completed_at
+ FROM tasks
+ WHERE id = ?
+ `).get(taskId) as any;
+
+ const statusStr = Number(updated.completed || 0) === 1 ? 'updated' : 'already';
+
+ const body = {
+ status: statusStr,
+ task: {
+ id: Number(updated.id),
+ description: String(updated.description || ''),
+ due_date: updated.due_date ? String(updated.due_date) : null,
+ display_code: updated.display_code != null ? Number(updated.display_code) : null,
+ completed: Number(updated.completed || 0),
+ completed_at: updated.completed_at ? String(updated.completed_at) : null
+ }
+ };
+
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+};
diff --git a/apps/web/src/routes/api/tasks/[id]/unassign/+server.ts b/apps/web/src/routes/api/tasks/[id]/unassign/+server.ts
new file mode 100644
index 0000000..9fab05c
--- /dev/null
+++ b/apps/web/src/routes/api/tasks/[id]/unassign/+server.ts
@@ -0,0 +1,103 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+
+export const POST: RequestHandler = async (event) => {
+ const userId = event.locals.userId ?? null;
+ if (!userId) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const idStr = event.params.id || '';
+ const taskId = parseInt(idStr, 10);
+ if (!Number.isFinite(taskId) || taskId <= 0) {
+ return new Response('Bad Request', { status: 400 });
+ }
+
+ const db = await getDb();
+
+ // Cargar tarea y validar abierta
+ const task = db
+ .prepare(
+ `SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
+ FROM tasks
+ WHERE id = ?`
+ )
+ .get(taskId) as any;
+
+ if (!task) {
+ return new Response(JSON.stringify({ status: 'not_found' }), {
+ status: 404,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+ if (Number(task.completed) !== 0 || task.completed_at) {
+ return new Response(JSON.stringify({ status: 'completed' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ // Gating: grupo permitido + usuario miembro activo (si tiene group_id)
+ const groupId: string | null = task.group_id ? String(task.group_id) : null;
+ if (groupId) {
+ const allowed = db
+ .prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
+ .get(groupId);
+ const active = db
+ .prepare(
+ `SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
+ )
+ .get(groupId, userId);
+
+ if (!allowed || !active) {
+ return new Response('Forbidden', { status: 403 });
+ }
+ }
+
+ // Bloqueo de tareas personales: si es la última asignación del propio usuario, denegar
+ const stats = db
+ .prepare(`
+ SELECT COUNT(*) AS cnt,
+ SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS mine
+ FROM task_assignments
+ WHERE task_id = ?
+ `)
+ .get(userId, taskId) as any;
+ const cnt = Number(stats?.cnt || 0);
+ const mine = Number(stats?.mine || 0) > 0;
+
+ if (!groupId && cnt === 1 && mine) {
+ return new Response('No puedes soltar una tarea personal. Márcala como completada para eliminarla', {
+ status: 409,
+ headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ // Eliminar asignación (idempotente)
+ const delRes = db
+ .prepare(`DELETE FROM task_assignments WHERE task_id = ? AND user_id = ?`)
+ .run(taskId, userId) as any;
+
+ const cntRow = db
+ .prepare(`SELECT COUNT(*) AS cnt FROM task_assignments WHERE task_id = ?`)
+ .get(taskId) as any;
+ const remaining = Number(cntRow?.cnt || 0);
+
+ const status = Number(delRes?.changes || 0) > 0 ? 'unassigned' : 'not_assigned';
+
+ const body = {
+ status,
+ now_unassigned: remaining === 0,
+ task: {
+ id: Number(task.id),
+ description: String(task.description || ''),
+ due_date: task.due_date ? String(task.due_date) : null,
+ display_code: task.display_code != null ? Number(task.display_code) : null
+ }
+ };
+
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+};
diff --git a/apps/web/src/routes/api/tasks/[id]/uncomplete/+server.ts b/apps/web/src/routes/api/tasks/[id]/uncomplete/+server.ts
new file mode 100644
index 0000000..7c4fec5
--- /dev/null
+++ b/apps/web/src/routes/api/tasks/[id]/uncomplete/+server.ts
@@ -0,0 +1,117 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+import { UNCOMPLETE_WINDOW_MIN } from '$lib/server/env';
+
+function toIsoSql(d: Date): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+export const POST: RequestHandler = async (event) => {
+ const userId = event.locals.userId ?? null;
+ if (!userId) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const idStr = event.params.id || '';
+ const taskId = parseInt(idStr, 10);
+ if (!Number.isFinite(taskId) || taskId <= 0) {
+ return new Response('Bad Request', { status: 400 });
+ }
+
+ const db = await getDb();
+
+ const task = db.prepare(`
+ SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
+ FROM tasks
+ WHERE id = ?
+ `).get(taskId) as any;
+
+ if (!task) {
+ return new Response(JSON.stringify({ status: 'not_found' }), {
+ status: 404,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ // Si ya está sin completar, es idempotente
+ if (Number(task.completed) === 0) {
+ const body = {
+ status: 'already',
+ task: {
+ id: Number(task.id),
+ description: String(task.description || ''),
+ due_date: task.due_date ? String(task.due_date) : null,
+ display_code: task.display_code != null ? Number(task.display_code) : null,
+ completed: 0,
+ completed_at: null
+ }
+ };
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+ }
+
+ // Gating:
+ // - Si tiene group_id: grupo allowed y miembro activo
+ // - Si NO tiene group_id: debe estar asignada al usuario
+ const groupId: string | null = task.group_id ? String(task.group_id) : null;
+ if (groupId) {
+ const allowed = db
+ .prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
+ .get(groupId);
+ const active = db
+ .prepare(`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`)
+ .get(groupId, userId);
+ if (!allowed || !active) {
+ return new Response('Forbidden', { status: 403 });
+ }
+ } else {
+ const isAssigned = db
+ .prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
+ .get(taskId, userId);
+ if (!isAssigned) {
+ return new Response('Forbidden', { status: 403 });
+ }
+ }
+
+ // Validar ventana: completed_at dentro de UNCOMPLETE_WINDOW_MIN
+ if (!task.completed_at) {
+ return new Response('Forbidden', { status: 403 });
+ }
+ const cutoff = toIsoSql(new Date(Date.now() - UNCOMPLETE_WINDOW_MIN * 60 * 1000));
+ if (String(task.completed_at) < String(cutoff)) {
+ return new Response('Forbidden', { status: 403 });
+ }
+
+ // Deshacer completar (no tocamos completed_by)
+ db.prepare(`
+ UPDATE tasks
+ SET completed = 0,
+ completed_at = NULL
+ WHERE id = ?
+ `).run(taskId);
+
+ const updated = db.prepare(`
+ SELECT id, description, due_date, display_code, COALESCE(completed, 0) AS completed, completed_at
+ FROM tasks
+ WHERE id = ?
+ `).get(taskId) as any;
+
+ const body = {
+ status: 'updated',
+ task: {
+ id: Number(updated.id),
+ description: String(updated.description || ''),
+ due_date: updated.due_date ? String(updated.due_date) : null,
+ display_code: updated.display_code != null ? Number(updated.display_code) : null,
+ completed: Number(updated.completed || 0),
+ completed_at: updated.completed_at ? String(updated.completed_at) : null
+ }
+ };
+
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
+ });
+};
diff --git a/apps/web/src/routes/app/+layout.server.ts b/apps/web/src/routes/app/+layout.server.ts
new file mode 100644
index 0000000..79628c5
--- /dev/null
+++ b/apps/web/src/routes/app/+layout.server.ts
@@ -0,0 +1,10 @@
+import type { LayoutServerLoad } from './$types';
+import { redirect } from '@sveltejs/kit';
+
+export const load: LayoutServerLoad = async (event) => {
+ const userId = event.locals.userId ?? null;
+ if (!userId) {
+ throw redirect(303, '/login');
+ }
+ return { userId };
+};
diff --git a/apps/web/src/routes/app/+layout.svelte b/apps/web/src/routes/app/+layout.svelte
new file mode 100644
index 0000000..ebdfe89
--- /dev/null
+++ b/apps/web/src/routes/app/+layout.svelte
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/apps/web/src/routes/app/+page.server.ts b/apps/web/src/routes/app/+page.server.ts
new file mode 100644
index 0000000..83df81a
--- /dev/null
+++ b/apps/web/src/routes/app/+page.server.ts
@@ -0,0 +1,118 @@
+import type { PageServerLoad } from './$types';
+import { redirect } from '@sveltejs/kit';
+
+export const load: PageServerLoad = async (event) => {
+ const userId = event.locals.userId ?? null;
+ if (!userId) {
+ // No hay sesión: redirigir a la home
+ throw redirect(303, '/');
+ }
+
+ // Parámetros de orden y paginación
+ const orderParam = (event.url.searchParams.get('order') || 'due').trim().toLowerCase();
+ const order: 'due' | 'group' = orderParam === 'group' ? 'group' : 'due';
+ const pageStr = (event.url.searchParams.get('page') || '1').trim();
+ const page = Math.max(1, parseInt(pageStr, 10) || 1);
+
+ // Cargar "mis tareas" desde la API interna
+ let openTasks: Array<{
+ id: number;
+ description: string;
+ due_date: string | null;
+ group_id: string | null;
+ display_code: number | null;
+ assignees: string[];
+ }> = [];
+ let recentTasks: Array<{
+ id: number;
+ description: string;
+ due_date: string | null;
+ group_id: string | null;
+ display_code: number | null;
+ assignees: string[];
+ completed?: boolean;
+ completed_at?: string | null;
+ }> = [];
+ let hasMore: boolean = false;
+
+ // Agregado: "Sin responsable de mis grupos"
+ let unassignedOpen: Array<{
+ id: number;
+ description: string;
+ due_date: string | null;
+ group_id: string | null;
+ display_code: number | null;
+ assignees: string[];
+ }> = [];
+ const groupNames: Record = {};
+
+ try {
+ // Mis tareas abiertas (paginadas, orden controlado por el servidor)
+ const orderParamApi = order === 'group' ? 'group_then_due' : 'due';
+ let fetchUrl = `/api/me/tasks?limit=20&order=${encodeURIComponent(orderParamApi)}`;
+ fetchUrl += `&page=${encodeURIComponent(String(page))}`;
+
+ const res = await event.fetch(fetchUrl, { headers: { 'cache-control': 'no-store' } });
+ if (res.ok) {
+ const json = await res.json();
+ openTasks = Array.isArray(json?.items) ? json.items : [];
+ hasMore = Boolean(json?.hasMore);
+ }
+
+ // Completadas en las últimas 24h (sin paginar por ahora)
+ const resRecent = await event.fetch('/api/me/tasks?limit=20&status=recent', {
+ headers: { 'cache-control': 'no-store' }
+ });
+ if (resRecent.ok) {
+ const jsonRecent = await resRecent.json();
+ recentTasks = Array.isArray(jsonRecent?.items) ? jsonRecent.items : [];
+ }
+
+ // Overview: obtener "sin responsable" de todos los grupos en una sola llamada
+ const overviewOrder = order === 'group' ? 'group_then_due' : 'due';
+ const resOverview = await event.fetch(
+ `/api/me/tasks/overview?order=${encodeURIComponent(overviewOrder)}`,
+ { headers: { 'cache-control': 'no-store' } }
+ );
+ if (resOverview.ok) {
+ const jsonOv = await resOverview.json();
+ const items: any[] = Array.isArray(jsonOv?.unassigned) ? jsonOv.unassigned : [];
+ unassignedOpen = items.map((it) => ({
+ id: Number(it.id),
+ description: String(it.description || ''),
+ due_date: it.due_date ? String(it.due_date) : null,
+ group_id: it.group_id ? String(it.group_id) : null,
+ display_code: it.display_code != null ? Number(it.display_code) : null,
+ assignees: Array.isArray(it.assignees) ? it.assignees.map(String) : []
+ }));
+ }
+
+ // Mis grupos (para nombres y para recolectar "sin responsable")
+ const resGroups = await event.fetch('/api/me/groups', {
+ headers: { 'cache-control': 'no-store' }
+ });
+ if (resGroups.ok) {
+ const jsonGroups = await resGroups.json();
+ const groups = Array.isArray(jsonGroups?.items) ? jsonGroups.items : [];
+ for (const g of groups) {
+ const gid = String(g.id);
+ const gname = g.name != null ? String(g.name) : null;
+ if (gname) groupNames[gid] = gname;
+ }
+ }
+
+ } catch {
+ // Ignorar errores y dejar listas vacías
+ }
+
+ return {
+ userId,
+ openTasks,
+ recentTasks,
+ unassignedOpen,
+ groupNames,
+ order,
+ page,
+ hasMore
+ };
+};
diff --git a/apps/web/src/routes/app/+page.svelte b/apps/web/src/routes/app/+page.svelte
new file mode 100644
index 0000000..979c27c
--- /dev/null
+++ b/apps/web/src/routes/app/+page.svelte
@@ -0,0 +1,335 @@
+
+
+
+ Tareas
+
+
+
+Sesión: {data.userId}
+
+
+
+Mis tareas
+{#if openTasks.length === 0}
+ No tienes tareas asignadas. Crea o reclama una para empezar.
+{:else}
+
+
+ {#each openTasks as t (t.id)}
+ updateTaskInLists(e.detail)}
+ />
+ {/each}
+
+
+{/if}
+
+{#if (data.page ?? 1) > 1 || data.hasMore}
+ 1
+ ? `/app?${buildQuery({ order: data.order, page: (data.page ?? 1) - 1 })}`
+ : null}
+ nextHref={data.hasMore
+ ? `/app?${buildQuery({ order: data.order, page: (data.page ?? 1) + 1 })}`
+ : null}
+ />
+{/if}
+
+Sin responsable de mis grupos
+{#if unassignedOpen.length === 0}
+
+ No hay tareas sin responsable en tus grupos. Crea una nueva o invita a
+ alguien.
+
+{:else if data.order === "group"}
+ {#each groupByGroup(unassignedOpen) as g (g.id)}
+ {g.name}
+
+
+ {#each g.tasks as t (t.id)}
+ updateTaskInLists(e.detail)}
+ />
+ {/each}
+
+
+ {/each}
+{:else}
+
+
+ {#each unassignedOpen as t (t.id)}
+ updateTaskInLists(e.detail)}
+ />
+ {/each}
+
+
+{/if}
+
+Completadas (últimas 24 h)
+{#if recentTasks.length === 0}
+ No hay tareas completadas recientemente.
+{:else}
+
+
+ {#each recentTasks as t (t.id)}
+ updateTaskInLists(e.detail)}
+ />
+ {/each}
+
+
+{/if}
+
+
diff --git a/apps/web/src/routes/app/groups/+page.server.ts b/apps/web/src/routes/app/groups/+page.server.ts
new file mode 100644
index 0000000..cc35d69
--- /dev/null
+++ b/apps/web/src/routes/app/groups/+page.server.ts
@@ -0,0 +1,35 @@
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async (event) => {
+ const userId = event.locals.userId ?? null;
+ const res = await event.fetch('/api/me/groups', { headers: { 'cache-control': 'no-store' } });
+ if (!res.ok) {
+ // El gate del layout debería impedir llegar aquí sin sesión; devolvemos vacío como salvaguarda.
+ return { groups: [], itemsByGroup: {}, unassignedFirst: false };
+ }
+ const data = await res.json();
+ const groups = Array.isArray(data?.items) ? data.items : [];
+
+ // Leer preferencia de orden para el listado del grupo
+ const unassignedFirst =
+ (event.url.searchParams.get('unassignedFirst') || '').trim().toLowerCase() === 'true';
+
+ // Recolectar TODAS las tareas abiertas por grupo (sin límite)
+ const itemsByGroup: Record = {};
+ for (const g of groups) {
+ try {
+ const url = `/api/groups/${encodeURIComponent(g.id)}/tasks?limit=0${
+ unassignedFirst ? '&unassignedFirst=true' : ''
+ }`;
+ const r = await event.fetch(url, { headers: { 'cache-control': 'no-store' } });
+ if (r.ok) {
+ const j = await r.json();
+ itemsByGroup[String(g.id)] = Array.isArray(j?.items) ? j.items : [];
+ }
+ } catch {
+ // ignorar errores de un grupo y continuar
+ }
+ }
+
+ return { groups, itemsByGroup, unassignedFirst, userId };
+};
diff --git a/apps/web/src/routes/app/groups/+page.svelte b/apps/web/src/routes/app/groups/+page.svelte
new file mode 100644
index 0000000..d3289c3
--- /dev/null
+++ b/apps/web/src/routes/app/groups/+page.svelte
@@ -0,0 +1,240 @@
+
+
+
+ Grupos
+
+
+
+{#if groups.length === 0}
+ No perteneces a ningún grupo permitido.
+{:else}
+ Grupos
+
+
+
+
+
+ {#each groups as g (g.id)}
+ handleToggle(g.id, e)}
+ >
+
+ {#if isOpen(g.id)}
+
+
+
+ {#each itemsByGroup[g.id] || [] as t (t.id)}
+ updateGroupTask(g.id, e.detail)}
+ />
+ {/each}
+
+
+
+ {/if}
+
+ {/each}
+{/if}
+
+
diff --git a/apps/web/src/routes/app/integrations/+page.server.ts b/apps/web/src/routes/app/integrations/+page.server.ts
new file mode 100644
index 0000000..b871c61
--- /dev/null
+++ b/apps/web/src/routes/app/integrations/+page.server.ts
@@ -0,0 +1,14 @@
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ fetch }) => {
+ const res = await fetch('/api/integrations/feeds', { method: 'GET', headers: { 'cache-control': 'no-store' } });
+ if (!res.ok) {
+ return {
+ personal: { url: null },
+ aggregate: { url: null },
+ groups: []
+ };
+ }
+ const data = await res.json();
+ return data;
+};
diff --git a/apps/web/src/routes/app/integrations/+page.svelte b/apps/web/src/routes/app/integrations/+page.svelte
new file mode 100644
index 0000000..0a60150
--- /dev/null
+++ b/apps/web/src/routes/app/integrations/+page.svelte
@@ -0,0 +1,101 @@
+
+
+
+ Integraciones
+
+
+
+
+ Integraciones
+
+ Feed personal
+ rotate('personal')}
+ />
+
+ Feed multigrupo
+ rotate('aggregate')}
+ />
+
+ Feeds por grupo (sin responsable)
+ {#if groups.length === 0}
+ No hay grupos disponibles todavía. Cuando los haya, verás aquí sus feeds ICS.
+ {:else}
+
+ {#each groups as g (g.groupId)}
+ rotate('group', g.groupId)}
+ />
+ {/each}
+
+ {/if}
+
diff --git a/apps/web/src/routes/app/preferences/+page.server.ts b/apps/web/src/routes/app/preferences/+page.server.ts
new file mode 100644
index 0000000..9a64e9b
--- /dev/null
+++ b/apps/web/src/routes/app/preferences/+page.server.ts
@@ -0,0 +1,178 @@
+import type { PageServerLoad, Actions } from './$types';
+import { getDb } from '$lib/server/db';
+import { redirect, fail } from '@sveltejs/kit';
+
+function ymdInTZ(d: Date, tz: string): string {
+ const parts = new Intl.DateTimeFormat('en-GB', {
+ timeZone: tz,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ }).formatToParts(d);
+ const get = (t: string) => parts.find((p) => p.type === t)?.value || '';
+ return `${get('year')}-${get('month')}-${get('day')}`;
+}
+
+function hmInTZ(d: Date, tz: string): string {
+ const parts = new Intl.DateTimeFormat('en-GB', {
+ timeZone: tz,
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false
+ }).formatToParts(d);
+ const get = (t: string) => parts.find((p) => p.type === t)?.value || '';
+ return `${get('hour')}:${get('minute')}`;
+}
+
+function weekdayShortInTZ(d: Date, tz: string): string {
+ return new Intl.DateTimeFormat('en-GB', { timeZone: tz, weekday: 'short' }).format(d);
+}
+
+function normalizeTime(input: string): string | null {
+ const m = /^\s*(\d{1,2}):(\d{1,2})\s*$/.exec(input || '');
+ if (!m) return null;
+ const h = Number(m[1]);
+ const min = Number(m[2]);
+ if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
+ if (h < 0 || h > 23 || min < 0 || min > 59) return null;
+ const hh = String(h).padStart(2, '0');
+ const mm = String(min).padStart(2, '0');
+ return `${hh}:${mm}`;
+}
+
+function computeNextReminder(
+ freq: 'off' | 'daily' | 'weekly' | 'weekdays',
+ time: string | null,
+ now: Date,
+ tz: string
+): string | null {
+ if (freq === 'off' || !time) return null;
+
+ const nowHM = hmInTZ(now, tz);
+ const [nowH, nowM] = String(nowHM).split(':');
+ const [cfgH, cfgM] = String(time).split(':');
+ const nowMin = (parseInt(nowH || '0', 10) || 0) * 60 + (parseInt(nowM || '0', 10) || 0);
+ const cfgMin = (parseInt(cfgH || '0', 10) || 0) * 60 + (parseInt(cfgM || '0', 10) || 0);
+
+ const allowDay = (w: string) => {
+ if (freq === 'daily') return true;
+ if (freq === 'weekly') return w === 'Mon';
+ // weekdays
+ return w !== 'Sat' && w !== 'Sun';
+ };
+
+ for (let offset = 0; offset < 14; offset++) {
+ const cand = new Date(now.getTime() + offset * 24 * 60 * 60 * 1000);
+ const wd = weekdayShortInTZ(cand, tz);
+ if (!allowDay(wd)) continue;
+
+ if (offset === 0 && nowMin >= cfgMin) {
+ // hoy ya pasó la hora → buscar siguiente día permitido
+ continue;
+ }
+ const ymd = ymdInTZ(cand, tz);
+ return `${ymd} ${normalizeTime(time)}`;
+ }
+ return null;
+}
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const userId = locals.userId ?? null;
+ if (!userId) {
+ throw redirect(302, '/login');
+ }
+
+ const db = await getDb();
+ const row = db
+ .prepare(
+ `SELECT reminder_freq AS freq, reminder_time AS time
+ FROM user_preferences
+ WHERE user_id = ?
+ LIMIT 1`
+ )
+ .get(userId) as any;
+
+ const pref =
+ row && row.freq
+ ? { freq: String(row.freq) as 'off' | 'daily' | 'weekly' | 'weekdays', time: row.time ? String(row.time) : null }
+ : { freq: 'off' as const, time: '08:30' as string };
+
+ const tz = (process.env.TZ && process.env.TZ.trim()) || 'Europe/Madrid';
+ const next = computeNextReminder(pref.freq, pref.time, new Date(), tz);
+
+ return {
+ pref,
+ tz,
+ next
+ };
+};
+
+export const actions: Actions = {
+ default: async ({ locals, request }) => {
+ const userId = locals.userId ?? null;
+ if (!userId) {
+ throw redirect(302, '/login');
+ }
+
+ const form = await request.formData();
+ const freqRaw = String(form.get('freq') || '').trim().toLowerCase();
+ const timeRaw = form.has('time') ? String(form.get('time') || '').trim() : null;
+
+ const allowed = new Set(['off', 'daily', 'weekly', 'weekdays']);
+ if (!allowed.has(freqRaw)) {
+ return fail(400, { error: 'freq inválida' });
+ }
+
+ function normalizeTime(input: string): string | null {
+ const m = /^\s*(\d{1,2}):(\d{1,2})\s*$/.exec(input || '');
+ if (!m) return null;
+ const h = Number(m[1]);
+ const min = Number(m[2]);
+ if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
+ if (h < 0 || h > 23 || min < 0 || min > 59) return null;
+ const hh = String(h).padStart(2, '0');
+ const mm = String(min).padStart(2, '0');
+ return `${hh}:${mm}`;
+ }
+
+ const db = await getDb();
+ let timeToSave: string | null = null;
+
+ if (freqRaw === 'off') {
+ if (timeRaw && timeRaw.length > 0) {
+ const norm = normalizeTime(timeRaw);
+ if (!norm) return fail(400, { error: 'hora inválida' });
+ timeToSave = norm;
+ } else {
+ const row = db
+ .prepare(
+ `SELECT reminder_time AS time
+ FROM user_preferences
+ WHERE user_id = ?
+ LIMIT 1`
+ )
+ .get(userId) as any;
+ timeToSave = row?.time ? String(row.time) : '08:30';
+ }
+ } else {
+ if (!timeRaw || timeRaw.length === 0) {
+ timeToSave = '08:30';
+ } else {
+ const norm = normalizeTime(timeRaw);
+ if (!norm) return fail(400, { error: 'hora inválida' });
+ timeToSave = norm;
+ }
+ }
+
+ db.prepare(
+ `INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
+ VALUES (?, ?, ?, (SELECT last_reminded_on FROM user_preferences WHERE user_id = ?), strftime('%Y-%m-%d %H:%M:%f','now'))
+ ON CONFLICT(user_id) DO UPDATE SET
+ reminder_freq = excluded.reminder_freq,
+ reminder_time = excluded.reminder_time,
+ updated_at = excluded.updated_at`
+ ).run(userId, freqRaw, timeToSave, userId);
+
+ return { success: true, pref: { freq: freqRaw, time: timeToSave } };
+ }
+};
diff --git a/apps/web/src/routes/app/preferences/+page.svelte b/apps/web/src/routes/app/preferences/+page.svelte
new file mode 100644
index 0000000..2bec34a
--- /dev/null
+++ b/apps/web/src/routes/app/preferences/+page.svelte
@@ -0,0 +1,119 @@
+
+
+
+ Preferencias de recordatorios
+
+
+
+
+ Preferencias de recordatorios
+
+
+
+
+
+
+
Próximo recordatorio
+
+ - Servidor: {data.next ?? "—"}
+
+
+
+
+
diff --git a/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts b/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts
new file mode 100644
index 0000000..cbab64d
--- /dev/null
+++ b/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts
@@ -0,0 +1,90 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+import { sha256Hex } from '$lib/server/crypto';
+import { icsHorizonMonths } from '$lib/server/env';
+import { buildIcsCalendar } from '$lib/server/ics';
+
+function toIsoSql(d = new Date()): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+function ymdUTC(date: Date): string {
+ const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
+ const dd = String(date.getUTCDate()).padStart(2, '0');
+ return `${yyyy}-${mm}-${dd}`;
+}
+
+function addMonthsUTC(date: Date, months: number): Date {
+ const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
+ d.setUTCMonth(d.getUTCMonth() + months);
+ return d;
+}
+
+export const GET: RequestHandler = async ({ params, request }) => {
+ const db = await getDb();
+ const token = params.token || '';
+ if (!token) return new Response('Not Found', { status: 404 });
+
+ const tokenHash = await sha256Hex(token);
+ const row = db
+ .prepare(
+ `SELECT id, type, user_id, group_id, revoked_at
+ FROM calendar_tokens
+ WHERE token_hash = ?
+ LIMIT 1`
+ )
+ .get(tokenHash) as any;
+
+ if (!row) return new Response('Not Found', { status: 404 });
+ if (row.revoked_at) return new Response('Gone', { status: 410 });
+ if (String(row.type) !== 'aggregate') return new Response('Not Found', { status: 404 });
+
+ const today = new Date();
+ const startYmd = ymdUTC(today);
+ const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
+
+ // Sin responsable en todos los grupos allowed donde el usuario esté activo
+ const tasks = db
+ .prepare(
+ `SELECT t.id, t.description, t.due_date, g.name AS group_name
+ FROM tasks t
+ INNER JOIN groups g ON g.id = t.group_id AND COALESCE(g.active,1)=1 AND COALESCE(g.archived,0)=0 AND COALESCE(g.is_community,0)=0
+ INNER JOIN group_members gm ON gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1
+ INNER JOIN allowed_groups ag ON ag.group_id = t.group_id AND ag.status = 'allowed'
+ WHERE COALESCE(t.completed, 0) = 0
+ AND t.due_date IS NOT NULL
+ AND t.due_date >= ? AND t.due_date <= ?
+ AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)
+ ORDER BY t.due_date ASC, t.id ASC`
+ )
+ .all(row.user_id, startYmd, endYmd) as Array<{ id: number; description: string; due_date: string; group_name: string | null }>;
+
+ const events = tasks.map((t) => ({
+ id: t.id,
+ description: t.description,
+ due_date: t.due_date,
+ group_name: t.group_name || null,
+ prefix: 'T'
+ }));
+
+ const { body, etag } = await buildIcsCalendar('Tareas sin responsable (mis grupos)', events);
+
+ // 304 si ETag coincide
+ const inm = request.headers.get('if-none-match');
+ if (inm && inm === etag) {
+ db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
+ return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
+ }
+
+ db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
+
+ return new Response(body, {
+ status: 200,
+ headers: {
+ 'content-type': 'text/calendar; charset=utf-8',
+ 'cache-control': 'public, max-age=300',
+ ETag: etag
+ }
+ });
+};
diff --git a/apps/web/src/routes/ics/group/[token].ics/+server.ts b/apps/web/src/routes/ics/group/[token].ics/+server.ts
new file mode 100644
index 0000000..4c2b78b
--- /dev/null
+++ b/apps/web/src/routes/ics/group/[token].ics/+server.ts
@@ -0,0 +1,98 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+import { sha256Hex } from '$lib/server/crypto';
+import { icsHorizonMonths } from '$lib/server/env';
+import { buildIcsCalendar } from '$lib/server/ics';
+
+function toIsoSql(d = new Date()): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+function ymdUTC(date: Date): string {
+ const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
+ const dd = String(date.getUTCDate()).padStart(2, '0');
+ return `${yyyy}-${mm}-${dd}`;
+}
+
+function addMonthsUTC(date: Date, months: number): Date {
+ const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
+ d.setUTCMonth(d.getUTCMonth() + months);
+ return d;
+}
+
+export const GET: RequestHandler = async ({ params, request }) => {
+ const db = await getDb();
+ const token = params.token || '';
+ if (!token) return new Response('Not Found', { status: 404 });
+
+ const tokenHash = await sha256Hex(token);
+ const row = db
+ .prepare(
+ `SELECT id, type, user_id, group_id, revoked_at
+ FROM calendar_tokens
+ WHERE token_hash = ?
+ LIMIT 1`
+ )
+ .get(tokenHash) as any;
+
+ if (!row) return new Response('Not Found', { status: 404 });
+ if (row.revoked_at) return new Response('Gone', { status: 410 });
+ if (String(row.type) !== 'group' || !row.group_id) return new Response('Not Found', { status: 404 });
+
+ // Validar estado del grupo (activo y no archivado); en caso contrario, tratar como feed caducado
+ const gRow = db
+ .prepare(`SELECT COALESCE(active,1) as active, COALESCE(archived,0) as archived, COALESCE(is_community,0) as is_community FROM groups WHERE id = ?`)
+ .get(row.group_id) as any;
+ if (!gRow || Number(gRow.active || 0) !== 1 || Number(gRow.archived || 0) === 1 || Number(gRow.is_community || 0) === 1) {
+ db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
+ return new Response('Gone', { status: 410 });
+ }
+
+ const today = new Date();
+ const startYmd = ymdUTC(today);
+ const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
+
+ const tasks = db
+ .prepare(
+ `SELECT t.id, t.description, t.due_date, g.name AS group_name
+ FROM tasks t
+ LEFT JOIN groups g ON g.id = t.group_id
+ WHERE t.group_id = ?
+ AND COALESCE(t.completed, 0) = 0
+ AND t.due_date IS NOT NULL
+ AND t.due_date >= ? AND t.due_date <= ?
+ AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)
+ ORDER BY t.due_date ASC, t.id ASC`
+ )
+ .all(row.group_id, startYmd, endYmd) as Array<{ id: number; description: string; due_date: string; group_name: string | null }>;
+
+ const events = tasks.map((t) => ({
+ id: t.id,
+ description: t.description,
+ due_date: t.due_date,
+ group_name: t.group_name || null,
+ prefix: 'T'
+ }));
+
+ const { body, etag } = await buildIcsCalendar('Tareas sin responsable (grupo)', events);
+
+ // 304 si ETag coincide
+ const inm = request.headers.get('if-none-match');
+ if (inm && inm === etag) {
+ // Actualizar last_used_at aunque sea 304
+ db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
+ return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
+ }
+
+ db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
+
+ return new Response(body, {
+ status: 200,
+ headers: {
+ 'content-type': 'text/calendar; charset=utf-8',
+ 'cache-control': 'public, max-age=300',
+ ETag: etag
+ }
+ });
+};
diff --git a/apps/web/src/routes/ics/personal/[token].ics/+server.ts b/apps/web/src/routes/ics/personal/[token].ics/+server.ts
new file mode 100644
index 0000000..539f99e
--- /dev/null
+++ b/apps/web/src/routes/ics/personal/[token].ics/+server.ts
@@ -0,0 +1,91 @@
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+import { sha256Hex } from '$lib/server/crypto';
+import { icsHorizonMonths } from '$lib/server/env';
+import { buildIcsCalendar } from '$lib/server/ics';
+
+function toIsoSql(d = new Date()): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+function ymdUTC(date: Date): string {
+ const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
+ const dd = String(date.getUTCDate()).padStart(2, '0');
+ return `${yyyy}-${mm}-${dd}`;
+}
+
+function addMonthsUTC(date: Date, months: number): Date {
+ const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
+ d.setUTCMonth(d.getUTCMonth() + months);
+ return d;
+}
+
+export const GET: RequestHandler = async ({ params, request }) => {
+ const db = await getDb();
+ const token = params.token || '';
+ if (!token) return new Response('Not Found', { status: 404 });
+
+ const tokenHash = await sha256Hex(token);
+ const row = db
+ .prepare(
+ `SELECT id, type, user_id, group_id, revoked_at
+ FROM calendar_tokens
+ WHERE token_hash = ?
+ LIMIT 1`
+ )
+ .get(tokenHash) as any;
+
+ if (!row) return new Response('Not Found', { status: 404 });
+ if (row.revoked_at) return new Response('Gone', { status: 410 });
+ if (String(row.type) !== 'personal') return new Response('Not Found', { status: 404 });
+
+ const today = new Date();
+ const startYmd = ymdUTC(today);
+ const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
+
+ // "Mis tareas": asignadas al usuario; incluir privadas (group_id IS NULL) y de grupos donde esté activo y allowed.
+ const tasks = db
+ .prepare(
+ `SELECT t.id, t.description, t.due_date, g.name AS group_name
+ FROM tasks t
+ LEFT JOIN groups g ON g.id = t.group_id
+ LEFT JOIN group_members gm ON gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1
+ LEFT JOIN allowed_groups ag ON ag.group_id = t.group_id AND ag.status = 'allowed'
+ WHERE COALESCE(t.completed, 0) = 0
+ AND t.due_date IS NOT NULL
+ AND t.due_date >= ? AND t.due_date <= ?
+ AND EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id AND a.user_id = ?)
+ AND (t.group_id IS NULL OR (gm.user_id IS NOT NULL AND ag.group_id IS NOT NULL AND COALESCE(g.active,1)=1 AND COALESCE(g.is_community,0)=0 AND COALESCE(g.archived,0)=0))
+ ORDER BY t.due_date ASC, t.id ASC`
+ )
+ .all(row.user_id, startYmd, endYmd, row.user_id) as Array<{ id: number; description: string; due_date: string; group_name: string | null }>;
+
+ const events = tasks.map((t) => ({
+ id: t.id,
+ description: t.description,
+ due_date: t.due_date,
+ group_name: t.group_name || null,
+ prefix: 'T'
+ }));
+
+ const { body, etag } = await buildIcsCalendar('Mis tareas', events);
+
+ // 304 si ETag coincide
+ const inm = request.headers.get('if-none-match');
+ if (inm && inm === etag) {
+ db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
+ return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
+ }
+
+ db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
+
+ return new Response(body, {
+ status: 200,
+ headers: {
+ 'content-type': 'text/calendar; charset=utf-8',
+ 'cache-control': 'public, max-age=300',
+ ETag: etag
+ }
+ });
+};
diff --git a/apps/web/src/routes/login/+server.ts b/apps/web/src/routes/login/+server.ts
new file mode 100644
index 0000000..04e0d43
--- /dev/null
+++ b/apps/web/src/routes/login/+server.ts
@@ -0,0 +1,176 @@
+import type { RequestHandler } from './$types';
+import { redirect } from '@sveltejs/kit';
+import { getDb } from '$lib/server/db';
+import { sha256Hex, randomTokenBase64Url } from '$lib/server/crypto';
+import { sessionIdleTtlMs, isProd, isDev, DEV_BYPASS_AUTH } from '$lib/server/env';
+
+function toIsoSql(d: Date): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+function escapeHtml(s: string): string {
+ return s
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+// GET: página intermedia que requiere una interacción mínima y establece una cookie "login_intent" vía JS.
+// Evita que bots de previsualización canjeen el token antes de que el usuario haga clic.
+export const GET: RequestHandler = async (event) => {
+ if (isDev() && DEV_BYPASS_AUTH) {
+ throw redirect(303, '/app');
+ }
+ const token = event.url.searchParams.get('token')?.trim();
+ if (!token) {
+ console.warn('[web/login] Solicitud sin token');
+ return new Response('Falta el token', { status: 400 });
+ }
+
+ // Nonce para "gate de JS"
+ const nonce = randomTokenBase64Url(18);
+
+ const html = `
+
+
+
+
+ Acceder
+
+
+
+
+
+
+
Acceso seguro
+
Para continuar, pulsa “Continuar”. Si no funciona, asegúrate de abrir este enlace en tu navegador.
+
+
+
+
+
+`;
+
+ return new Response(html, {
+ status: 200,
+ headers: {
+ 'content-type': 'text/html; charset=utf-8',
+ 'cache-control': 'no-store, max-age=0'
+ }
+ });
+};
+
+// POST: canje real del token (uso único). Crea sesión y redirige a /app.
+export const POST: RequestHandler = async (event) => {
+ if (isDev() && DEV_BYPASS_AUTH) {
+ throw redirect(303, '/app');
+ }
+ const form = await event.request.formData();
+ const token = String(form.get('token') || '').trim();
+ if (!token) {
+ console.warn('[web/login] POST sin token');
+ return new Response('Falta el token', { status: 400 });
+ }
+
+ // Validación del "gate de JS": cookie + nonce deben coincidir
+ const nonce = String(form.get('nonce') || '').trim();
+ const loginIntent = event.cookies.get('login_intent') || '';
+ if (!nonce || !loginIntent || nonce !== loginIntent) {
+ console.warn('[web/login] Falta o no cuadra login_intent; posible previsualizador sin JS.');
+ return new Response('Solicitud inválida', { status: 400 });
+ }
+
+ const tokenHash = await sha256Hex(token);
+ const db = await getDb();
+
+ // Intentar canjear el token (un solo uso, no caducado)
+ const res = db
+ .prepare(
+ `UPDATE web_tokens
+ SET used_at = strftime('%Y-%m-%d %H:%M:%f','now')
+ WHERE token_hash = ?
+ AND used_at IS NULL
+ AND expires_at > strftime('%Y-%m-%d %H:%M:%f','now')`
+ )
+ .run(tokenHash);
+
+ const changes = Number(res?.changes || 0);
+ if (changes < 1) {
+ console.warn('[web/login] Token no canjeado (0 cambios). Posible caducado/ya usado/no existe.');
+ return new Response('Enlace inválido o caducado', { status: 400 });
+ }
+
+ // Recuperar el user_id asociado
+ const row = db
+ .prepare(`SELECT user_id FROM web_tokens WHERE token_hash = ?`)
+ .get(tokenHash) as { user_id: string } | null;
+
+ const userId = row?.user_id?.trim();
+ if (!userId) {
+ return new Response('Token canjeado pero usuario no encontrado', { status: 500 });
+ }
+
+ // Crear sesión
+ const sessionToken = randomTokenBase64Url(32);
+ const sessionHash = await sha256Hex(sessionToken);
+ const sessionId = randomTokenBase64Url(16);
+ const expiresAtIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs));
+
+ // Datos de agente e IP (best-effort)
+ const userAgent = event.request.headers.get('user-agent') || null;
+ let ip: string | null = null;
+ try {
+ // SvelteKit 2: getClientAddress en adapters compatibles
+ // @ts-ignore
+ if (typeof event.getClientAddress === 'function') {
+ // @ts-ignore
+ ip = event.getClientAddress() || null;
+ }
+ } catch { }
+ if (!ip) {
+ const fwd = event.request.headers.get('x-forwarded-for');
+ ip = fwd ? fwd.split(',')[0].trim() : null;
+ }
+
+ db.prepare(
+ `INSERT INTO web_sessions (id, user_id, session_hash, expires_at, user_agent, ip)
+ VALUES (?, ?, ?, ?, ?, ?)`
+ ).run(sessionId, userId, sessionHash, expiresAtIso, userAgent, ip);
+
+ // Cookie de sesión
+ event.cookies.set('sid', sessionToken, {
+ path: '/',
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: isProd(),
+ maxAge: Math.floor(sessionIdleTtlMs / 1000)
+ });
+
+ // Eliminar cookie de intento (ya no es necesaria)
+ event.cookies.delete('login_intent', { path: '/' });
+
+ // Redirigir a /app
+ throw redirect(303, '/app');
+};
diff --git a/apps/web/static/delay-icon.svg b/apps/web/static/delay-icon.svg
new file mode 100644
index 0000000..7888f41
--- /dev/null
+++ b/apps/web/static/delay-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/static/edit-list-icon.svg b/apps/web/static/edit-list-icon.svg
new file mode 100644
index 0000000..56d9fc0
--- /dev/null
+++ b/apps/web/static/edit-list-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/static/emergency-exit-icon.svg b/apps/web/static/emergency-exit-icon.svg
new file mode 100644
index 0000000..a9e69a8
--- /dev/null
+++ b/apps/web/static/emergency-exit-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/static/friends-icon.svg b/apps/web/static/friends-icon.svg
new file mode 100644
index 0000000..0e2d7be
--- /dev/null
+++ b/apps/web/static/friends-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/static/mining-icon.svg b/apps/web/static/mining-icon.svg
new file mode 100644
index 0000000..9b58fab
--- /dev/null
+++ b/apps/web/static/mining-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/static/on-time-icon.svg b/apps/web/static/on-time-icon.svg
new file mode 100644
index 0000000..d769744
--- /dev/null
+++ b/apps/web/static/on-time-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/static/robots.txt b/apps/web/static/robots.txt
new file mode 100644
index 0000000..b6dd667
--- /dev/null
+++ b/apps/web/static/robots.txt
@@ -0,0 +1,3 @@
+# allow crawling everything by default
+User-agent: *
+Disallow:
diff --git a/apps/web/static/sand-clock-half-icon.svg b/apps/web/static/sand-clock-half-icon.svg
new file mode 100644
index 0000000..37754a1
--- /dev/null
+++ b/apps/web/static/sand-clock-half-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/static/time-expire-icon.svg b/apps/web/static/time-expire-icon.svg
new file mode 100644
index 0000000..06a1bb8
--- /dev/null
+++ b/apps/web/static/time-expire-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/static/time-period-icon.svg b/apps/web/static/time-period-icon.svg
new file mode 100644
index 0000000..382e449
--- /dev/null
+++ b/apps/web/static/time-period-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/svelte.config.js b/apps/web/svelte.config.js
new file mode 100644
index 0000000..6878d7d
--- /dev/null
+++ b/apps/web/svelte.config.js
@@ -0,0 +1,21 @@
+import adapter from '@sveltejs/adapter-node';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ // Consult https://svelte.dev/docs/kit/integrations
+ // for more information about preprocessors
+ preprocess: vitePreprocess(),
+
+ kit: {
+ // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
+ // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
+ // See https://svelte.dev/docs/kit/adapters for more information about adapters.
+ adapter: adapter({ precompress: false }),
+ csrf: {
+ trustedOrigins: ['*']
+ }
+ }
+};
+
+export default config;
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..a5567ee
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
+ //
+ // To make changes to top-level options such as include and exclude, we recommend extending
+ // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
new file mode 100644
index 0000000..6c38dd2
--- /dev/null
+++ b/apps/web/vite.config.ts
@@ -0,0 +1,25 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig(({ mode }) => {
+ const isDev = mode === 'development';
+
+ return {
+ plugins: [sveltekit()],
+ resolve: {
+ // En desarrollo, alias para usar better-sqlite3 (Vite/HMR no entiende 'bun:sqlite')
+ alias: isDev ? { 'bun:sqlite': 'better-sqlite3' } : {}
+ },
+ ssr: {
+ // En dev, externalizar better-sqlite3 (CJS nativo) para que se cargue vía require;
+ // en producción, externalizar 'bun:sqlite' y que lo resuelva Bun en runtime.
+ external: isDev ? ['better-sqlite3'] : ['bun:sqlite']
+ },
+ optimizeDeps: {
+ // Evitar prebundling de drivers nativos
+ exclude: ['bun:sqlite', 'better-sqlite3']
+ },
+ // Permitir host remoto en desarrollo
+ server: isDev ? { allowedHosts: ['server.brobert.net'] } : undefined
+ };
+});
diff --git a/bunfig.toml b/bunfig.toml
new file mode 100644
index 0000000..6b2f890
--- /dev/null
+++ b/bunfig.toml
@@ -0,0 +1 @@
+workspaces = ["apps/*"]
diff --git a/docs/commands-inventory.md b/docs/commands-inventory.md
new file mode 100644
index 0000000..f41905f
--- /dev/null
+++ b/docs/commands-inventory.md
@@ -0,0 +1,185 @@
+# Inventario de Comandos del Bot de Tareas
+
+Ámbito: WhatsApp (DM y grupos)
+Objetivo: fuente única y estable de copy y comportamiento actual.
+
+Notas generales
+- El bot responde por DM, incluso cuando escribes en un grupo, salvo mensajes muy concretos de estado. En modo de “gating” estricto de grupos (GROUP_GATING_MODE='enforce'), si el grupo no está permitido, el bot puede no responder en absoluto.
+- Zona horaria: se usa TZ (por defecto Europe/Madrid) para calcular “hoy”, “mañana” y vencimientos.
+- IDs: se muestran con 4 dígitos (ej.: `0026`), pero puedes escribirlos sin ceros (ej.: 26).
+- Límite de listados: 10 elementos por sección; si hay más, se muestra “... y N más”.
+- Fechas: formato visual DD/MM; indicador de vencida con ⚠️.
+
+---
+
+## /t nueva (crear)
+
+Alias: `n`, `nueva`, `crear`, `+`
+Sintaxis: `/t n [fecha] [@menciones...]`
+
+Parámetros
+- descripción: texto libre.
+- fecha (opcional): formatos aceptados:
+ - `YYYY-MM-DD`
+ - `YY-MM-DD` (se expande a `20YY-MM-DD`)
+ - Tokens naturales: `hoy`, `mañana` (con o sin acento)
+ - Se ignora puntuación adyacente simple; se usa la última fecha válida encontrada; no se aceptan fechas pasadas.
+- @menciones (opcional): puedes mencionar JIDs crudos o tokens `@...`. Se filtran no plausibles y se intenta resolver alias. Si no se puede, se envía un DM al creador con instrucciones de onboarding (activar).
+
+Asignación por contexto
+- En grupos: si no hay menciones, la tarea queda “sin responsable”.
+- En DM: si no hay menciones, se asigna al creador.
+
+Grupo asociado
+- Solo se asigna `group_id` si el grupo está activo. Si GROUP_GATING_MODE='enforce' y el grupo no está permitido, se crea “sin grupo”.
+
+Ejemplos
+- `/t n Preparar informe 2025-11-05 @600123456`
+- `/t + Comprar pan mañana`
+- `/t crear Llamar a proveedores @ana @juan`
+- `/t n Presentación 25-02-02` (→ 2025-02-02)
+
+---
+
+## /t ver (listar)
+
+Alias: `ver`, `mostrar`, `listar`, `ls`
+Sintaxis: `/t ver [grupo|mis|todos|sin]` (el alcance es opcional)
+
+Alcances
+- `grupo`: lista pendientes del grupo actual (solo desde grupo activo).
+- `mis`: tus tareas pendientes (por DM).
+- `todos`: “Tus tareas” + “sin responsable”.
+ - En grupo: incluye “sin responsable” solo del grupo actual (compatibilidad).
+ - En DM: incluye “sin responsable” de todos los grupos donde eres miembro activo (si el snapshot de membresía es fresco).
+- `sin`: solo tareas sin responsable del grupo actual (desde grupo).
+
+Indicadores
+- Fechas en `DD/MM`.
+- ⚠️ delante de la fecha si está vencida (según TZ configurada).
+
+Límites
+- Máx. 10 elementos por sección; si hay más, se añade “... y N más”.
+
+Ejemplos
+- En grupo: `/t ver` (equivale a `grupo`), `/t ver sin`
+- Por DM: `/t ver mis`, `/t ver todos`
+
+---
+
+## /t x (completar)
+
+Alias: `x`, `hecho`, `completar`, `done`
+Sintaxis: `/t x `
+Soporta múltiples IDs separados por espacios y/o comas. Máx. 10 IDs.
+
+Resolución de ID
+- Primero intenta `display_code` (código corto de 4 dígitos) en tareas activas; si no, usa el ID real.
+
+Gating de membresía (opcional)
+- Si `GROUP_MEMBERS_ENFORCE=true` y el snapshot del grupo es fresco, debes ser miembro activo para completar.
+
+Ejemplos
+- `/t x 26`
+- `/t x 14 19 24`
+- `/t x 14,19,24`
+
+---
+
+## /t tomar (asumir)
+
+Alias: `tomar`, `claim`, `asumir`, `asumo`
+Sintaxis: `/t tomar `
+Múltiples IDs; máx. 10.
+
+Gating de membresía (opcional)
+- Si `GROUP_MEMBERS_ENFORCE=true` y snapshot fresco, debes ser miembro activo para tomar tareas del grupo.
+
+Ejemplos
+- `/t tomar 12`
+- `/t tomar 12 19 50`
+- `/t tomar 12,19,50`
+
+---
+
+## /t soltar (unassign)
+
+Alias: `soltar`, `unassign`, `dejar`, `liberar`, `renunciar`
+Sintaxis: `/t soltar `
+Un solo ID.
+
+Gating de membresía (opcional)
+- Si `GROUP_MEMBERS_ENFORCE=true` y snapshot fresco, debes ser miembro activo para soltar tareas del grupo.
+
+Ejemplos
+- `/t soltar 26`
+
+---
+
+## /t configurar (recordatorios)
+
+Alias: `config`, `configurar`
+Sintaxis: `/t configurar diario|l-v|semanal|off [HH:MM]`
+
+Valores admitidos y alias
+- `diario`/`diaria` → recordatorio diario (se guarda como `daily`).
+- `laborables` (`l-v`, `lv`) → lunes a viernes (se guarda como `weekdays`).
+- `semanal` → semanal (asume lunes; se guarda como `weekly`).
+- `off`/`apagar`/`ninguno` → sin recordatorios (se guarda como `off`).
+
+Hora
+- Formato `HH:MM` (minutos 00–59; hora se normaliza a 0–23).
+- Si omites la hora, se conserva la anterior o se usa `08:30` por defecto (y `lunes` para semanal).
+
+Nota de localización
+- Internamente se almacenan claves en inglés (`daily`, `weekdays`, `weekly`, `off`), pero el copy al usuario es en español. Pendiente de revisión futura para evitar fugas como “weekly” en mensajes.
+
+Ejemplos
+- `/t configurar diaria 09:00`
+- `/t configurar l-v 08:30`
+- `/t configurar semanal` (→ lunes 08:30)
+- `/t configurar off`
+
+---
+
+## /t ayuda
+
+Alias: `ayuda`, `help`, `?`
+Sintaxis: `/t ayuda` | `/t ayuda avanzada`
+
+Comportamiento actual
+- Ayuda rápida con comandos básicos, límites y ejemplos cortos.
+- “Ayuda avanzada” lista alias y detalla opciones y límites.
+
+Nota
+- El contenido de ayuda está centralizado y consistente.
+
+---
+
+## /t web
+
+Sintaxis: `/t web` (solo por DM)
+
+Descripción
+- Genera un token de acceso one‑shot válido 10 minutos, invalida tokens previos y devuelve una URL de login basada en `WEB_BASE_URL`.
+
+Ejemplo
+- `Acceso web: https://…/login?token=...`
+ “Válido durante 10 minutos. Si caduca, vuelve a enviar `/t web`.”
+
+---
+
+## Comandos desconocidos
+
+Ante comandos no reconocidos, el bot responde por DM con un mensaje que incluye el encabezado “❓ Comando no reconocido”, la sugerencia “Prueba `/t ayuda`” y la ayuda rápida inline.
+
+## Notas adicionales
+
+- Estilo y formato:
+ - IDs: `codeId()` → 4 dígitos entre backticks.
+ - Fechas: `formatDDMM()` → `DD/MM`.
+ - Estilos disponibles: negrita `*...*`, cursiva `_<...>_`. Próximamente: `code()`, `section()`, `bullets()`.
+- Gating de grupos:
+ - Si `GROUP_GATING_MODE='enforce'` y el grupo no está permitido, los comandos en ese grupo pueden quedar bloqueados (sin respuesta).
+- Membresía de grupo:
+ - Si `GROUP_MEMBERS_ENFORCE=true` y el snapshot es fresco, algunas acciones requieren ser miembro activo (ver grupo, completar, tomar, soltar).
diff --git a/docs/operations.md b/docs/operations.md
index c094ef6..4d1ad6e 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -6,7 +6,9 @@ Variables de entorno (principales)
- EVOLUTION_API_KEY: API key para peticiones salientes (contacts, etc.).
- WEBHOOK_URL: URL pública del webhook (puede usarse para auto-registro/config).
- WHATSAPP_COMMUNITY_ID: comunidad cuyos grupos se sincronizan.
-- PORT: puerto HTTP (por defecto 3007).
+- PORT: puerto HTTP del proxy interno (por defecto 3000).
+- BOT_PORT: puerto interno del bot (por defecto 3007).
+- WEB_PORT: puerto interno de la web SvelteKit (por defecto 3008).
- NODE_ENV: 'development' | 'test' | 'production'.
- METRICS_ENABLED: 'true'|'false'|'1'|'0' (por defecto habilitado salvo en test). Ej.: METRICS_ENABLED='true'
- RATE_LIMIT_PER_MIN: tokens por minuto por usuario (default 15).
@@ -20,17 +22,50 @@ Variables de entorno (principales)
- ADMIN_USERS: lista separada por comas de IDs/JIDs autorizados para /admin (se normalizan a dígitos). Ej.: ADMIN_USERS='34600123456, 5554443333, +34 600 111 222'
- ALLOWED_GROUPS: lista separada por comas de group_id@g.us para sembrado inicial en arranque. Ej.: ALLOWED_GROUPS='12345-67890@g.us, 11111-22222@g.us'
- NOTIFY_ADMINS_ON_DISCOVERY: 'true'/'false' para avisar por DM a ADMIN_USERS al descubrir un grupo (modo 'discover'). Ej.: NOTIFY_ADMINS_ON_DISCOVERY='true'
+- DATA_DIR: directorio base para la base de datos SQLite (por defecto ./data).
+- DB_PATH: ruta absoluta o relativa al archivo SQLite; si se define, tiene prioridad sobre DATA_DIR. Ej.: DB_PATH='./data/tasks.db'
+- MIGRATIONS_LOG_LEVEL: 'silent' para silenciar logs del migrador (en test se silencian automáticamente).
+- WEB_BASE_URL: base pública de la interfaz web para construir enlaces absolutos (p. ej., /login?token=...). Obligatoria para /t web. Ej.: WEB_BASE_URL='https://wtask.org'
+- DEV_AUTOSEED_DB: 'true'/'false' para sembrar automáticamente la BD en desarrollo cuando está vacía (apps/web). Ej.: DEV_AUTOSEED_DB='true'
+- DEV_DEFAULT_USER: ID de usuario por defecto en desarrollo (bypass y semilla). Idealmente numérico (solo dígitos). Ej.: DEV_DEFAULT_USER='34600123456'
Endpoints operativos
- GET /metrics
- 200 si Metrics.enabled() y formato Prometheus por defecto.
- 404 si métricas deshabilitadas; 405 si método no permitido.
+- GET /health
+ - 200 siempre (proxy interno), útil para healthcheck del contenedor.
+- APIs web (requieren sesión)
+ - GET /api/me/tasks?status=open|recent&search=...
+ - status=open (por defecto): orden por due_date asc (NULL al final). Aplica gating por AllowedGroups + membresía activa (group_members). Búsqueda con LIKE ESCAPE '\'. Filtros dueBefore y soonDays (días). Paginación page/limit y hasMore/total.
+ - status=recent: tareas asignadas al usuario completadas en las últimas 24 h; orden por completed_at DESC; incluye campos completed y completed_at; mismo gating y búsqueda.
+ - GET /api/me/groups
+ - Devuelve solo grupos permitidos donde el usuario está activo. Incluye counts.open y counts.unassigned por grupo.
+ - GET /api/groups/:id/tasks?unassignedFirst=true
+ - Requiere que el usuario sea miembro activo del grupo y que el grupo esté permitido. Orden por due_date (NULL al final); admite parámetros unassignedFirst, onlyUnassigned y limit (clamp a 100).
+ - GET /api/me/preferences
+ - Devuelve las preferencias del usuario para recordatorios como { freq, time }. Si no hay registro previo, responde { freq: 'off', time: '08:30' }.
+ - POST /api/me/preferences
+ - Actualiza preferencias. Valida freq ∈ {off,daily,weekly,weekdays} y time en formato HH:MM (24h); normaliza hora (p. ej., 7:5 → 07:05). Upsert con updated_at; si freq='off' y no se envía time, conserva la última hora guardada (o '08:30' por defecto).
+ - POST /api/tasks/:id/claim
+ - Reclama la tarea para el usuario actual (idempotente). Requiere sesión; valida que la tarea esté abierta y aplica gating: t.group_id IS NULL o (grupo permitido y membresía activa).
+ - POST /api/tasks/:id/unassign
+ - Elimina la asignación del usuario actual (idempotente) si existe. Requiere sesión; tarea abierta y gating equivalente.
+ - POST /api/tasks/:id/complete
+ - Marca como completada (idempotente). Si es de grupo y no tiene responsables, auto-asigna al usuario que completa antes de marcarla como completada. Gating: si tiene group_id, cualquier miembro activo del grupo de un grupo allowed; si no tiene group_id, solo un asignado. Devuelve la tarea con completed y completed_at.
+ - PATCH /api/tasks/:id
+ - Actualiza { due_date: 'YYYY-MM-DD' | null, description?: string }. Valida due_date y normaliza/sanea description (texto plano, 1–1000 chars, colapsa espacios). Requiere sesión, tarea abierta y gating.
Arranque y servicios
-- src/server.ts::start()
+- src/server.ts::start() (bot)
+- proxy.ts y startup.sh (contenedor único con Bun):
+ - El proxy escucha en PORT (3000 por defecto) y enruta /webhook y /metrics → BOT_PORT; el resto → WEB_PORT.
+ - startup.sh normaliza DB_PATH/DATA_DIR a absolutas, arranca bot, espera tablas web_tokens/web_sessions y arranca la web antes del proxy.
- Valida entorno (logs de variables presentes/faltantes).
- Aplica migraciones up-only.
- Inicia HTTP y (según entorno) schedulers.
+ - Compresión HTTP: desactivada temporalmente (el proxy fuerza Accept-Encoding: identity hacia la web y elimina Content-Encoding/Vary/Content-Length en las respuestas; además, SvelteKit se construye con precompress=false para no generar .br/.gz).
+ - En tests, el migrador silencia logs; puede forzarse en cualquier entorno con MIGRATIONS_LOG_LEVEL='silent'.
Schedulers
- GroupSyncService.startGroupsScheduler() y .startMembersScheduler()
@@ -41,9 +76,31 @@ Schedulers
- Tarea diaria; borra miembros inactivos según retención.
Datos y backups
-- Data path: data/tasks.db (por defecto).
+- Data path: /app/data/tasks.db (por defecto).
+- startup.sh normaliza DB_PATH y DATA_DIR a rutas absolutas para que bot y web apunten al mismo archivo, y espera a que existan web_tokens/web_sessions antes de iniciar la web.
- Migraciones con backup opcional (withBackup=false por defecto en initializeDatabase).
- Recomendación: planificar copia de seguridad periódica del directorio data/ y retención externa.
+- DB_PATH permite aislar BD por rama/entorno sin tocar DATA_DIR; útil para pruebas en CapRover.
+- En Docker/CapRover, el volumen por defecto es /app/data. Para persistencia, usa rutas de DB_PATH dentro de ese directorio (p. ej., /app/data/tasks-next.db).
+
+Semilla de desarrollo (apps/web)
+- Activación: establecer DEV_AUTOSEED_DB='true'. La semilla solo se ejecuta en desarrollo cuando la tabla tasks está vacía.
+- Usuario por defecto: definir DEV_DEFAULT_USER con un ID numérico (p. ej., 34600123456). Se crea como usuario, se hace miembro activo de varios grupos y se usa para asignaciones.
+- Ruta del archivo en dev (apps/web): por defecto tmp/tasks.db (véase apps/web/src/lib/server/env.ts). En producción, la web usa /app/data por defecto.
+- Regenerar la BD de dev: detener el servidor web, borrar el archivo de BD y reiniciar con DEV_AUTOSEED_DB='true'.
+ - Ejemplo: rm -f tmp/tasks.db
+- Datos que se crean:
+ - Usuarios: 3–5 (incluido el usuario por defecto).
+ - Grupos: “Familia”, “Trabajo”, “Voluntariado”, “Compras” (allowed) y “Varios” (pending).
+ - Allowed groups: allowed para los grupos principales; “Compras” allowed sin membresía del usuario por defecto (sirve para validar gating); “Varios” en pending.
+ - Membresías: el usuario por defecto activo en Familia, Trabajo y Voluntariado; otros usuarios repartidos para soportar múltiples responsables.
+ - Preferencias: recordatorios diarios a las 08:30 para el usuario por defecto.
+ - Tareas: ~30–35 con mezcla rica:
+ - Personales (sin grupo) y de grupo.
+ - due_date en pasado, hoy, futuro y NULL.
+ - Sin responsables, con 1 responsable y con múltiples responsables (incluye “tú”).
+ - Completadas recientemente (≤24h) y antiguas (>48h), con completed_by coherente.
+- Idempotencia: si ya existen tareas no vuelve a sembrar. Para resembrar, borra el archivo de BD o define un DB_PATH nuevo.
Métricas de referencia
- sync_runs_total, identity_alias_resolved_total, contadores/gauges específicos de colas y limpieza.
@@ -59,6 +116,8 @@ Métricas de referencia
Buenas prácticas
- No arrancar schedulers en test salvo que FORCE_SCHEDULERS='true'.
- Validar nuevas env en src/server.ts::validateEnv() y documentarlas aquí.
+- En apps/web, kit.csrf.checkOrigin=false debido al proxy interno; considerar alternativas si se elimina el proxy.
+- Tests web con bun:test: construcción programática de apps/web (build/), arranque real del servidor y peticiones HTTP reales; ver tests/web/helpers/server.ts.
Formato de fechas en comandos
- Se aceptan únicamente YYYY-MM-DD y YY-MM-DD (YY se expande a 20YY).
diff --git a/docs/plan-ayuda-bot.md b/docs/plan-ayuda-bot.md
new file mode 100644
index 0000000..c88ac0a
--- /dev/null
+++ b/docs/plan-ayuda-bot.md
@@ -0,0 +1,255 @@
+# Plan de Modernización de Ayuda y Estilo de Mensajes (Help v2)
+
+Estado: propuesta
+Ámbito: bot (responde siempre por DM)
+Objetivo: hacer la ayuda consistente, útil ante comandos desconocidos, visible el acceso web, y estandarizar el estilo de todos los mensajes.
+
+## Principios
+
+- Responder siempre por DM (incluso si el comando se escribe en un grupo).
+- Ayuda accesible y progresiva: ayuda rápida en flujos comunes y ayuda extendida bajo demanda.
+- Estilo WhatsApp consistente: secciones en negrita y mayúsculas, listas con “- ”, comandos en monoespaciado, notas en cursiva.
+- Fuente única para contenidos de ayuda (centralizar copy).
+- Cambios compatibles con tests: minimizar fragilidad de asserts por copy.
+
+---
+
+## Inventario de comandos actual (derivado de src/services/command.ts)
+
+- Crear
+ - Comandos: `/t n`, `/t nueva`, `/t crear`, `/t +`
+ - Soporta: fecha explícita `YYYY-MM-DD`, `YY-MM-DD` (expande a `20YY`), tokens `hoy`/`mañana`
+ - Asignación:
+ - En DM: por defecto asignada al creador si no hay menciones
+ - En grupo: por defecto “sin responsable” si no hay menciones
+ - Menciones: detecta `@tokens` y JIDs crudos; filtra no plausibles; emite DM “JIT onboarding” si no se pudo resolver
+- Ver
+ - Comando base: `/t ver` (alias: `ver`, `mostrar`, `listar`, `ls`)
+ - Alcances: `grupo` (si se escribe desde grupo activo), `mis` (DM), `todos` (mis + sin responsable de grupos donde soy miembro activo), `sin` (solo sin responsable del grupo actual)
+ - Límite: 10 ítems; “… y N más” cuando excede
+ - Indicadores:
+ - Fecha en formato `DD/MM`
+ - Aviso de vencida (⚠️) cuando `due_date < hoy` (calculado por TZ configurada)
+- Completar
+ - Comandos: `/t x`, `/t hecho`, `/t completar`, `/t done`
+ - Acepta múltiples IDs (separados por espacios y/o comas); máx. 10
+ - Resolución de ID: primero por `display_code` de tareas activas; si no, por PK
+ - Gating opcional: si `GROUP_MEMBERS_ENFORCE=true` y snapshot fresca, requiere ser miembro activo
+- Tomar
+ - Comandos: `/t tomar`, `/t claim`, `/t asumir`, `/t asumo`
+ - Múltiples IDs; máx. 10; gating de membresía igual que “completar”
+- Soltar
+ - Comandos: `/t soltar`, `/t unassign`, `/t dejar`, `/t liberar`, `/t renunciar`
+ - Un solo ID
+- Configurar recordatorios
+ - Comandos: `/t configurar diario|l-v|semanal|off [HH:MM]`
+ - Mapea alias a `daily`, `weekdays`, `weekly`, `off`; hora opcional con normalización
+- Ayuda
+ - Comandos: `/t ayuda`, `/t help`, `/t ?`, y variante “ayuda avanzada”
+ - Actualmente genera mensajes en línea (no centralizados)
+- Web
+ - Comando: `/t web`
+ - Genera token one-shot, invalida tokens previos, devuelve URL de login basada en `WEB_BASE_URL`
+- Notas de formato ya en uso
+ - IDs se muestran con 4 dígitos (backticks)
+ - Estilos disponibles: `bold`, `italic`; se usa `codeId()` para IDs y `formatDDMM()` para fechas
+
+---
+
+## Roadmap por fases
+
+### Fase 0 — Documentación de inventario y estilo (docs)
+
+- Objetivo: dejar por escrito el inventario de comandos y una guía de estilo para WhatsApp.
+- Tareas:
+ - Crear `docs/commands-inventory.md` con matriz de comandos, alias, alcance, ejemplos, límites y prerequisitos.
+ - Crear `docs/whatsapp-style-guide.md` con convenciones de formato y ejemplos.
+- Archivos a crear:
+ - `docs/commands-inventory.md`
+ - `docs/whatsapp-style-guide.md`
+- Criterios de aceptación:
+ - El inventario cubre todos los comandos listados arriba.
+ - La guía incluye: secciones, comandos en monoespaciado, bullets, notas en cursiva y ejemplos cortos.
+
+### Fase 1 — Helpers mínimos de formato (código)
+
+- Objetivo: ofrecer utilidades simples y reutilizables sin romper lo existente.
+- Cambios:
+ - Extender `src/utils/formatting.ts` añadiendo:
+ - `export function code(s: string): string` → wrap con backticks
+ - `export function section(s: string): string` → `*${s.toUpperCase()}*`
+ - `export function bullets(items: string[]): string` → `- ${item}` por línea
+- Archivos a tocar:
+ - `src/utils/formatting.ts` (añadir funciones)
+- Tests:
+ - Nuevo: `tests/unit/utils/formatting.test.ts` (para `code`, `section`, `bullets`)
+- Criterios de aceptación:
+ - Formateadores devuelven exactamente el formato esperado y no rompen los existentes.
+
+### Fase 2 — Centralizar contenido de ayuda (completado)
+
+- Objetivo: tener una única fuente de verdad para la ayuda.
+- Cambios:
+ - Crear `src/services/messages/help.ts` con:
+ - `getQuickHelp(baseUrl?: string): string`
+ - `getFullHelp(baseUrl?: string): string`
+ - Contenido sugerido (resumen):
+ - Ayuda rápida:
+ - Secciones: “COMANDOS BÁSICOS”, “LISTADOS”, “ACCESO WEB”
+ - Bullets con: crear (`/t n ...`), ver (`/t ver mis|grupo|todos|sin`), completar/tomar/soltar, configurar recordatorios, y `/t web`
+ - Nota: _El bot responde por DM, incluso si escribes desde un grupo._
+ - Ayuda extendida:
+ - Además: formatos de fecha (`YYYY-MM-DD`, `YY-MM-DD`→`20YY-MM-DD`, `hoy|mañana`), límites (máx. 10 IDs), reglas de asignación por contexto, gating de grupos, detalles de “ver todos”.
+- Archivos a crear:
+ - `src/services/messages/help.ts`
+- Archivos a consultar:
+ - `src/services/command.ts` (para mantener alineación de copy con funcionalidad)
+- Tests:
+ - Nuevo: `tests/unit/services/help-content.test.ts` (asserts por substrings clave, no igualdad exacta)
+- Criterios de aceptación:
+ - `getQuickHelp()` incluye `/t web` y comandos básicos.
+ - `getFullHelp()` cubre scopes de “ver”, formatos de fecha y límites.
+
+### Fase 3 — Comportamiento ante comandos desconocidos (completado)
+
+- Objetivo: responder útilmente cuando no se reconoce la acción.
+- Cambios en `src/services/command.ts`:
+ - Reemplazar la respuesta “Acción X no implementada aún” por:
+ - Encabezado tipo: `❓ Comando no reconocido`
+ - Sugerencia: “Prueba `/t ayuda`”
+ - Adjuntar `getQuickHelp(baseUrl)` en el mismo mensaje
+ - Mantener logging/telemetría si aplica (ej. `Metrics.inc('commands_unknown_total')` opcional)
+- Archivos a tocar:
+ - `src/services/command.ts`
+ - `src/services/messages/help.ts` (uso desde aquí)
+- Tests:
+ - Nuevo: `tests/unit/services/command.unknown-help.test.ts`
+ - Input: `/t qué tareas tengo hoy?`
+ - Expect: mensaje contenga indicador de comando desconocido, `/t ayuda`, y fragmentos de quick help (p.ej., `/t ver mis`, `/t web`)
+- Criterios de aceptación:
+ - DM siempre; mensaje claro y accionable.
+
+### Fase 4 — Unificar el comando /t ayuda (completado)
+
+- Objetivo: que `/t ayuda` y “ayuda avanzada” usen el módulo centralizado.
+- Cambios en `src/services/command.ts`:
+ - Si `ayuda` con “avanzada” → `getFullHelp(baseUrl)`
+ - Si `ayuda` sin “avanzada” → `getQuickHelp(baseUrl)` + CTA a “ayuda avanzada”
+ - Quitar los textos embebidos actuales en `command.ts` para estos casos
+- Archivos a tocar:
+ - `src/services/command.ts`
+ - `src/services/messages/help.ts`
+- Archivos a consultar:
+ - `src/services/command.ts` (acción `ayuda`)
+- Tests:
+ - Nuevo: `tests/unit/services/command.help.test.ts`
+ - “/t ayuda” incluye `/t web`
+ - “/t ayuda avanzada” incluye scopes de “ver” y formatos de fecha
+- Criterios de aceptación:
+ - Ayuda centralizada y consistente en ambos modos.
+
+### Fase 5 — Flag de activación y configuración
+
+- Objetivo: habilitar rollback rápido si hiciera falta.
+- Cambios:
+ - Soportar `FEATURE_HELP_V2` (por defecto `true`). Si `false`, usar el comportamiento actual (fallback).
+ - Fuente para `baseUrl`: `process.env.WEB_BASE_URL` (ya empleada por `/t web`); pasarla opcionalmente a `help.ts`.
+- Archivos a tocar:
+ - `src/services/command.ts` (condicionar branches de ayuda/fallback con el flag)
+ - `src/services/messages/help.ts` (aceptar `baseUrl?`)
+- Tests:
+ - Añadir caso con `FEATURE_HELP_V2=false` que mantenga el behavior anterior (solo si compensa; opcional).
+- Criterios de aceptación:
+ - Con flag on/off, el bot responde acorde.
+
+### Fase 6 — Estilo global en mensajes (incremental)
+
+- Objetivo: aplicar el estilo unificado en más respuestas (crear, tomar, soltar, completar, ver).
+- Cambios (incrementales, por PRs pequeños):
+ - Introducir helpers de estilo donde falten (`bold`, `italic`, `code`, `section`, `bullets`)
+ - Estandarizar encabezados, bloques de detalle y notas
+- Archivos a tocar:
+ - `src/services/command.ts` (copys de confirmaciones y listados)
+ - Potenciales módulos de mensajes: `src/services/messages/*.ts` (si extraemos piezas)
+- Tests:
+ - Actualizar asserts frágiles para comparar substrings semánticos, o crear helper de test `stripFormatting` que quite `*`, `_`, `` ` `` para aserciones menos frágiles.
+- Criterios de aceptación:
+ - Mensajes alineados con la guía de estilo, sin romper funcionalidad.
+
+### Fase 7 — Tests y mantenimiento
+
+- Nuevos tests a añadir:
+ - `tests/unit/utils/formatting.test.ts`
+ - `tests/unit/services/help-content.test.ts`
+ - `tests/unit/services/command.unknown-help.test.ts`
+ - `tests/unit/services/command.help.test.ts`
+- Tests existentes a revisar (no editar salvo necesario):
+ - `tests/unit/services/command.*.test.ts`
+ - `tests/unit/server.*.test.ts` (si comparan copys)
+ - `tests/unit/web/*` no deberían verse afectados
+- Estrategia:
+ - Preferir asserts por “contiene” para evitar fragilidad.
+ - Añadir helper `stripFormatting` en tests si se requiere.
+
+---
+
+## Archivos implicados
+
+- A crear (docs):
+ - `docs/commands-inventory.md`
+ - `docs/whatsapp-style-guide.md`
+- A crear (código):
+ - `src/services/messages/help.ts`
+- A modificar (código — ya disponibles en este chat):
+ - `src/services/command.ts`
+ - `src/utils/formatting.ts`
+- A consultar (sin cambios, solo referencia):
+ - `src/services/webhook-manager.ts` (no requiere cambios para Help v2)
+- A modificar (tests — NO añadidos aún a este chat; cuando llegue el momento, añadir):
+ - `tests/unit/utils/formatting.test.ts`
+ - `tests/unit/services/help-content.test.ts`
+ - `tests/unit/services/command.unknown-help.test.ts`
+ - `tests/unit/services/command.help.test.ts`
+
+Cuando ejecutemos las fases de código/tests, si estos archivos no están en el chat, pediré que los añadas para poder proponer los parches exactos.
+
+---
+
+## Criterios de aceptación globales
+
+- Un comando desconocido devuelve un mensaje útil con ayuda rápida (incluye `/t ayuda` y referencia a `/t web`).
+- `/t ayuda` usa contenido centralizado; “ayuda avanzada” despliega la versión extendida.
+- Estilo consistente: secciones en negrita y mayúsculas; comandos/IDs en monoespaciado; listas con “- ”; notas en cursiva.
+- Documentación (inventario y guía de estilo) creada.
+- Tests nuevos cubriendo formateadores y flujos de ayuda.
+
+---
+
+## Riesgos y mitigaciones
+
+- Riesgo: rotura de tests por cambios de copy.
+ - Mitigación: asserts por substrings; helper `stripFormatting`; cambios incrementales.
+- Riesgo: ambigüedad de URLs/web.
+ - Mitigación: mostrar CTA a `/t web` en la ayuda; opcionalmente mostrar `WEB_BASE_URL` como referencia informativa sin token.
+- Riesgo: sobrecarga de mensajes.
+ - Mitigación: quick vs full help; mantener mensajes cortos y con bullets.
+
+---
+
+## Siguientes pasos
+
+1) Fase 0 (docs) — crear `docs/commands-inventory.md` y `docs/whatsapp-style-guide.md`.
+2) Fase 1 (helpers) — añadir `code`, `section`, `bullets` a `src/utils/formatting.ts` + tests.
+3) Fase 2 (help.ts) — centralizar ayuda + tests de contenido.
+4) Fase 3-4 (wire-up) — usar help.ts en `/t ayuda` y en comando desconocido.
+5) Fase 5-6 — flag `FEATURE_HELP_V2` y estandarización incremental de copys.
+
+Incluye validación manual: probar `/t ayuda`, `/t ayuda avanzada`, un comando desconocido y `/t web`.
+
+---
+
+## Comandos útiles
+
+- Añadir y confirmar este documento:
+ - `git add docs/commands-inventory.md docs/whatsapp-style-guide.md docs/plan-ayuda-bot.md`
+ - `git commit -m "docs: plan Help v2, inventario y guía de estilo"`
diff --git a/docs/plan-diseno-web.md b/docs/plan-diseno-web.md
new file mode 100644
index 0000000..dc6c6cf
--- /dev/null
+++ b/docs/plan-diseno-web.md
@@ -0,0 +1,93 @@
+# Plan de Diseño Web: Claridad de interacción, “tareas mías”, deadlines e identidad visual por grupo
+
+Objetivos
+- Distinguir de un vistazo qué elementos son clicables (box-shadow y estados).
+- Señalar cuando “tú” estás entre los responsables (acento visual claro).
+- Sustituir el emoji de calendario por un icono más entendible de “fecha límite” (SVG consistente).
+- Dar color estable y reconocible a las pills de grupo con una paleta accesible y determinista.
+
+Fases
+
+Fase B1 — Paleta determinista de grupos (COMPLETADA)
+- Idea: asignar una de 12–15 combinaciones (border, fondo tenue, texto) por group_id usando un hash determinista (mod N). Si hay más grupos que colores, se repiten.
+- Paleta sugerida (AA sobre fondo claro):
+ 1) Blue: border #2563EB, fondo #DBEAFE, texto #1E3A8A
+ 2) Indigo: border #4F46E5, fondo #E0E7FF, texto #312E81
+ 3) Violet: border #7C3AED, fondo #EDE9FE, texto #4C1D95
+ 4) Purple: border #9333EA, fondo #F3E8FF, texto #581C87
+ 5) Fuchsia: border #C026D3, fondo #FAE8FF, texto #701A75
+ 6) Pink: border #DB2777, fondo #FCE7F3, texto #831843
+ 7) Rose: border #E11D48, fondo #FFE4E6, texto #881337
+ 8) Red: border #DC2626, fondo #FEE2E2, texto #7F1D1D
+ 9) Orange: border #EA580C, fondo #FFE7D1 (aprox.), texto #7C2D12
+ 10) Amber: border #D97706, fondo #FEF3C7, texto #78350F
+ 11) Green: border #16A34A, fondo #DCFCE7, texto #14532D
+ 12) Teal: border #0D9488, fondo #CCFBF1, texto #134E4A
+- Implementación:
+ - Crear apps/web/src/lib/utils/groupColor.ts con una función colorForGroup(groupId) → { border, bg, text } usando hash ligero (p. ej., sumatoria de charCodes) y modulo N.
+ - Aplicar en la pill de grupo de TaskItem.svelte y donde aparezcan chips/etiquetas de grupo.
+
+Fase B2 — Icono de “fecha límite” en SVG (COMPLETADA)
+- Sustituir emoji de calendario por un icono más semántico:
+ - Recomendación: “clock” (reloj) o “hourglass” (arena).
+- Implementación:
+ - Crear apps/web/src/lib/ui/icons/Clock.svelte (y/o Hourglass.svelte) como SVG inline con fill="currentColor", tamaño 16–18px.
+ - Reemplazar en TaskItem.svelte donde se muestra due_date. Mantener aria-label/tooltip pertinentes.
+
+Fase B3 — Indicador cuando “tú” estás asignado
+- Mantener icono/contador de responsables y añadir acento visual si el usuario actual está entre los assignees:
+ - Anillo/borde con el color primario alrededor del icono/badge o un pequeño dot superpuesto.
+ - aria-label dinámico: “n responsables; tú incluido” | “n responsables; tú excluido”.
+ - Tooltip opcional en desktop.
+- Implementación:
+ - En TaskItem.svelte, derivar isMine comprobando si App.locals.userId (o prop userId) ∈ assignees[] y aplicar clase/modificador que active el acento.
+
+Fase B4 — Box-shadow solo en elementos interactivos
+- Principio: toda superficie clicable debe tener pistas visuales coherentes (cursor, sombra/hover, focus visible). Superficies no interactivas no deben tener sombra.
+- Implementación:
+ - apps/web/src/lib/styles/tokens.css: definir variables de sombras (--shadow-sm, --shadow-md, --shadow-focus).
+ - apps/web/src/lib/styles/base.css: patrones de hover/focus/active para botones, links con rol=button y chips clicables (sombra sutil en reposo, incremento ligero en hover/focus-visible, compresión en active).
+ - Mantener focus-visible claro (ring) y contraste AA.
+- QA: verificar en móvil ≤480px que no haya desbordes; mantener targets ~44px sin inflar paddings.
+
+Accesibilidad
+- Contraste AA para texto sobre fondo en las pills de grupo.
+- Focus visible en todos los elementos interactivos.
+- aria-label correcto en iconos y tooltips; roles adecuados si se usan popovers/modales.
+
+Archivos a editar/crear
+
+Crear
+- apps/web/src/lib/utils/groupColor.ts (hash + paleta).
+- apps/web/src/lib/ui/icons/Clock.svelte (y/u Hourglass.svelte) (SVG).
+
+Editar
+- apps/web/src/lib/ui/data/TaskItem.svelte
+ - Aplicar paleta determinista en pill de grupo.
+ - Sustituir emoji de fecha por SVG “deadline”.
+ - Añadir acento visual “isMine” en el indicador de responsables con aria/tooltip.
+- apps/web/src/lib/styles/tokens.css
+ - Añadir variables de sombras y, si procede, refinar escala de colores.
+- apps/web/src/lib/styles/base.css
+ - Estados interactivos con sombras coherentes y cursor correcto.
+- apps/web/src/lib/ui/layout/Card.svelte (opcional)
+ - Ajustar padding vertical si hiciese falta para mantener densidad.
+
+Criterios de aceptación
+- Elementos interactivos distinguibles de un vistazo (sombra + cursor + focus).
+- El indicador de responsables comunica “es mía” sin leer texto.
+- El icono de due se interpreta como “fecha límite”.
+- Las pills de grupo mantienen color estable sesión tras sesión.
+
+Caveats
+- Evitar sombras en contenedores no interactivos para no elevar ruido visual.
+- Mantener densidad: no incrementar la altura de filas.
+- Si hay dark-mode en el futuro, revisar la paleta para asegurar contraste.
+
+Orden de entrega sugerido
+- B1 + B2 primero (impacto alto, bajo riesgo).
+- B3 después (requiere condicionar por userId).
+- B4 al final (pulido transversal + QA de accesibilidad).
+
+Notas operativas
+- Para aplicar estos cambios, comparte en este chat los archivos UI relevantes (TaskItem.svelte, tokens.css, base.css y/o componentes de iconos) y propondré los parches en bloques SEARCH/REPLACE.
diff --git a/docs/plan-interfaz-web.md b/docs/plan-interfaz-web.md
new file mode 100644
index 0000000..c1997a3
--- /dev/null
+++ b/docs/plan-interfaz-web.md
@@ -0,0 +1,475 @@
+# Plan de implementación: Interfaz Web (SvelteKit) + Bot/Webhook (separado)
+
+Este documento define el plan para añadir una interfaz web al sistema, manteniendo el bot/webhook existente como proceso independiente y compartiendo la misma base de datos SQLite (WAL). El objetivo es proporcionar al usuario un acceso seguro, rápido y cómodo a sus tareas, configuración y feeds de calendario, con especial atención a la seguridad y a la mínima fricción.
+
+## Estado actual (2025-10-13)
+- PR 1 (fundaciones de UI) integrado: tokens.css, base.css, AppShell, layout de /app y gating coherente a /login.
+- PR 2 (UX/UI etapa 18 — base) integrado:
+ - Componentes base: Button, Card, Badge, Pagination, Skeleton, VisuallyHidden, TextField, SegmentedControl.
+ - Utilidades: lib/utils/date.ts (todayYmdUTC, addDaysYmd, dueStatus).
+ - Componentes de datos: TaskItem (con badges de vencimiento), GroupCard (contadores y “sin responsable”).
+ - Páginas: /app, /app/groups y /app/preferences refactorizadas para usar los componentes; frecuencia ahora como radios (SegmentedControl).
+ - AppShell: navegación con estado activo, espaciado compacto moderado, modo oscuro automático.
+ - Calidad: tests de /app/preferences actualizados; resuelto warning de export no usado en TaskItem.
+- Incidencia de producción resuelta: la causa era Content-Encoding (brotli/gzip) no compatible en la cadena. Se desactivó la compresión end-to-end: SvelteKit se construye con precompress=false y, en el proxy Bun, se fuerza Accept-Encoding: identity hacia la web y se eliminan Content-Encoding/Vary/Content-Length en las respuestas al cliente.
+- Verificación: los assets /_app/* sirven 200 sin Content-Encoding y con Content-Type correcto. Estilos y JavaScript cargan correctamente.
+- Edición de tareas en web integrada: reclamar/soltar, edición de fecha y descripción (PATCH /api/tasks/:id), completar (POST /api/tasks/:id/complete) y sección “Completadas (24 h)” en /app; con gating por AllowedGroups + membresía activa.
+- Grupos: botón “Reclamar” en tarjetas; listado "sin responsable" sin límite; fichas ordenadas por número de "sin responsable".
+
+## 1) Decisiones fijadas
+
+- Arquitectura: dos procesos (apps/bot y apps/web) ejecutándose en la misma app de CapRover, con un proxy interno en Bun (puerto 3000) que enruta /webhook y /metrics al bot (3007) y el resto a la web (3008). SvelteKit para la web (SSR, rutas de API, cookies).
+- Acceso: enlace mágico por DM con token de 10 minutos, de un solo uso. Sin “recordarme”.
+- Sesión: cookie de sesión (HttpOnly, SameSite=Lax, Secure en prod) + expiración por inactividad de servidor de 2 horas.
+- Orden por defecto: tareas por fecha de vencimiento ascendente (NULL al final).
+- ICS:
+ - Horizonte temporal: 12 meses.
+ - Excluir tareas sin fecha.
+ - Feeds:
+ - B (por usuario+grupo, solo tareas sin responsable) como opción por defecto, con autogeneración.
+ - C (personal multigrupo, solo sin responsable) opcional para power users.
+- Monorepo: estructura apps/bot y apps/web. Posible “shared” en el futuro para reutilizar utilidades.
+
+## 2) Alcance funcional (MVP)
+
+- Mis tareas: lista (orden por fecha de vencimiento asc), filtros (abiertas, vencen pronto), búsqueda por texto simple.
+- Tareas de mis grupos: solo grupos permitidos y en los que el usuario está activo; sección destacada de “sin responsable” sin límite y con botón “Reclamar”; fichas ordenadas por cantidad de “sin responsable”.
+- Edición de tareas desde la web: reclamar/soltar asignación y editar fecha de vencimiento (YYYY-MM-DD).
+- Preferencias de recordatorios: ver y modificar frecuencia (daily/weekly/weekdays/off) y hora. Visualización de próximo recordatorio según TZ.
+- Autenticación: comando /t web que devuelve URL con token. Canje en /login y cookie de sesión.
+- Integraciones:
+ - ICS personal (solo “mis tareas” con due_date).
+ - ICS por usuario+grupo (solo sin responsable), autogenerados (sin clic de creación).
+ - ICS personal multigrupo opcional (solo sin responsable).
+
+## 3) Arquitectura técnica
+
+- apps/web (SvelteKit):
+ - SSR + endpoints (rutas +server.ts) para login, APIs, ICS.
+ - Gestión de cookies/sesión en hooks.server.ts.
+ - UI con Svelte (sin framework adicional salvo CSS utilitario si se desea).
+- apps/bot:
+ - Se mantiene el webhook/servicios actuales.
+ - Emisión de tokens de login: puede implementarse desde el bot (insertando en DB) o delegarse al web (si el bot solo notifica al usuario la URL base + token emitido por la web). Para MVP: el bot crea el token directamente en DB y envía la URL.
+- Concurrencia DB:
+ - SQLite en modo WAL con PRAGMA busy_timeout para ambos procesos. Reutilizar convenciones actuales de PRAGMA.
+
+## 4) Autenticación y sesiones
+
+- Emisión de token (bot):
+ - En /t web por DM: crear token aleatorio, guardar hash (no el token en claro), TTL 10 min, uso único, rate-limit por usuario.
+ - Devolver URL del tipo: https://app.example.com/login?token=XYZ
+- Canje (web):
+ - GET /login muestra una página intermedia sin auto-submit; requiere interacción mínima. Un script establece una cookie efímera login_intent y habilita el botón.
+ - POST /login valida hash y caducidad y comprueba la cookie login_intent; si ok, invalida el token (marcar usado).
+ - Crea sesión en DB (web_sessions) y emite cookie de sesión (solo cookie de sesión, sin persistencia en disco).
+ - Redirige a /app (sin token en la URL).
+- Expiración:
+ - Idle timeout: 2 horas de inactividad. Si excede, pedir un nuevo token /t web.
+- Seguridad:
+ - Cookies: HttpOnly, SameSite=Lax, Secure (en prod), path acotado.
+ - Rate limit en /login para evitar bruteforce de tokens.
+ - Redirección inmediata tras canje para evitar fugas por Referer.
+ - CSRF: checkOrigin desactivado en SvelteKit debido al proxy interno que reenvía las peticiones (mismo dominio).
+
+## 5) Calendario ICS
+
+- Contenido:
+ - Solo tareas con due_date, dentro de los próximos 12 meses.
+ - Título con id y descripción, notas con URL a la tarea (opcional).
+- Feeds:
+ - Personal (usuario): “mis tareas” con due_date.
+ - Por usuario+grupo (B, por defecto): “sin responsable” del grupo.
+ - Autogeneración: “perezosa” (on-demand) al solicitar listado de feeds en la UI o al primer acceso a la URL; o proactiva (sembrado) mediante tarea que cree un token por cada par usuario+grupo activo. Recomendado: perezosa, con garantía de existencia al cargar la página “Integraciones”.
+ - Un feed activo por usuario+grupo (regla de unicidad). Rotación manual por el usuario (revocar y crear nuevo).
+ - Personal multigrupo (C, opcional): “sin responsable” agregadas de todos los grupos en los que el usuario esté activo. Un único token; revocable.
+- Seguridad y control:
+ - Tokens largos, no caducan por tiempo (estilo ICS), siempre revocables.
+ - Para B/C: revocación automática al detectar que el usuario dejó de ser miembro activo del grupo (B) o de todos los grupos (C), en conciliaciones de miembros.
+ - Rate limit de peticiones por token/IP (ligero) y soporte de ETag/Last-Modified para minimizar carga.
+
+## 6) Estructura del repo (monorepo)
+
+- apps/bot: código actual del webhook, servicios, schedulers, migraciones.
+- apps/web: SvelteKit (adapter-node).
+- data/: carpeta con la base de datos SQLite compartida (ya existente).
+- docs/: documentación (este archivo).
+- Opcional futuro: packages/shared para extraer utilidades (normalizeWhatsAppId, métricas, tipos, etc.).
+
+## 7) Migraciones de base de datos (nuevas tablas)
+
+- web_tokens
+ - id, user_id, token_hash, created_at, expires_at, used_at (nullable), metadata (JSON opcional).
+ - Índices: (user_id), (expires_at), (token_hash único).
+- web_sessions
+ - id (session_id), user_id, session_hash, created_at, last_seen_at, expires_at (idle cutoff), user_agent, ip (opcional).
+ - Índices: (user_id), (expires_at), (session_hash único).
+- calendar_tokens
+ - id, type (‘personal’, ‘group’, ‘aggregate’), user_id, group_id (nullable), token_hash, created_at, revoked_at (nullable), last_used_at.
+ - Unicidad: (type, user_id, group_id) activa (si revoked_at IS NULL).
+ - Índices: (user_id), (group_id), (token_hash único).
+
+Notas:
+- Guardar siempre hashes de tokens (no tokens en claro).
+- Para autogeneración perezosa: crear on-demand si no existe registro activo para (user_id, group_id).
+
+## 8) Endpoints (apps/web)
+
+- Autenticación:
+ - GET /login?token=… (página intermedia con gate de JS)
+ - POST /login (canjea token, crea sesión, redirige a /app)
+ - POST /api/logout (revoca sesión actual)
+- APIs (todas requieren sesión válida):
+ - GET /api/me/tasks?status=open|recent&search=...&page=...&limit=...
+ - GET /api/me/groups (grupos en los que está activo; solo allowed)
+ - GET /api/groups/:id/tasks?unassignedFirst=true (respeta gating y membresía)
+ - GET /api/me/preferences
+ - POST /api/me/preferences (actualiza frecuencia/hora)
+ - POST /api/tasks/:id/claim (reclamar tarea; idempotente; requiere sesión, tarea abierta y gating)
+ - POST /api/tasks/:id/unassign (soltar tarea; idempotente; requiere sesión, tarea abierta y gating)
+ - POST /api/tasks/:id/complete (marca como completada; idempotente. Si tiene group_id: cualquier miembro activo del grupo allowed; si no, solo un asignado. Devuelve completed y completed_at)
+ - PATCH /api/tasks/:id (actualiza { due_date: 'YYYY-MM-DD' | null, description?: string }; valida due_date y normaliza/sanea description como texto plano, 1–1000 chars, colapsando espacios; requiere sesión, tarea abierta y gating)
+ - GET /api/integrations/feeds
+ - Genera automáticamente (si faltan) tokens B por cada grupo activo del usuario.
+ - Devuelve URLs para: ICS personal (mis tareas), ICS por grupo (B), y opcional ICS multigrupo (C).
+ - POST /api/integrations/feeds/rotate { type, groupId? } (revoca y recrea token)
+- ICS (no requieren sesión; usan token en la URL):
+ - GET /ics/personal/:token.ics
+ - GET /ics/group/:token.ics
+ - GET /ics/aggregate/:token.ics
+
+## 9) UI (apps/web)
+
+- Páginas:
+ - /app (dashboard): “Mis tareas” con acciones (reclamar/soltar/editar fecha).
+ - /app/groups: lista de grupos del usuario; tarjetas ordenadas por número de “sin responsable”; en cada una, “sin responsable” sin límite y botón “Reclamar”.
+ - /app/preferences: frecuencia y hora de recordatorios; vista “próximo recordatorio”.
+ - /app/integrations: enlaces ICS
+ - Autogenerados: mostrar directamente botones “Copiar” y breve guía (Google/Apple/Outlook).
+ - Rotar/revocar: botones por feed. Avisar que rotar invalida suscripción previa.
+- Interacciones:
+ - Filtros rápidos, búsqueda, paginación liviana.
+ - Estado de sesión (2h de inactividad): al expirar, mostrar mensaje con instrucción de enviar /t web.
+
+## 10) Seguridad
+
+- Tokens:
+ - Aleatorios criptográficos; hash en DB; TTL 10 min (web_tokens); uso único.
+ - calendar_tokens sin TTL (estilo ICS), siempre revocables.
+- Cookies: HttpOnly, SameSite=Lax, Secure en prod, path acotado. No almacenar PII en cookies.
+- CSRF: bajo riesgo con SameSite y API same-origin; añadir token anti-CSRF a mutaciones como defensa en profundidad.
+- Cabeceras:
+ - X-Frame-Options: DENY, Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff, Content-Security-Policy básica.
+ - Robots: noindex, nofollow.
+- Gating:
+ - Todas las consultas filtran por user_id y validan AllowedGroups + membresía activa (group_members).
+- Logs: nunca registrar tokens en claro; solo hashes o IDs.
+
+## 11) Observabilidad y límites
+
+- Métricas (via Metrics):
+ - web_tokens_issued_total, web_tokens_redeemed_total, web_login_success_total, web_login_failed_total
+ - web_sessions_active, web_api_requests_total{route=…}, ics_requests_total{type=…}
+ - ics_tokens_revoked_total, ics_tokens_created_total
+- Rate limiting:
+ - Emisión de token /t web (en el bot) y /login (web).
+ - ICS por token/IP (p. ej., 4 req/min).
+- Caching ICS:
+ - ETag/Last-Modified y Cache-Control: public, max-age=300 (suave), para que los clientes no abusen.
+
+## 12) DevOps y despliegue
+
+- Entornos:
+ - WEB_BASE_URL, COOKIE_SECRET, SESSION_IDLE_TTL_MIN=120, ICS_HORIZON_MONTHS=12, ICS_RATE_LIMIT, etc.
+ - Reutilizar EVOLUTION_API_* donde aplique (si se consulta API desde web).
+- Build:
+ - SvelteKit con adapter-node; ejecución con Bun o Node en producción.
+- Reverse proxy:
+ - Un solo contenedor con proxy interno en Bun:
+ - /webhook y /metrics → bot (puerto interno 3007).
+ - Resto de rutas → web (puerto interno 3008, SvelteKit adapter-node).
+ - Passthrough explícito de /_app/* hacia la web (sin reescrituras ni catch-all que devuelva HTML).
+ - Asegurar Content-Type correcto para /_app/**/*.js (application/javascript) y /_app/**/*.css (text/css); no añadir nosniff en assets externos al HTML.
+ - El build de SvelteKit debe desplegarse completo (build/index.js + build/client) y mantenerse coherente con el HTML servido para evitar hashes huérfanos.
+ - Evitar cachear el HTML de la app en el proxy/CDN (o purgar tras cada deploy); los assets /_app/immutable pueden cachearse largo con immutable.
+ - CapRover debe exponer el puerto 3000 del contenedor (PORT).
+ - WEBHOOK_URL debe apuntar a https:///webhook (mismo dominio).
+- Schedulers:
+ - Permanecen en el proceso del bot. apps/web no arranca ningún scheduler.
+
+## 13) Plan de trabajo por etapas
+
+Etapa 0 — Preparación
+- Crear estructura apps/web (SvelteKit con adapter-node).
+- Configurar ESLint/Prettier y CI mínimos (lint, build).
+- Asegurar que la web abre la misma DB (PRAGMAs coherentes).
+
+Etapa 1 — Autenticación
+- Migraciones: web_tokens, web_sessions. — HECHO
+- Bot: emisión de token de 10 min (hash, rate limit) en /t web. — HECHO
+- Web: endpoint /login (GET intermedio + POST canje), cookie de sesión, redirect limpio; hooks de sesión con idle timeout 2h; gate de JS; CSRF checkOrigin desactivado por proxy interno. — HECHO
+- Páginas de error/expiración.
+
+Etapa 2 — Lectura de datos (MVP) — COMPLETADA: GET /api/me/tasks (orden por due_date asc con NULL al final; búsqueda con ESCAPE; filtros soonDays/dueBefore; paginación page/limit), GET /api/me/groups (contadores open/unassigned) y GET /api/groups/:id/tasks (unassignedFirst, onlyUnassigned, limit). UI: /app con filtros/búsqueda/paginación y /app/groups con prefetch "sin responsable".
+- APIs: /api/me/tasks, /api/me/groups, /api/groups/:id/tasks, /api/me/preferences (GET).
+- UI: “Mis tareas” y “Grupos” (solo lectura).
+- Orden por fecha de vencimiento asc (NULL al final), filtros básicos, búsqueda.
+
+Etapa 3 — Preferencias — COMPLETADA
+- APIs: GET/POST /api/me/preferences (validación y upsert; normalización HH:MM; conservación de hora al desactivar).
+- UI: /app/preferences con formulario (frecuencia y hora) y “próximo recordatorio” calculado en servidor, alineado con la TZ y la semántica del bot (weekly = lunes, weekdays = L–V).
+
+Etapa 4 — ICS
+- Migraciones: calendar_tokens.
+- APIs/UI Integraciones: autogeneración perezosa de feeds B (por usuario+grupo) y C (multigrupo opcional).
+- Endpoints ICS: personal, group (B), aggregate (C), con horizonte 12 meses y solo con due_date.
+- Revocación/rotación manual. Revocación automática al perder membresía (cron en bot o check dinámico).
+
+Etapa 5 — Pulido y observabilidad
+- Métricas, rate limits, ETag/Last-Modified en ICS.
+- CSP y cabeceras de seguridad.
+- UX: copiar enlace, avisos claros, vacíos de estado.
+
+Etapa 6 — Evolutivos (posteriores)
+- Edición de tareas (claim/unassign, fechas) — HECHO.
+- Búsqueda avanzada y atajos.
+- Notificaciones (SSE/polling).
+- Panel admin (opcional).
+
+## 14) Pruebas
+
+Implementado: suite web con bun:test y build programático (helpers en tests/web/helpers/server.ts). Los tests arrancan la web real (adapter-node) y ejercitan endpoints y páginas vía HTTP, usando una base SQLite temporal.
+
+- Unit:
+ - Emisión/canje de token, expiración, cookie y expiración por inactividad.
+ - Autorización de endpoints (gating, membresía).
+ - Generación ICS y filtros (due_date, horizonte).
+- Integración:
+ - Flujo end-to-end: /t web → /login → /app.
+ - Listado de feeds y autogeneración B.
+- Regresión:
+ - Aislar que schedulers solo corran en el bot.
+
+## 15) Riesgos y mitigaciones
+
+- Fuga de enlace de ICS:
+ - Tokens largos, revocación sencilla, métricas y rate-limit. Evitar ICS compartido de grupo. B y C permiten revocar por usuario.
+- Doble arranque de tareas en web:
+ - Flag para no iniciar schedulers en apps/web.
+- Concurrencia SQLite:
+ - WAL + busy_timeout ya configurados; operaciones ICE (lectura) mayoritarias en web.
+- Fricción de login:
+ - /t web es rápido; expiración 10 min adecuada; mensajes claros si expira.
+
+## 16) Variables de entorno (propuestas, apps/web)
+
+- WEB_BASE_URL
+- COOKIE_SECRET
+- SESSION_IDLE_TTL_MIN=120
+- ICS_HORIZON_MONTHS=12
+- ICS_RATE_LIMIT_PER_MIN=4
+- NODE_ENV / BUN_ENV
+- (Opcional) METRICS_ENABLED
+
+## 17) Métricas (nombres sugeridos)
+
+- web_tokens_issued_total
+- web_tokens_redeemed_total
+- web_login_success_total
+- web_login_failed_total
+- web_sessions_active
+- web_api_requests_total{route}
+- ics_tokens_created_total{type}
+- ics_tokens_revoked_total{type}
+- ics_requests_total{type}
+- ics_rate_limit_hits_total
+
+## 18) Plan UX/UI (detallado, sin dependencias externas)
+
+Objetivo
+- Disponer de una guía exhaustiva para diseñar e implementar la interfaz web usando SvelteKit/Svelte, sin dependencias externas de UI, asegurando consistencia, accesibilidad y un flujo de trabajo por etapas (vertical slices).
+
+18.1) Principios de diseño y restricciones
+- Sin dependencias externas: no Tailwind ni librerías de componentes. CSS moderno con variables y módulos Svelte.
+- Aprovechar SvelteKit:
+ - SSR por defecto, progressive enhancement en eventos/acciones.
+ - +page.svelte / +page.server.ts para data loading; endpoints +server.ts ya existentes.
+ - Stores de Svelte para estado global mínimo (toasts, sesión).
+- Mobile-first, responsive fluido; desktop con anchos máximos (contenedor ~960–1200px) y layout en 2 columnas donde aplique.
+- Accesibilidad AA: foco visible, roles ARIA en componentes custom, labels asociados, contraste >= 4.5:1.
+- Rendimiento: CSS mínimo crítico inline, diferir lo no esencial, listas paginadas; sin icon fonts (usar SVG inline).
+- Seguridad: estados de sesión claros; nunca exponer tokens; evitar “copiar URL” en texto plano (usar botón Copy).
+
+18.2) Lenguaje visual y Design Tokens
+- Tipografía: usar fuentes del sistema (Inter/SF Pro/Segoe UI/Roboto/Noto/Sans-Serif fallback).
+- Escala tipográfica: 12/14/16/20/24 px; line-height 1.4–1.6.
+- Espaciado: 4/8/12/16/24/32 px; grid de 8 px.
+- Radios: 6/8 px; sombra suave para elevaciones (header sticky, tarjetas).
+- Paleta (light/dark con prefers-color-scheme):
+ - Neutral: bg, surface, border, text, text-muted.
+ - Acentos: primary (acciones), danger (rotar/revocar), warning (pronto), success (ok).
+- Badges semánticos:
+ - Overdue: rojo.
+ - Due soon (≤3 días): ámbar.
+ - Unassigned: gris/azul neutro.
+- Tokens (variables CSS en :root):
+ - color-bg, color-surface, color-text, color-text-muted, color-border, color-primary, color-danger, color-warning, color-success
+ - radius-sm/md, shadow-sm/md, space-1..5
+- Modo oscuro: ajustar variables sin duplicar estilos.
+
+18.3) Accesibilidad (checklist)
+- Navegación por teclado completa; focus ring perceptible.
+- Contraste verificado para texto y controles (>=4.5:1).
+- Labels y aria-describedby en inputs; botones con aria-label si solo icono.
+- Estados y errores anunciados (role="status"/"alert" donde aplique).
+- Trampas de foco evitadas; orden lógico en DOM.
+- Tamaño táctil mínimo 44x44 px.
+
+18.4) Inventario de componentes (Design System v0)
+- Base
+ - Button (variants: primary/secondary/ghost/danger; tamaños sm/md; con/ sin icono).
+ - IconButton (solo icono, aria-label).
+ - TextField (búsqueda), TimeField HH:MM (validación simple).
+ - SegmentedControl (frecuencia: daily/weekly/weekdays/off).
+ - Select básico (nativo estilizado).
+ - Switch/Checkbox (para activar feed C).
+ - Badge (overdue/soon/default).
+ - Card (surface + padding + shadow).
+ - Pagination (prev/next + indicador página).
+ - Toast/Snackbar (store global; auto-dismiss; role="status").
+ - ConfirmDialog (portal sencillo con focus trap básico).
+ - Skeleton (rectángulos/filas).
+ - EmptyState y ErrorBanner.
+- Datos
+ - TaskItem (fila) con: [id], descripción, fecha (badge), grupo, asignación (solo lectura).
+ - GroupCard con nombre, contadores open/unassigned.
+ - FeedCard con nombre, descripción, botón Copiar y Rotar, estado (revocado/no disponible).
+- Utilidades
+ - CopyToClipboard (navigator.clipboard con fallback).
+ - RelativeDate / DueBadge (lógica de overdue/soon).
+ - VisuallyHidden (accesibilidad).
+ - AppShell (header con usuario/logout, contenedor principal).
+
+18.5) Patrones de interacción
+- Búsqueda: submit explícito o debounce 250–300 ms con actualización de query params; mantener estado al navegar atrás.
+- Filtros: chips/segmented con sync a URL (page reset a 1 cuando cambian).
+- Paginación: enlaces con URL (page, limit); accesible.
+- Formularios: usar fetch desde el cliente con progressive enhancement; validación en cliente (básica) + servidor (autoritativa).
+- Copiar: icono “copiar” con feedback de toast y aria-live.
+- Confirmaciones peligrosas: diálogo modal con foco dentro y acciones claras.
+- Estados: loading (skeletons), vacío (mensaje y CTA contextual), error (retry).
+
+18.6) IA y flujos por pantalla
+- /login
+ - Objetivo: canjear token con gate de interacción mínima.
+ - Contenido: mensaje, botón “Continuar”, estado token inválido/expirado con instrucciones /t web.
+ - Accesibilidad: botón enfocable, mensajes claros.
+- /app (Mis tareas)
+ - Controles: búsqueda texto, chips “Abiertas”, “Pronto (≤3 días)”, selector “Vencen antes de…” (3/7/14 días).
+ - Lista: TaskItem con fecha badge, grupo, asignación; paginación.
+ - Estados: vacío, sin resultados, error de carga.
+ - Mobile: lista de una columna; Desktop: contenido centrado con ancho máx; opcional 2 columnas si hay filtros persistentes.
+- /app/groups
+ - Grid de GroupCard (2–3 col en desktop, 1 en móvil).
+ - “Sin responsable” destacado sin límite, con botón “Reclamar”; ordenar tarjetas por cantidad de sin responsable; prefetch a /api/groups/:id/tasks?onlyUnassigned=1.
+ - Estados: sin grupos, error.
+- /app/preferences
+ - Frecuencia (Segmented), Hora (TimeField HH:MM).
+ - “Próximo recordatorio” calculado por servidor (mostrar string amigable e ISO en tooltip).
+ - Acciones: Guardar y Revertir; toasts en éxito/error.
+ - Validación: normalizar hora en cliente (HH:MM) y servidor.
+- /app/integrations
+ - Autogeneración perezosa de feeds B en la carga (el backend garantiza creación si falta).
+ - Tarjetas: Personal (mis tareas), Grupo (B) por cada grupo activo, Multigrupo (C) opcional con switch.
+ - Acciones: Copiar (URL oculta, se copia con click), Rotar (confirmación).
+ - Estados: sin grupos → mostrar solo Personal; feed revocado → indicador y opción recrear.
+ - Microcopy: guía breve (Google/Apple/Outlook), aviso privacidad.
+
+18.7) Contratos de datos (UI)
+- TaskItem
+ - id: number
+ - description: string
+ - due_date: string | null (YYYY-MM-DD)
+ - group: { id: string; name: string } | null
+ - assignees: string[] (ids normalizados)
+ - flags: { overdue: boolean; dueSoon: boolean }
+- TasksList meta
+ - page: number; limit: number; total: number
+- Group
+ - id: string; name: string
+ - counts: { open: number; unassigned: number }
+- Preferences
+ - freq: 'daily'|'weekly'|'weekdays'|'off'
+ - time: string | null (HH:MM)
+ - nextReminder: { human: string; iso: string | null }
+- Feed
+ - type: 'personal'|'group'|'aggregate'
+ - groupId?: string
+ - url?: string (solo UI, nunca persistida)
+ - created_at?: string; last_used_at?: string | null
+ - status?: 'active'|'revoked'|'unavailable'
+
+18.8) Arquitectura front (sin librerías externas)
+- Estructura sugerida en apps/web/src
+ - lib/ui/atoms: Button.svelte, IconButton.svelte, Badge.svelte, Skeleton.svelte, VisuallyHidden.svelte
+ - lib/ui/inputs: TextField.svelte, TimeField.svelte, SegmentedControl.svelte, Switch.svelte, Select.svelte
+ - lib/ui/feedback: Toast.svelte (+ store), ConfirmDialog.svelte, EmptyState.svelte, ErrorBanner.svelte
+ - lib/ui/layout: AppShell.svelte, Card.svelte, Pagination.svelte
+ - lib/ui/data: TaskItem.svelte, GroupCard.svelte, FeedCard.svelte
+ - lib/stores: toasts.ts, session.ts (mínimo, p. ej. userId)
+ - lib/styles: tokens.css (variables), base.css (reset + utilidades mínimas)
+ - routes/app/*: páginas; usar load con SSR y fetch interno
+- Theming y estilos
+ - tokens.css con variables; base.css con reset ligero (normalize reducido) y utilidades puntuales (sr-only, container, grid).
+ - Modo oscuro con prefers-color-scheme; clase .theme-dark opcional.
+- Iconos
+ - SVG inline en componentes; set mínimo (copy, rotate, search, warning, check, x).
+- Utilidades
+ - copyToClipboard util con fallback.
+ - date helpers en lib/utils/date.ts (formatos UI, dueSoon/overdue).
+
+18.9) Roadmap por etapas (2 semanas sugerido)
+- Día 1: Tokens y base.css; AppShell; definición final de contratos de datos por pantalla.
+ - Entregable: estilos base, header, contenedor, tipografía; documento de contratos de datos firmado.
+- Días 2–3: Componentes base (Button, TextField, Segmented, Badge, Card, Toast, Skeleton, EmptyState).
+ - Entregable: catálogo mínimo interactivo (página de sandbox oculta /app/_sandbox).
+- Días 4–5: Página /app (Mis tareas) end-to-end con APIs existentes (GET /api/me/tasks).
+ - Entregable: búsqueda, filtros, paginación, estados; lighthouse > 90 en móvil.
+- Día 6: /app/groups con GroupCard y prefetch de “sin responsable”.
+ - Entregable: grid responsive con contadores.
+- Día 7: /app/preferences (GET/POST) con vista de “próximo recordatorio”.
+ - Entregable: validación y toasts.
+- Días 8–9: /app/integrations UI completa (autogeneración perezosa, Copiar, Rotar con confirmación).
+ - Backend ICS puede avanzar en paralelo; usar mocks si falta endpoint.
+- Día 10: QA accesibilidad y responsive; pulido de microcopy; revisión con 1–2 usuarios internos.
+ - Entregable: checklist AA, correcciones.
+
+18.10) Criterios de aceptación UX
+- Navegación completa con teclado; foco visible.
+- Estados loading/vacío/error presentes y claros en todas las pantallas.
+- “Copiar enlace” funciona y anuncia feedback; Rotar pide confirmación y comunica impacto.
+- Preferencias reflejan correctamente TZ y el “próximo recordatorio”.
+- Rendimiento aceptable: TTI < 2s en 3G rápida para pantallas principales; CSS < 15KB inicial.
+
+18.11) Validación y métricas (sin dependencias)
+- Instrumentación mínima:
+ - web_ui_interaction_total{type='copy'|'rotate'|'save_prefs'|'search'} mediante el sistema de métricas existente (si expuesto al front vía endpoint).
+ - Alternativa: logs discretos en servidor al invocar endpoints relevantes.
+- Feedback usuario:
+ - Recoger observaciones de claridad en /app/integrations y /login.
+
+18.12) Riesgos y mitigaciones
+- Falta de librerías UI → más trabajo inicial: mitigado con un Design System v0 bien delimitado.
+- Desalineación back/front → trabajar en vertical por pantalla con contratos de datos acordados.
+- Accesibilidad ignorada al final → checklist desde el inicio y QA día 10.
+
+18.13) Notas de implementación (guía sin código)
+- Mantener estilos de componentes scopeados por defecto de Svelte.
+- Evitar CSS complejo; preferir componentes pequeños y composables.
+- Sin icon fonts ni frameworks; SVG inline o componentes Svelte de icono.
+- Usar acciones de Svelte (use:) para patrón copy-to-clipboard y focus-trap del modal.
+- Sin degradación de SEO relevante (app privada); aún así, SSR establece base de contenido.
+
+Con esto, el equipo puede trabajar por etapas, validar tempranamente con usuarios y mantener coherencia visual sin dependencias externas.
+
+Fin del documento.
diff --git a/docs/plan-onboarding-usuarios.md b/docs/plan-onboarding-usuarios.md
new file mode 100644
index 0000000..514d538
--- /dev/null
+++ b/docs/plan-onboarding-usuarios.md
@@ -0,0 +1,153 @@
+# Plan de Onboarding de Usuarios y Resolución de Alias (JID opaco → número)
+
+Resumen y diagnóstico (basado en el código actual)
+- Fuentes automáticas actuales de aprendizaje de alias (sin intervención de usuarios):
+ 1) Mensajes en grupos: en src/server.ts, si participantAlt y participant difieren, se hace IdentityService.upsertAlias(participant, participantAlt, 'message.key'), mapeando un @lid “opaco” al JID real @s.whatsapp.net.
+ 2) Sincronización de miembros: en src/services/group-sync.ts, si el payload trae p.id y p.jid, se hace upsertAlias(id, jid, 'group.participants') y además se asegura el usuario con ensureUserExists.
+ 3) Actualizaciones de contactos/chats: en src/services/contacts.ts, si se recibe objeto con id=@lid y jid=@s.whatsapp.net, se hace upsertAlias(alias, jid, 'contacts.update').
+- Alta de usuarios sin DM: src/services/group-sync.ts::reconcileGroupMembers llama ensureUserExists(userId) para cada miembro activo; si tenemos número, el usuario “existe” aunque no haya enviado DM.
+- Normalización robusta: utils/whatsapp.normalizeWhatsAppId elimina dominio y sufijo “:xx”, quedando el número limpio si venía en el JID.
+- Problema detectado: en src/services/command.ts, al parsear menciones, si una mención llega como @lid sin alias aún, se descarta (aunque a veces podría venir como JID real). Resultado: “se puede crear tarea pero no asignar responsables” en algunos casos, especialmente con usuarios “silenciosos” (no escriben en grupo) cuando la API no aporta número en ninguno de los eventos.
+
+Objetivo
+- Minimizar fricción y tiempo hasta poder asignar y autenticar: que los usuarios queden asignables con la mínima acción (idealmente sin DM).
+- Evitar spam y códigos por-usuario. Publicar, solo si hace falta, un único mensaje por grupo con enlace wa.me.
+- Cubrir casos de usuarios “silenciosos” (no escriben en grupo) garantizando una vía de mapeo fiable (DM “hola”).
+
+Estrategia general
+- Exprimir al máximo el aprendizaje automático que ya tenemos (participants y contacts).
+- Ajustar CommandService para no descartar menciones válidas con números reales.
+- Medir cobertura y publicar un único mensaje por grupo con wa.me únicamente si faltan usuarios por resolver; con cooldown.
+- DM “activar” como último recurso confiable para cerrar huecos de usuarios silenciosos; considerar “código por grupo” solo como fallback extremo si una instancia no correlaciona jamás sin token.
+
+Fases
+
+Fase A0 — Verificación y observabilidad (rápida, sin UX visible)
+- Estado: completada (deploy tras commit d25efb0).
+- Métricas nuevas:
+ - alias_coverage_ratio{group_id}: gauge con el porcentaje aproximado de miembros activos con número resoluble (o alias).
+ - onboarding_prompts_sent_total / onboarding_prompts_skipped_total: counters para controlar ruido.
+ - onboarding_assign_failures_total: counter de menciones no resolubles al crear tareas.
+- Logs de desarrollo (NODE_ENV=development): en src/server.ts, comparar participant vs participantAlt (normalizados) y mentionedJid normalizados para comprobar la frecuencia de correlación automática en tu instancia Evolution.
+
+Fase A1 — Aprendizaje “agresivo” al entrar en grupos (Completada)
+- Al recibir groups.upsert (src/server.ts):
+ - syncGroups → refreshActiveGroupsCache → syncMembersForActiveGroups (ya implementado).
+ - Efectos:
+ - Crea usuarios con ensureUserExists(userId).
+ - Si el payload incluye id + jid, rellena alias automáticamente.
+- Mantener activo ContactsService.updateFromWebhook para capturar correlaciones adicionales en los primeros minutos.
+
+Fase A2 — Ajuste clave sin fricción: conservar menciones con números (Completada)
+- Estado: completada (deploy tras commit 8b1af56).
+- En src/services/command.ts, al construir los candidatos a assignees:
+ - Si normalizeWhatsAppId(token) produce dígitos y resolveAliasOrNull devuelve null, CONSERVAR ese número (no descartarlo).
+ - Asegurar ensureUserExists para esos IDs conservados antes de usarlos.
+ - Filtrar CHATBOT_PHONE_NUMBER para evitar autoasignaciones al bot.
+- Métrica: se incrementa onboarding_assign_failures_total con labels {group_id, source, reason} cuando una mención/token no es resoluble ni plausible.
+- Configuración: ONBOARDING_FALLBACK_MIN_DIGITS (por defecto 8) controla la longitud mínima para considerar un número “plausible”.
+- Efecto: reduce drásticamente los fallos de asignación por mención sin necesidad de DM.
+
+Fase A3 — Mensaje único por grupo con wa.me (Completada)
+- Condición de publicación:
+ - Tras A1 y un breve grace period (≈1–2 min) para permitir contacts/chats updates, calcular alias_coverage_ratio{group_id}.
+ - Si cobertura = 100% → NO publicar.
+ - Si cobertura < 100% → publicar UNA vez un mensaje por grupo con el texto:
+ - “Para poder asignarte tareas y acceder a la web, envía ‘activar’ al bot por privado. Solo hace falta una vez. Enlace: https://wa.me/”
+- Control de ruido:
+ - Persistir timestamp de último envío por grupo (cooldown, p. ej., 7 días) y re-publicar solo si entran nuevos miembros sin resolver tras el cooldown.
+- Fallback extremo (probablemente no necesario con tu stack):
+ - Si en una instancia concreta el DM “hola” no basta para correlacionar, usar “código por grupo” como texto pre-rellenado en wa.me: “alta XYZ123” (único por grupo, nunca por-usuario), almacenado con caducidad. Aplicarlo solo si la métrica demuestra que el caso existe.
+
+Fase A4 — Asistentes “just-in-time” y UX mínima (Completada)
+- Si una asignación falla por mención no resoluble:
+ - Enviar DM al asignador (ResponseQueue) con: “No puedo asignar a X aún. Pídele que toque este enlace y diga ‘activar’: https://wa.me/”.
+- Primer DM “activar” de un usuario:
+ - Asegurar ensureUserExists y responder con: “Listo, ya puedes reclamar/ser responsable en: …”.
+- Opcional web: si el usuario llega sin estar identificado, mostrar banner con botón a wa.me “activar”.
+
+Fase A5 — Optimizaciones post-A1 (pendiente)
+- Optimizar encadenado tras groups.upsert para sincronizar solo los grupos afectados cuando el payload lo permita.
+- Añadir debounce/backoff por grupo (2–5 s) para coalescer ráfagas de upserts en corto intervalo.
+- Añadir tests: uno que valide el filtrado a “grupos afectados” y otro que verifique que el debounce evita ejecuciones duplicadas dentro de la ventana.
+
+Fase Final — Pruebas E2E
+- Objetivo: validar end-to-end en un entorno de staging con Evolution API que los prompts A3 funcionan sin efectos secundarios.
+- Casos a verificar:
+ - Envío al grupo (@g.us) mediante sendText: que el backend acepte recipient con @g.us y el mensaje se entregue.
+ - Publicación condicional: cobertura < 100% tras el grace → se envía; cobertura = 100% → se omite; cooldown activo → se omite.
+ - Gating: en modo enforce, grupos no allowed → se omite.
+ - Configuración: sin CHATBOT_PHONE_NUMBER o ONBOARDING_PROMPTS_ENABLED=false → se omite.
+ - Métricas: alias_coverage_ratio, onboarding_prompts_sent_total y onboarding_prompts_skipped_total con su reason se actualizan.
+- Preparación recomendada:
+ - Instancia Evolution apuntando a un grupo de pruebas; CHATBOT_PHONE_NUMBER configurado; METRICS_ENABLED=true.
+ - Reducir ONBOARDING_GRACE_SECONDS y ONBOARDING_COOLDOWN_DAYS para acelerar validación.
+ - Confirmar que ResponseQueue.process está activo y que los workers pueden enviar.
+
+Criterios de aceptación
+- p95 del tiempo desde que un usuario toca el enlace a quedar asignable < 1 minuto.
+- En la mayoría de grupos no se publica ningún mensaje (cobertura ≈100% tras primer sync + contacts).
+- Caída significativa de onboarding_assign_failures_total respecto al baseline.
+- Sin spam: un único mensaje por grupo y cooldown aplicado.
+
+Archivos a ver/editar/crear
+
+A) Núcleo bot
+- src/services/command.ts (EDITAR)
+ - Puntos: construcción de mentionsNormalizedFromContext y normalizedFromAtTokens.
+ - Cambio: fallback a número normalizado cuando resolveAliasOrNull no resuelva; ensureUserExists; filtrar CHATBOT_PHONE_NUMBER; incrementar onboarding_assign_failures_total cuando una mención no sea resoluble en absoluto.
+- src/services/group-sync.ts (EDITAR)
+ - Tras reconcileGroupMembers, computar cobertura aproximada (miembros activos con número conocido / miembros activos totales).
+ - Exponer alias_coverage_ratio{group_id} vía Metrics.set. Si coverage < 100% y cooldown vencido → disparar encolado de un mensaje único por grupo (ResponseQueue).
+ - Método util: getActiveGroupIdsForUser, isSnapshotFresh, etc., ya existen; añadir tracking de onboarding_prompted_at (ver persistencia).
+- src/server.ts (EDITAR mínimo)
+ - Logs de desarrollo comparando participant vs participantAlt y mentionedJid normalizados para verificar correlación automática.
+ - Opcional: hook tras groups.upsert para forzar el chequeo de cobertura post-sync (o dejarlo en scheduler de miembros).
+- src/services/contacts.ts y src/services/identity.ts (VER)
+ - Ya aportan alias automáticamente; no requieren cambios inmediatos.
+- src/services/response-queue.ts (VER/EDITAR si hiciera falta)
+ - Asegurar que puedes encolar mensajes hacia group_id@g.us sin cambios (parece OK con sendText si el backend lo admite). Añadir, si quieres, etiquetas/metadata para identificar “onboarding”.
+
+B) Persistencia
+- Opción simple recomendada: columna en groups
+ - Nueva columna: groups.onboarding_prompted_at TEXT NULL.
+ - Lógica: publicar si coverage < 100% y (onboarding_prompted_at IS NULL o han pasado ≥ X días).
+- Alternativa: tabla dedicada group_onboarding (group_id PK, last_prompt_at, last_coverage, last_pending_count, last_sent_by).
+- Archivos:
+ - src/db/migrations/index.ts (EDITAR): añadir migración para onboarding_prompted_at (o tabla nueva).
+ - src/db.ts (VER): no requiere cambios, migrador ya está integrado.
+
+C) Mensajería y copy
+- Texto base:
+ - “Para poder asignarte tareas y acceder a la web, envía ‘hola’ al bot por privado. Solo hace falta una vez. Enlace: https://wa.me/”
+- Fallback extremo (solo si es necesario, ver A3):
+ - “… o envía ‘alta XYZ123’ si el enlace no funciona.”
+- Dónde generarlo: en GroupSyncService (o un OnboardingService nuevo) cuando coverage < 100% y cooldown vencido; encolar con ResponseQueue para recipient = group_id@g.us.
+
+D) UI web (opcional a posteriori)
+- Banner SSR-safe cuando App.Locals.userId no esté disponible: botón wa.me con “hola”; desaparecer al resolver.
+
+Métricas propuestas
+- alias_coverage_ratio{group_id} (gauge).
+- onboarding_prompts_sent_total / onboarding_prompts_skipped_total (counters).
+- onboarding_assign_failures_total (counter).
+- identity_alias_upserts_total, identity_alias_resolved_total / identity_alias_unresolved_total (ya existen).
+- time_to_link_p95 (opcional; aproximable por “primer avistamiento” → “primer DM/alias resuelto”).
+
+Caveats y buenas prácticas
+- No spamear: un único mensaje por grupo + cooldown; nada si coverage=100%.
+- Privacidad: DM “hola” iniciado por el usuario; no mostrar datos sensibles en grupos.
+- Entornos de test: suprimir publicación y logs invasivos; respetar NODE_ENV='test'.
+- Resiliencia: si Evolution deja de enviar participantAlt/p.jid, el flujo de DM cubre a los silenciosos; si los envía, el DM apenas será necesario.
+
+Checklist de ejecución
+- A0: Añadir métricas y logs de verificación (dev).
+- A1: Confirmar que el sync de miembros corre al entrar; mantener contacts.update. (hecho)
+- A2: Ajuste en CommandService (fallback a número + ensureUserExists + métricas de fallo).
+- A3: Publicación condicional de mensaje por grupo con cooldown + persistencia mínima.
+- A4: DM “just-in-time” al asignador ante fallo de mención + confirmación al primer DM de usuario.
+
+Archivos adicionales que podríamos necesitar añadir al chat en la implementación
+- src/utils/whatsapp.ts (para confirmar normalizeDigits si lo reutilizamos en copy/URLs).
+- apps/web/src/app.d.ts (si añadimos banner basado en session/locals).
+- tests/unit/* y tests/web/* (para cubrir la nueva lógica de fallback y métricas).
diff --git a/docs/plan-trabajo-25-10-18.md b/docs/plan-trabajo-25-10-18.md
new file mode 100644
index 0000000..7fa55e6
--- /dev/null
+++ b/docs/plan-trabajo-25-10-18.md
@@ -0,0 +1,100 @@
+# Plan somero de cierre de rama — 25-10-18
+
+Objetivo
+- Cerrar esta rama asegurando funcionalidad clave, fiabilidad percibida y coherencia de UX, para mergear con main con confianza.
+
+Criterios generales de “listo”
+- No romper flujos existentes.
+- Feedback claro en interacciones sin cambio visual evidente.
+- Estado de UI estable (sin saltos de scroll ni pérdidas de colapso).
+- Cobertura mínima en tests para los cambios críticos.
+
+Bloque 1: Bloqueantes para merge (función y confianza)
+1) Feeds de calendario (multiusuario) (Completada)
+ - Hipótesis: la UI no recibe token en no-admin o no refresca tras rotar; posible gating de backend por rol/sesión.
+ - Señales de listo:
+ - No-admin ve/usa su URL tras rotar; .ics responde 200 con contenido válido.
+ - Rotar invalida el token anterior (URL vieja deja de servir).
+ - Tests cubren 2 no-admin y 1 admin.
+
+2) Copiar y Rotar (feedback) (Completada)
+ - Hipótesis: botones funcionan de forma intermitente y sin feedback; usar toasts existentes.
+ - Señales de listo:
+ - Copiar: “URL copiada” o “No se pudo copiar” según resultado.
+ - Rotar: “Feed de calendario rotado” y la UI actualiza la URL inmediatamente.
+ - Fallback si Clipboard falla.
+
+3) Estado de colapso y scroll al completar tareas (Completada)
+ - Hipótesis: rerender global resetea colapso y reposiciona scroll.
+ - Señales de listo:
+ - Completar/descompletar no altera colapso ni posición de scroll.
+ - Colapso persiste por groupId (localStorage) y tras refresh.
+
+4) Validación: no permitir tareas sin descripción
+ - Señales de listo:
+ - Front bloquea envío vacío con mensaje claro.
+ - API devuelve 400 con error entendible.
+
+5) Modo oscuro en página intermedia de acceso
+ - Señales de listo:
+ - Respeta prefers-color-scheme y/o preferencia guardada.
+ - Sin parpadeo ni estilos rotos.
+
+Bloque 2: Refinamientos UX de bajo riesgo
+1) Mensajes para “clics silenciosos” (Completada)
+ - Criterio: usar toast solo cuando no hay cambio visible inmediato (copiar, rotar, acciones async).
+ - Señales de listo: catálogo simple de interacciones con su feedback (info/success/error).
+
+2) Bloquear acciones alrededor durante edición
+ - Señales de listo:
+ - Estado “editing” deshabilita acciones peligrosas cercanas (aria-disabled).
+ - Salir de edición restaura interactividad sin perder foco ni contenido.
+
+3) Animaciones sutiles (colapsar/expandir) (Completada)
+ - Señales de listo:
+ - Transiciones 150–200 ms; respetar prefers-reduced-motion.
+ - Sin jank en listas largas.
+
+Bloque 3: Navegación y coherencia visual (mini exploración)
+1) Unificar tabs entre desktop y mobile (top siempre)
+ - Idea: tabs arriba en ambos; mini barra superior con sesión y logout.
+ - Señales de listo:
+ - Variante elegida (wireframe simple).
+ - Tokens de densidad definidos (tipografía/espaciado) y patrón de iconografía coherente.
+
+2) Personalidad y densidad
+ - Pistas rápidas:
+ - Tipografías y espaciado más compactos en listas.
+ - Uso consistente del color de grupo.
+ - Etiquetas concisas e iconos de apoyo.
+ - Señales de listo: 2 pantallas “antes/después” aprobadas, cambios acotados a tokens/variables.
+
+Bloque 4: Integridad de datos y ciclo de vida
+1) Eliminación de grupo (Completada)
+ - Propuesta: borrado duro con ON DELETE CASCADE para tareas, asignaciones y tokens; invalidar feeds asociados.
+ - Señales de listo:
+ - Contrato decidido y documentado.
+ - Constraints y tests que demuestran que tareas y feeds desaparecen de UI y endpoints.
+
+2) Crear tareas desde la web (post-merge, MVP)
+ - Alcance mínimo: descripción obligatoria, fecha opcional, grupo opcional, auto-asignación.
+ - Señales de listo: validaciones consistentes con el bot.
+
+Riesgos y verificaciones rápidas
+- Sesión/locals.userId inconsistentes en no-admin → verificar endpoints de rotar/listar tokens.
+- Invalidez de tokens viejos tras rotar → asegurar revocación real y no solo visual.
+- Re-renders que destruyen estado local → preservar claves de lista, actualizaciones puntuales.
+
+Métricas y tests mínimos
+- Tests web para: rotar token personal en no-admin (200 .ics), copiar con éxito (toast), completar tarea sin perder estado.
+- Unit tests: validación de descripción en API.
+- Smoke: modo oscuro en página intermedia.
+
+Siguientes pasos inmediatos (quick wins)
+- Añadir toasts a Copiar/Rotar y verificar copyToClipboard con fallback.
+- Persistir colapso por groupId en localStorage y restaurar en montaje.
+- Revisar flujo de tokens ICS con dos usuarios no-admin y un admin (rotar → validar .ics nuevo y caducidad del viejo).
+
+Notas
+- La navegación unificada y la “personalidad visual” se abordan después de merge si crecen en alcance.
+- Priorizar siempre claridad y facilidad de uso sobre adorno; la forma sigue a la función.
diff --git a/docs/plan-web-fases.md b/docs/plan-web-fases.md
new file mode 100644
index 0000000..a2cae65
--- /dev/null
+++ b/docs/plan-web-fases.md
@@ -0,0 +1,374 @@
+# Plan de trabajo — Web (SvelteKit) orientado a móvil y acciones de tareas
+
+Contexto y objetivos
+- Reforzar la utilidad móvil, mostrar grupos y responsables correctamente, y simplificar acciones prioritarias (completar/undo) manteniendo seguridad (gating) alineada con el backend.
+- Mantener el diseño sin truncar descripciones; ofrecer orden por fecha o por grupo; y mostrar TODAS las abiertas en /app/groups.
+- Evitar regresiones, con cambios iterativos y fácilmente revertibles.
+
+Decisiones globales
+- No truncar descripciones en TaskItem; envolver en varias líneas. Acciones secundarias se desplazan a una segunda línea/menú contextual en móvil.
+- “Completar” se promueve como acción principal (checkbox/botón destacado). Incluir “Deshacer completar” con ventana configurable (24h por defecto).
+- “Mis tareas” tendrá dos secciones:
+ 1) Asignadas a mí (todas abiertas).
+ 2) Sin responsable de mis grupos (todas abiertas).
+- “Grupos” mostrará secciones por grupo, con TODAS las tareas abiertas, expandibles/colapsables, y con toggle “Unassigned first”.
+- Búsqueda por texto desaparece de la UI (se reserva a futuro si fuese necesaria).
+- Orden conmutado por el usuario: Fecha (due asc, NULL al final) | Grupo (agrupación por grupo y dentro por fecha).
+- Seguridad: endurecer PATCH de tareas sin grupo para exigir ser responsable (y opcionalmente creador si el esquema lo permite).
+- Ventana de uncomplete (deshacer completar): configurable por variable de entorno UNCOMPLETE_WINDOW_MIN (1440 por defecto).
+
+Medición de impacto y riesgos
+- N+1 inicial en /app (al agregar “sin responsable” de todos los grupos) tolerable en MVP, mitigado luego con endpoint “overview”.
+- Posible crecimiento de DOM en /app/groups: se mitiga con secciones colapsables.
+- Cambios de gating en PATCH requieren revisar casos edge (tareas personales sin asignados previos).
+
+Archivos ya disponibles para edición (confirmados)
+- UI:
+ - apps/web/src/lib/ui/layout/AppShell.svelte
+ - apps/web/src/lib/ui/data/TaskItem.svelte
+ - apps/web/src/lib/ui/data/GroupCard.svelte
+ - apps/web/src/routes/app/+page.svelte
+ - apps/web/src/routes/app/+page.server.ts
+ - apps/web/src/routes/app/groups/+page.svelte
+ - apps/web/src/routes/app/groups/+page.server.ts
+- API:
+ - apps/web/src/routes/api/me/tasks/+server.ts
+ - apps/web/src/routes/api/me/groups/+server.ts
+ - apps/web/src/routes/api/groups/[id]/tasks/+server.ts
+ - apps/web/src/routes/api/tasks/[id]/+server.ts (PATCH)
+ - apps/web/src/routes/api/tasks/[id]/claim/+server.ts
+ - apps/web/src/routes/api/tasks/[id]/unassign/+server.ts
+ - apps/web/src/routes/api/tasks/[id]/complete/+server.ts
+- Infra web:
+ - apps/web/src/lib/server/env.ts
+ - apps/web/src/lib/server/db.ts
+- Núcleo:
+ - src/db.ts
+
+Archivos que solicitaremos añadir al chat cuando toque editar
+- Documentación: docs/operations.md (para documentar UNCOMPLETE_WINDOW_MIN y notas UX).
+- Tests: tests/web/* y tests/unit/* (para cubrir uncomplete y cambios de gating).
+- (Opcional) Migraciones: src/db/migrations/* si llegamos a necesitar created_by en gating de PATCH.
+
+Fase 1 — UX base en páginas y TaskItem (sin backend nuevo) — Estado: Completada
+Objetivos
+- /app: añadir sección “Sin responsable de mis grupos”, quitar búsqueda, añadir conmutador de orden (Fecha | Grupo).
+- /app/groups: mostrar TODAS las tareas por grupo en secciones expandibles/colapsables; toggle “Unassigned first”.
+- TaskItem: mostrar chip de grupo; mantener descripción sin truncar; reorganizar acciones para móvil.
+Archivos a editar
+- apps/web/src/routes/app/+page.server.ts
+ - Cargar /api/me/tasks (open, asignadas a mí).
+ - Cargar /api/me/groups; para cada grupo, solicitar /api/groups/:id/tasks?onlyUnassigned=true&limit=0 y reunir la lista “unassigned”.
+ - Construir map {groupId → groupName} para la UI.
+ - Gestionar query param order=due|group.
+- apps/web/src/routes/app/+page.svelte
+ - Renderizar dos secciones (asignadas y sin responsable). Añadir conmutador de orden. Eliminar campo de búsqueda.
+- apps/web/src/routes/app/groups/+page.server.ts
+ - En vez de previews, cargar /api/groups/:id/tasks?limit=0 para cada grupo (todas abiertas).
+ - Permitir query unassignedFirst=true (por grupo o global).
+- apps/web/src/routes/app/groups/+page.svelte
+ - Reemplazar cuadrícula de GroupCard por secciones por grupo (cabecera con +/– para colapsar).
+ - Dentro de cada sección, lista de TaskItem reutilizando las mismas acciones.
+- apps/web/src/lib/ui/data/TaskItem.svelte
+ - Mostrar chip con el nombre del grupo si group_id != null; “Personal” si no tiene grupo (usar map pasado desde el server o prop groupName).
+ - Promover “Completar” visualmente (sin cambiar aún la API).
+- apps/web/src/lib/ui/data/GroupCard.svelte
+ - Mantener para posibles resúmenes; la página de grupos dejará de usarla de forma principal.
+Decisiones
+- Orden “por grupo” inicialmente puede agruparse en cliente para el agregado “sin responsable”; “asignadas” ya vienen por fecha. En fases posteriores, endpoint overview dará orden estable en servidor.
+
+Fase 2 — Backend: Uncomplete (24h configurable) + seguridad de PATCH — Estado: Completada
+Objetivos
+- Añadir POST /api/tasks/:id/uncomplete (idempotente) con gating simétrico a /complete y ventana configurable.
+- Endurecer PATCH /api/tasks/:id para tareas sin group_id: exigir que el usuario sea asignado (y, si luego confirmamos schema, permitir también si created_by = usuario).
+Archivos a editar/crear
+- apps/web/src/lib/server/env.ts
+ - Añadir export const UNCOMPLETE_WINDOW_MIN = ... (leer de env, default 1440). Exponer helper uncompleteWindowMs si conviene.
+- apps/web/src/routes/api/tasks/[id]/uncomplete/+server.ts (nuevo)
+ - Validar sesión, id, existencia de tarea.
+ - Gating:
+ - Con group_id: miembro activo de grupo allowed.
+ - Sin group_id: debe estar asignado a la tarea.
+ - Validar ventana: completed=1 y completed_at >= now - UNCOMPLETE_WINDOW_MIN.
+ - UPDATE: completed=0, completed_at=NULL (y opcional completed_by=NULL).
+ - Respuesta: status ('updated'|'already'|'not_found'|'forbidden'), tarea resultante.
+- apps/web/src/routes/api/tasks/[id]/+server.ts (PATCH)
+ - Añadir chequeo cuando group_id IS NULL: exigir que exista asignación del user sobre la tarea (alineado con complete/unassign).
+- UI
+ - apps/web/src/lib/ui/data/TaskItem.svelte — añadir acción “Deshacer completar” (usando el nuevo endpoint) y toast con feedback.
+ - apps/web/src/routes/app/+page.svelte — en la sección “Completadas (24 h)” añadir botón “Deshacer completar”.
+Documentación y configuración (solicitaremos permiso para editar)
+- docs/operations.md — documentar UNCOMPLETE_WINDOW_MIN (minutos; default 1440), semántica e idempotencia.
+
+Fase 3 — Navegación móvil (barra de pestañas) y jerarquía de acciones — Estado: Completada
+Objetivos
+- Evitar que el header rebose en móvil. Añadir barra inferior con iconos (tabs) para Tareas, Grupos, Integraciones (calendario), Preferencias (alarma). Reducir peso visual de “Integraciones” en móvil.
+- Consolidar “Completar” como acción primaria (checkbox/botón destacado); otras acciones (Reclamar/Soltar/Editar/Fecha) en segunda línea o menú contextual.
+Archivos a editar/crear
+- apps/web/src/lib/ui/layout/AppShell.svelte
+ - Añadir barra inferior sticky (solo en viewport ≤768px), con safe-area, iconos (SVG inline o emojis inicialmente), aria-labels y foco accesible.
+ - Mantener header para desktop.
+- (Opcional) apps/web/src/lib/ui/icons/* (nuevos)
+ - CalendarIcon.svelte, AlarmIcon.svelte, TasksIcon.svelte, GroupsIcon.svelte (si prefieres SVGs en vez de emojis).
+- apps/web/src/lib/ui/data/TaskItem.svelte
+ - Ajustar layout mobile-first: acciones secundarias y espaciado compacto.
+
+Resultado (implementado)
+- En móvil (≤768px):
+ - Header de desktop oculto; tabbar inferior con 5 pestañas: Tareas (✅), Grupos (👥), Recordatorios (⏰), Calendarios (📅) y Salir (🚪, POST).
+ - Barra superior mínima con título dinámico (“Tareas”, “Grupos”, “Recordatorios”, “Calendarios”), altura 24px y safe-area superior.
+ - Iconografía con emojis; icono + texto hasta 768px y solo icono en ≤480px.
+ - Safe-area inferior respetado y offset de Toast ajustado para no solapar la tabbar.
+- En desktop (>768px):
+ - Header visible con navegación renombrada/reordenada: Tareas / Grupos / Recordatorios / Calendarios.
+- Accesibilidad:
+ - aria-labels en pestañas y logout, estado activo visible, orden de tabulación coherente.
+- TaskItem:
+ - “Completar/Deshacer” promovido como acción primaria; acciones secundarias (Reclamar/Soltar, Editar, Fecha) en segunda línea compacta en móvil.
+ - Descripciones sin truncar, manteniendo legibilidad.
+- Integración:
+ - Cambios aplicados en AppShell y Toast, evitando solapes y reservando espacio en main.
+
+Fase 4 — Optimización: endpoint “overview” y orden en servidor — Estado: Completada
+Objetivos
+- Evitar N peticiones en /app para el bloque “sin responsable”.
+- Servir orden “por grupo” ya resuelto en servidor.
+Archivos a crear/editar
+- apps/web/src/routes/api/me/tasks/overview/+server.ts (nuevo)
+ - Respuesta: { assigned: Task[], unassigned: Task[] } (open).
+ - Params: order=due|group_then_due (por defecto due).
+ - Cada Task incluye: id, description, due_date, group_id, group_name, display_code, assignees[].
+ - Gating: igual que /api/me/tasks.
+- apps/web/src/routes/api/me/tasks/+server.ts
+ - (Opcional) Añadir order=group_then_due y group_name via JOIN; mantener compatibilidad con tests existentes.
+- apps/web/src/routes/app/+page.server.ts
+ - Consumir overview para reducir llamadas y aplicar el orden de servidor.
+
+Resultado (implementado)
+- Endpoint GET /api/me/tasks/overview creado.
+- Devuelve assigned y unassigned (abiertas) con order=due|group_then_due (mapeo desde la UI: group → group_then_due).
+- En cada tarea: id, description, due_date, group_id, group_name (null en personales), display_code, assignees[] (vacío en unassigned).
+- Gating aplicado: assigned según /api/me/tasks; unassigned solo de grupos allowed con membresía activa del usuario; exclusión de personales en unassigned.
+- /app/+page.server.ts consume overview para “sin responsable” y elimina el N+1; se mantiene /api/me/tasks para “Mis tareas (abiertas)” con su paginación actual.
+- Respuestas con cache-control: no-store.
+
+Fase 5 — Responsables: conteo, marca “tú” y popover con wa.me — Estado: Completada
+Objetivos
+- Mostrar de forma compacta cuántas personas están asignadas; marcar si el usuario actual está entre ellas; listar números y permitir mensaje directo (wa.me) bajo demanda.
+Archivos a editar/crear
+- apps/web/src/lib/ui/data/TaskItem.svelte
+ - Badge “Responsables: n” + “tú” si corresponde; al pulsar, abrir popover/modal con lista.
+- apps/web/src/lib/ui/feedback/Popover.svelte (nuevo) o reutilizar un modal ligero existente
+ - Accesibilidad: rol="dialog", focus trap, cierre con ESC.
+- (Opcional) apps/web/src/lib/utils/phone.ts (nuevo)
+ - Helpers para abreviar números y construir URL segura wa.me.
+
+Resultado (implementado)
+- TaskItem: badge con conteo e indicador “tú”; en ausencia de responsables se muestra botón deshabilitado con icono 🙅.
+- Popover accesible (rol="dialog", focus trap, cierre con ESC, restauración de foco) y compatible con SSR.
+- Enlaces directos a WhatsApp usando wa.me/, con normalización de números.
+- Unificación de UI en escritorio y móvil.
+- No se requirieron cambios de backend; los endpoints ya devolvían assignees[].
+
+Fase 6 — Pulido y peso visual de “Integraciones” — Estado: Completada
+Objetivos (cumplidos)
+- Mantener “Integraciones” (renombrada a “Calendarios”) accesible pero con menor jerarquía en móvil.
+- Ajustar densidad, estados vacíos y recordar colapsado por grupo en localStorage.
+
+Decisiones aplicadas y resultado
+- Etiqueta y jerarquía:
+ - La pestaña se renombra a “Calendarios” en navegación de escritorio y tabbar móvil; títulos y aria-labels actualizados.
+ - Atenuación en móvil cuando inactiva; estado activo mantiene color primario y foco visible.
+- Safe-areas y solapes:
+ - Offsets en main y Toast para no solapar con la tabbar; sticky de topbar móvil verificado.
+- Estados vacíos:
+ - Opción B aplicada: mensajes con pista de acción (“Crea o reclama…” / “Crea una nueva o invita…”).
+- Persistencia de colapsado por usuario en /app/groups:
+ - Clave localStorage: groupsCollapsed:v1:{userId}.
+ - Por defecto: abiertos los grupos con al menos una tarea abierta; colapsados los que no tienen tareas abiertas.
+ - Limpieza de IDs obsoletos; restauración en onMount, SSR-safe (sin parpadeos apreciables).
+- Accesibilidad:
+ - Foco visible en tabs; uso de / con estado coherente con aria-expanded implícito.
+
+Archivos editados
+- apps/web/src/lib/ui/layout/AppShell.svelte (renombrado a “Calendarios”, atenuación móvil, offsets).
+- apps/web/src/routes/app/groups/+page.svelte (persistencia de colapsado por usuario + defaults basados en tareas).
+- apps/web/src/routes/app/+page.svelte (textos de estados vacíos opción B).
+- apps/web/src/lib/ui/feedback/Toast.svelte (offset móvil contra tabbar).
+
+Criterios de aceptación (OK)
+- /app muestra dos secciones con todas las tareas requeridas; sin truncar descripciones; orden conmutado.
+- /app/groups muestra todas las tareas abiertas por grupo; secciones colapsables; “Unassigned first” operativo.
+- Completar y Deshacer completar funcionan desde ambas páginas; ventana de 24h configurable; gating correcto.
+- PATCH no permite editar tareas sin grupo si no eres responsable (o creador, si se habilita).
+- Navegación móvil no rebosa; barra inferior accesible.
+- Asignados: conteo visible, “tú” resaltado y lista en popover con wa.me.
+
+Fase 7 — Densidad y acciones en una sola fila (TaskItem) — Estado: Completada
+Objetivos
+- Compactar la fila de acciones de TaskItem:
+ - Convertir el botón/indicador de responsables en “icono + número” sin texto (“personas asignadas” → solo icono + contador), manteniendo aria-label y tooltip accesibles.
+ - Ubicar en la misma fila: Responsables (icono+conteo), Reclamar/Soltar, Editar, Fecha.
+- Reducir padding vertical excesivo para ganar densidad, manteniendo objetivos de accesibilidad (área táctil ≈44px y foco visible).
+
+Plan de trabajo
+1) TaskItem.svelte
+ - Sustituir texto “Responsables: n” por icono + n; aria-label dinámico (“n responsables; tú incluido/excluido”).
+ - Reorganizar contenedor de acciones para una sola fila en móvil y desktop; permitir wrap en pantallas muy pequeñas si es necesario.
+ - Ajustar tamaños (icon-size 16–18px) y gaps a 6–8px.
+2) Estilos globales y utilidades
+ - Revisar variables de espacio en tokens.css/base.css; reducir ligeramente los márgenes/paddings verticales de:
+ - Listas de tareas (ul.list > li o contenedor del TaskItem).
+ - Card.svelte (padding vertical).
+ - AppShell .main en móvil (si procede).
+ - Mantener contraste y focus-visible.
+3) QA
+ - Verificar que en ≤480px no haya desbordes; que los tooltips/aria sean correctos; y targets táctiles respeten accesibilidad.
+
+Archivos a editar
+- apps/web/src/lib/ui/data/TaskItem.svelte (layout de acciones, icono+conteo).
+- apps/web/src/lib/styles/base.css (ajustes finos de paddings/gaps).
+- apps/web/src/lib/styles/tokens.css (si se decide ajustar variables globales de spacing).
+- apps/web/src/lib/ui/layout/Card.svelte (si requiere reducir padding vertical interno).
+
+Criterios de aceptación
+- TaskItem muestra todas las acciones en una sola fila en móvil estándar (≥360px) sin saltos.
+- El indicador de responsables conserva accesibilidad (aria-label/tooltip) y se entiende su semántica.
+- La densidad aumenta perceptiblemente sin comprometer legibilidad ni foco.
+
+Fase 8 — Orden por fecha o por grupo (corrección y alineación) — Estado: Completada
+Objetivos
+- Alinear el comportamiento de “Orden: Fecha | Grupo” con expectativas:
+ - Fecha: due_date asc; NULL al final; estable por id.
+ - Grupo: agrupar por grupo (Personal al final); dentro de cada grupo ordenar por due_date asc; NULL al final.
+- Que el orden seleccionado afecte coherentemente a las secciones relevantes (asignadas y/o sin responsable), evitando inconsistencias entre cliente y servidor.
+
+Plan de trabajo
+1) Auditoría actual
+ - Revisar apps/web/src/routes/app/+page.server.ts y el consumo de /api/me/tasks/overview.
+ - Revisar apps/web/src/routes/app/+page.svelte (groupByGroup/sortByDue) para evitar doble orden contradictorio.
+2) Backend
+ - apps/web/src/routes/api/me/tasks/overview/+server.ts: asegurar order=due|group_then_due y aplicar NULLS LAST consistente.
+ - Añadir tests que validen el orden en ambos modos.
+3) UI
+ - apps/web/src/routes/app/+page.server.ts: pasar order al backend y confiar en su orden siempre que sea posible.
+ - apps/web/src/routes/app/+page.svelte: limitar orden en cliente a casos estrictamente necesarios; evitar reordenar lo ya ordenado por servidor.
+4) QA y tests
+ - Casos con due_date iguales, NULLs, mezcla de grupos, y tareas personales.
+
+Archivos a editar
+- apps/web/src/routes/api/me/tasks/overview/+server.ts
+- apps/web/src/routes/app/+page.server.ts
+- apps/web/src/routes/app/+page.svelte
+- tests/web/* (añadir/ajustar tests de orden)
+
+Criterios de aceptación
+- El cambio de orden se refleja de forma predecible y consistente en toda la página /app.
+- Tests cubren due_date NULL, empates y orden de grupos (Personal al final).
+
+Resultado (implementado)
+- Backend autoritativo: /api/me/tasks y /api/me/tasks/overview aceptan order=due|group_then_due.
+- Modo Fecha: due_date ASC con NULL al final; desempate estable por id.
+- Modo Grupo: grupos A→Z con “Personal” al final; dentro de cada grupo due_date ASC con NULL al final; desempate por id.
+- Gating consistente aplicado en ambos endpoints.
+- UI /app:
+ - Pasa el parámetro de orden al backend para ambas secciones.
+ - Evita reordenar en cliente; solo agrupa visualmente “Sin responsable” cuando order=group.
+ - “Mis tareas (abiertas)” respeta el orden recibido (sin agrupar).
+
+Pendiente (futuro opcional)
+- Añadir pruebas automatizadas de orden para /api/me/tasks y /api/me/tasks/overview (casos con NULL y empates).
+- Si se desea, agrupar visualmente por grupo en “Mis tareas (abiertas)” cuando order=group (solo encabezados; sin alterar el orden).
+- Considerar índices adicionales si el dataset crece (p. ej., índices por due_date y group_id) para acelerar ORDER BY.
+
+Fase 9 — Semilla de desarrollo enriquecida — Estado: Completada
+Objetivos
+- Disponer de una BD de desarrollo amplia para probar casos reales:
+ - Grupos con y sin tareas; tareas personales; varias tareas sin responsable; tareas con múltiples responsables; tareas completadas recientemente y antiguas; due_dates en pasado/presente/sin fecha.
+ - Varios usuarios para validar “tú” y múltiples assignees.
+
+Plan de trabajo
+1) Semilla
+ - apps/web/src/lib/server/dev-seed.ts: ampliar dataset con:
+ - 3–4 usuarios (incluido el por defecto).
+ - 4–5 grupos; al menos 1 sin tareas, 1 con muchas tareas, 1 mixto.
+ - 25–40 tareas variadas (diferentes due, estados, grupos/personales).
+ - Relaciones de asignación múltiples en algunas tareas.
+ - Asegurar idempotencia y que no se sobreescriba si ya hay datos.
+2) Helpers de test (si procede)
+ - tests/web/helpers/db.ts: exponer utilidades para crear fixtures específicas.
+3) Documentación
+ - Añadir nota en docs/operations.md sobre cómo regenerar BD local y variables relacionadas.
+
+Archivos a editar
+- apps/web/src/lib/server/dev-seed.ts
+- tests/web/helpers/db.ts (si se añaden utilidades)
+- docs/operations.md (documentación de uso de seed)
+
+Criterios de aceptación
+- Entorno dev listo tras bootstrap: datos variados y suficientes para probar todas las vistas/secciones.
+- Tests pueden apoyarse en fixtures reproducibles.
+
+Resultado (implementado)
+- Semilla enriquecida que crea ~30–35 tareas, 5 grupos (allowed/pending), usuarios múltiples y membresías coherentes; incluye tareas personales, sin/uno/múltiples responsables y completadas recientes/antiguas con due variado.
+- Idempotente: se ejecuta solo si la tabla tasks está vacía y con DEV_AUTOSEED_DB='true' en desarrollo; usa DEV_DEFAULT_USER numérico cuando está definido.
+- Documentación actualizada en docs/operations.md con instrucciones para activar y regenerar la base de datos de desarrollo.
+
+Fase 10 — Completar tarea sin responsable: auto-asignación al completador — Estado: Completada
+Objetivos
+- Resolver el edge case: al completar una tarea sin responsables, debe aparecer en “Completadas (24h)” del usuario y permitir “Deshacer”.
+- Mantener gating y trazabilidad coherentes.
+
+Plan de trabajo
+1) Backend
+ - apps/web/src/routes/api/tasks/[id]/complete/+server.ts:
+ - Si la tarea no tiene responsables, añadir (de forma atómica) una asignación al usuario que completa antes de marcar completed=1.
+ - Registrar completed_by (si existe) o equivalente.
+ - apps/web/src/routes/api/tasks/[id]/uncomplete/+server.ts:
+ - Mantener ventana; permitir deshacer si el usuario es responsable (lo será por la auto-asignación) o fue quien completó.
+ - apps/web/src/routes/api/me/tasks/overview/+server.ts y/o consultas de “recent”:
+ - Asegurar que la consulta de recientes recoge estas tareas asignadas durante el complete.
+2) UI
+ - apps/web/src/lib/ui/data/TaskItem.svelte:
+ - Mensaje de feedback claro al completar una tarea que no tenía responsables (p.ej., “Te has asignado y completado la tarea”).
+3) Tests
+ - Flujo: tarea sin responsables → complete → aparece en completadas → uncomplete permitido dentro de ventana.
+
+Archivos a editar
+- apps/web/src/routes/api/tasks/[id]/complete/+server.ts
+- apps/web/src/routes/api/tasks/[id]/uncomplete/+server.ts
+- apps/web/src/routes/api/me/tasks/overview/+server.ts (si la consulta de recientes depende de esto)
+- apps/web/src/lib/ui/data/TaskItem.svelte (feedback UI)
+- tests/web/* (casos de integración)
+
+Resultado (implementado)
+- Auto-asignación atómica al completar tareas de grupo sin responsables; se registra completed_by.
+- Listado “Completadas (24 h)” incluye estas tareas gracias a la auto-asignación.
+- Uncomplete permitido dentro de la ventana configurada, manteniendo la asignación creada.
+- Tests de integración añadidos: complete-autoassign-recent, uncomplete-window y carrera de complete (con dos usuarios).
+
+Criterios de aceptación
+- Completar una tarea sin responsables la vincula al usuario y aparece inmediatamente en “Completadas (24h)”.
+- “Deshacer completar” funciona para ese caso dentro de la ventana configurada.
+
+Notas de implementación y buenas prácticas
+- Mantener cabeceras cache-control: no-store en endpoints de listas/acciones.
+- Reutilizar el gating ya presente en claim/unassign/complete; factorizar si conviene (pero sin sobre-ingeniería).
+- Idempotencia en endpoints de mutación (claim, unassign, complete, uncomplete).
+- Evitar dependencias externas para UI; usar SVG inline o emojis como placeholder.
+- Accesibilidad: aria-label en iconos, focus visible, roles correctos en popovers/diálogos.
+
+Siguientes pasos
+1) Implementar Fase 1 (UI base) con los archivos listados.
+2) Implementar Fase 2 (uncomplete + PATCH gating).
+3) Validar en móvil; luego abordar Fase 3 (tabs inferiores).
+4) Optimizar con overview (Fase 4) y cerrar UX de responsables (Fase 5).
+5) Pulido y documentación (Fase 6).
+
+Anexo — Ajustes opcionales futuros (Fase 4)
+- Parámetro include=assigned|unassigned|both en /api/me/tasks/overview (por defecto unassigned) para reducir coste cuando solo se necesite una parte.
+- Paginación en overview (assigned y/o unassigned) con parámetros page/limit independientes.
+- Índices de rendimiento sugeridos (si el dataset crece):
+ - CREATE INDEX IF NOT EXISTS idx_tasks_group ON tasks(group_id);
+ - CREATE INDEX IF NOT EXISTS idx_tasks_due_open ON tasks(due_date) WHERE COALESCE(completed, 0) = 0;
+- Cacheabilidad opcional con ETag/If-None-Match si se añade una versión por usuario; mantener no-store por defecto.
diff --git a/docs/whatsapp-style-guide.md b/docs/whatsapp-style-guide.md
new file mode 100644
index 0000000..d51129a
--- /dev/null
+++ b/docs/whatsapp-style-guide.md
@@ -0,0 +1,96 @@
+# Guía de Estilo de Mensajes para WhatsApp (Help v2)
+
+Objetivo: mensajes claros, consistentes y resistentes a cambios menores de copy.
+
+Principios
+- Responder por DM: incluso si el comando viene de un grupo, las respuestas al usuario llegan por privado. Nota: en modo gating estricto (GROUP_GATING_MODE='enforce') el bot puede no responder en grupos no permitidos.
+- Estructura visual reconocible:
+ - Secciones en negrita y MAYÚSCULAS: `*COMANDOS BÁSICOS*`.
+ - Comandos e IDs en monoespaciado (backticks).
+ - Listas con “- ” por línea.
+ - Notas en cursiva.
+- Brevedad y accionabilidad: priorizar ejemplos cortos y CTAs (“Prueba `/t ayuda`”, “Envía `/t web`”).
+- Estabilidad para tests: evitar asserts por igualdad exacta; preferir substrings semánticos.
+
+Componentes de formato
+- Encabezados de sección:
+ - Patrón: `*${TÍTULO EN MAYÚSCULAS}*`
+ - Ej.: `*COMANDOS BÁSICOS*`
+- Comandos:
+ - Siempre en backticks: `` `/t ver mis` ``
+- IDs:
+ - Mostrar con 4 dígitos entre backticks: `` `0026` `` (usar `codeId()`).
+- Fechas:
+ - Mostrar como `DD/MM`, precedidas de icono si aplica (ej.: `⚠️` si vencida). Usar `formatDDMM()`.
+- Notas:
+ - En cursiva: `_Este grupo no está activo._`
+- Bullets:
+ - “- ” al inicio de cada línea. Evitar listas demasiado largas (>10).
+
+Emojis recurrentes
+- ⚠️ Advertencia (vencida, no encontrado, truncado).
+- ✅ Confirmación genérica.
+- 📅 Fecha (según `ICONS.date`).
+- 👤 / 👥 Responsables (uno o varios).
+- ➕ Crear, ✔️ Completar, 🧲/✋ Tomar/Soltar (según `ICONS` disponibles).
+- Evitar exceso: 1–2 por línea como máximo.
+
+Patrones comunes
+- Confirmación de creación:
+ - Línea 1: icono + ID + descripción
+ - Línea 2: fecha (si existe)
+ - Línea 3: responsable(s) o “sin responsable”
+- Listados:
+ - Título (nombre de grupo o `Tus tareas`)
+ - Bullets de items con: ID, descripción, fecha (con `⚠️` si vencida), responsable
+ - Sufijo “... y N más” si aplica
+- Ayuda rápida:
+ - Secciones: “COMANDOS BÁSICOS”, “LISTADOS”, “ACCESO WEB”
+ - Bullets con ejemplos: `` `/t n ...` ``, `` `/t ver mis|grupo|todos|sin` ``, `` `/t x 26` ``, `` `/t tomar 12` ``, `` `/t configurar ...` ``, `` `/t web` ``
+
+Localización
+- Todo copy en español. Evitar fugas de claves internas en inglés (ej. “weekly”).
+- En recordatorios, exponer etiquetas en español:
+ - daily → “diario”
+ - weekdays → “laborables (lunes a viernes)”
+ - weekly → “semanal (lunes)”
+ - off → “apagado”
+- Si por compatibilidad se aceptan términos en inglés como input, la respuesta debe mantener español.
+
+Buenas prácticas
+- Evitar párrafos largos; preferir 1–3 líneas por bloque.
+- Los mensajes de 'Uso:' llevan el prefijo ℹ️.
+- Incluir uso cuando falten argumentos:
+ - Ej.: `ℹ️ Uso: \`/t tomar 26\` o múltiples: \`/t tomar 12 19 50\` o \`/t tomar 12,19,50\` (máx. 10)`
+- Mensajes de error claros y accionables: “No puedes tomar esta tarea… Pide acceso a un admin si crees que es un error.”
+- En listados, omitir líneas en blanco finales.
+
+Para tests
+- Preferir asserts de “contiene” con fragmentos estables (IDs en backticks, comandos en backticks, nombres de secciones).
+- Si hace falta, crear helper `stripFormatting` que quite `*`, `_` y `` ` `` para comparar texto plano.
+
+Ejemplos
+
+Ayuda rápida
+```
+*COMANDOS BÁSICOS*
+- `/t n Descripción 2025-11-05 @Ana`
+- `/t ver` (en grupo) · `/t ver mis` (DM) · `/t ver todos`
+- `/t x 26` · `/t tomar 12`
+- `/t configurar diario|l-v|semanal|off [HH:MM]`
+- `/t web`
+_El bot responde por DM, incluso si escribes desde un grupo._
+```
+
+Confirmación de completar (ya estaba)
+```
+ℹ️ `0026` ya estaba completada — Preparar informe — 📅 05/11
+```
+
+Listado de “sin responsable”
+```
+Nombre del Grupo — Sin responsable
+- `0142` Revisión de PR — 📅 12/03
+- `0185` Montar demo — ⚠️ 📅 09/03
+... y 3 más
+```
diff --git a/proxy.ts b/proxy.ts
new file mode 100644
index 0000000..9489f4b
--- /dev/null
+++ b/proxy.ts
@@ -0,0 +1,82 @@
+const BOT_ORIGIN = 'http://127.0.0.1:3007';
+const WEB_ORIGIN = 'http://127.0.0.1:3008';
+
+function shouldRouteToBot(pathname: string): boolean {
+ if (pathname === '/metrics' || pathname.startsWith('/metrics/')) return true;
+ if (pathname === '/webhook' || pathname.startsWith('/webhook/')) return true;
+ return false;
+}
+
+function buildForwardHeaders(req: Request): Headers {
+ const headers = new Headers(req.headers);
+ try {
+ const proto = headers.get('x-forwarded-proto') || 'https';
+ const fwdFor = headers.get('x-forwarded-for');
+ headers.set('x-forwarded-proto', proto);
+ headers.set('x-forwarded-for', fwdFor ? `${fwdFor}, 127.0.0.1` : '127.0.0.1');
+ const host = headers.get('host') || '';
+ if (!host) headers.set('host', 'localhost');
+ } catch {}
+ return headers;
+}
+
+Bun.serve({
+ port: Number(process.env.PORT || process.env.ROUTER_PORT || 3000),
+ fetch: async (req) => {
+ const url = new URL(req.url);
+
+ // Health local para el contenedor (evita 404 en healthcheck)
+ if (url.pathname === '/health') {
+ return new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } });
+ }
+
+ const routeToBot = shouldRouteToBot(url.pathname);
+ const targetOrigin = routeToBot ? BOT_ORIGIN : WEB_ORIGIN;
+ const targetUrl = targetOrigin + url.pathname + url.search;
+
+ const headers = buildForwardHeaders(req);
+ if (!routeToBot) {
+ try { headers.set('accept-encoding', 'identity'); } catch {}
+ }
+ const init: RequestInit = {
+ method: req.method,
+ headers,
+ body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req.body,
+ redirect: 'manual',
+ };
+
+ const started = Date.now();
+ try {
+ const res = await fetch(targetUrl, init);
+ const ms = Date.now() - started;
+ try {
+ console.log(`[proxy] ${req.method} ${url.pathname}${url.search} -> ${routeToBot ? 'bot' : 'web'} ${res.status} (${ms}ms)`);
+ } catch {}
+ // Devuelve la respuesta (incluye Set-Cookie, Location, etc.), asegurando Content-Type en assets por si faltase
+ const passthroughHeaders = new Headers(res.headers);
+ if (!routeToBot) {
+ try {
+ // Forzar respuesta sin compresión hacia el cliente
+ passthroughHeaders.delete('content-encoding');
+ passthroughHeaders.delete('vary');
+ passthroughHeaders.delete('content-length');
+ const cc = passthroughHeaders.get('cache-control');
+ if (cc && !/no-transform/i.test(cc)) {
+ passthroughHeaders.set('cache-control', cc + ', no-transform');
+ } else if (!cc) {
+ passthroughHeaders.set('cache-control', 'no-transform');
+ }
+ } catch {}
+ }
+ if (!passthroughHeaders.get('content-type')) {
+ if (url.pathname.endsWith('.js')) passthroughHeaders.set('content-type', 'application/javascript; charset=utf-8');
+ if (url.pathname.endsWith('.css')) passthroughHeaders.set('content-type', 'text/css; charset=utf-8');
+ }
+ return new Response(res.body, { status: res.status, headers: passthroughHeaders });
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error(`[proxy] ${req.method} ${url.pathname}${url.search} -> ERROR: ${msg}`);
+ return new Response(`Proxy error: ${msg}\n`, { status: 502 });
+ }
+ },
+});
diff --git a/src/db.ts b/src/db.ts
index 2c25bb8..3f5d2d2 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -1,8 +1,9 @@
import { Database } from 'bun:sqlite';
import { normalizeWhatsAppId } from './utils/whatsapp';
import { mkdirSync } from 'fs';
-import { join } from 'path';
+import { join, resolve, dirname } from 'path';
import { Migrator } from './db/migrator';
+import { migrations } from './db/migrations';
function applyDefaultPragmas(instance: Database): void {
try {
@@ -18,15 +19,36 @@ function applyDefaultPragmas(instance: Database): void {
}
}
-// Function to get a database instance. Defaults to 'data/tasks.db'
+ // Function to get a database instance. Defaults to 'data/tasks.db'
export function getDb(filename: string = 'tasks.db'): Database {
+ // Prioridad 1: DB_PATH (ruta completa al archivo). Si está definida, se usa tal cual.
+ const dbPathEnv = process?.env?.DB_PATH ? String(process.env.DB_PATH).trim() : '';
+ if (dbPathEnv) {
+ const absolutePath = resolve(dbPathEnv);
+ // Crear directorio padre si no existe
+ try {
+ mkdirSync(dirname(absolutePath), { recursive: true });
+ } catch (err) {
+ if ((err as any)?.code !== 'EEXIST') throw err; // Solo ignorar "ya existe"
+ }
+ const instance = new Database(absolutePath);
+ applyDefaultPragmas(instance);
+ return instance;
+ }
+
+ // Prioridad 2: DATA_DIR + filename (comportamiento actual)
+ // Determine base directory for the database (env DATA_DIR or default './data'), resolve to absolute
+ const dataDir = process?.env?.DATA_DIR ? String(process.env.DATA_DIR) : 'data';
+ const dirPath = resolve(dataDir);
+
// Try to create data directory if it doesn't exist (ignore if already exists)
try {
- mkdirSync('data', { recursive: true });
+ mkdirSync(dirPath, { recursive: true });
} catch (err) {
- if (err.code !== 'EEXIST') throw err; // Only ignore "already exists" errors
+ if ((err as any)?.code !== 'EEXIST') throw err; // Only ignore "already exists" errors
}
- const instance = new Database(join('data', filename));
+
+ const instance = new Database(join(dirPath, filename));
applyDefaultPragmas(instance);
return instance;
}
@@ -39,12 +61,48 @@ export function initializeDatabase(instance: Database) {
// Aplicar PRAGMAs por defecto (WAL, busy_timeout, FK, etc.)
applyDefaultPragmas(instance);
- // Ejecutar migraciones up-only (sin baseline por defecto). Evitar backup duplicado aquí.
+ // Ejecutar migraciones con el Migrator; si no deja el esquema listo, aplicar fallback.
+ let migratorError: unknown = null;
try {
Migrator.migrateToLatest(instance, { withBackup: false, allowBaseline: false });
} catch (e) {
- console.error('[initializeDatabase] Error al aplicar migraciones:', e);
- throw e;
+ migratorError = e;
+ console.error('[initializeDatabase] Error al aplicar migraciones con Migrator:', e);
+ }
+
+ // Verificación mínima: si las tablas base no existen, aplicar fallback secuencial.
+ const tableExists = (name: string): boolean => {
+ try {
+ const row = instance
+ .query(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`)
+ .get(name) as any;
+ return Boolean(row && row.name === name);
+ } catch {
+ return false;
+ }
+ };
+
+ const needsFallback =
+ !tableExists('users') ||
+ !tableExists('tasks') ||
+ !tableExists('response_queue');
+
+ if (needsFallback) {
+ console.warn('[initializeDatabase] Migrator no dejó el esquema listo; aplicando fallback de migraciones secuenciales');
+ try {
+ instance.transaction(() => {
+ try { instance.exec(`PRAGMA foreign_keys = ON;`); } catch {}
+ for (const m of migrations) {
+ m.up(instance);
+ }
+ })();
+ } catch (fallbackErr) {
+ console.error('[initializeDatabase] Fallback de migraciones falló:', fallbackErr);
+ throw fallbackErr;
+ }
+ } else if (migratorError) {
+ // Si el Migrator falló pero el esquema ya está correcto, sólo loggeamos.
+ console.warn('[initializeDatabase] Migrator reportó error, pero el esquema parece estar correcto. Continuando.');
}
}
diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts
index 72cf0c2..1f44184 100644
--- a/src/db/migrations/index.ts
+++ b/src/db/migrations/index.ts
@@ -288,5 +288,170 @@ export const migrations: Migration[] = [
ON allowed_groups (status);
`);
}
+ },
+ {
+ version: 10,
+ name: 'web-auth-tables',
+ checksum: 'v10-web-auth-2025-10-12',
+ up: (db: Database) => {
+ db.exec(`PRAGMA foreign_keys = ON;`);
+
+ // Tokens de login web (válidos 10 min, de un solo uso)
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS web_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
+ expires_at TEXT NOT NULL,
+ used_at TEXT NULL,
+ metadata TEXT NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ );
+ `);
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_web_tokens_user ON web_tokens (user_id);`);
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_web_tokens_expires ON web_tokens (expires_at);`);
+
+ // Sesiones de la web (idle timeout gestionado por la app web)
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS web_sessions (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ session_hash TEXT NOT NULL UNIQUE,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
+ last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
+ expires_at TEXT NOT NULL,
+ user_agent TEXT NULL,
+ ip TEXT NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ );
+ `);
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_web_sessions_user ON web_sessions (user_id);`);
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions (expires_at);`);
+ }
+ },
+ {
+ version: 11,
+ name: 'calendar-tokens',
+ checksum: 'v11-calendar-tokens-2025-10-14',
+ up: (db: Database) => {
+ db.exec(`PRAGMA foreign_keys = ON;`);
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS calendar_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ type TEXT NOT NULL CHECK (type IN ('personal','group','aggregate')),
+ user_id TEXT NOT NULL,
+ group_id TEXT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
+ revoked_at TEXT NULL,
+ last_used_at TEXT NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
+ );
+ `);
+ db.exec(`
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_calendar_tokens_active
+ ON calendar_tokens (type, user_id, group_id)
+ WHERE revoked_at IS NULL;
+ `);
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_tokens_user ON calendar_tokens (user_id);`);
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_tokens_group ON calendar_tokens (group_id);`);
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_tokens_type ON calendar_tokens (type);`);
+ }
+ },
+ {
+ version: 12,
+ name: 'calendar-tokens-plain',
+ checksum: 'v12-calendar-tokens-plain-2025-10-14',
+ up: (db: Database) => {
+ // Añadir columna para poder mostrar siempre la URL (guardando el token en claro).
+ // Nota: mantenemos token_hash para validación; token_plain se usa solo para construir la URL en UI.
+ try {
+ const cols = db.query(`PRAGMA table_info(calendar_tokens)`).all() as any[];
+ const hasPlain = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'token_plain');
+ if (!hasPlain) {
+ db.exec(`ALTER TABLE calendar_tokens ADD COLUMN token_plain TEXT NULL;`);
+ }
+ } catch {}
+ }
+ },
+ {
+ version: 13,
+ name: 'groups-onboarding-prompted-at',
+ checksum: 'v13-groups-onboarding-2025-10-17',
+ up: (db: Database) => {
+ try {
+ const cols = db.query(`PRAGMA table_info(groups)`).all() as any[];
+ const hasCol = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'onboarding_prompted_at');
+ if (!hasCol) {
+ db.exec(`ALTER TABLE groups ADD COLUMN onboarding_prompted_at TEXT NULL;`);
+ }
+ } catch {}
+ }
+ },
+ {
+ version: 14,
+ name: 'groups-archived-flag',
+ checksum: 'v14-groups-archived-2025-10-19',
+ up: (db: Database) => {
+ try {
+ const cols = db.query(`PRAGMA table_info(groups)`).all() as any[];
+ const hasArchived = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'archived');
+ if (!hasArchived) {
+ db.exec(`ALTER TABLE groups ADD COLUMN archived BOOLEAN NOT NULL DEFAULT 0;`);
+ }
+ } catch {}
+ }
+ }
+ ,
+ {
+ version: 15,
+ name: 'tasks-personal-unassign-guard',
+ checksum: 'v15-personal-unassign-2025-10-19',
+ up: (db: Database) => {
+ db.exec(`PRAGMA foreign_keys = ON;`);
+
+ // Reparar: reasignar tareas personales abiertas sin asignatarios a created_by
+ db.exec(`
+ INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at)
+ SELECT t.id, t.created_by, t.created_by, strftime('%Y-%m-%d %H:%M:%f','now')
+ FROM tasks t
+ WHERE t.group_id IS NULL
+ AND COALESCE(t.completed,0) = 0
+ AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id);
+ `);
+
+ // Trigger: impedir borrar el último asignatario en tareas personales
+ db.exec(`
+ CREATE TRIGGER IF NOT EXISTS trg_block_unassign_last_personal
+ BEFORE DELETE ON task_assignments
+ FOR EACH ROW
+ WHEN EXISTS (SELECT 1 FROM tasks WHERE id = OLD.task_id)
+ AND EXISTS (SELECT 1 FROM users WHERE id = OLD.user_id)
+ AND (SELECT group_id FROM tasks WHERE id = OLD.task_id) IS NULL
+ AND (SELECT COUNT(*) FROM task_assignments WHERE task_id = OLD.task_id) = 1
+ BEGIN
+ SELECT RAISE(ABORT, 'PERSONAL_UNASSIGN_FORBIDDEN');
+ END;
+ `);
+ }
+ },
+ {
+ version: 16,
+ name: 'groups-is-community',
+ checksum: 'v16-groups-is-community-2025-10-19',
+ up: (db: Database) => {
+ try {
+ const cols = db.query(`PRAGMA table_info(groups)`).all() as any[];
+ const hasCol = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'is_community');
+ if (!hasCol) {
+ db.exec(`ALTER TABLE groups ADD COLUMN is_community BOOLEAN NOT NULL DEFAULT 0;`);
+ }
+ } catch {}
+ try {
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_groups_is_community ON groups (is_community);`);
+ } catch {}
+ }
}
];
diff --git a/src/db/migrator.ts b/src/db/migrator.ts
index fab6b21..f4dfabc 100644
--- a/src/db/migrator.ts
+++ b/src/db/migrator.ts
@@ -3,11 +3,16 @@ import { mkdirSync, appendFileSync } from 'fs';
import { join } from 'path';
import { migrations, type Migration } from './migrations';
+const MIGRATIONS_LOG_LEVEL = (process.env.MIGRATIONS_LOG_LEVEL || '').toLowerCase();
+const MIGRATIONS_QUIET = process.env.NODE_ENV === 'test' || MIGRATIONS_LOG_LEVEL === 'silent';
+
function nowIso(): string {
return new Date().toISOString().replace('T', ' ').replace('Z', '');
}
function logEvent(level: 'info' | 'error', event: string, data: any = {}) {
+ // En modo test o nivel 'silent', no registrar eventos para evitar ruido
+ if (MIGRATIONS_QUIET) return;
try {
mkdirSync('data', { recursive: true });
} catch {}
@@ -64,10 +69,10 @@ function backupDatabaseIfNeeded(db: Database): string | null {
try {
// VACUUM INTO hace copia consistente del estado actual
db.exec(`VACUUM INTO '${backupPath.replace(/'/g, "''")}'`);
- console.log(`ℹ️ Backup de base de datos creado en: ${backupPath}`);
+ if (!MIGRATIONS_QUIET) console.log(`ℹ️ Backup de base de datos creado en: ${backupPath}`);
return backupPath;
} catch (e) {
- console.warn('⚠️ No se pudo crear el backup con VACUUM INTO (continuando de todos modos):', e);
+ if (!MIGRATIONS_QUIET) console.warn('⚠️ No se pudo crear el backup con VACUUM INTO (continuando de todos modos):', e);
return null;
}
}
@@ -100,7 +105,7 @@ export const Migrator = {
const jmRow = db.query(`PRAGMA journal_mode`).get() as any;
const journalMode = jmRow ? (jmRow.journal_mode || jmRow.value || jmRow.mode || 'unknown') : 'unknown';
const currentVersion = applied.size ? Math.max(...Array.from(applied.keys())) : 0;
- console.log(`ℹ️ Migrador — journal_mode=${journalMode}, versión_actual=${currentVersion}, pendientes=${pending.length}`);
+ if (!MIGRATIONS_QUIET) console.log(`ℹ️ Migrador — journal_mode=${journalMode}, versión_actual=${currentVersion}, pendientes=${pending.length}`);
try { logEvent('info', 'startup_summary', { journal_mode: journalMode, current_version: currentVersion, pending: pending.length }); } catch {}
if (applied.size === 0 && allowBaseline && detectExistingSchema(db)) {
@@ -109,14 +114,14 @@ export const Migrator = {
db.transaction(() => {
insertMigrationRow(db, v1);
})();
- console.log('ℹ️ Baseline aplicado: schema_migrations marcada en v1 (sin ejecutar up)');
+ if (!MIGRATIONS_QUIET) console.log('ℹ️ Baseline aplicado: schema_migrations marcada en v1 (sin ejecutar up)');
try { logEvent('info', 'baseline_applied', { version: 1 }); } catch {}
// Recalcular pendientes
pending.splice(0, pending.length, ...migrations.filter(m => m.version > 1));
}
if (pending.length === 0) {
- console.log('ℹ️ No hay migraciones pendientes');
+ if (!MIGRATIONS_QUIET) console.log('ℹ️ No hay migraciones pendientes');
try { logEvent('info', 'no_pending', {}); } catch {}
return;
}
@@ -127,7 +132,7 @@ export const Migrator = {
}
for (const mig of pending) {
- console.log(`➡️ Aplicando migración v${mig.version} - ${mig.name}`);
+ if (!MIGRATIONS_QUIET) console.log(`➡️ Aplicando migración v${mig.version} - ${mig.name}`);
try {
try { logEvent('info', 'apply_start', { version: mig.version, name: mig.name, checksum: mig.checksum }); } catch {}
const t0 = Date.now();
@@ -141,7 +146,7 @@ export const Migrator = {
insertMigrationRow(db, mig);
})();
const ms = Date.now() - t0;
- console.log(`✅ Migración v${mig.version} aplicada (${ms} ms)`);
+ if (!MIGRATIONS_QUIET) console.log(`✅ Migración v${mig.version} aplicada (${ms} ms)`);
try { logEvent('info', 'apply_success', { version: mig.version, name: mig.name, checksum: mig.checksum, duration_ms: ms }); } catch {}
} catch (e) {
console.error(`❌ Error aplicando migración v${mig.version}:`, e);
diff --git a/src/server.ts b/src/server.ts
index 20d1587..838002c 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -282,6 +282,15 @@ export class WebhookServer {
try {
const nAlt = normalizeWhatsAppId(pAlt);
const n = normalizeWhatsAppId(p);
+ if (process.env.NODE_ENV !== 'test') {
+ console.log('[A0] message.key participants', {
+ participant: p,
+ participantAlt: pAlt,
+ normalized_participant: n,
+ normalized_participantAlt: nAlt,
+ alias_upsert: !!(nAlt && n && nAlt !== n)
+ });
+ }
if (nAlt && n && nAlt !== n) {
IdentityService.upsertAlias(p, pAlt, 'message.key');
}
@@ -326,6 +335,18 @@ export class WebhookServer {
const messageTextTrimmed = messageText.trim();
const isAdminCmd = messageTextTrimmed.startsWith('/admin');
+ // A4: Primer DM "activar" — alta/confirmación idempotente (solo en DM)
+ if (!isGroupId(remoteJid) && messageTextTrimmed === 'activar') {
+ const base = (process.env.WEB_BASE_URL || '').trim();
+ const msg = base
+ ? "Listo, ya puedes reclamar/ser responsable y acceder a la web. Para acceder a la web, envía '/t web' y abre el enlace."
+ : "Listo, ya puedes reclamar/ser responsable.";
+ try {
+ await ResponseQueue.add([{ recipient: normalizedSenderId, message: msg }]);
+ } catch {}
+ return;
+ }
+
// Etapa 2: Descubrimiento seguro de grupos (modo 'discover')
if (isGroupId(remoteJid)) {
try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {}
@@ -504,6 +525,12 @@ export class WebhookServer {
const PORT = process.env.PORT || '3007';
console.log('✅ Environment variables validated');
+ // A0: pre-crear contadores para que aparezcan en /metrics
+ try {
+ Metrics.inc('onboarding_prompts_sent_total', 0);
+ Metrics.inc('onboarding_prompts_skipped_total', 0);
+ Metrics.inc('onboarding_assign_failures_total', 0);
+ } catch {}
if (process.env.NODE_ENV !== 'test') {
try {
@@ -558,6 +585,13 @@ export class WebhookServer {
try {
MaintenanceService.start();
console.log('✅ MaintenanceService started');
+ // Ejecutar reconciliación de alias una vez al arranque (one-shot)
+ try {
+ await MaintenanceService.reconcileAliasUsersOnce();
+ console.log('✅ MaintenanceService: reconciliación de alias ejecutada (one-shot)');
+ } catch (e2) {
+ console.error('⚠️ Failed to run alias reconciliation one-shot:', e2);
+ }
} catch (e) {
console.error('⚠️ Failed to start MaintenanceService:', e);
}
diff --git a/src/services/admin.ts b/src/services/admin.ts
index 815354c..24c1cd8 100644
--- a/src/services/admin.ts
+++ b/src/services/admin.ts
@@ -44,6 +44,7 @@ export class AdminService {
'- /admin pendientes (alias: pending, pend)',
'- /admin habilitar-aquí (alias: enable)',
'- /admin deshabilitar-aquí (alias: disable)',
+ '- /admin allow all (alias: habilitar-todos, enable all)',
'- /admin allow-group (alias: allow)',
'- /admin block-group (alias: block)',
'- /admin sync-grupos (alias: group-sync, syncgroups)',
@@ -103,8 +104,109 @@ export class AdminService {
return [{ recipient: sender, message: `✅ Grupo deshabilitado: ${ctx.groupId}` }];
}
+ // /admin archivar-aquí
+ if (rest === 'archivar-aquí' || rest === 'archivar-aqui' || rest === 'archive here' || rest === 'archive-aqui' || rest === 'archive-aquí') {
+ if (!isGroupId(ctx.groupId)) {
+ return [{ recipient: sender, message: 'ℹ️ Este comando se debe usar dentro de un grupo.' }];
+ }
+ this.dbInstance.transaction(() => {
+ this.dbInstance.prepare(`
+ UPDATE groups
+ SET active = 0, archived = 1, last_verified = strftime('%Y-%m-%d %H:%M:%f','now')
+ WHERE id = ?
+ `).run(ctx.groupId);
+ this.dbInstance.prepare(`
+ UPDATE calendar_tokens
+ SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now')
+ WHERE group_id = ? AND revoked_at IS NULL
+ `).run(ctx.groupId);
+ this.dbInstance.prepare(`
+ UPDATE group_members
+ SET is_active = 0
+ WHERE group_id = ? AND is_active = 1
+ `).run(ctx.groupId);
+ })();
+ try { AllowedGroups.setStatus(ctx.groupId, 'blocked'); } catch {}
+ return [{ recipient: sender, message: `📦 Grupo archivado: ${ctx.groupId}` }];
+ }
+
+ // /admin archivar-grupo
+ if (rest.startsWith('archivar-grupo ') || rest.startsWith('archive-group ')) {
+ const arg = rest.startsWith('archivar-grupo ') ? rest.slice('archivar-grupo '.length).trim() : rest.slice('archive-group '.length).trim();
+ if (!isGroupId(arg)) {
+ return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
+ }
+ this.dbInstance.transaction(() => {
+ this.dbInstance.prepare(`
+ UPDATE groups
+ SET active = 0, archived = 1, last_verified = strftime('%Y-%m-%d %H:%M:%f','now')
+ WHERE id = ?
+ `).run(arg);
+ this.dbInstance.prepare(`
+ UPDATE calendar_tokens
+ SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now')
+ WHERE group_id = ? AND revoked_at IS NULL
+ `).run(arg);
+ this.dbInstance.prepare(`
+ UPDATE group_members
+ SET is_active = 0
+ WHERE group_id = ? AND is_active = 1
+ `).run(arg);
+ })();
+ try { AllowedGroups.setStatus(arg, 'blocked'); } catch {}
+ return [{ recipient: sender, message: `📦 Grupo archivado: ${arg}` }];
+ }
+
+ // /admin borrar-aquí
+ if (rest === 'borrar-aquí' || rest === 'borrar-aqui' || rest === 'delete here' || rest === 'delete-here') {
+ if (!isGroupId(ctx.groupId)) {
+ return [{ recipient: sender, message: 'ℹ️ Este comando se debe usar dentro de un grupo.' }];
+ }
+ this.dbInstance.transaction(() => {
+ this.dbInstance.prepare(`DELETE FROM tasks WHERE group_id = ?`).run(ctx.groupId);
+ this.dbInstance.prepare(`DELETE FROM groups WHERE id = ?`).run(ctx.groupId);
+ try { this.dbInstance.prepare(`DELETE FROM allowed_groups WHERE group_id = ?`).run(ctx.groupId); } catch {}
+ })();
+ return [{ recipient: sender, message: `🗑️ Grupo borrado y datos asociados eliminados: ${ctx.groupId}` }];
+ }
+
+ // /admin borrar-grupo
+ if (rest.startsWith('borrar-grupo ') || rest.startsWith('delete-group ')) {
+ const arg = rest.startsWith('borrar-grupo ') ? rest.slice('borrar-grupo '.length).trim() : rest.slice('delete-group '.length).trim();
+ if (!isGroupId(arg)) {
+ return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
+ }
+ this.dbInstance.transaction(() => {
+ this.dbInstance.prepare(`DELETE FROM tasks WHERE group_id = ?`).run(arg);
+ this.dbInstance.prepare(`DELETE FROM groups WHERE id = ?`).run(arg);
+ try { this.dbInstance.prepare(`DELETE FROM allowed_groups WHERE group_id = ?`).run(arg); } catch {}
+ })();
+ return [{ recipient: sender, message: `🗑️ Grupo borrado y datos asociados eliminados: ${arg}` }];
+ }
+
+ // /admin allow all
+ if (
+ rest === 'allow all' ||
+ rest === 'allow-all' ||
+ rest === 'habilitar-todos' ||
+ rest === 'permitir todos' ||
+ rest === 'enable all'
+ ) {
+ const pendings = AllowedGroups.listByStatus('pending');
+ if (!pendings || pendings.length === 0) {
+ return [{ recipient: sender, message: '✅ No hay grupos pendientes.' }];
+ }
+ let changed = 0;
+ for (const r of pendings) {
+ const didChange = AllowedGroups.setStatus(r.group_id, 'allowed', r.label ?? null);
+ if (didChange) changed++;
+ try { Metrics.inc('admin_actions_total_allow'); } catch {}
+ }
+ return [{ recipient: sender, message: `✅ Grupos habilitados: ${changed}` }];
+ }
+
// /admin allow-group
- if (rest.startsWith('allow-group ') || rest.startsWith('allow ')) {
+ if (rest.startsWith('allow-group ') || (rest.startsWith('allow ') && rest !== 'allow all' && rest !== 'allow-all')) {
const arg = (rest.startsWith('allow-group ') ? rest.slice('allow-group '.length) : rest.slice('allow '.length)).trim();
if (!isGroupId(arg)) {
return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
diff --git a/src/services/command.ts b/src/services/command.ts
index 370ed15..f276d81 100644
--- a/src/services/command.ts
+++ b/src/services/command.ts
@@ -5,10 +5,12 @@ import { TaskService } from '../tasks/service';
import { GroupSyncService } from './group-sync';
import { ContactsService } from './contacts';
import { ICONS } from '../utils/icons';
-import { padTaskId, codeId, formatDDMM, bold, italic } from '../utils/formatting';
+import { padTaskId, codeId, formatDDMM, bold, italic, code, section } from '../utils/formatting';
+import { getQuickHelp, getFullHelp } from './messages/help';
import { IdentityService } from './identity';
import { AllowedGroups } from './allowed-groups';
import { Metrics } from './metrics';
+import { randomTokenBase64Url, sha256Hex } from '../utils/crypto';
type CommandContext = {
sender: string; // normalized user id (digits only), but accept raw too
@@ -205,43 +207,64 @@ export class CommandService {
const todayYMD = ymdInTZ(new Date());
if (!action || action === 'ayuda') {
+ const feature = String(process.env.FEATURE_HELP_V2 ?? 'true').toLowerCase();
+ const helpV2Enabled = !['false', '0', 'no'].includes(feature);
+
const isAdvanced = (tokens[2] || '').toLowerCase() === 'avanzada';
- if (isAdvanced) {
- const adv = [
- '*Ayuda avanzada:*',
- 'Comandos y alias:',
- ' · Crear: `n`, `nueva`, `crear`, `+`',
- ' · Ver: `ver`, `mostrar`, `listar`, `ls` (opciones: `grupo` | `mis` | `todos` | `sin`)',
- ' · Completar: `x`, `hecho`, `completar`, `done` (acepta varios IDs: "`/t x 14 19 24`" o "`/t x 14,19,24`"; máximo 10)',
- ' · Tomar: `tomar`, `claim` (acepta varios IDs: "`/t tomar 12 19 50`" o "`/t tomar 12,19,50`"; máximo 10)',
- ' · Soltar: `soltar`, `unassign`',
- 'Preferencias:',
- ' · `/t configurar daily|l-v|weekly|off [HH:MM]` (por defecto _08:30_; semanal: _lunes_; l-v: lunes a viernes)',
- 'Notas:',
- ' · En grupos, el bot responde por DM (no publica en el grupo).',
- ' · Si creas una tarea en un grupo y no mencionas a nadie → “sin responsable”; en DM → se asigna a quien la cree.',
- ' · Fechas dd/MM con ⚠️ si está vencida.',
- ' · Mostramos los IDs de las tareas con 4 dígitos, pero puedes escribirlos sin ceros (p. ej., 26).',
+
+ // Fallback legacy (Help v1)
+ if (!helpV2Enabled) {
+ if (isAdvanced) {
+ const adv = [
+ '*Ayuda avanzada:*',
+ 'Comandos y alias:',
+ ' · Crear: `n`, `nueva`, `crear`, `+`',
+ ' · Ver: `ver`, `mostrar`, `listar`, `ls` (opciones: `grupo` | `mis` | `todos` | `sin`)',
+ ' · Completar: `x`, `hecho`, `completar`, `done` (acepta varios IDs: "`/t x 14 19 24`" o "`/t x 14,19,24`"; máximo 10)',
+ ' · Tomar: `tomar`, `claim` (acepta varios IDs: "`/t tomar 12 19 50`" o "`/t tomar 12,19,50`"; máximo 10)',
+ ' · Soltar: `soltar`, `unassign`',
+ 'Preferencias:',
+ ' · `/t configurar diario|l-v|semanal|off [HH:MM]` (por defecto _08:30_; semanal: _lunes_; l-v: lunes a viernes)',
+ 'Notas:',
+ ' · En grupos, el bot responde por DM (no publica en el grupo).',
+ ' · Si creas una tarea en un grupo y no mencionas a nadie → “sin responsable”; en DM → se asigna a quien la crea.',
+ ' · Fechas dd/MM con ⚠️ si está vencida.',
+ ' · Mostramos los IDs de las tareas con 4 dígitos, pero puedes escribirlos sin ceros (p. ej., 26).',
+ ].join('\n');
+ return [{
+ recipient: context.sender,
+ message: adv
+ }];
+ }
+ const help = [
+ 'Guía rápida:',
+ '- Crear: `/t n Descripción 2028-11-26 @Ana`',
+ '- Ver grupo: `/t ver` (en el grupo)',
+ '- Ver mis tareas: `/t ver mis` (por DM)',
+ '- Ver todas: `/t ver todas` (por DM)',
+ '- Completar: `/t x 123` (máx. 10)',
+ '- Tomar: `/t tomar 12` (máx. 10)',
+ '- Configurar recordatorios: `/t configurar diario|l-v|semanal|off [HH:MM]`',
+ '- Ayuda avanzada: `/t ayuda avanzada`'
].join('\n');
return [{
recipient: context.sender,
- message: adv
+ message: help
+ }];
+ }
+
+ // Help v2
+ if (isAdvanced) {
+ return [{
+ recipient: context.sender,
+ message: getFullHelp()
}];
}
- const help = [
- 'Guía rápida:',
- '- Crear: `/t n Descripción 2028-11-26 @Ana`',
- '- Ver grupo: `/t ver` (en el grupo)',
- '- Ver mis tareas: `/t ver mis` (por DM)',
- '- Ver todas: `/t ver todas` (por DM)',
- '- Completar: `/t x 123` (máx. 10)',
- '- Tomar: `/t tomar 12` (máx. 10)',
- '- Configurar recordatorios: `/t configurar diario|l-v|semanal|off [HH:MM]`',
- '- Ayuda avanzada: `/t ayuda avanzada`'
- ].join('\n');
+ const quick = getQuickHelp();
+ const msg = [quick, '', `Ayuda avanzada: ${code('/t ayuda avanzada')}`].join('\n');
return [{
recipient: context.sender,
- message: help
+ message: msg
}];
}
@@ -265,7 +288,7 @@ export class CommandService {
if (!isGroupId(context.groupId)) {
return [{
recipient: context.sender,
- message: '_Este comando se usa en grupos. Prueba:_ `/t ver mis`'
+ message: 'ℹ️ _Este comando se usa en grupos. Prueba:_ `/t ver mis`'
}];
}
if (!GroupSyncService.isGroupActive(context.groupId)) {
@@ -424,13 +447,13 @@ export class CommandService {
if (!isGroupId(context.groupId)) {
return [{
recipient: context.sender,
- message: 'Este comando se usa en grupos. Prueba: `/t ver mis`'
+ message: 'ℹ️ _Este comando se usa en grupos. Prueba:_ `/t ver mis`'
}];
}
if (!GroupSyncService.isGroupActive(context.groupId)) {
return [{
recipient: context.sender,
- message: '⚠️ Este grupo no está activo.'
+ message: '⚠️ _Este grupo no está activo._'
}];
}
// Enforcement opcional basado en membresía si la snapshot es fresca
@@ -439,7 +462,7 @@ export class CommandService {
if (enforce && fresh && !GroupSyncService.isUserActiveInGroup(context.sender, context.groupId)) {
return [{
recipient: context.sender,
- message: 'No puedes ver las tareas de este grupo porque no apareces como miembro activo. Pide acceso a un admin si crees que es un error.'
+ message: 'No puedes ver las tareas de este grupo. Pide que te añadan si crees que es un error.'
}];
}
@@ -449,7 +472,7 @@ export class CommandService {
if (items.length === 0) {
return [{
recipient: context.sender,
- message: `No hay pendientes en ${groupName}.`
+ message: italic(`No hay pendientes en ${groupName}.`)
}];
}
@@ -497,7 +520,7 @@ export class CommandService {
byGroup.set(key, arr);
}
- const sections: string[] = [];
+ const sections: string[] = [bold('Tus tareas')];
for (const [groupId, arr] of byGroup.entries()) {
const groupName =
(groupId && GroupSyncService.activeGroupsCache.get(groupId)) ||
@@ -581,7 +604,7 @@ export class CommandService {
if (task && task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
return [{
recipient: context.sender,
- message: 'No puedes completar esta tarea porque no apareces como miembro activo del grupo.'
+ message: 'No puedes completar esta tarea porque no eres de este grupo.'
}];
}
@@ -597,14 +620,14 @@ export class CommandService {
const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
return [{
recipient: context.sender,
- message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`
+ message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} _Ya estaba completada_ — ${res.task?.description || '(sin descripción)'}${due}`
}];
}
const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
return [{
recipient: context.sender,
- message: `${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} completada — ${res.task?.description || '(sin descripción)'}${due}`
+ message: `${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} _completada_ — ${res.task?.description || '(sin descripción)'}${due}`
}];
}
@@ -710,7 +733,7 @@ export class CommandService {
if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
return [{
recipient: context.sender,
- message: 'No puedes tomar esta tarea: no apareces como miembro activo del grupo. Pide acceso a un admin si crees que es un error.'
+ message: 'No puedes tomar esta tarea porque no eres de este grupo.'
}];
}
@@ -836,13 +859,20 @@ export class CommandService {
if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
return [{
recipient: context.sender,
- message: 'No puedes soltar esta tarea porque no apareces como miembro activo del grupo.'
+ message: '⚠️ No puedes soltar esta tarea porque no eres de este grupo.'
}];
}
const res = TaskService.unassignTask(resolvedId, context.sender);
const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
+ if (res.status === 'forbidden_personal') {
+ return [{
+ recipient: context.sender,
+ message: '⚠️ No puedes soltar una tarea personal. Márcala como completada para eliminarla'
+ }];
+ }
+
if (res.status === 'not_found') {
return [{
recipient: context.sender,
@@ -912,7 +942,7 @@ export class CommandService {
if (!m) {
return [{
recipient: context.sender,
- message: 'Uso: `/t configurar daily|l-v|weekly|off [HH:MM]`'
+ message: 'ℹ️ Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`'
}];
}
const hh = Math.max(0, Math.min(23, parseInt(m[1], 10)));
@@ -922,7 +952,7 @@ export class CommandService {
if (!freq) {
return [{
recipient: context.sender,
- message: 'Uso: `/t configurar daily|l-v|weekly|off [HH:MM]`'
+ message: 'ℹ️ Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`'
}];
}
const ensured = ensureUserExists(context.sender, this.dbInstance);
@@ -955,36 +985,178 @@ export class CommandService {
}];
}
+ // Enlace de acceso a la web (/t web)
+ if (action === 'web') {
+ // Solo por DM
+ if (isGroupId(context.groupId)) {
+ return [{
+ recipient: context.sender,
+ message: 'ℹ️ Este comando se usa por privado. Envíame `/t web` por DM.'
+ }];
+ }
+
+ const base = (process.env.WEB_BASE_URL || '').trim();
+ if (!base) {
+ return [{
+ recipient: context.sender,
+ message: '⚠️ La web no está configurada todavía. Contacta con el administrador (falta WEB_BASE_URL).'
+ }];
+ }
+
+ const ensured = ensureUserExists(context.sender, this.dbInstance);
+ if (!ensured) {
+ throw new Error('No se pudo asegurar el usuario');
+ }
+
+ const toIso = (d: Date) => d.toISOString().replace('T', ' ').replace('Z', '');
+ const now = new Date();
+ const nowIso = toIso(now);
+ const expiresIso = toIso(new Date(now.getTime() + 10 * 60 * 1000)); // 10 minutos
+
+ // Invalidar tokens vigentes (uso único)
+ this.dbInstance.prepare(`
+ UPDATE web_tokens
+ SET used_at = ?
+ WHERE user_id = ?
+ AND used_at IS NULL
+ AND expires_at > ?
+ `).run(nowIso, ensured, nowIso);
+
+ // Generar nuevo token y guardar solo el hash
+ const token = randomTokenBase64Url(32);
+ const tokenHash = await sha256Hex(token);
+
+ this.dbInstance.prepare(`
+ INSERT INTO web_tokens (user_id, token_hash, expires_at, metadata)
+ VALUES (?, ?, ?, NULL)
+ `).run(ensured, tokenHash, expiresIso);
+
+ try { Metrics.inc('web_tokens_issued_total'); } catch {}
+
+ const url = new URL(`/login?token=${encodeURIComponent(token)}`, base).toString();
+ return [{
+ recipient: context.sender,
+ message: `Acceso web: ${url}\nVálido durante 10 minutos. Si caduca, vuelve a enviar "/t web".`
+ }];
+ }
+
if (action !== 'nueva') {
+ const feature = String(process.env.FEATURE_HELP_V2 ?? 'true').toLowerCase();
+ const helpV2Enabled = !['false', '0', 'no'].includes(feature);
+
+ try { Metrics.inc('commands_unknown_total'); } catch {}
+ if (!helpV2Enabled) {
+ return [{
+ recipient: context.sender,
+ message: `Acción ${rawAction || '(vacía)'} no implementada aún`
+ }];
+ }
+ const header = `❓ ${section('Comando no reconocido')}`;
+ const cta = `Prueba ${code('/t ayuda')}`;
+ const help = getQuickHelp();
return [{
recipient: context.sender,
- message: `Acción ${rawAction || '(vacía)'} no implementada aún`
+ message: [header, cta, '', help].join('\n')
}];
}
// Parseo específico de "nueva"
- // Normalizar menciones del contexto para parseo y asignaciones
+ // Normalizar menciones del contexto para parseo y asignaciones (A2: fallback a números plausibles)
+ const MIN_FALLBACK_DIGITS = (() => {
+ const raw = (process.env.ONBOARDING_FALLBACK_MIN_DIGITS || '').trim();
+ const n = parseInt(raw || '8', 10);
+ return Number.isFinite(n) && n > 0 ? n : 8;
+ })();
+ const MAX_FALLBACK_DIGITS = (() => {
+ const raw = (process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '').trim();
+ const n = parseInt(raw || '15', 10);
+ return Number.isFinite(n) && n > 0 ? n : 15;
+ })();
+
+ type FailReason = 'non_numeric' | 'too_short' | 'too_long' | 'from_lid' | 'invalid';
+ const isDigits = (s: string) => /^\d+$/.test(s);
+ const plausibility = (s: string, opts?: { fromLid?: boolean }): { ok: boolean; reason?: FailReason } => {
+ if (!s) return { ok: false, reason: 'invalid' };
+ if (opts?.fromLid) return { ok: false, reason: 'from_lid' };
+ if (!isDigits(s)) return { ok: false, reason: 'non_numeric' };
+ if (s.length < MIN_FALLBACK_DIGITS) return { ok: false, reason: 'too_short' };
+ if (s.length >= MAX_FALLBACK_DIGITS) return { ok: false, reason: 'too_long' };
+ return { ok: true };
+ };
+ const incOnboardingFailure = (source: 'mentions' | 'tokens', reason: FailReason) => {
+ try {
+ const gid = isGroupId(context.groupId) ? context.groupId : 'dm';
+ Metrics.inc('onboarding_assign_failures_total', 1, { group_id: String(gid), source, reason });
+ } catch {}
+ };
+
+ // 1) Menciones aportadas por el backend (JIDs crudos)
+ const unresolvedAssigneeDisplays: string[] = [];
const mentionsNormalizedFromContext = Array.from(new Set(
- (context.mentions || [])
- .map(j => normalizeWhatsAppId(j))
- .map(id => id ? IdentityService.resolveAliasOrNull(id) : null)
- .filter((id): id is string => !!id)
+ (context.mentions || []).map((j) => {
+ const norm = normalizeWhatsAppId(j);
+ if (!norm) {
+ // agregar a no resolubles para JIT (mostrar sin @ ni dominio)
+ const raw = String(j || '');
+ const disp = raw.split('@')[0].split(':')[0].replace(/^@+/, '').replace(/^\+/, '');
+ if (disp) unresolvedAssigneeDisplays.push(disp);
+ incOnboardingFailure('mentions', 'invalid');
+ return null;
+ }
+ const resolved = IdentityService.resolveAliasOrNull(norm);
+ if (resolved) return resolved;
+ // detectar si la mención proviene de un JID @lid (no plausible aunque sea numérico)
+ const dom = String(j || '').split('@')[1]?.toLowerCase() || '';
+ const fromLid = dom.includes('lid');
+ const p = plausibility(norm, { fromLid });
+ if (p.ok) return norm;
+ // conservar para copy JIT
+ unresolvedAssigneeDisplays.push(norm);
+ incOnboardingFailure('mentions', p.reason!);
+ return null;
+ }).filter((id): id is string => !!id)
));
- // Detectar también tokens de texto que empiezan por '@' como posibles asignados
+
+ // 2) Tokens de texto que empiezan por '@' como posibles asignados
const atTokenCandidates = tokens.slice(2)
.filter(t => t.startsWith('@'))
- .map(t => t.replace(/^@+/, ''));
+ .map(t => t.replace(/^@+/, '').replace(/^\+/, ''));
const normalizedFromAtTokens = Array.from(new Set(
- atTokenCandidates
- .map(v => normalizeWhatsAppId(v))
- .map(id => id ? IdentityService.resolveAliasOrNull(id) : null)
- .filter((id): id is string => !!id)
+ atTokenCandidates.map((v) => {
+ const norm = normalizeWhatsAppId(v);
+ if (!norm) {
+ // agregar a no resolubles para JIT (texto ya viene sin @/+)
+ if (v) unresolvedAssigneeDisplays.push(v);
+ incOnboardingFailure('tokens', 'invalid');
+ return null;
+ }
+ const resolved = IdentityService.resolveAliasOrNull(norm);
+ if (resolved) return resolved;
+ const p = plausibility(norm, { fromLid: false });
+ if (p.ok) return norm;
+ // conservar para copy JIT (preferimos el token limpio v)
+ unresolvedAssigneeDisplays.push(v);
+ incOnboardingFailure('tokens', p.reason!);
+ return null;
+ }).filter((id): id is string => !!id)
));
+
+ // 3) Unir y deduplicar
const combinedAssigneeCandidates = Array.from(new Set([
...mentionsNormalizedFromContext,
...normalizedFromAtTokens
]));
+ if (process.env.NODE_ENV !== 'test') {
+ console.log('[A0] /t nueva menciones', {
+ context_mentions: context.mentions || [],
+ mentions_normalized: mentionsNormalizedFromContext,
+ at_tokens: atTokenCandidates,
+ at_normalized: normalizedFromAtTokens,
+ combined: combinedAssigneeCandidates
+ });
+ }
+
const { description, dueDate } = this.parseNueva(trimmed, mentionsNormalizedFromContext);
// Asegurar creador
@@ -1086,7 +1258,38 @@ export class CommandService {
});
}
-
+ // A4: DM JIT al asignador si quedaron menciones/tokens irrecuperables
+ {
+ const unresolvedList = Array.from(new Set(unresolvedAssigneeDisplays.filter(Boolean)));
+ if (unresolvedList.length > 0) {
+ const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test';
+ const enabled = isTest
+ ? String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true'
+ : (() => {
+ const v = process.env.ONBOARDING_PROMPTS_ENABLED;
+ return v == null ? true : ['true','1','yes'].includes(String(v).toLowerCase());
+ })();
+ const groupLabel = isGroupId(context.groupId) ? String(context.groupId) : 'dm';
+ if (!enabled) {
+ try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'disabled' }); } catch {}
+ } else {
+ const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
+ if (!bot || !/^\d+$/.test(bot)) {
+ try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'missing_bot_number' }); } catch {}
+ } else {
+ const list = unresolvedList.join(', ');
+ let groupCtx = '';
+ if (isGroupId(context.groupId)) {
+ const name = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId;
+ groupCtx = ` (en el grupo ${name})`;
+ }
+ const msg = `No puedo asignar a ${list} aún${groupCtx}. Pídele que toque este enlace y diga 'activar': https://wa.me/${bot}`;
+ responses.push({ recipient: createdBy, message: msg });
+ try { Metrics.inc('onboarding_prompts_sent_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure' }); } catch {}
+ }
+ }
+ }
+ }
return responses;
}
diff --git a/src/services/contacts.ts b/src/services/contacts.ts
index 5225d7d..d13bacd 100644
--- a/src/services/contacts.ts
+++ b/src/services/contacts.ts
@@ -67,6 +67,9 @@ export class ContactsService {
const rawJid = typeof rec?.jid === 'string' ? rec.jid : null;
if (rawId && rawJid && rawId.includes('@lid') && rawJid.endsWith('@s.whatsapp.net')) {
IdentityService.upsertAlias(rawId, rawJid, 'contacts.update');
+ if (process.env.NODE_ENV !== 'test') {
+ console.log('[A0] contacts.update learned alias', { alias: rawId, jid: rawJid });
+ }
}
} catch {}
diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts
index 2f241a1..1578d09 100644
--- a/src/services/group-sync.ts
+++ b/src/services/group-sync.ts
@@ -4,6 +4,7 @@ import { normalizeWhatsAppId } from '../utils/whatsapp';
import { Metrics } from './metrics';
import { IdentityService } from './identity';
import { AllowedGroups } from './allowed-groups';
+import { ResponseQueue } from './response-queue';
// In-memory cache for active groups
// const activeGroupsCache = new Map(); // groupId -> groupName
@@ -86,14 +87,82 @@ export class GroupSyncService {
console.log('ℹ️ Grupos crudos de la API:', JSON.stringify(groups, null, 2));
console.log('ℹ️ Sin filtrar por comunidad (modo multicomunidad). Total grupos:', groups.length);
- const dbGroupsBefore = this.dbInstance.prepare('SELECT id, active FROM groups').all();
+ const dbGroupsBefore = this.dbInstance.prepare('SELECT id, active, COALESCE(archived,0) AS archived, COALESCE(is_community,0) AS is_community, name FROM groups').all();
console.log('ℹ️ Grupos en DB antes de upsert:', dbGroupsBefore);
const result = await this.upsertGroups(groups);
- const dbGroupsAfter = this.dbInstance.prepare('SELECT id, active FROM groups').all();
+ const dbGroupsAfter = this.dbInstance.prepare('SELECT id, active, COALESCE(archived,0) AS archived, COALESCE(is_community,0) AS is_community, name FROM groups').all();
console.log('ℹ️ Grupos en DB después de upsert:', dbGroupsAfter);
+ // Detectar grupos que pasaron de activos a inactivos (y no están archivados) en este sync
+ try {
+ const beforeMap = new Map();
+ for (const r of dbGroupsBefore as any[]) {
+ beforeMap.set(String(r.id), { active: Number(r.active || 0), archived: Number(r.archived || 0), is_community: Number((r as any).is_community || 0), name: r.name ? String(r.name) : null });
+ }
+ const afterMap = new Map();
+ for (const r of dbGroupsAfter as any[]) {
+ afterMap.set(String(r.id), { active: Number(r.active || 0), archived: Number(r.archived || 0), is_community: Number((r as any).is_community || 0), name: r.name ? String(r.name) : null });
+ }
+
+ const newlyDeactivated: Array<{ id: string; name: string | null }> = [];
+ for (const [id, b] of beforeMap.entries()) {
+ const a = afterMap.get(id);
+ if (!a) continue;
+ if (Number(b.active) === 1 && Number(a.active) === 0 && Number(a.archived) === 0 && Number(a.is_community || 0) === 0 && Number(b.is_community || 0) === 0) {
+ newlyDeactivated.push({ id, name: a.name ?? b.name ?? null });
+ }
+ }
+
+ if (newlyDeactivated.length > 0) {
+ // Revocar tokens y desactivar membresía para estos grupos
+ this.dbInstance.transaction(() => {
+ for (const g of newlyDeactivated) {
+ this.dbInstance.prepare(`
+ UPDATE calendar_tokens
+ SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now')
+ WHERE group_id = ? AND revoked_at IS NULL
+ `).run(g.id);
+ this.dbInstance.prepare(`
+ UPDATE group_members
+ SET is_active = 0
+ WHERE group_id = ? AND is_active = 1
+ `).run(g.id);
+ }
+ })();
+
+ // Notificar a admins (omitir en tests)
+ if (String(process.env.NODE_ENV || '').toLowerCase() !== 'test') {
+ const adminSet = new Set();
+ const rawAdmins = String(process.env.ADMIN_USERS || '');
+ for (const token of rawAdmins.split(',').map(s => s.trim()).filter(Boolean)) {
+ const n = normalizeWhatsAppId(token);
+ if (n) adminSet.add(n);
+ }
+ const admins = Array.from(adminSet);
+ if (admins.length > 0) {
+ const messages = [];
+ const makeMsg = (g: { id: string; name: string | null }) => {
+ const label = g.name ? `${g.name} (${g.id})` : g.id;
+ return `⚠️ El grupo ${label} parece haber dejado de existir o no está disponible.\n\nAcciones disponibles:\n- Archivar (recomendado): /admin archivar-grupo ${g.id}\n- Borrar definitivamente: /admin borrar-grupo ${g.id}`;
+ };
+ for (const g of newlyDeactivated) {
+ const msg = makeMsg(g);
+ for (const admin of admins) {
+ messages.push({ recipient: admin, message: msg });
+ }
+ }
+ if (messages.length > 0) {
+ try { await ResponseQueue.add(messages as any); } catch (e) { console.warn('No se pudo encolar notificación a admins:', e); }
+ }
+ }
+ }
+ }
+ } catch (e) {
+ console.warn('⚠️ Error al procesar grupos desactivados para notificación/limpieza:', e);
+ }
+
// Completar labels faltantes en allowed_groups usando todos los grupos devueltos por la API
try { (AllowedGroups as any).dbInstance = this.dbInstance; this.fillMissingAllowedGroupLabels(groups); } catch {}
@@ -205,7 +274,7 @@ export class GroupSyncService {
}
private static cacheActiveGroups(): void {
- const groups = this.dbInstance.prepare('SELECT id, name FROM groups WHERE active = TRUE').all();
+ const groups = this.dbInstance.prepare('SELECT id, name FROM groups WHERE active = TRUE AND COALESCE(is_community,0) = 0 AND COALESCE(archived,0) = 0').all();
this.activeGroupsCache.clear();
for (const group of groups) {
this.activeGroupsCache.set(group.id, group.name);
@@ -219,6 +288,9 @@ export class GroupSyncService {
if (!Array.isArray(allGroups) || allGroups.length === 0) return 0;
const nameById = new Map();
for (const g of allGroups) {
+ // Omitir grupos "comunidad/announce" no operativos
+ const isComm = !!((g as any)?.isCommunity || (g as any)?.is_community || (g as any)?.isCommunityAnnounce || (g as any)?.is_community_announce);
+ if (isComm) continue;
if (!g?.id) continue;
const name = String(g.subject || '').trim();
if (!name) continue;
@@ -255,7 +327,7 @@ export class GroupSyncService {
}
private static getActiveGroupsCount(): number {
- const result = this.dbInstance.prepare('SELECT COUNT(*) as count FROM groups WHERE active = TRUE').get();
+ const result = this.dbInstance.prepare('SELECT COUNT(*) as count FROM groups WHERE active = TRUE AND COALESCE(is_community,0) = 0 AND COALESCE(archived,0) = 0').get();
return result?.count || 0;
}
@@ -343,21 +415,49 @@ export class GroupSyncService {
const existing = this.dbInstance.prepare('SELECT 1 FROM groups WHERE id = ?').get(group.id);
console.log('Checking group:', group.id, 'exists:', !!existing);
+ const isCommunityFlag = !!(((group as any)?.isCommunity) || ((group as any)?.is_community) || ((group as any)?.isCommunityAnnounce) || ((group as any)?.is_community_announce));
+
if (existing) {
const updateResult = this.dbInstance.prepare(
- 'UPDATE groups SET name = ?, community_id = COALESCE(?, community_id), active = TRUE, last_verified = CURRENT_TIMESTAMP WHERE id = ?'
- ).run(group.subject, group.linkedParent || null, group.id);
+ 'UPDATE groups SET name = ?, community_id = COALESCE(?, community_id), is_community = ?, active = TRUE, last_verified = CURRENT_TIMESTAMP WHERE id = ?'
+ ).run(group.subject, group.linkedParent || null, isCommunityFlag ? 1 : 0, group.id);
console.log('Updated group:', group.id, 'result:', updateResult);
updated++;
} else {
const insertResult = this.dbInstance.prepare(
- 'INSERT INTO groups (id, community_id, name, active) VALUES (?, ?, ?, TRUE)'
- ).run(group.id, (group.linkedParent ?? ''), group.subject);
+ 'INSERT INTO groups (id, community_id, name, active, is_community) VALUES (?, ?, ?, TRUE, ?)'
+ ).run(group.id, (group.linkedParent ?? ''), group.subject, isCommunityFlag ? 1 : 0);
console.log('Added group:', group.id, 'result:', insertResult);
added++;
}
- // Propagar subject como label a allowed_groups (no degrada estado; actualiza label si cambia)
- try { (AllowedGroups as any).dbInstance = this.dbInstance; AllowedGroups.upsertPending(group.id, group.subject, null); } catch {}
+ // Propagar subject a allowed_groups:
+ // - Si es grupo "comunidad/announce", bloquearlo.
+ // - En caso contrario, upsert pending y label.
+ try {
+ (AllowedGroups as any).dbInstance = this.dbInstance;
+ if (isCommunityFlag) {
+ AllowedGroups.setStatus(group.id, 'blocked', group.subject);
+ } else {
+ AllowedGroups.upsertPending(group.id, group.subject, null);
+ }
+ } catch {}
+ // Si es grupo de comunidad, limpiar residuos: revocar tokens y desactivar membresías
+ if (isCommunityFlag) {
+ try {
+ this.dbInstance.prepare(`
+ UPDATE calendar_tokens
+ SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now')
+ WHERE group_id = ? AND revoked_at IS NULL
+ `).run(group.id);
+ } catch {}
+ try {
+ this.dbInstance.prepare(`
+ UPDATE group_members
+ SET is_active = 0
+ WHERE group_id = ? AND is_active = 1
+ `).run(group.id);
+ } catch {}
+ }
}
return { added, updated };
@@ -427,7 +527,7 @@ export class GroupSyncService {
// Aprender mapping alias→número si vienen ambos
if (rawId && rawJid) {
- IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants');
+ try { IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); } catch {}
}
if (typeof p.isAdmin === 'boolean') {
@@ -439,14 +539,21 @@ export class GroupSyncService {
}
}
- const norm = normalizeWhatsAppId(jid);
+ let norm = normalizeWhatsAppId(jid);
+ if (!norm) {
+ const digits = (jid || '').replace(/\D+/g, '');
+ norm = digits || null;
+ }
if (!norm) continue;
result.push({ userId: norm, isAdmin });
}
- const resolved = (() => {
+ let resolved: Array<{ userId: string; isAdmin: boolean }>;
+ try {
const map = IdentityService.resolveMany(result.map(r => r.userId));
- return result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin }));
- })();
+ resolved = result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin }));
+ } catch {
+ resolved = result;
+ }
return resolved;
}
// Si no viene en el formato esperado, caemos al plan B
@@ -512,7 +619,7 @@ export class GroupSyncService {
// Aprender mapping alias→número si vienen ambos
if (rawId && rawJid) {
- IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants');
+ try { IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); } catch {}
}
if (typeof p.isAdmin === 'boolean') {
@@ -525,14 +632,21 @@ export class GroupSyncService {
}
}
- const norm = normalizeWhatsAppId(jid);
+ let norm = normalizeWhatsAppId(jid);
+ if (!norm) {
+ const digits = (jid || '').replace(/\D+/g, '');
+ norm = digits || null;
+ }
if (!norm) continue;
result.push({ userId: norm, isAdmin });
}
- const resolved = (() => {
+ let resolved: Array<{ userId: string; isAdmin: boolean }>;
+ try {
const map = IdentityService.resolveMany(result.map(r => r.userId));
- return result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin }));
- })();
+ resolved = result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin }));
+ } catch {
+ resolved = result;
+ }
return resolved;
}
@@ -615,9 +729,149 @@ export class GroupSyncService {
}
})();
+ try { this.computeAndPublishAliasCoverage(groupId); } catch {}
return { added, updated, deactivated };
}
+ private static computeAndPublishAliasCoverage(groupId: string): void {
+ try {
+ const rows = this.dbInstance.prepare(`
+ SELECT user_id
+ FROM group_members
+ WHERE group_id = ? AND is_active = 1
+ `).all(groupId) as Array<{ user_id: string }>;
+
+ const total = rows.length;
+ if (total === 0) {
+ try { Metrics.set('alias_coverage_ratio', 1, { group_id: groupId }); } catch {}
+ return;
+ }
+
+ let resolvable = 0;
+ for (const r of rows) {
+ const uid = String(r.user_id || '');
+ if (/^\d+$/.test(uid)) {
+ resolvable++;
+ continue;
+ }
+ try {
+ const resolved = IdentityService.resolveAliasOrNull(uid);
+ if (resolved && /^\d+$/.test(resolved)) {
+ resolvable++;
+ }
+ } catch {}
+ }
+ const ratio = Math.max(0, Math.min(1, total > 0 ? resolvable / total : 1));
+ try { Metrics.set('alias_coverage_ratio', ratio, { group_id: groupId }); } catch {}
+
+ // A3: publicación condicional del mensaje de onboarding (sin spam)
+ try {
+ // Flags y parámetros
+ const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test';
+ const enabled =
+ isTest
+ ? String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true'
+ : (() => {
+ const v = process.env.ONBOARDING_PROMPTS_ENABLED;
+ return v == null ? true : ['true', '1', 'yes'].includes(String(v).toLowerCase());
+ })();
+
+ if (!enabled) {
+ try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'disabled' }); } catch {}
+ return;
+ }
+
+ const thrRaw = Number(process.env.ONBOARDING_COVERAGE_THRESHOLD);
+ const threshold = Number.isFinite(thrRaw) ? Math.min(1, Math.max(0, thrRaw)) : 1;
+ if (ratio >= threshold) {
+ try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'coverage_100' }); } catch {}
+ return;
+ }
+
+ // Gating en modo enforce: no publicar en grupos no allowed
+ try {
+ const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
+ if (mode === 'enforce') {
+ try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
+ if (!AllowedGroups.isAllowed(groupId)) {
+ try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'not_allowed' }); } catch {}
+ return;
+ }
+ }
+ } catch {}
+
+ // Grace y cooldown desde tabla groups
+ const rowG = this.dbInstance.prepare(`
+ SELECT last_verified, onboarding_prompted_at
+ FROM groups
+ WHERE id = ?
+ `).get(groupId) as any;
+
+ const nowMs = Date.now();
+ const graceRaw = Number(process.env.ONBOARDING_GRACE_SECONDS);
+ const graceSec = Number.isFinite(graceRaw) && graceRaw >= 0 ? Math.floor(graceRaw) : 90;
+
+ const lv = rowG?.last_verified ? String(rowG.last_verified) : null;
+ if (lv) {
+ const iso = lv.includes('T') ? lv : (lv.replace(' ', 'T') + 'Z');
+ const ms = Date.parse(iso);
+ if (Number.isFinite(ms)) {
+ const ageSec = Math.floor((nowMs - ms) / 1000);
+ if (ageSec < graceSec) {
+ try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'grace_period' }); } catch {}
+ return;
+ }
+ }
+ }
+
+ const cdRaw = Number(process.env.ONBOARDING_COOLDOWN_DAYS);
+ const cdDays = Number.isFinite(cdRaw) && cdRaw >= 0 ? Math.floor(cdRaw) : 7;
+ const promptedAt = rowG?.onboarding_prompted_at ? String(rowG.onboarding_prompted_at) : null;
+ if (promptedAt) {
+ const iso = promptedAt.includes('T') ? promptedAt : (promptedAt.replace(' ', 'T') + 'Z');
+ const ms = Date.parse(iso);
+ if (Number.isFinite(ms)) {
+ const diffMs = nowMs - ms;
+ if (diffMs < cdDays * 24 * 60 * 60 * 1000) {
+ try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'cooldown_active' }); } catch {}
+ return;
+ }
+ }
+ }
+
+ // Número del bot para construir wa.me
+ const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
+ if (!bot || !/^\d+$/.test(bot)) {
+ try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'missing_bot_number' }); } catch {}
+ return;
+ }
+
+ // Encolar mensaje en la cola persistente y marcar timestamp en groups
+ const msg = `Para poder asignarte tareas y acceder a la web, envía 'activar' al bot por privado. Solo hace falta una vez. Enlace: https://wa.me/${bot}`;
+ this.dbInstance.transaction(() => {
+ this.dbInstance.prepare(`
+ INSERT INTO response_queue (recipient, message, status, attempts, metadata, created_at, updated_at, next_attempt_at)
+ VALUES (?, ?, 'queued', 0, NULL, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
+ `).run(groupId, msg);
+ this.dbInstance.prepare(`
+ UPDATE groups
+ SET onboarding_prompted_at = strftime('%Y-%m-%d %H:%M:%f','now')
+ WHERE id = ?
+ `).run(groupId);
+ })();
+
+ try { Metrics.inc('onboarding_prompts_sent_total', 1, { group_id: groupId, reason: 'coverage_below_threshold' }); } catch {}
+ } catch (e) {
+ // Evitar romper el flujo si falla el encolado
+ if (process.env.NODE_ENV !== 'test') {
+ console.warn('⚠️ Onboarding prompt skipped due to internal error for', groupId, e);
+ }
+ }
+ } catch (e) {
+ console.warn('⚠️ No se pudo calcular alias_coverage_ratio para', groupId, e);
+ }
+ }
+
/**
* Sync members for all active groups by calling Evolution API and reconciling.
* Devuelve contadores agregados.
@@ -791,6 +1045,8 @@ export class GroupSyncService {
FROM group_members gm
JOIN groups g ON g.id = gm.group_id
WHERE gm.user_id = ? AND gm.is_active = 1 AND g.active = 1
+ AND COALESCE(g.is_community,0) = 0
+ AND COALESCE(g.archived,0) = 0
`).all(userId) as any[];
const set = new Set();
for (const r of rows) {
diff --git a/src/services/maintenance.ts b/src/services/maintenance.ts
index b68e01e..965e8c4 100644
--- a/src/services/maintenance.ts
+++ b/src/services/maintenance.ts
@@ -23,6 +23,9 @@ export class MaintenanceService {
this.cleanupInactiveMembersOnce().catch(err => {
console.error('❌ Error en cleanup de miembros inactivos:', err);
});
+ this.reconcileAliasUsersOnce().catch(err => {
+ console.error('❌ Error en reconcile de alias de usuarios:', err);
+ });
}, intervalMs);
}
@@ -44,4 +47,59 @@ export class MaintenanceService {
const deleted = Number(res?.changes || 0);
return deleted;
}
+
+ /**
+ * Reconciliación de usuarios: fusiona IDs alias (LID u opacos) hacia el número real
+ * en todas las tablas relevantes, basándose en user_aliases.
+ * Devuelve el número de alias procesados.
+ */
+ static async reconcileAliasUsersOnce(instance: Database = db): Promise {
+ try {
+ const rows = instance.prepare(`SELECT alias, user_id FROM user_aliases WHERE alias != user_id`).all() as any[];
+ let merged = 0;
+
+ for (const r of rows) {
+ const alias = String(r.alias);
+ const real = String(r.user_id);
+
+ instance.transaction(() => {
+ const nowIso = toIsoSql(new Date());
+ // Asegurar existencia del usuario real
+ try {
+ instance.prepare(`INSERT OR IGNORE INTO users (id, created_at, updated_at) VALUES (?, ?, ?)`)
+ .run(real, nowIso, nowIso);
+ } catch {}
+
+ const updates = [
+ `UPDATE tasks SET created_by = ? WHERE created_by = ?`,
+ `UPDATE task_assignments SET user_id = ? WHERE user_id = ?`,
+ `UPDATE task_assignments SET assigned_by = ? WHERE assigned_by = ?`,
+ `UPDATE user_preferences SET user_id = ? WHERE user_id = ?`,
+ `UPDATE web_tokens SET user_id = ? WHERE user_id = ?`,
+ `UPDATE group_members SET user_id = ? WHERE user_id = ?`
+ ];
+
+ for (const sql of updates) {
+ try {
+ instance.prepare(sql).run(real, alias);
+ } catch {
+ // Ignorar si la tabla no existe en este despliegue
+ }
+ }
+
+ // Intentar eliminar el usuario alias si ya no tiene referencias
+ try {
+ instance.prepare(`DELETE FROM users WHERE id = ?`).run(alias);
+ } catch {}
+ })();
+
+ merged++;
+ }
+
+ return merged;
+ } catch {
+ // Si no existe la tabla user_aliases o hay error de DB, no hacemos nada
+ return 0;
+ }
+ }
}
diff --git a/src/services/messages/help.ts b/src/services/messages/help.ts
new file mode 100644
index 0000000..324f7b7
--- /dev/null
+++ b/src/services/messages/help.ts
@@ -0,0 +1,100 @@
+/**
+ * Centralización de contenidos de ayuda (Help v2)
+ * Nota: Solo copy; no depende de flags ni del runtime. Integración en command.ts llega en Fase 4.
+ */
+import { section, bullets, code, italic } from '../../utils/formatting';
+
+export function getQuickHelp(baseUrl?: string): string {
+ const parts: string[] = [];
+
+ parts.push(section('Comandos básicos'));
+ parts.push(
+ bullets([
+ `${code('/t n ...')} crear (acepta fecha y menciones)`,
+ `${code('/t ver mis')} por DM · ${code('/t ver todos')}`,
+ `${code('/t x 26')} completar (máx. 10) · ${code('/t tomar 12')} · ${code('/t soltar 26')}`,
+ `${code('/t configurar diario|l-v|semanal|off [HH:MM]')}`,
+ `${code('/t web')}`,
+ ])
+ );
+
+ parts.push(
+ italic('El bot responde por DM, incluso si escribes desde un grupo.')
+ );
+
+ return parts.join('\n');
+}
+
+export function getFullHelp(baseUrl?: string): string {
+ const out: string[] = [];
+
+ // Crear
+ out.push(section('Crear'));
+ out.push(
+ bullets([
+ `${code('/t n Descripción [YYYY-MM-DD|YY-MM-DD|hoy|mañana] [@menciones...]')}`,
+ 'En DM: sin menciones → asignada a quien la crea.',
+ 'En grupo: sin menciones → queda “sin responsable”.',
+ 'Fechas: usa la última válida encontrada; no acepta pasadas.',
+ ])
+ );
+
+ // Listados
+ out.push('');
+ out.push(section('Listados'));
+ out.push(
+ bullets([
+ `${code('/t ver grupo')} pendientes del grupo actual (desde grupo activo).`,
+ `${code('/t ver mis')} tus pendientes (por DM).`,
+ `${code('/t ver todos')} tus pendientes + “sin responsable”.`,
+ 'En grupo: “sin responsable” solo del grupo actual.',
+ 'En DM: “sin responsable” de tus grupos.',
+ `${code('/t ver sin')} solo “sin responsable” del grupo actual (desde grupo).`,
+ 'Máx. 10 elementos por sección; se añade “… y N más” si hay más.',
+ 'Fechas en DD/MM y ⚠️ si están vencidas.',
+ ])
+ );
+
+ // Fechas
+ out.push('');
+ out.push(section('Fechas'));
+ out.push(
+ bullets([
+ '`YYYY-MM-DD` o `YY-MM-DD` (se expande a `20YY-MM-DD`).',
+ '`hoy` y `mañana` (según TZ; por defecto Europe/Madrid).',
+ ])
+ );
+
+ // Recordatorios
+ out.push('');
+ out.push(section('Recordatorios'));
+ out.push(
+ bullets([
+ `${code('/t configurar diario|l-v|semanal|off [HH:MM]')}`,
+ 'Alias: diario/diaria, laborables (l-v/lv), semanal, off/apagar.',
+ 'Si omites hora, se conserva la anterior o se usa 08:30 por defecto (semanal asume lunes).',
+ ])
+ );
+
+ // Acceso web
+ out.push('');
+ out.push(section('Acceso web'));
+ out.push(
+ bullets([
+ `${code('/t web')} genera un enlace de acceso de un solo uso (10 min).`,
+ ])
+ );
+
+ // Otros
+ out.push('');
+ out.push(section('Otros'));
+ out.push(
+ bullets([
+ 'IDs visibles con 4 dígitos, pero puedes escribirlos sin ceros (ej.: 26).',
+ 'Máx. 10 IDs en completar/tomar; separa por espacios o comas.',
+ 'En “gating” estricto de grupos, el bot puede no responder en grupos no permitidos.',
+ ])
+ );
+
+ return out.join('\n');
+}
diff --git a/src/services/metrics.ts b/src/services/metrics.ts
index 7ebd228..ce02942 100644
--- a/src/services/metrics.ts
+++ b/src/services/metrics.ts
@@ -1,6 +1,8 @@
export class Metrics {
private static counters = new Map();
private static gauges = new Map();
+ private static labeledCounters = new Map>();
+ private static labeledGauges = new Map>();
static enabled(): boolean {
if (typeof process !== 'undefined' && process.env) {
@@ -13,14 +15,48 @@ export class Metrics {
return true;
}
- static inc(name: string, value: number = 1): void {
+ private static serializeLabels(labels: Record | undefined | null): string | null {
+ if (!labels) return null;
+ const keys = Object.keys(labels).sort();
+ const parts = keys.map(k => {
+ const val = String(labels[k] ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
+ return `${k}="${val}"`;
+ });
+ return parts.join(',');
+ }
+
+ private static ensureLabeledMap(map: Map>, name: string): Map {
+ let inner = map.get(name);
+ if (!inner) {
+ inner = new Map();
+ map.set(name, inner);
+ }
+ return inner;
+ }
+
+ static inc(name: string, value: number = 1, labels?: Record): void {
if (!this.enabled()) return;
+ if (labels && Object.keys(labels).length > 0) {
+ const key = this.serializeLabels(labels);
+ if (!key) return;
+ const inner = this.ensureLabeledMap(this.labeledCounters, name);
+ const v = inner.get(key) || 0;
+ inner.set(key, v + value);
+ return;
+ }
const v = this.counters.get(name) || 0;
this.counters.set(name, v + value);
}
- static set(name: string, value: number): void {
+ static set(name: string, value: number, labels?: Record): void {
if (!this.enabled()) return;
+ if (labels && Object.keys(labels).length > 0) {
+ const key = this.serializeLabels(labels);
+ if (!key) return;
+ const inner = this.ensureLabeledMap(this.labeledGauges, name);
+ inner.set(key, value);
+ return;
+ }
this.gauges.set(name, value);
}
@@ -35,6 +71,12 @@ export class Metrics {
const json = {
counters: Object.fromEntries(this.counters.entries()),
gauges: Object.fromEntries(this.gauges.entries()),
+ labeledCounters: Object.fromEntries(
+ Array.from(this.labeledCounters.entries()).map(([name, inner]) => [name, Object.fromEntries(inner.entries())])
+ ),
+ labeledGauges: Object.fromEntries(
+ Array.from(this.labeledGauges.entries()).map(([name, inner]) => [name, Object.fromEntries(inner.entries())])
+ )
};
return JSON.stringify(json);
}
@@ -43,15 +85,29 @@ export class Metrics {
lines.push(`# TYPE ${k} counter`);
lines.push(`${k} ${v}`);
}
+ for (const [name, inner] of this.labeledCounters.entries()) {
+ lines.push(`# TYPE ${name} counter`);
+ for (const [labelKey, v] of inner.entries()) {
+ lines.push(`${name}{${labelKey}} ${v}`);
+ }
+ }
for (const [k, v] of this.gauges.entries()) {
lines.push(`# TYPE ${k} gauge`);
lines.push(`${k} ${v}`);
}
+ for (const [name, inner] of this.labeledGauges.entries()) {
+ lines.push(`# TYPE ${name} gauge`);
+ for (const [labelKey, v] of inner.entries()) {
+ lines.push(`${name}{${labelKey}} ${v}`);
+ }
+ }
return lines.join('\n') + '\n';
}
static reset(): void {
this.counters.clear();
this.gauges.clear();
+ this.labeledCounters.clear();
+ this.labeledGauges.clear();
}
}
diff --git a/src/services/reminders.ts b/src/services/reminders.ts
index ce6b366..ba1d06a 100644
--- a/src/services/reminders.ts
+++ b/src/services/reminders.ts
@@ -165,7 +165,7 @@ export class RemindersService {
}
const sections: string[] = [];
- sections.push(pref.reminder_freq === 'weekly' ? `${ICONS.reminder} Recordatorio semanal — tus tareas` : `${ICONS.reminder} Recordatorio diario — tus tareas`);
+ sections.push(pref.reminder_freq === 'weekly' ? `${ICONS.reminder} RECORDATORIO SEMANAL — TUS TAREAS` : `${ICONS.reminder} RECORDATORIO DIARIO — TUS TAREAS`);
for (const [groupId, arr] of byGroup.entries()) {
const groupName =
diff --git a/src/services/response-queue.ts b/src/services/response-queue.ts
index feb90f6..18d09b7 100644
--- a/src/services/response-queue.ts
+++ b/src/services/response-queue.ts
@@ -2,6 +2,15 @@ import type { Database } from 'bun:sqlite';
import { db } from '../db';
import { IdentityService } from './identity';
import { normalizeWhatsAppId } from '../utils/whatsapp';
+import { Metrics } from './metrics';
+
+const MAX_FALLBACK_DIGITS = (() => {
+ const raw = (process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '').trim();
+ const n = parseInt(raw || '15', 10);
+ return Number.isFinite(n) && n > 0 ? n : 15;
+})();
+
+const isDigits = (s: string) => /^\d+$/.test(s);
type QueuedResponse = {
recipient: string;
@@ -117,9 +126,47 @@ export const ResponseQueue = {
const url = `${baseUrl}/message/sendText/${instance}`;
try {
+ // Resolver destinatario efectivo (alias → número) y validar antes de construir el payload
+ const rawRecipient = String(item.recipient || '');
+ let numberOrJid = rawRecipient;
+
+ if (rawRecipient.includes('@')) {
+ if (rawRecipient.endsWith('@g.us')) {
+ // Envío a grupo: usar el JID completo tal cual
+ numberOrJid = rawRecipient;
+ } else if (rawRecipient.endsWith('@s.whatsapp.net')) {
+ // JID de usuario: normalizar a dígitos
+ const n = normalizeWhatsAppId(rawRecipient);
+ if (!n || !isDigits(n)) {
+ try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'non_numeric' }); } catch {}
+ return { ok: false, status: 422, error: 'unresolvable_recipient_non_numeric' };
+ }
+ if (n.length >= MAX_FALLBACK_DIGITS) {
+ try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'too_long' }); } catch {}
+ return { ok: false, status: 422, error: 'unresolvable_recipient_too_long' };
+ }
+ numberOrJid = n;
+ } else {
+ try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'bad_domain' }); } catch {}
+ return { ok: false, status: 422, error: 'unresolvable_recipient_bad_domain' };
+ }
+ } else {
+ // Sin dominio: resolver alias si existe y validar
+ const resolved = IdentityService.resolveAliasOrNull(rawRecipient) || rawRecipient;
+ if (!isDigits(resolved)) {
+ try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'non_numeric' }); } catch {}
+ return { ok: false, status: 422, error: 'unresolvable_recipient_non_numeric' };
+ }
+ if (resolved.length >= MAX_FALLBACK_DIGITS) {
+ try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'too_long' }); } catch {}
+ return { ok: false, status: 422, error: 'unresolvable_recipient_too_long' };
+ }
+ numberOrJid = resolved;
+ }
+
// Build payload, adding mentioned JIDs if present in metadata
const payload: any = {
- number: item.recipient,
+ number: numberOrJid,
text: item.message,
};
diff --git a/src/tasks/service.ts b/src/tasks/service.ts
index c4645cc..0a87a88 100644
--- a/src/tasks/service.ts
+++ b/src/tasks/service.ts
@@ -25,9 +25,13 @@ export class TaskService {
const pickNextDisplayCode = (): number => {
const rows = this.dbInstance
.prepare(`
- SELECT display_code
- FROM tasks
- WHERE COALESCE(completed, 0) = 0 AND display_code IS NOT NULL
+ SELECT display_code
+ FROM tasks
+ WHERE display_code IS NOT NULL
+ AND (
+ COALESCE(completed, 0) = 0
+ OR (completed_at IS NOT NULL AND completed_at >= strftime('%Y-%m-%d %H:%M:%f','now','-24 hours'))
+ )
ORDER BY display_code ASC
`)
.all() as Array<{ display_code: number }>;
@@ -74,7 +78,7 @@ export class TaskService {
} catch {}
if (groupIdToInsert) {
- const exists = this.dbInstance.prepare(`SELECT 1 FROM groups WHERE id = ?`).get(groupIdToInsert);
+ const exists = this.dbInstance.prepare(`SELECT 1 FROM groups WHERE id = ? AND COALESCE(is_community,0) = 0`).get(groupIdToInsert);
if (!exists) {
groupIdToInsert = null;
}
@@ -242,7 +246,7 @@ export class TaskService {
const existing = this.dbInstance
.prepare(`
- SELECT id, description, due_date, completed, completed_at, display_code
+ SELECT id, description, due_date, completed, completed_at, display_code, group_id
FROM tasks
WHERE id = ?
`)
@@ -409,7 +413,7 @@ export class TaskService {
// Soltar tarea (unassign): idempotente
static unassignTask(taskId: number, userId: string): {
- status: 'unassigned' | 'not_assigned' | 'not_found' | 'completed';
+ status: 'unassigned' | 'not_assigned' | 'not_found' | 'completed' | 'forbidden_personal';
task?: { id: number; description: string; due_date: string | null; display_code: number | null };
now_unassigned?: boolean; // true si tras soltar no quedan asignados
} {
@@ -442,6 +446,30 @@ export class TaskService {
};
}
+ // Regla: no permitir soltar si es tarea personal y el usuario es el único asignatario
+ try {
+ const stats = this.dbInstance.prepare(`
+ SELECT COUNT(*) AS cnt,
+ SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS mine
+ FROM task_assignments
+ WHERE task_id = ?
+ `).get(ensuredUser, taskId) as any;
+ const cnt = Number(stats?.cnt || 0);
+ const mine = Number(stats?.mine || 0) > 0;
+ if ((existing.group_id == null || existing.group_id === null) && cnt === 1 && mine) {
+ return {
+ status: 'forbidden_personal',
+ task: {
+ id: Number(existing.id),
+ description: String(existing.description || ''),
+ due_date: existing.due_date ? String(existing.due_date) : null,
+ display_code: existing.display_code != null ? Number(existing.display_code) : null,
+ },
+ now_unassigned: false,
+ };
+ }
+ } catch {}
+
const deleteStmt = this.dbInstance.prepare(`
DELETE FROM task_assignments
WHERE task_id = ? AND user_id = ?
@@ -573,6 +601,13 @@ export class TaskService {
FROM tasks t
LEFT JOIN groups g ON g.id = t.group_id
WHERE COALESCE(t.completed, 0) = 0 AND t.completed_at IS NULL
+ AND (t.group_id IS NULL OR EXISTS (
+ SELECT 1 FROM groups g2
+ WHERE g2.id = t.group_id
+ AND COALESCE(g2.active,1)=1
+ AND COALESCE(g2.archived,0)=0
+ AND COALESCE(g2.is_community,0)=0
+ ))
ORDER BY
CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END,
t.due_date ASC,
@@ -595,8 +630,15 @@ export class TaskService {
const row = this.dbInstance
.prepare(`
SELECT COUNT(*) AS cnt
- FROM tasks
- WHERE COALESCE(completed, 0) = 0 AND completed_at IS NULL
+ FROM tasks t
+ WHERE COALESCE(t.completed, 0) = 0 AND t.completed_at IS NULL
+ AND (t.group_id IS NULL OR EXISTS (
+ SELECT 1 FROM groups g2
+ WHERE g2.id = t.group_id
+ AND COALESCE(g2.active,1)=1
+ AND COALESCE(g2.archived,0)=0
+ AND COALESCE(g2.is_community,0)=0
+ ))
`)
.get() as any;
return Number(row?.cnt || 0);
diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts
new file mode 100644
index 0000000..c7b355c
--- /dev/null
+++ b/src/utils/crypto.ts
@@ -0,0 +1,20 @@
+/**
+ * Utilidades criptográficas sin dependencias externas.
+ * - randomTokenBase64Url: genera un token aleatorio (base64url, sin relleno).
+ * - sha256Hex: calcula SHA-256 y devuelve en hex.
+ */
+
+export function randomTokenBase64Url(bytes: number = 32): string {
+ const arr = new Uint8Array(bytes);
+ crypto.getRandomValues(arr);
+ const b64 = Buffer.from(arr).toString('base64');
+ // base64url (RFC 4648) sin padding
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
+}
+
+export async function sha256Hex(input: string): Promise {
+ const data = new TextEncoder().encode(input);
+ const hashBuf = await crypto.subtle.digest('SHA-256', data);
+ const bytes = new Uint8Array(hashBuf);
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
+}
diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts
index c950c2b..5908763 100644
--- a/src/utils/formatting.ts
+++ b/src/utils/formatting.ts
@@ -30,3 +30,15 @@ export function bold(s: string): string {
export function italic(s: string): string {
return `_${s}_`;
}
+
+export function code(s: string): string {
+ return '`' + String(s) + '`';
+}
+
+export function section(s: string): string {
+ return `*${String(s).toUpperCase()}*`;
+}
+
+export function bullets(items: string[]): string {
+ return (items || []).map((i) => `- ${String(i)}`).join('\n');
+}
diff --git a/startup.sh b/startup.sh
index 1610d46..0866318 100644
--- a/startup.sh
+++ b/startup.sh
@@ -1,7 +1,59 @@
#!/bin/bash
+set -euo pipefail
-# Wait for server to be ready
+# Normalizar rutas de DB a absolutas para que bot y web apunten al mismo archivo
+if [ -n "${DB_PATH:-}" ]; then
+ # readlink -f resuelve a ruta absoluta y elimina ./, ../, etc.
+ DB_PATH="$(readlink -f "$DB_PATH")" || true
+ export DB_PATH
+else
+ DATA_DIR="${DATA_DIR:-/app/data}"
+ DATA_DIR="$(readlink -f "$DATA_DIR")" || true
+ export DATA_DIR
+fi
+
+# Determinar archivo de base de datos para esperas
+if [ -n "${DB_PATH:-}" ]; then
+ DB_FILE="$DB_PATH"
+else
+ DB_FILE="${DATA_DIR:-/app/data}/tasks.db"
+fi
+
+# Arranca el bot en segundo plano (puerto 3007 por defecto)
+BOT_PORT="${BOT_PORT:-3007}"
+PORT="$BOT_PORT" bun run index.ts &
+
+# Esperar a que exista el archivo de DB (máx ~30s)
+echo "[startup] Esperando a que exista la base de datos en: $DB_FILE"
+for i in $(seq 1 150); do
+ if [ -f "$DB_FILE" ]; then break; fi
+ sleep 0.2
+done
+
+# Esperar a que las tablas de auth estén creadas por las migraciones del bot (máx ~30s)
+if command -v sqlite3 >/dev/null 2>&1; then
+ echo "[startup] Verificando tablas de autenticación (web_tokens, web_sessions)..."
+ for i in $(seq 1 150); do
+ if [ -f "$DB_FILE" ]; then
+ HAS_AUTH=$(sqlite3 "$DB_FILE" "SELECT 1 FROM sqlite_master WHERE type='table' AND name IN ('web_tokens','web_sessions') LIMIT 1;" || true)
+ if [ "$HAS_AUTH" = "1" ]; then
+ break
+ fi
+ fi
+ sleep 0.2
+ done
+else
+ echo "[startup] sqlite3 no disponible; se omite verificación de tablas (continuando)."
+fi
+
+# Arranca la web (SvelteKit) en segundo plano en el puerto 3008
+WEB_PORT="${WEB_PORT:-3008}"
+pushd apps/web >/dev/null
+PORT="$WEB_PORT" bun ./build/index.js &
+popd >/dev/null
+
+# Pequeña espera para evitar condiciones de carrera
sleep 1
-# Start the main process
-exec bun run index.ts
+# Arranca el router en primer plano en el puerto 3000 (o $PORT si viene de CapRover)
+exec bun proxy.ts
diff --git a/tests/unit/db.test.ts b/tests/unit/db.test.ts
index f50d05b..8f94cfd 100644
--- a/tests/unit/db.test.ts
+++ b/tests/unit/db.test.ts
@@ -21,16 +21,31 @@ describe('Database', () => {
});
beforeEach(() => {
- // Reset database schema between tests by dropping tables and re-initializing (respect FKs)
+ // Reset del esquema entre tests.
+ // Desactivar FKs para poder dropear en cualquier orden, incluyendo tablas nuevas con FKs (p.ej., calendar_tokens).
+ testDb.exec('PRAGMA foreign_keys = OFF;');
+
+ // Tablas añadidas en migraciones posteriores (limpieza preventiva)
+ testDb.exec('DROP TABLE IF EXISTS calendar_tokens');
+ testDb.exec('DROP TABLE IF EXISTS web_sessions');
+ testDb.exec('DROP TABLE IF EXISTS web_tokens');
+ testDb.exec('DROP TABLE IF EXISTS allowed_groups');
+ testDb.exec('DROP TABLE IF EXISTS user_aliases');
+ testDb.exec('DROP TABLE IF EXISTS user_preferences');
+
+ // Tablas base (dependientes primero)
testDb.exec('DROP TABLE IF EXISTS task_assignments'); // Drop dependent tables first
testDb.exec('DROP TABLE IF EXISTS tasks');
testDb.exec('DROP TABLE IF EXISTS response_queue');
testDb.exec('DROP TABLE IF EXISTS group_members');
testDb.exec('DROP TABLE IF EXISTS groups');
testDb.exec('DROP TABLE IF EXISTS users');
- // También reiniciar histórico de migraciones para forzar recreación de tablas
+
+ // Reiniciar histórico de migraciones para forzar recreación íntegra
testDb.exec('DROP TABLE IF EXISTS schema_migrations');
- // Initialize schema on the test database instance
+
+ // Re-activar FKs y re-inicializar el esquema
+ testDb.exec('PRAGMA foreign_keys = ON;');
initializeDatabase(testDb);
});
diff --git a/tests/unit/server.onboarding-activar.test.ts b/tests/unit/server.onboarding-activar.test.ts
new file mode 100644
index 0000000..bada311
--- /dev/null
+++ b/tests/unit/server.onboarding-activar.test.ts
@@ -0,0 +1,51 @@
+import { describe, it, expect, beforeAll, beforeEach } from 'bun:test';
+import { Database } from 'bun:sqlite';
+import { initializeDatabase } from '../../src/db';
+import { WebhookServer } from '../../src/server';
+import { ResponseQueue } from '../../src/services/response-queue';
+
+describe('WebhookServer - DM "activar" (A4)', () => {
+ let memdb: Database;
+
+ beforeAll(() => {
+ memdb = new Database(':memory:');
+ initializeDatabase(memdb);
+ (WebhookServer as any).dbInstance = memdb;
+ (ResponseQueue as any).dbInstance = memdb;
+ });
+
+ beforeEach(() => {
+ process.env.NODE_ENV = 'test';
+ memdb.exec('DELETE FROM response_queue');
+ memdb.exec('DELETE FROM users');
+ });
+
+ function rowCount(): number {
+ const r = memdb.query("SELECT COUNT(*) AS c FROM response_queue").get() as any;
+ return Number(r?.c || 0);
+ }
+
+ it('al recibir "activar" por DM, asegura usuario y encola confirmación', async () => {
+ const data = {
+ key: { remoteJid: '7001@s.whatsapp.net', fromMe: false },
+ message: { conversation: 'activar' }
+ };
+ await WebhookServer.handleMessageUpsert(data);
+
+ expect(rowCount()).toBe(1);
+ const row = memdb.query("SELECT recipient, message FROM response_queue ORDER BY id").get() as any;
+ expect(row.recipient).toBe('7001');
+ expect(String(row.message).toLowerCase()).toContain('listo');
+ });
+
+ it('es idempotente: si envía "activar" de nuevo, se vuelve a encolar', async () => {
+ const data = {
+ key: { remoteJid: '8002@s.whatsapp.net', fromMe: false },
+ message: { conversation: 'activar' }
+ };
+ await WebhookServer.handleMessageUpsert(data);
+ await WebhookServer.handleMessageUpsert(data);
+
+ expect(rowCount()).toBe(2);
+ });
+});
diff --git a/tests/unit/server/groups-upsert.sync.test.ts b/tests/unit/server/groups-upsert.sync.test.ts
new file mode 100644
index 0000000..df999a1
--- /dev/null
+++ b/tests/unit/server/groups-upsert.sync.test.ts
@@ -0,0 +1,87 @@
+import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
+import { WebhookServer } from '../../../src/server';
+import { GroupSyncService } from '../../../src/services/group-sync';
+import { makeMemDb, injectAllServices, resetServices } from '../../helpers/db';
+import type { Database as SqliteDatabase } from 'bun:sqlite';
+
+describe('WebhookServer - groups.upsert encadena syncGroups y syncMembersForActiveGroups', () => {
+ const envBackup = { ...process.env };
+ let db: SqliteDatabase;
+
+ let originalSyncGroups: any;
+ let originalRefresh: any;
+ let originalSyncMembers: any;
+
+ let calledSyncGroups = 0;
+ let calledRefresh = 0;
+ let calledSyncMembers = 0;
+
+ beforeEach(() => {
+ process.env = {
+ ...envBackup,
+ NODE_ENV: 'production',
+ EVOLUTION_API_INSTANCE: 'inst-1',
+ EVOLUTION_API_URL: 'http://localhost:1234',
+ EVOLUTION_API_KEY: 'dummy',
+ CHATBOT_PHONE_NUMBER: '123456789',
+ WEBHOOK_URL: 'http://localhost:3000/webhook'
+ };
+
+ db = makeMemDb();
+ // Inyectar DB en servicios
+ (WebhookServer as any).dbInstance = db;
+ injectAllServices(db);
+
+ // Guardar originales y stubear
+ originalSyncGroups = GroupSyncService.syncGroups;
+ originalRefresh = GroupSyncService.refreshActiveGroupsCache;
+ originalSyncMembers = GroupSyncService.syncMembersForActiveGroups;
+
+ calledSyncGroups = 0;
+ calledRefresh = 0;
+ calledSyncMembers = 0;
+
+ GroupSyncService.syncGroups = async () => {
+ calledSyncGroups++;
+ return { added: 0, updated: 0 };
+ };
+ GroupSyncService.refreshActiveGroupsCache = () => {
+ calledRefresh++;
+ };
+ GroupSyncService.syncMembersForActiveGroups = async () => {
+ calledSyncMembers++;
+ return { groups: 0, added: 0, updated: 0, deactivated: 0 };
+ };
+ });
+
+ afterEach(async () => {
+ // Restaurar stubs
+ GroupSyncService.syncGroups = originalSyncGroups;
+ GroupSyncService.refreshActiveGroupsCache = originalRefresh;
+ GroupSyncService.syncMembersForActiveGroups = originalSyncMembers;
+
+ resetServices();
+ try { db.close(); } catch {}
+ process.env = envBackup;
+ });
+
+ test('dispara syncGroups -> refreshActiveGroupsCache -> syncMembersForActiveGroups y responde 200', async () => {
+ const payload = {
+ event: 'groups.upsert',
+ instance: 'inst-1',
+ data: { any: 'thing' }
+ };
+
+ const req = new Request('http://localhost/webhook', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ const res = await WebhookServer.handleRequest(req);
+ expect(res.status).toBe(200);
+ expect(calledSyncGroups).toBe(1);
+ expect(calledRefresh).toBe(1);
+ expect(calledSyncMembers).toBe(1);
+ });
+});
diff --git a/tests/unit/services/admin.test.ts b/tests/unit/services/admin.test.ts
index e2a87b0..248780e 100644
--- a/tests/unit/services/admin.test.ts
+++ b/tests/unit/services/admin.test.ts
@@ -5,12 +5,14 @@ import { AllowedGroups } from '../../../src/services/allowed-groups';
describe('AdminService - comandos básicos', () => {
const envBackup = process.env;
+ let memdb: any;
beforeEach(() => {
process.env = { ...envBackup, NODE_ENV: 'test', ADMIN_USERS: '34600123456' };
- const memdb = makeMemDb();
+ memdb = makeMemDb();
(AdminService as any).dbInstance = memdb;
(AllowedGroups as any).dbInstance = memdb;
+ AllowedGroups.resetForTests();
});
it('rechaza a usuarios no admin', async () => {
@@ -58,4 +60,63 @@ describe('AdminService - comandos básicos', () => {
expect(out.length).toBe(1);
expect(AllowedGroups.isAllowed('g2@g.us')).toBe(true);
});
+
+ it('archivar-aquí: marca archived=1, active=0; revoca tokens; desactiva membresías; bloquea allowed_groups', async () => {
+ // Sembrar grupo, token, membresía y allowed
+ memdb.exec(`INSERT INTO groups (id, community_id, name, active, archived, last_verified) VALUES ('g1@g.us','comm-1','G1',1,0,strftime('%Y-%m-%d %H:%M:%f','now'))`);
+ memdb.exec(`INSERT INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('g1@g.us','allowed',strftime('%Y-%m-%d %H:%M:%f','now'),strftime('%Y-%m-%d %H:%M:%f','now'))`);
+ memdb.exec(`INSERT INTO users (id, first_seen, last_seen) VALUES ('34600123456',strftime('%Y-%m-%d %H:%M:%f','now'),strftime('%Y-%m-%d %H:%M:%f','now'))`);
+ memdb.exec(`INSERT INTO calendar_tokens (type, user_id, group_id, token_hash, created_at) VALUES ('group','34600123456','g1@g.us','h1',strftime('%Y-%m-%d %H:%M:%f','now'))`);
+ memdb.exec(`INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) VALUES ('g1@g.us','34600123456',0,1,strftime('%Y-%m-%d %H:%M:%f','now'),strftime('%Y-%m-%d %H:%M:%f','now'))`);
+
+ const out = await AdminService.handle({
+ sender: '34600123456',
+ groupId: 'g1@g.us',
+ message: '/admin archivar-aquí'
+ });
+ expect(out.length).toBe(1);
+
+ const g = memdb.query(`SELECT active, archived FROM groups WHERE id='g1@g.us'`).get() as any;
+ expect(Number(g.active)).toBe(0);
+ expect(Number(g.archived)).toBe(1);
+
+ const tok = memdb.query(`SELECT revoked_at FROM calendar_tokens WHERE group_id='g1@g.us'`).get() as any;
+ expect(tok && tok.revoked_at).toBeTruthy();
+
+ const gm = memdb.query(`SELECT is_active FROM group_members WHERE group_id='g1@g.us'`).get() as any;
+ expect(Number(gm.is_active)).toBe(0);
+
+ // allowed_groups bloqueado
+ const ag = memdb.query(`SELECT status FROM allowed_groups WHERE group_id='g1@g.us'`).get() as any;
+ expect(String(ag.status)).toBe('blocked');
+ });
+
+ it('borrar-aquí: borra tasks, assignments, grupo y allowed_groups', async () => {
+ memdb.exec(`INSERT INTO groups (id, community_id, name, active) VALUES ('g2@g.us','comm-1','G2',1)`);
+ memdb.exec(`INSERT INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('g2@g.us','allowed',strftime('%Y-%m-%d %H:%M:%f','now'),strftime('%Y-%m-%d %H:%M:%f','now'))`);
+ memdb.exec(`INSERT INTO users (id, first_seen, last_seen) VALUES ('34600123456',strftime('%Y-%m-%d %H:%M:%f','now'),strftime('%Y-%m-%d %H:%M:%f','now'))`);
+ const r1 = memdb.query(`INSERT INTO tasks (description, group_id, created_by) VALUES ('t1','g2@g.us','34600123456') RETURNING id`).get() as any;
+ const r2 = memdb.query(`INSERT INTO tasks (description, group_id, created_by) VALUES ('t2','g2@g.us','34600123456') RETURNING id`).get() as any;
+ memdb.exec(`INSERT INTO task_assignments (task_id, user_id, assigned_by) VALUES (${Number(r1.id)}, '34600123456', '34600123456')`);
+ memdb.exec(`INSERT INTO task_assignments (task_id, user_id, assigned_by) VALUES (${Number(r2.id)}, '34600123456', '34600123456')`);
+
+ const out = await AdminService.handle({
+ sender: '34600123456',
+ groupId: 'g2@g.us',
+ message: '/admin borrar-aquí'
+ });
+ expect(out.length).toBe(1);
+
+ const tcount = memdb.query(`SELECT COUNT(*) AS c FROM tasks WHERE group_id='g2@g.us'`).get() as any;
+ expect(Number(tcount.c)).toBe(0);
+
+ const g = memdb.query(`SELECT 1 FROM groups WHERE id='g2@g.us'`).get() as any;
+ expect(g == null).toBe(true);
+
+ const ag = memdb.query(`SELECT 1 FROM allowed_groups WHERE group_id='g2@g.us'`).get() as any;
+ expect(ag == null).toBe(true);
+
+ const acount = memdb.query(`SELECT COUNT(*) AS c FROM task_assignments`).get() as any;
+ expect(Number(acount.c)).toBe(0);
+ });
});
diff --git a/tests/unit/services/command.claim-unassign.test.ts b/tests/unit/services/command.claim-unassign.test.ts
index cbf729c..c0c2683 100644
--- a/tests/unit/services/command.claim-unassign.test.ts
+++ b/tests/unit/services/command.claim-unassign.test.ts
@@ -86,11 +86,10 @@ describe('CommandService - /t tomar y /t soltar', () => {
expect(res[0].message).toContain('no encontrada');
});
- it('soltar: si queda sin dueño, mensaje adecuado', async () => {
+ it('soltar: personal única asignación → denegado', async () => {
const taskId = createTask('Desc soltar', '999', '2025-09-12', ['111']);
const res = await CommandService.handle(ctx('111', `/t soltar ${taskId}`));
- expect(res[0].message).toContain('queda sin responsable');
- expect(res[0].message).toContain(String(taskId));
+ expect(res[0].message).toContain('No puedes soltar una tarea personal. Márcala como completada para eliminarla');
});
it('soltar: not_assigned muestra mensaje informativo', async () => {
diff --git a/tests/unit/services/command.help.test.ts b/tests/unit/services/command.help.test.ts
index e167f62..24a7c51 100644
--- a/tests/unit/services/command.help.test.ts
+++ b/tests/unit/services/command.help.test.ts
@@ -1,54 +1,44 @@
-import { describe, it, expect, beforeAll, beforeEach } from 'bun:test';
-import { Database } from 'bun:sqlite';
-import { initializeDatabase } from '../../../src/db';
-import { TaskService } from '../../../src/tasks/service';
+import { describe, it, expect } from 'bun:test';
import { CommandService } from '../../../src/services/command';
-describe('CommandService - ayuda por DM', () => {
- let memdb: Database;
-
- beforeAll(() => {
- memdb = new Database(':memory:');
- initializeDatabase(memdb);
- TaskService.dbInstance = memdb;
- CommandService.dbInstance = memdb;
- });
-
- beforeEach(() => {
- process.env.NODE_ENV = 'test';
- process.env.TZ = 'Europe/Madrid';
- });
-
- it('responde con ayuda cuando el usuario escribe "/t"', async () => {
- const sender = '600111222';
- const responses = await CommandService.handle({
- sender,
- groupId: '12345@g.us',
- message: '/t',
+describe('CommandService - /t ayuda y /t ayuda avanzada usando help centralizado', () => {
+ it('"/t ayuda" incluye quick help y CTA a ayuda avanzada', async () => {
+ const res = await CommandService.handle({
+ sender: '600000001',
+ groupId: '',
+ message: '/t ayuda',
mentions: [],
});
- expect(Array.isArray(responses)).toBe(true);
- expect(responses.length).toBe(1);
- const r = responses[0];
- expect(r.recipient).toBe(sender);
- expect(r.message).toContain('Guía rápida:');
- expect(r.message).toContain('/t ver mis');
+ expect(Array.isArray(res)).toBe(true);
+ expect(res.length).toBeGreaterThan(0);
+ const msg = res[0].message;
+
+ expect(msg).toContain('/t ver mis');
+ expect(msg).toContain('/t web');
+ expect(msg).toContain('Ayuda avanzada');
+ expect(msg).toContain('/t ayuda avanzada');
+ // Configurar etiquetas en español
+ expect(msg).toContain('diario|l-v|semanal|off');
});
- it('responde con ayuda cuando el usuario escribe "/t ayuda"', async () => {
- const sender = '600111222';
- const responses = await CommandService.handle({
- sender,
- groupId: `${sender}@s.whatsapp.net`, // DM
- message: '/t ayuda',
+ it('"/t ayuda avanzada" incluye scopes de ver y formatos de fecha', async () => {
+ const res = await CommandService.handle({
+ sender: '600000001',
+ groupId: '',
+ message: '/t ayuda avanzada',
mentions: [],
});
- expect(responses.length).toBe(1);
- const r = responses[0];
- expect(r.recipient).toBe(sender);
- expect(r.message).toContain('Guía rápida:');
- expect(r.message).toContain('/t n');
+ const msg = res[0].message;
+ // Scopes de ver
+ expect(msg).toContain('/t ver sin');
+ expect(msg).toContain('/t ver grupo');
+ expect(msg).toContain('/t ver todos');
+ // Formatos de fecha
+ expect(msg).toContain('YY-MM-DD');
+ expect(msg).toContain('20YY');
+ // Configurar etiquetas en español
+ expect(msg).toContain('diario|l-v|semanal|off');
});
});
diff --git a/tests/unit/services/command.nueva-assignees.test.ts b/tests/unit/services/command.nueva-assignees.test.ts
new file mode 100644
index 0000000..293d423
--- /dev/null
+++ b/tests/unit/services/command.nueva-assignees.test.ts
@@ -0,0 +1,121 @@
+import { describe, it, expect, beforeAll, beforeEach } from 'bun:test';
+import { Database } from 'bun:sqlite';
+import { initializeDatabase } from '../../../src/db';
+import { TaskService } from '../../../src/tasks/service';
+import { CommandService } from '../../../src/services/command';
+import { Metrics } from '../../../src/services/metrics';
+
+describe('CommandService - /t nueva (A2: fallback menciones)', () => {
+ let memdb: Database;
+
+ beforeAll(() => {
+ memdb = new Database(':memory:');
+ initializeDatabase(memdb);
+ TaskService.dbInstance = memdb;
+ CommandService.dbInstance = memdb;
+ });
+
+ beforeEach(() => {
+ process.env.NODE_ENV = 'test';
+ process.env.METRICS_ENABLED = 'true';
+ process.env.CHATBOT_PHONE_NUMBER = '1234567890';
+ process.env.ONBOARDING_FALLBACK_MIN_DIGITS = '8';
+ Metrics.reset();
+
+ memdb.exec(`
+ DELETE FROM task_assignments;
+ DELETE FROM tasks;
+ DELETE FROM users;
+ DELETE FROM user_preferences;
+ `);
+ });
+
+ function getLastTaskId(): number {
+ const row = memdb.prepare(`SELECT id FROM tasks ORDER BY id DESC LIMIT 1`).get() as any;
+ return row ? Number(row.id) : 0;
+ }
+
+ function getAssignees(taskId: number): string[] {
+ const rows = memdb.prepare(`SELECT user_id FROM task_assignments WHERE task_id = ? ORDER BY assigned_at ASC`).all(taskId) as any[];
+ return rows.map(r => String(r.user_id));
+ }
+
+ it('asigna por mención de JID real sin alias (fallback a dígitos)', async () => {
+ const res = await CommandService.handle({
+ sender: '111',
+ groupId: '', // DM
+ message: '/t n Tarea por mención',
+ mentions: ['34600123456@s.whatsapp.net'],
+ });
+ expect(res.length).toBeGreaterThan(0);
+
+ const taskId = getLastTaskId();
+ expect(taskId).toBeGreaterThan(0);
+ const assignees = getAssignees(taskId);
+ expect(assignees).toContain('34600123456');
+ });
+
+ it('asigna por token @ con + (fallback y normalización de +)', async () => {
+ const res = await CommandService.handle({
+ sender: '222',
+ groupId: '',
+ message: '/t nueva Tarea token @+34600123456',
+ mentions: [],
+ });
+ expect(res.length).toBeGreaterThan(0);
+
+ const taskId = getLastTaskId();
+ const assignees = getAssignees(taskId);
+ expect(assignees).toContain('34600123456');
+ });
+
+ it('mención/tokens irrecuperables: no bloquea, incrementa métrica', async () => {
+ const res = await CommandService.handle({
+ sender: '333',
+ groupId: '',
+ message: '/t n Mixta @34600123456 @lid-opaque',
+ mentions: [],
+ });
+ expect(res.length).toBeGreaterThan(0);
+
+ const taskId = getLastTaskId();
+ const assignees = getAssignees(taskId);
+ expect(assignees).toContain('34600123456');
+
+ const prom = Metrics.render('prom');
+ expect(prom).toContain('onboarding_assign_failures_total');
+ expect(prom).toContain('source="tokens"');
+ });
+
+ it('filtra el número del bot entre candidatos', async () => {
+ // CHATBOT_PHONE_NUMBER = '1234567890' (ver beforeEach)
+ const res = await CommandService.handle({
+ sender: '444',
+ groupId: '',
+ message: '/t n Asignar @1234567890 @34600123456',
+ mentions: [],
+ });
+ expect(res.length).toBeGreaterThan(0);
+
+ const taskId = getLastTaskId();
+ const assignees = getAssignees(taskId);
+ expect(assignees).toContain('34600123456');
+ expect(assignees).not.toContain('1234567890');
+ });
+
+ it('deduplica cuando el mismo usuario aparece por mención y token', async () => {
+ const res = await CommandService.handle({
+ sender: '555',
+ groupId: '',
+ message: '/t n Dedupe @34600123456',
+ mentions: ['34600123456@s.whatsapp.net'],
+ });
+ expect(res.length).toBeGreaterThan(0);
+
+ const taskId = getLastTaskId();
+ const assignees = getAssignees(taskId);
+ // Solo un registro para el mismo usuario
+ const count = assignees.filter(v => v === '34600123456').length;
+ expect(count).toBe(1);
+ });
+});
diff --git a/tests/unit/services/command.onboarding-jit-lid.test.ts b/tests/unit/services/command.onboarding-jit-lid.test.ts
new file mode 100644
index 0000000..e69a729
--- /dev/null
+++ b/tests/unit/services/command.onboarding-jit-lid.test.ts
@@ -0,0 +1,61 @@
+import { describe, it, expect, beforeAll, beforeEach } from 'bun:test';
+import { Database } from 'bun:sqlite';
+import { initializeDatabase } from '../../../src/db';
+import { CommandService } from '../../../src/services/command';
+import { ResponseQueue } from '../../../src/services/response-queue';
+import { TaskService } from '../../../src/tasks/service';
+
+describe('CommandService - JIT onboarding para menciones @lid y números demasiado largos', () => {
+ let memdb: Database;
+
+ beforeAll(() => {
+ memdb = new Database(':memory:');
+ initializeDatabase(memdb);
+ (CommandService as any).dbInstance = memdb;
+ (TaskService as any).dbInstance = memdb;
+ (ResponseQueue as any).dbInstance = memdb;
+ });
+
+ beforeEach(() => {
+ process.env.NODE_ENV = 'test';
+ process.env.ONBOARDING_ENABLE_IN_TEST = 'true';
+ process.env.CHATBOT_PHONE_NUMBER = '34600000000';
+ memdb.exec('DELETE FROM response_queue');
+ memdb.exec('DELETE FROM users');
+ memdb.exec('DELETE FROM tasks');
+ memdb.exec('DELETE FROM task_assignments');
+ });
+
+ it('cuando la mención proviene de @lid, no se asigna y se devuelve un DM JIT al creador con wa.me', async () => {
+ const res = await CommandService.handle({
+ sender: '34611111111',
+ groupId: '123@g.us',
+ message: '/t n Pedir cita @166348562894911',
+ mentions: ['166348562894911@lid']
+ });
+
+ const toCreator = res.filter(r => r.recipient === '34611111111').map(r => r.message).join('\n');
+ expect(toCreator).toMatch(/activar/i);
+ expect(toCreator).toMatch(/https:\/\/wa\.me\/34600000000/);
+
+ // No se devuelve ningún mensaje dirigido al "número" opaco
+ const recipients = res.map(r => r.recipient);
+ expect(recipients).not.toContain('166348562894911');
+ });
+
+ it('cuando el token @ lleva 15+ dígitos, no es plausible y devuelve DM JIT al creador', async () => {
+ const res = await CommandService.handle({
+ sender: '34622222222',
+ groupId: '123@g.us',
+ message: '/t n Tarea prueba @123456789012345',
+ mentions: []
+ });
+
+ const toCreator = res.filter(r => r.recipient === '34622222222').map(r => r.message).join('\n');
+ expect(toCreator).toMatch(/activar/i);
+ expect(toCreator).toMatch(/https:\/\/wa\.me\/34600000000/);
+
+ const recipients = res.map(r => r.recipient);
+ expect(recipients).not.toContain('123456789012345');
+ });
+});
diff --git a/tests/unit/services/command.onboarding-jit.test.ts b/tests/unit/services/command.onboarding-jit.test.ts
new file mode 100644
index 0000000..2f47cd6
--- /dev/null
+++ b/tests/unit/services/command.onboarding-jit.test.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect, beforeAll, beforeEach } from 'bun:test';
+import { Database } from 'bun:sqlite';
+import { initializeDatabase } from '../../../src/db';
+import { TaskService } from '../../../src/tasks/service';
+import { CommandService } from '../../../src/services/command';
+import { Metrics } from '../../../src/services/metrics';
+
+describe('CommandService - A4 JIT DM al asignador', () => {
+ let memdb: Database;
+
+ beforeAll(() => {
+ memdb = new Database(':memory:');
+ initializeDatabase(memdb);
+ TaskService.dbInstance = memdb as any;
+ CommandService.dbInstance = memdb as any;
+ });
+
+ beforeEach(() => {
+ process.env.NODE_ENV = 'test';
+ process.env.METRICS_ENABLED = 'true';
+ process.env.ONBOARDING_ENABLE_IN_TEST = 'true';
+ process.env.CHATBOT_PHONE_NUMBER = '555111222';
+ Metrics.reset();
+
+ memdb.exec(`
+ DELETE FROM task_assignments;
+ DELETE FROM tasks;
+ DELETE FROM users;
+ `);
+ });
+
+ it('envía un único DM JIT al asignador con la lista y enlace wa.me cuando hay tokens no resolubles', async () => {
+ const res = await CommandService.handle({
+ sender: '111',
+ groupId: '', // DM
+ message: '/t n Mixta @34600123456 @lid-opaque',
+ mentions: [],
+ });
+
+ // Debe existir al menos el ACK y el JIT
+ expect(res.length).toBeGreaterThan(0);
+
+ const toSender = res.filter(r => r.recipient === '111');
+ expect(toSender.length).toBeGreaterThan(0);
+
+ const jit = toSender.find(r => r.message.includes('activar'));
+ expect(jit).toBeTruthy();
+ expect(jit!.message).toContain('lid-opaque');
+ expect(jit!.message).toContain('https://wa.me/555111222');
+
+ const prom = Metrics.render('prom');
+ expect(prom).toContain('onboarding_prompts_sent_total');
+ expect(prom).toContain('source="jit_assignee_failure"');
+ });
+
+ it('no envía JIT si falta CHATBOT_PHONE_NUMBER y contabiliza skipped:missing_bot_number', async () => {
+ process.env.CHATBOT_PHONE_NUMBER = '';
+
+ const res = await CommandService.handle({
+ sender: '222',
+ groupId: '',
+ message: '/t n Solo opaco @alias-xyz',
+ mentions: [],
+ });
+
+ const anyJit = res.find(r => r.recipient === '222' && r.message.includes('activar'));
+ expect(anyJit).toBeUndefined();
+
+ const prom = Metrics.render('prom');
+ expect(prom).toContain('onboarding_prompts_skipped_total');
+ expect(prom).toContain('reason="missing_bot_number"');
+ });
+});
diff --git a/tests/unit/services/command.reminders-config.test.ts b/tests/unit/services/command.reminders-config.test.ts
index 3c8915f..0584a85 100644
--- a/tests/unit/services/command.reminders-config.test.ts
+++ b/tests/unit/services/command.reminders-config.test.ts
@@ -73,7 +73,7 @@ describe('CommandService - configurar recordatorios', () => {
it('configurar con opción inválida devuelve uso correcto y no escribe en DB', async () => {
const res = await runCmd('/t configurar foo');
expect(res).toHaveLength(1);
- expect(res[0].message).toContain('Uso: `/t configurar daily|l-v|weekly|off [HH:MM]`');
+ expect(res[0].message).toContain('Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`');
const pref = getPref();
expect(pref).toBeNull();
diff --git a/tests/unit/services/command.unknown-help.test.ts b/tests/unit/services/command.unknown-help.test.ts
new file mode 100644
index 0000000..d835ad8
--- /dev/null
+++ b/tests/unit/services/command.unknown-help.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect } from 'bun:test';
+import { CommandService } from '../../../src/services/command';
+
+describe('CommandService - comando desconocido devuelve ayuda rápida', () => {
+ it('responde con encabezado y CTA a /t ayuda incluyendo quick help', async () => {
+ const res = await CommandService.handle({
+ sender: '600000001',
+ groupId: '',
+ message: '/t qué tareas tengo hoy?',
+ mentions: [],
+ });
+
+ expect(Array.isArray(res)).toBe(true);
+ expect(res.length).toBeGreaterThan(0);
+ const msg = res[0].message;
+
+ expect(msg).toContain('COMANDO NO RECONOCIDO');
+ expect(msg).toContain('/t ayuda');
+ expect(msg).toContain('/t ver mis');
+ expect(msg).toContain('/t web');
+ expect(msg).toContain('/t configurar');
+ });
+});
diff --git a/tests/unit/services/command.web-login.test.ts b/tests/unit/services/command.web-login.test.ts
new file mode 100644
index 0000000..3b03f0d
--- /dev/null
+++ b/tests/unit/services/command.web-login.test.ts
@@ -0,0 +1,132 @@
+import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
+import { Database } from 'bun:sqlite';
+import { initializeDatabase } from '../../../src/db';
+import { CommandService } from '../../../src/services/command';
+import { sha256Hex } from '../../../src/utils/crypto';
+import { Metrics } from '../../../src/services/metrics';
+
+const envBackup = { ...process.env };
+let memdb: Database;
+
+describe('CommandService - /t web (emisión de token de login)', () => {
+ beforeEach(() => {
+ process.env = {
+ ...envBackup,
+ NODE_ENV: 'test',
+ TZ: 'Europe/Madrid',
+ WEB_BASE_URL: 'https://app.example.test',
+ METRICS_ENABLED: 'true'
+ };
+ Metrics.reset?.();
+ memdb = new Database(':memory:');
+ initializeDatabase(memdb);
+ (CommandService as any).dbInstance = memdb;
+ });
+
+ afterEach(() => {
+ process.env = envBackup;
+ try { memdb.close(); } catch {}
+ });
+
+ test('DM feliz: devuelve URL con token y persiste hash en web_tokens', async () => {
+ const res = await CommandService.handle({
+ sender: '34600123456',
+ groupId: '34600123456@s.whatsapp.net', // DM (no @g.us)
+ message: '/t web',
+ mentions: []
+ });
+ expect(Array.isArray(res)).toBe(true);
+ expect(res.length).toBe(1);
+ expect(res[0].recipient).toBe('34600123456');
+ expect(res[0].message).toContain('https://app.example.test');
+
+ const m = res[0].message.match(/https?:\/\/\S+/);
+ expect(m).toBeTruthy();
+ const url = new URL(m![0]);
+ expect(url.pathname).toBe('/login');
+ const token = url.searchParams.get('token') || '';
+ expect(token.length).toBeGreaterThan(0);
+
+ const hash = await sha256Hex(token);
+ const row = memdb.prepare(`
+ SELECT user_id, token_hash, used_at, expires_at
+ FROM web_tokens
+ WHERE user_id = ? AND token_hash = ?
+ `).get('34600123456', hash) as any;
+
+ expect(row).toBeTruthy();
+ expect(row.user_id).toBe('34600123456');
+ expect(row.token_hash).toBe(hash);
+ expect(row.used_at).toBeNull();
+ // expires_at debe ser en el futuro
+ const now = new Date();
+ const exp = new Date(String(row.expires_at).replace(' ', 'T') + 'Z');
+ expect(exp.getTime()).toBeGreaterThan(now.getTime() + 9 * 60 * 1000 - 10 * 1000); // ~>= 9min50s
+ expect(Metrics.get('web_tokens_issued_total')).toBe(1);
+ });
+
+ test('En grupo: responde que debe usarse por privado y no inserta token', async () => {
+ const res = await CommandService.handle({
+ sender: '34600123456',
+ groupId: '123@g.us',
+ message: '/t web',
+ mentions: []
+ });
+ expect(res.length).toBe(1);
+ expect(res[0].recipient).toBe('34600123456');
+ expect(res[0].message.toLowerCase()).toContain('privado');
+
+ const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM web_tokens WHERE user_id = ?`).get('34600123456') as any;
+ expect(Number(cnt.c)).toBe(0);
+ });
+
+ test('Sin WEB_BASE_URL: error claro y no inserta token', async () => {
+ delete process.env.WEB_BASE_URL;
+
+ const res = await CommandService.handle({
+ sender: '34600123456',
+ groupId: '34600123456@s.whatsapp.net',
+ message: '/t web',
+ mentions: []
+ });
+ expect(res.length).toBe(1);
+ expect(res[0].message).toContain('no está configurada');
+ const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM web_tokens WHERE user_id = ?`).get('34600123456') as any;
+ expect(Number(cnt.c)).toBe(0);
+ });
+
+ test('Token vigente: se invalida y se emite uno nuevo (queda solo 1 activo)', async () => {
+ // Primera emisión
+ {
+ const r1 = await CommandService.handle({
+ sender: '34600123456',
+ groupId: '34600123456@s.whatsapp.net',
+ message: '/t web',
+ mentions: []
+ });
+ expect(r1.length).toBe(1);
+ }
+
+ // Segunda emisión (debe invalidar el anterior)
+ {
+ const r2 = await CommandService.handle({
+ sender: '34600123456',
+ groupId: '34600123456@s.whatsapp.net',
+ message: '/t web',
+ mentions: []
+ });
+ expect(r2.length).toBe(1);
+ }
+
+ const counts = memdb.prepare(`
+ SELECT
+ SUM(CASE WHEN used_at IS NULL THEN 1 ELSE 0 END) AS active,
+ COUNT(*) AS total
+ FROM web_tokens
+ WHERE user_id = ?
+ `).get('34600123456') as any;
+
+ expect(Number(counts.total)).toBeGreaterThanOrEqual(2);
+ expect(Number(counts.active)).toBe(1);
+ });
+});
diff --git a/tests/unit/services/group-sync.coverage.test.ts b/tests/unit/services/group-sync.coverage.test.ts
new file mode 100644
index 0000000..2629676
--- /dev/null
+++ b/tests/unit/services/group-sync.coverage.test.ts
@@ -0,0 +1,53 @@
+import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
+import type { Database as SqliteDatabase } from 'bun:sqlite';
+import { makeMemDb, injectAllServices, resetServices } from '../../helpers/db';
+import { GroupSyncService } from '../../../src/services/group-sync';
+import { IdentityService } from '../../../src/services/identity';
+import { Metrics } from '../../../src/services/metrics';
+
+describe('GroupSyncService - alias_coverage_ratio', () => {
+ const envBackup = { ...process.env };
+ let db: SqliteDatabase;
+
+ beforeEach(() => {
+ process.env = { ...envBackup, NODE_ENV: 'test', METRICS_ENABLED: 'true' };
+ Metrics.reset();
+ db = makeMemDb();
+ injectAllServices(db);
+ (GroupSyncService as any).dbInstance = db;
+ (IdentityService as any).dbInstance = db;
+
+ // Crear grupo activo requerido por FK
+ db.prepare(`INSERT INTO groups (id, community_id, name, active) VALUES (?, ?, ?, 1)`)
+ .run('g1@g.us', 'comm-1', 'G1');
+ });
+
+ afterEach(() => {
+ resetServices();
+ try { db.close(); } catch {}
+ Metrics.reset();
+ process.env = envBackup;
+ try { (IdentityService as any).inMemoryAliases?.clear?.(); } catch {}
+ });
+
+ test('calcula cobertura en función de dígitos y alias resolubles', () => {
+ // Sembrar un alias resoluble: aliasB -> 222
+ IdentityService.upsertAlias('aliasB', '222', 'test');
+
+ // Reconciliar miembros: 111 (dígitos), aliasA (no resoluble), aliasB (resoluble a 222)
+ GroupSyncService.reconcileGroupMembers('g1@g.us', [
+ { userId: '111', isAdmin: false },
+ { userId: 'aliasA', isAdmin: false },
+ { userId: 'aliasB', isAdmin: false },
+ ], '2025-01-01 00:00:00.000');
+
+ const json = JSON.parse(Metrics.render('json'));
+ const key = 'group_id="g1@g.us"';
+ const ratio = json?.labeledGauges?.alias_coverage_ratio?.[key];
+
+ expect(typeof ratio).toBe('number');
+ // 2 resolubles (111 y aliasB) de 3 totales -> 0.666...
+ expect(ratio).toBeGreaterThan(0.66 - 1e-6);
+ expect(ratio).toBeLessThan(0.67 + 1e-6);
+ });
+});
diff --git a/tests/unit/services/group-sync.onboarding.test.ts b/tests/unit/services/group-sync.onboarding.test.ts
new file mode 100644
index 0000000..4b38a5b
--- /dev/null
+++ b/tests/unit/services/group-sync.onboarding.test.ts
@@ -0,0 +1,83 @@
+import { describe, it, beforeEach, afterEach, expect } from 'bun:test';
+import Database from 'bun:sqlite';
+import { initializeDatabase } from '../../../src/db';
+import { GroupSyncService } from '../../../src/services/group-sync';
+import { AllowedGroups } from '../../../src/services/allowed-groups';
+
+const envBackup = { ...process.env } as NodeJS.ProcessEnv;
+
+describe('GroupSyncService - onboarding A3', () => {
+ let memdb: Database;
+
+ beforeEach(() => {
+ process.env = {
+ ...envBackup,
+ NODE_ENV: 'test',
+ ONBOARDING_ENABLE_IN_TEST: 'true',
+ ONBOARDING_PROMPTS_ENABLED: 'true',
+ ONBOARDING_GRACE_SECONDS: '0',
+ ONBOARDING_COOLDOWN_DAYS: '7',
+ ONBOARDING_COVERAGE_THRESHOLD: '1',
+ CHATBOT_PHONE_NUMBER: '555111222'
+ };
+ memdb = new Database(':memory:');
+ initializeDatabase(memdb);
+ (GroupSyncService as any).dbInstance = memdb;
+ (AllowedGroups as any).dbInstance = memdb;
+
+ // Sembrar grupo activo
+ memdb.prepare(`INSERT INTO groups (id, community_id, name, active, last_verified) VALUES (?,?,?,?, strftime('%Y-%m-%d %H:%M:%f','now'))`)
+ .run('g1@g.us', 'comm-1', 'Grupo 1', 1);
+ });
+
+ afterEach(() => {
+ memdb.close();
+ process.env = envBackup;
+ });
+
+ it('publica prompt cuando coverage < 100, grace cumplido y sin cooldown', () => {
+ // snapshot con un resoluble (dígitos) y uno no resoluble (alias sin mapeo)
+ const res = GroupSyncService.reconcileGroupMembers('g1@g.us', [
+ { userId: '111', isAdmin: false },
+ { userId: 'alias_lid', isAdmin: false }
+ ], '2025-01-01 00:00:00.000');
+
+ expect(res).toEqual(expect.objectContaining({ added: 2 }));
+ const row = memdb.query(`SELECT recipient, message, status FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any;
+ expect(row).toBeTruthy();
+ expect(row.recipient).toBe('g1@g.us');
+ expect(String(row.message)).toContain('https://wa.me/555111222');
+
+ const g = memdb.query(`SELECT onboarding_prompted_at FROM groups WHERE id = 'g1@g.us'`).get() as any;
+ expect(g).toBeTruthy();
+ expect(g.onboarding_prompted_at).toBeTruthy();
+ });
+
+ it('omite prompt cuando coverage = 100', () => {
+ // Previo: no debe existir prompt para este grupo
+ const before = memdb.query(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
+ expect(Number(before.c)).toBe(0);
+
+ // snapshot totalmente resoluble (dos dígitos)
+ GroupSyncService.reconcileGroupMembers('g1@g.us', [
+ { userId: '111', isAdmin: false },
+ { userId: '222', isAdmin: false }
+ ], '2025-01-01 00:00:00.000');
+
+ const after = memdb.query(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
+ expect(Number(after.c)).toBe(0);
+ });
+
+ it('omite prompt en modo enforce si el grupo no está allowed', () => {
+ process.env.GROUP_GATING_MODE = 'enforce';
+ AllowedGroups.setStatus('g1@g.us', 'blocked');
+
+ GroupSyncService.reconcileGroupMembers('g1@g.us', [
+ { userId: '111', isAdmin: false },
+ { userId: 'alias_lid', isAdmin: false }
+ ], '2025-01-01 00:00:00.000');
+
+ const count = memdb.query(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
+ expect(Number(count.c)).toBe(0);
+ });
+});
diff --git a/tests/unit/services/group-sync.scheduler.test.ts b/tests/unit/services/group-sync.scheduler.test.ts
index 0363a7c..76619ae 100644
--- a/tests/unit/services/group-sync.scheduler.test.ts
+++ b/tests/unit/services/group-sync.scheduler.test.ts
@@ -1,5 +1,8 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
+import Database from 'bun:sqlite';
+import { initializeDatabase, ensureUserExists } from '../../../src/db';
import { GroupSyncService } from '../../../src/services/group-sync';
+import { ResponseQueue } from '../../../src/services/response-queue';
const envBackup = { ...process.env };
let originalSyncMembers: any;
@@ -81,4 +84,42 @@ describe('GroupSyncService - scheduler de miembros', () => {
GroupSyncService.stopGroupsScheduler();
expect(called).toBeGreaterThanOrEqual(1);
});
+
+ test('al desactivarse un grupo en sync: revoca tokens, desactiva membresía y notifica admins', async () => {
+ process.env = { ...envBackup, NODE_ENV: 'development', ADMIN_USERS: '34600123456' };
+
+ const memdb = new Database(':memory:');
+ initializeDatabase(memdb);
+ (GroupSyncService as any).dbInstance = memdb;
+ (ResponseQueue as any).dbInstance = memdb;
+
+ // Sembrar grupo activo con miembro y token de calendario
+ memdb.exec(`INSERT INTO groups (id, community_id, name, active, archived, last_verified) VALUES ('g1@g.us','comm-1','G1',1,0,strftime('%Y-%m-%d %H:%M:%f','now'))`);
+ const uid = ensureUserExists('34600123456', memdb)!;
+ memdb.exec(`INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) VALUES ('g1@g.us','${uid}',0,1,strftime('%Y-%m-%d %H:%M:%f','now'),strftime('%Y-%m-%d %H:%M:%f','now'))`);
+ memdb.exec(`INSERT INTO calendar_tokens (type, user_id, group_id, token_hash, created_at) VALUES ('group','${uid}','g1@g.us','h1',strftime('%Y-%m-%d %H:%M:%f','now'))`);
+
+ // Stub: API devuelve 0 grupos → el existente pasa a inactivo
+ const originalFetch = (GroupSyncService as any).fetchGroupsFromAPI;
+ (GroupSyncService as any).fetchGroupsFromAPI = async () => [];
+
+ try {
+ await GroupSyncService.syncGroups(true);
+ } finally {
+ // Restaurar stub
+ (GroupSyncService as any).fetchGroupsFromAPI = originalFetch;
+ }
+
+ // Tokens revocados
+ const tok = memdb.query(`SELECT revoked_at FROM calendar_tokens WHERE group_id='g1@g.us'`).get() as any;
+ expect(tok && tok.revoked_at).toBeTruthy();
+
+ // Membresías desactivadas
+ const mem = memdb.query(`SELECT is_active FROM group_members WHERE group_id='g1@g.us'`).get() as any;
+ expect(Number(mem?.is_active || 0)).toBe(0);
+
+ // Notificación encolada a admins
+ const msg = memdb.query(`SELECT message FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any;
+ expect(msg && String(msg.message)).toContain('/admin archivar-grupo g1@g.us');
+ });
});
diff --git a/tests/unit/services/group-sync.test.ts b/tests/unit/services/group-sync.test.ts
index 5e2bdcc..1e7f7e6 100644
--- a/tests/unit/services/group-sync.test.ts
+++ b/tests/unit/services/group-sync.test.ts
@@ -137,13 +137,14 @@ describe('GroupSyncService', () => {
await GroupSyncService.syncGroups();
const group = testDb.query('SELECT * FROM groups WHERE id = ?').get('old-group');
- expect(group).toEqual({
+ expect(group).toEqual(expect.objectContaining({
id: 'old-group',
community_id: 'test-community',
name: 'Old Group',
active: 0,
- last_verified: expect.any(String)
- });
+ last_verified: expect.any(String),
+ onboarding_prompted_at: null
+ }));
expect(group.last_verified).not.toBe('2023-01-01'); // Should be updated
});
diff --git a/tests/unit/services/help-content.test.ts b/tests/unit/services/help-content.test.ts
new file mode 100644
index 0000000..2782d8c
--- /dev/null
+++ b/tests/unit/services/help-content.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect } from 'bun:test';
+import { getQuickHelp, getFullHelp } from '../../../src/services/messages/help';
+
+describe('Help content (centralizado)', () => {
+ it('quick help incluye comandos básicos y /t web', () => {
+ const s = getQuickHelp();
+ expect(s).toContain('/t n');
+ expect(s).toContain('/t ver mis');
+ expect(s).toContain('/t x 26');
+ expect(s).toContain('/t configurar');
+ expect(s).toContain('/t web');
+ // Debe usar etiquetas en español para configurar
+ expect(s).toContain('diario|l-v|semanal|off');
+ expect(s).not.toContain('daily|l-v|weekly|off');
+ });
+
+ it('full help cubre scopes de "ver", formatos de fecha y límites', () => {
+ const s = getFullHelp();
+ // Scopes
+ expect(s).toContain('/t ver grupo');
+ expect(s).toContain('/t ver mis');
+ expect(s).toContain('/t ver todos');
+ expect(s).toContain('/t ver sin');
+
+ // Fechas
+ expect(s).toContain('YY-MM-DD');
+ expect(s).toContain('20YY');
+ expect(s).toContain('hoy');
+ expect(s).toContain('mañana');
+
+ // Límites
+ expect(s).toContain('Máx. 10');
+
+ // Configuración en español
+ expect(s).toContain('diario|l-v|semanal|off');
+ expect(s).not.toContain('daily|l-v|weekly|off');
+ });
+});
diff --git a/tests/unit/tasks/claim-unassign.test.ts b/tests/unit/tasks/claim-unassign.test.ts
index 3f8d85b..7ba67a4 100644
--- a/tests/unit/tasks/claim-unassign.test.ts
+++ b/tests/unit/tasks/claim-unassign.test.ts
@@ -61,18 +61,22 @@ describe('TaskService - claim/unassign', () => {
expect(res.task?.id).toBe(taskId);
});
- it('unassign: happy path; luego not_assigned; now_unassigned=true', () => {
- const taskId = createTask('Soltar luego de asignar', '111', '2025-09-20', ['222']);
- // soltar por el mismo usuario
+ it('unassign: happy path con múltiples asignados; luego not_assigned; now_unassigned=false', () => {
+ const taskId = createTask('Soltar luego de asignar', '111', '2025-09-20', ['222', '333']);
const res1 = TaskService.unassignTask(taskId, '222');
expect(res1.status).toBe('unassigned');
expect(res1.task?.id).toBe(taskId);
- expect(res1.now_unassigned).toBe(true);
+ expect(res1.now_unassigned).toBe(false);
- // idempotente si no estaba asignado
const res2 = TaskService.unassignTask(taskId, '222');
expect(res2.status).toBe('not_assigned');
- expect(res2.now_unassigned).toBe(true);
+ expect(res2.now_unassigned).toBe(false);
+ });
+
+ it('unassign: personal + único asignado → forbidden_personal', () => {
+ const taskId = createTask('Personal única asignación', '111', '2025-09-21', ['222']);
+ const res = TaskService.unassignTask(taskId, '222');
+ expect(res.status).toBe('forbidden_personal');
});
it('unassign: not_found', () => {
diff --git a/tests/unit/tasks/service.list-active.test.ts b/tests/unit/tasks/service.list-active.test.ts
index c357d6b..396ff85 100644
--- a/tests/unit/tasks/service.list-active.test.ts
+++ b/tests/unit/tasks/service.list-active.test.ts
@@ -60,4 +60,26 @@ describe('TaskService - listAllActive', () => {
const rows = TaskService.listAllActive(2);
expect(rows.length).toBe(2);
});
+
+ it('excluye tareas de grupos archivados o inactivos en listAllActive y countAllActive', () => {
+ seedGroup('g1@g.us', 'G1');
+ seedGroup('g2@g.us', 'G2');
+
+ const c = '34600123456';
+ createTask('G1 A', '2025-11-01', 'g1@g.us', c);
+ createTask('G2 A', '2025-11-02', 'g2@g.us', c);
+
+ // Archivar g1 -> solo debe aparecer G2 A
+ memdb.prepare(`UPDATE groups SET archived = 1 WHERE id = ?`).run('g1@g.us');
+ let rows = TaskService.listAllActive(10);
+ expect(rows.map(r => r.description)).toEqual(['G2 A']);
+ expect(TaskService.countAllActive()).toBe(1);
+
+ // Reactivar g1 y desactivar g2 -> solo debe aparecer G1 A
+ memdb.prepare(`UPDATE groups SET archived = 0 WHERE id = ?`).run('g1@g.us');
+ memdb.prepare(`UPDATE groups SET active = 0 WHERE id = ?`).run('g2@g.us');
+ rows = TaskService.listAllActive(10);
+ expect(rows.map(r => r.description)).toEqual(['G1 A']);
+ expect(TaskService.countAllActive()).toBe(1);
+ });
});
diff --git a/tests/unit/utils/formatting.test.ts b/tests/unit/utils/formatting.test.ts
new file mode 100644
index 0000000..4c93395
--- /dev/null
+++ b/tests/unit/utils/formatting.test.ts
@@ -0,0 +1,19 @@
+import { describe, it, expect } from 'bun:test';
+import { code, section, bullets } from '../../../src/utils/formatting';
+
+describe('utils/formatting helpers', () => {
+ it('code envuelve en backticks', () => {
+ expect(code('abc')).toBe('`abc`');
+ expect(code('')).toBe('``');
+ });
+
+ it('section devuelve mayúsculas en negrita', () => {
+ expect(section('Comandos básicos')).toBe('*COMANDOS BÁSICOS*');
+ expect(section('web')).toBe('*WEB*');
+ });
+
+ it('bullets genera lista con guiones', () => {
+ expect(bullets(['uno', 'dos'])).toBe('- uno\n- dos');
+ expect(bullets([])).toBe('');
+ });
+});
diff --git a/tests/web/api.integrations.feeds.test.ts b/tests/web/api.integrations.feeds.test.ts
new file mode 100644
index 0000000..a3dc156
--- /dev/null
+++ b/tests/web/api.integrations.feeds.test.ts
@@ -0,0 +1,124 @@
+import { describe, it, expect, afterAll } from 'bun:test';
+import Database from 'bun:sqlite';
+import { startWebServer } from './helpers/server';
+import { createTempDb } from './helpers/db';
+
+async function sha256Hex(input: string): Promise {
+ const enc = new TextEncoder().encode(input);
+ const buf = await crypto.subtle.digest('SHA-256', enc);
+ const bytes = new Uint8Array(buf);
+ return Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
+function toIsoSql(d = new Date()): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+describe('API - /api/integrations/feeds', () => {
+ const PORT = 19123;
+ const BASE = `http://127.0.0.1:${PORT}`;
+ const USER = '34600123456';
+ const GROUP = '123@g.us';
+ const SID = 'sid-test-123';
+
+ const tmp = createTempDb();
+ const db: any = tmp.db as Database;
+
+ // Sembrar datos mínimos
+ db.exec(`INSERT OR IGNORE INTO users (id) VALUES ('${USER}')`);
+ db.exec(`INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES ('${GROUP}', 'comm1', 'Group 1', 1)`);
+ db.exec(`INSERT OR IGNORE INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('${GROUP}', 'allowed', '${toIsoSql()}', '${toIsoSql()}')`);
+ db.exec(`INSERT OR IGNORE INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) VALUES ('${GROUP}', '${USER}', 0, 1, '${toIsoSql()}', '${toIsoSql()}')`);
+
+ // Crear sesión web válida (cookie sid)
+ const sidHashPromise = sha256Hex(SID);
+ const serverPromise = startWebServer({
+ port: PORT,
+ env: {
+ DB_PATH: tmp.path,
+ WEB_BASE_URL: BASE
+ }
+ });
+
+ let server: Awaited | null = null;
+
+ afterAll(async () => {
+ try { await server?.stop(); } catch {}
+ try { tmp.cleanup(); } catch {}
+ });
+
+ it('GET: autogenera y devuelve URLs para personal, grupo y aggregate; POST rotate rota el de grupo', async () => {
+ server = await serverPromise;
+
+ const sidHash = await sidHashPromise;
+ // Insertar sesión después de lanzar el server (mismo archivo)
+ db.exec(`
+ INSERT OR REPLACE INTO web_sessions (id, user_id, session_hash, created_at, last_seen_at, expires_at)
+ VALUES ('sess-1', '${USER}', '${sidHash}', '${toIsoSql()}', '${toIsoSql()}', '${toIsoSql(new Date(Date.now() + 2 * 60 * 60 * 1000))}')
+ `);
+
+ // GET feeds
+ const res = await fetch(`${BASE}/api/integrations/feeds`, {
+ headers: { cookie: `sid=${SID}` }
+ });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ // Personal URL presente (recién creada)
+ expect(typeof body.personal).toBe('object');
+ expect(typeof body.personal.url === 'string' && body.personal.url.endsWith('.ics')).toBe(true);
+ // Aggregate URL presente (recién creada)
+ expect(typeof body.aggregate).toBe('object');
+ expect(typeof body.aggregate.url === 'string' && body.aggregate.url.endsWith('.ics')).toBe(true);
+ // Grupo autogenerado con URL presente
+ const groupFeed = (body.groups || []).find((g: any) => g.groupId === GROUP);
+ expect(groupFeed).toBeDefined();
+ expect(typeof groupFeed.url === 'string' && groupFeed.url.endsWith('.ics')).toBe(true);
+
+ const previousGroupUrl = groupFeed.url;
+
+ // POST rotate para el grupo
+ const resRotate = await fetch(`${BASE}/api/integrations/feeds/rotate`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ cookie: `sid=${SID}`
+ },
+ body: JSON.stringify({ type: 'group', groupId: GROUP })
+ });
+ expect(resRotate.status).toBe(200);
+ const bodyRotate = await resRotate.json();
+ expect(typeof bodyRotate.url === 'string' && bodyRotate.url.endsWith('.ics')).toBe(true);
+ expect(bodyRotate.url).not.toBe(previousGroupUrl);
+ });
+
+ it('el feed de grupo devuelve 410 cuando el grupo está archivado y 200 cuando está activo', async () => {
+ server = await serverPromise;
+
+ const sidHash = await sidHashPromise;
+ // Asegurar sesión
+ db.exec(`
+ INSERT OR REPLACE INTO web_sessions (id, user_id, session_hash, created_at, last_seen_at, expires_at)
+ VALUES ('sess-2', '${USER}', '${sidHash}', '${toIsoSql()}', '${toIsoSql()}', '${toIsoSql(new Date(Date.now() + 2 * 60 * 60 * 1000))}')
+ `);
+
+ // Obtener URL de grupo
+ const res = await fetch(`${BASE}/api/integrations/feeds`, {
+ headers: { cookie: `sid=${SID}` }
+ });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ const groupFeed = (body.groups || []).find((g: any) => g.groupId === GROUP);
+ expect(groupFeed).toBeDefined();
+
+ // Activo: debe devolver 200
+ const ok1 = await fetch(groupFeed.url);
+ expect(ok1.status).toBe(200);
+
+ // Archivar grupo y verificar 410
+ db.exec(`UPDATE groups SET archived = 1 WHERE id = '${GROUP}'`);
+ const gone = await fetch(groupFeed.url);
+ expect(gone.status).toBe(410);
+ });
+});
diff --git a/tests/web/api.me.preferences.test.ts b/tests/web/api.me.preferences.test.ts
new file mode 100644
index 0000000..29356d7
--- /dev/null
+++ b/tests/web/api.me.preferences.test.ts
@@ -0,0 +1,166 @@
+import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
+import { Database } from 'bun:sqlite';
+import { mkdtempSync, rmSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { startWebServer } from './helpers/server';
+import { initializeDatabase, ensureUserExists } from '../../src/db';
+
+async function sha256Hex(input: string): Promise {
+ const enc = new TextEncoder().encode(input);
+ const buf = await crypto.subtle.digest('SHA-256', enc);
+ const bytes = new Uint8Array(buf);
+ return Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
+function toIsoSql(d = new Date()): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+describe('Web API - GET /api/me/preferences', () => {
+ const userId = '34600123456';
+ let dbPath: string;
+ let server: Awaited> | null = null;
+ let tmpDir: string;
+
+ beforeAll(async () => {
+ tmpDir = mkdtempSync(join(tmpdir(), 'webtest-'));
+ dbPath = join(tmpDir, 'tasks.db');
+
+ // Inicializar DB en archivo (como en prod)
+ const db = new Database(dbPath);
+ initializeDatabase(db);
+ ensureUserExists(userId, db);
+
+ // Crear sesión válida
+ const sid = 'sid-test-pref';
+ const hash = await sha256Hex(sid);
+ const now = new Date();
+ const nowIso = toIsoSql(now);
+ const expIso = toIsoSql(new Date(now.getTime() + 60 * 60 * 1000)); // +1h
+
+ db.prepare(`
+ INSERT INTO web_sessions (session_hash, user_id, created_at, last_seen_at, expires_at)
+ VALUES (?, ?, ?, ?, ?)
+ `).run(hash, userId, nowIso, nowIso, expIso);
+ db.close();
+
+ // Arrancar web apuntando a este DB
+ server = await startWebServer({
+ port: 19100,
+ env: { DB_PATH: dbPath, TZ: 'UTC' }
+ });
+
+ // Probar que el endpoint responde (no asertivo aún)
+ const res = await fetch(`${server.baseUrl}/api/me/preferences`, {
+ headers: { Cookie: `sid=${sid}` }
+ });
+ expect(res.status).toBe(200);
+ });
+
+ afterAll(async () => {
+ try { await server?.stop(); } catch {}
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
+ });
+
+ it('devuelve valores por defecto cuando no hay preferencias guardadas', async () => {
+ const sid = 'sid-test-pref';
+ const res = await fetch(`${server!.baseUrl}/api/me/preferences`, {
+ headers: { Cookie: `sid=${sid}` }
+ });
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json).toEqual({ freq: 'off', time: '08:30' });
+ });
+
+ it('POST /api/me/preferences - flujo completo', async () => {
+ const sid = 'sid-test-pref';
+ const base = server!.baseUrl;
+
+ // 1) daily sin hora -> usa default '08:30'
+ let res = await fetch(`${base}/api/me/preferences`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', Cookie: `sid=${sid}` },
+ body: JSON.stringify({ freq: 'daily' })
+ });
+ expect(res.status).toBe(200);
+ let json = await res.json();
+ expect(json).toEqual({ freq: 'daily', time: '08:30' });
+
+ // GET refleja lo guardado
+ res = await fetch(`${base}/api/me/preferences`, { headers: { Cookie: `sid=${sid}` } });
+ expect(res.status).toBe(200);
+ json = await res.json();
+ expect(json).toEqual({ freq: 'daily', time: '08:30' });
+
+ // 2) weekly con hora '7:5' → normaliza a '07:05'
+ res = await fetch(`${base}/api/me/preferences`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', Cookie: `sid=${sid}` },
+ body: JSON.stringify({ freq: 'weekly', time: '7:5' })
+ });
+ expect(res.status).toBe(200);
+ json = await res.json();
+ expect(json).toEqual({ freq: 'weekly', time: '07:05' });
+
+ // 3) weekdays con hora inválida → 400
+ res = await fetch(`${base}/api/me/preferences`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', Cookie: `sid=${sid}` },
+ body: JSON.stringify({ freq: 'weekdays', time: '25:00' })
+ });
+ expect(res.status).toBe(400);
+
+ // 4) freq inválida → 400
+ res = await fetch(`${base}/api/me/preferences`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', Cookie: `sid=${sid}` },
+ body: JSON.stringify({ freq: 'foo', time: '08:00' })
+ });
+ expect(res.status).toBe(400);
+
+ // 5) off sin hora → conserva última ('07:05')
+ res = await fetch(`${base}/api/me/preferences`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', Cookie: `sid=${sid}` },
+ body: JSON.stringify({ freq: 'off' })
+ });
+ expect(res.status).toBe(200);
+ json = await res.json();
+ expect(json).toEqual({ freq: 'off', time: '07:05' });
+
+ // GET refleja off con hora conservada
+ res = await fetch(`${base}/api/me/preferences`, { headers: { Cookie: `sid=${sid}` } });
+ expect(res.status).toBe(200);
+ json = await res.json();
+ expect(json).toEqual({ freq: 'off', time: '07:05' });
+
+ // 6) weekdays con hora válida
+ res = await fetch(`${base}/api/me/preferences`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', Cookie: `sid=${sid}` },
+ body: JSON.stringify({ freq: 'weekdays', time: '18:45' })
+ });
+ expect(res.status).toBe(200);
+ json = await res.json();
+ expect(json).toEqual({ freq: 'weekdays', time: '18:45' });
+
+ // 7) daily con hora '6:7' -> normaliza '06:07'
+ res = await fetch(`${base}/api/me/preferences`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', Cookie: `sid=${sid}` },
+ body: JSON.stringify({ freq: 'daily', time: '6:7' })
+ });
+ expect(res.status).toBe(200);
+ json = await res.json();
+ expect(json).toEqual({ freq: 'daily', time: '06:07' });
+
+ // GET final
+ res = await fetch(`${base}/api/me/preferences`, { headers: { Cookie: `sid=${sid}` } });
+ expect(res.status).toBe(200);
+ json = await res.json();
+ expect(json).toEqual({ freq: 'daily', time: '06:07' });
+ });
+});
diff --git a/tests/web/api.me.tasks.test.ts b/tests/web/api.me.tasks.test.ts
new file mode 100644
index 0000000..a36b405
--- /dev/null
+++ b/tests/web/api.me.tasks.test.ts
@@ -0,0 +1,337 @@
+import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
+import { Database } from 'bun:sqlite';
+import { mkdtempSync, rmSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { startWebServer } from './helpers/server';
+import { initializeDatabase, ensureUserExists } from '../../src/db';
+
+async function sha256Hex(input: string): Promise {
+ const enc = new TextEncoder().encode(input);
+ const buf = await crypto.subtle.digest('SHA-256', enc);
+ const bytes = new Uint8Array(buf);
+ return Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
+function toIsoSql(d = new Date()): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+describe('Web API - GET /api/me/tasks', () => {
+ const USER = '34600123456';
+ const OTHER = '34600999888';
+ const GROUP_OK = '123@g.us';
+ const GROUP_BAD = '999@g.us';
+
+ let dbPath: string;
+ let server: Awaited> | null = null;
+ let tmpDir: string;
+ let sid = 'sid-test-tasks';
+
+ beforeAll(async () => {
+ tmpDir = mkdtempSync(join(tmpdir(), 'webtest-'));
+ dbPath = join(tmpDir, 'tasks.db');
+
+ const db = new Database(dbPath);
+ initializeDatabase(db);
+
+ // Asegurar usuarios
+ const uid = ensureUserExists(USER, db)!;
+ const oid = ensureUserExists(OTHER, db)!;
+
+ // Sembrar grupos y gating
+ db.prepare(`
+ INSERT INTO groups (id, community_id, name, active, last_verified)
+ VALUES (?, 'comm-1', 'Group OK', 1, strftime('%Y-%m-%d %H:%M:%f','now'))
+ `).run(GROUP_OK);
+ db.prepare(`
+ INSERT INTO groups (id, community_id, name, active, last_verified)
+ VALUES (?, 'comm-1', 'Group BAD', 1, strftime('%Y-%m-%d %H:%M:%f','now'))
+ `).run(GROUP_BAD);
+
+ db.prepare(`INSERT INTO allowed_groups (group_id, status, updated_at) VALUES (?, 'allowed', strftime('%Y-%m-%d %H:%M:%f','now'))`).run(GROUP_OK);
+ db.prepare(`INSERT INTO allowed_groups (group_id, status, updated_at) VALUES (?, 'blocked', strftime('%Y-%m-%d %H:%M:%f','now'))`).run(GROUP_BAD);
+
+ // Membresía activa solo en GROUP_OK
+ const nowIso = toIsoSql(new Date());
+ db.prepare(`
+ INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at, last_role_change_at)
+ VALUES (?, ?, 0, 1, ?, ?, ?)
+ `).run(GROUP_OK, uid, nowIso, nowIso, nowIso);
+
+ // Tareas en GROUP_OK: una con due_date, otra sin due_date
+ const insertTask = db.prepare(`
+ INSERT INTO tasks (description, due_date, group_id, created_by, display_code)
+ VALUES (?, ?, ?, ?, ?)
+ `);
+ const insertAssign = db.prepare(`
+ INSERT INTO task_assignments (task_id, user_id, assigned_by)
+ VALUES (?, ?, ?)
+ `);
+
+ const r1 = insertTask.run('alpha', '2025-01-02', GROUP_OK, uid, 101) as any;
+ const t1 = Number(r1.lastInsertRowid);
+ insertAssign.run(t1, uid, uid);
+
+ const r2 = insertTask.run('beta_100% exacta', null, GROUP_OK, uid, 102) as any;
+ const t2 = Number(r2.lastInsertRowid);
+ insertAssign.run(t2, uid, uid);
+
+ // Tarea en GROUP_BAD asignada al usuario (debe ser filtrada por gating)
+ const r3 = insertTask.run('gamma filtrada', '2025-02-01', GROUP_BAD, oid, 103) as any;
+ const t3 = Number(r3.lastInsertRowid);
+ insertAssign.run(t3, uid, oid);
+
+ // Crear sesión válida
+ const hash = await sha256Hex(sid);
+ db.prepare(`
+ INSERT INTO web_sessions (session_hash, user_id, created_at, last_seen_at, expires_at)
+ VALUES (?, ?, ?, ?, ?)
+ `).run(hash, uid, nowIso, nowIso, toIsoSql(new Date(Date.now() + 60 * 60 * 1000)));
+
+ db.close();
+
+ // Arrancar servidor
+ server = await startWebServer({
+ port: 19101,
+ env: { DB_PATH: dbPath, TZ: 'UTC' }
+ });
+ });
+
+ afterAll(async () => {
+ try { await server?.stop(); } catch {}
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
+ });
+
+ it('aplica gating y ordena por due_date asc con NULL al final', async () => {
+ const res = await fetch(`${server!.baseUrl}/api/me/tasks?limit=10`, {
+ headers: { Cookie: `sid=${sid}` }
+ });
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ const ids = json.items.map((it: any) => it.display_code ?? it.id);
+ // Deben venir solo t1 (101) y t2 (102), en ese orden (t1 con fecha primero, luego NULL)
+ expect(ids).toEqual([101, 102]);
+ });
+
+ it('filtra por búsqueda escapando % y _ correctamente', async () => {
+ const q = encodeURIComponent('beta_100%');
+ const res = await fetch(`${server!.baseUrl}/api/me/tasks?limit=10&search=${q}`, {
+ headers: { Cookie: `sid=${sid}` }
+ });
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.items.length).toBe(1);
+ expect(json.items[0].description).toContain('beta_100%');
+ });
+
+ it('permite actualizar la descripción de una tarea con PATCH', async () => {
+ // Localizar la tarea por búsqueda
+ const q = encodeURIComponent('beta_100%');
+ const listRes = await fetch(`${server!.baseUrl}/api/me/tasks?limit=10&search=${q}`, {
+ headers: { Cookie: `sid=${sid}` }
+ });
+ expect(listRes.status).toBe(200);
+ const list = await listRes.json();
+ expect(list.items.length).toBe(1);
+ const taskId = list.items[0].id;
+
+ // Actualizar descripción
+ const newDesc = 'beta renombrada';
+ const patchRes = await fetch(`${server!.baseUrl}/api/tasks/${taskId}`, {
+ method: 'PATCH',
+ headers: {
+ Cookie: `sid=${sid}`,
+ 'content-type': 'application/json'
+ },
+ body: JSON.stringify({ description: newDesc })
+ });
+ expect(patchRes.status).toBe(200);
+ const patched = await patchRes.json();
+ expect(patched.task.description).toBe(newDesc);
+
+ // Verificar en el listado
+ const q2 = encodeURIComponent('renombrada');
+ const listRes2 = await fetch(`${server!.baseUrl}/api/me/tasks?limit=10&search=${q2}`, {
+ headers: { Cookie: `sid=${sid}` }
+ });
+ expect(listRes2.status).toBe(200);
+ const list2 = await listRes2.json();
+ expect(list2.items.length).toBe(1);
+ expect(list2.items[0].description).toContain('renombrada');
+ });
+
+ it('rechaza descripciones vacías o demasiado largas', async () => {
+ // Reutiliza la misma tarea localizada arriba (buscar por 'renombrada')
+ const q = encodeURIComponent('renombrada');
+ const listRes = await fetch(`${server!.baseUrl}/api/me/tasks?limit=10&search=${q}`, {
+ headers: { Cookie: `sid=${sid}` }
+ });
+ expect(listRes.status).toBe(200);
+ const list = await listRes.json();
+ expect(list.items.length).toBe(1);
+ const taskId = list.items[0].id;
+
+ // Vacía/espacios
+ const resEmpty = await fetch(`${server!.baseUrl}/api/tasks/${taskId}`, {
+ method: 'PATCH',
+ headers: {
+ Cookie: `sid=${sid}`,
+ 'content-type': 'application/json'
+ },
+ body: JSON.stringify({ description: ' ' })
+ });
+ expect(resEmpty.status).toBe(400);
+
+ // Demasiado larga (>1000)
+ const longText = 'a'.repeat(1001);
+ const resLong = await fetch(`${server!.baseUrl}/api/tasks/${taskId}`, {
+ method: 'PATCH',
+ headers: {
+ Cookie: `sid=${sid}`,
+ 'content-type': 'application/json'
+ },
+ body: JSON.stringify({ description: longText })
+ });
+ expect(resLong.status).toBe(400);
+ });
+
+ it('permite completar una tarea asignada y aparece en recent', async () => {
+ // Buscar una tarea abierta por texto
+ const q = encodeURIComponent('alpha');
+ const listRes = await fetch(`${server!.baseUrl}/api/me/tasks?limit=10&search=${q}`, {
+ headers: { Cookie: `sid=${sid}` }
+ });
+ expect(listRes.status).toBe(200);
+ const list = await listRes.json();
+ expect(list.items.length).toBe(1);
+ const taskId = list.items[0].id;
+
+ // Completar
+ const resComplete = await fetch(`${server!.baseUrl}/api/tasks/${taskId}/complete`, {
+ method: 'POST',
+ headers: { Cookie: `sid=${sid}` }
+ });
+ expect(resComplete.status).toBe(200);
+ const done = await resComplete.json();
+ expect(done.status === 'updated' || done.status === 'already').toBe(true);
+
+ // Ver en recent
+ const recentRes = await fetch(`${server!.baseUrl}/api/me/tasks?status=recent&limit=10`, {
+ headers: { Cookie: `sid=${sid}` }
+ });
+ expect(recentRes.status).toBe(200);
+ const recent = await recentRes.json();
+ const ids = recent.items.map((it: any) => it.id);
+ expect(ids.includes(taskId)).toBe(true);
+ });
+
+ it('oculta tareas de grupo archivado aunque esté asignada y allowed', async () => {
+ // Abrir la misma DB del servidor para sembrar datos adicionales
+ const db2 = new Database(dbPath);
+ const nowIso = toIsoSql(new Date());
+
+ // Grupo activo inicialmente
+ db2.prepare(`
+ INSERT INTO groups (id, community_id, name, active, last_verified, archived)
+ VALUES (?, 'comm-1', 'Group ARCH', 1, ?, 0)
+ `).run('arch@g.us', nowIso);
+ db2.prepare(`INSERT OR REPLACE INTO allowed_groups (group_id, status, updated_at) VALUES ('arch@g.us','allowed',?)`).run(nowIso);
+ db2.prepare(`
+ INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
+ VALUES ('arch@g.us', ?, 0, 1, ?, ?)
+ `).run(USER, nowIso, nowIso);
+
+ // Tarea asignada al usuario en ese grupo
+ const r = db2.prepare(`
+ INSERT INTO tasks (description, due_date, group_id, created_by, display_code)
+ VALUES ('delta archivada', '2025-03-01', 'arch@g.us', ?, 150)
+ `).run(USER) as any;
+ const tid = Number(r.lastInsertRowid);
+ db2.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?)`).run(tid, USER, USER);
+
+ // Archivar el grupo
+ db2.prepare(`UPDATE groups SET archived = 1 WHERE id = 'arch@g.us'`).run();
+
+ db2.close();
+
+ const q = encodeURIComponent('delta');
+ const res = await fetch(`${server!.baseUrl}/api/me/tasks?limit=50&search=${q}`, {
+ headers: { Cookie: `sid=${sid}` }
+ });
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ const descs = json.items.map((it: any) => String(it.description));
+ expect(descs.some((d: string) => d.includes('delta archivada'))).toBe(false);
+ });
+
+ it('oculta tareas de grupo inactivo (active=0) aunque esté asignada y allowed', async () => {
+ const db2 = new Database(dbPath);
+ const nowIso = toIsoSql(new Date());
+
+ db2.prepare(`
+ INSERT INTO groups (id, community_id, name, active, last_verified, archived)
+ VALUES ('inact@g.us', 'comm-1', 'Group INACT', 1, ?, 0)
+ `).run(nowIso);
+ db2.prepare(`INSERT OR REPLACE INTO allowed_groups (group_id, status, updated_at) VALUES ('inact@g.us','allowed',?)`).run(nowIso);
+ db2.prepare(`
+ INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
+ VALUES ('inact@g.us', ?, 0, 1, ?, ?)
+ `).run(USER, nowIso, nowIso);
+
+ const r = db2.prepare(`
+ INSERT INTO tasks (description, due_date, group_id, created_by, display_code)
+ VALUES ('omega inactiva', '2025-04-01', 'inact@g.us', ?, 151)
+ `).run(USER) as any;
+ const tid = Number(r.lastInsertRowid);
+ db2.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?)`).run(tid, USER, USER);
+
+ // Desactivar el grupo
+ db2.prepare(`UPDATE groups SET active = 0 WHERE id = 'inact@g.us'`).run();
+ db2.close();
+
+ const q = encodeURIComponent('omega inactiva');
+ const res = await fetch(`${server!.baseUrl}/api/me/tasks?limit=50&search=${q}`, {
+ headers: { Cookie: `sid=${sid}` }
+ });
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.items.length).toBe(0);
+ });
+
+ it('recent no muestra tareas completadas de grupos archivados', async () => {
+ const db2 = new Database(dbPath);
+ const nowIso = toIsoSql(new Date());
+
+ db2.prepare(`
+ INSERT INTO groups (id, community_id, name, active, last_verified, archived)
+ VALUES ('rec@g.us', 'comm-1', 'Group REC', 1, ?, 0)
+ `).run(nowIso);
+ db2.prepare(`INSERT OR REPLACE INTO allowed_groups (group_id, status, updated_at) VALUES ('rec@g.us','allowed',?)`).run(nowIso);
+ db2.prepare(`
+ INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
+ VALUES ('rec@g.us', ?, 0, 1, ?, ?)
+ `).run(USER, nowIso, nowIso);
+
+ const r = db2.prepare(`
+ INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at, display_code)
+ VALUES ('epsilon reciente', '2025-05-01', 'rec@g.us', ?, 1, ?, 152)
+ `).run(USER, nowIso) as any;
+ const tid = Number(r.lastInsertRowid);
+ db2.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?)`).run(tid, USER, USER);
+
+ // Archivar el grupo
+ db2.prepare(`UPDATE groups SET archived = 1 WHERE id = 'rec@g.us'`).run();
+ db2.close();
+
+ const q = encodeURIComponent('epsilon reciente');
+ const res = await fetch(`${server!.baseUrl}/api/me/tasks?status=recent&limit=50&search=${q}`, {
+ headers: { Cookie: `sid=${sid}` }
+ });
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.items.length).toBe(0);
+ });
+});
diff --git a/tests/web/app.integrations.page.test.ts b/tests/web/app.integrations.page.test.ts
new file mode 100644
index 0000000..443663b
--- /dev/null
+++ b/tests/web/app.integrations.page.test.ts
@@ -0,0 +1,76 @@
+import { describe, it, expect, afterAll } from 'bun:test';
+import Database from 'bun:sqlite';
+import { startWebServer } from './helpers/server';
+import { createTempDb } from './helpers/db';
+
+async function sha256Hex(input: string): Promise {
+ const enc = new TextEncoder().encode(input);
+ const buf = await crypto.subtle.digest('SHA-256', enc);
+ const bytes = new Uint8Array(buf);
+ return Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
+function toIsoSql(d = new Date()): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+describe('Web page - /app/integrations', () => {
+ const PORT = 19124;
+ const BASE = `http://127.0.0.1:${PORT}`;
+ const USER = '34600123456';
+ const GROUP = '123@g.us';
+ const SID = 'sid-test-456';
+
+ const tmp = createTempDb();
+ const db: any = tmp.db as Database;
+
+ // Sembrar datos mínimos
+ db.exec(`INSERT OR IGNORE INTO users (id) VALUES ('${USER}')`);
+ db.exec(`INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES ('${GROUP}', 'comm1', 'Group Test', 1)`);
+ db.exec(`INSERT OR IGNORE INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('${GROUP}', 'allowed', '${toIsoSql()}', '${toIsoSql()}')`);
+ db.exec(`INSERT OR IGNORE INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) VALUES ('${GROUP}', '${USER}', 0, 1, '${toIsoSql()}', '${toIsoSql()}')`);
+
+ const sidHashPromise = sha256Hex(SID);
+ const serverPromise = startWebServer({
+ port: PORT,
+ env: {
+ DB_PATH: tmp.path,
+ WEB_BASE_URL: BASE
+ }
+ });
+
+ let server: Awaited | null = null;
+
+ afterAll(async () => {
+ try { await server?.stop(); } catch {}
+ try { tmp.cleanup(); } catch {}
+ });
+
+ it('GET /app/integrations: muestra URLs ICS (personal, grupo y multigrupo) cuando se autogeneran', async () => {
+ server = await serverPromise;
+
+ const sidHash = await sidHashPromise;
+ // Insertar sesión
+ db.exec(`
+ INSERT OR REPLACE INTO web_sessions (id, user_id, session_hash, created_at, last_seen_at, expires_at)
+ VALUES ('sess-2', '${USER}', '${sidHash}', '${toIsoSql()}', '${toIsoSql()}', '${toIsoSql(new Date(Date.now() + 2 * 60 * 60 * 1000))}')
+ `);
+
+ const res = await fetch(`${BASE}/app/integrations`, {
+ headers: { cookie: `sid=${SID}` }
+ });
+ expect(res.status).toBe(200);
+ const html = await res.text();
+
+ // Debe incluir títulos y al menos una URL .ics
+ expect(html.includes('Integraciones')).toBe(true);
+ expect(html.includes('Mis tareas (con fecha)')).toBe(true);
+ expect(html.includes('Mis grupos (sin responsable)')).toBe(true);
+ expect(html.includes('/ics/personal/')).toBe(true);
+ expect(html.includes('/ics/group/')).toBe(true);
+ expect(html.includes('/ics/aggregate/')).toBe(true);
+ expect(html.includes('.ics')).toBe(true);
+ });
+});
diff --git a/tests/web/app.preferences.page.test.ts b/tests/web/app.preferences.page.test.ts
new file mode 100644
index 0000000..28d8cd4
--- /dev/null
+++ b/tests/web/app.preferences.page.test.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
+import { Database } from 'bun:sqlite';
+import { mkdtempSync, rmSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { startWebServer } from './helpers/server';
+import { initializeDatabase, ensureUserExists } from '../../src/db';
+
+async function sha256Hex(input: string): Promise {
+ const enc = new TextEncoder().encode(input);
+ const buf = await crypto.subtle.digest('SHA-256', enc);
+ const bytes = new Uint8Array(buf);
+ return Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
+function toIsoSql(d = new Date()): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+describe('Web UI - /app/preferences', () => {
+ const userId = '34600123456';
+ let dbPath: string;
+ let server: Awaited> | null = null;
+ let tmpDir: string;
+
+ beforeAll(async () => {
+ tmpDir = mkdtempSync(join(tmpdir(), 'webtest-'));
+ dbPath = join(tmpDir, 'tasks.db');
+
+ // Inicializar DB en archivo (como en prod)
+ const db = new Database(dbPath);
+ initializeDatabase(db);
+ ensureUserExists(userId, db);
+
+ // Crear sesión válida
+ const sid = 'sid-test-pref-ui';
+ const hash = await sha256Hex(sid);
+ const now = new Date();
+ const nowIso = toIsoSql(now);
+ const expIso = toIsoSql(new Date(now.getTime() + 60 * 60 * 1000)); // +1h
+
+ db.prepare(`
+ INSERT INTO web_sessions (session_hash, user_id, created_at, last_seen_at, expires_at)
+ VALUES (?, ?, ?, ?, ?)
+ `).run(hash, userId, nowIso, nowIso, expIso);
+ db.close();
+
+ // Arrancar web apuntando a este DB
+ server = await startWebServer({
+ port: 19110,
+ env: { DB_PATH: dbPath, TZ: 'UTC' }
+ });
+ });
+
+ afterAll(async () => {
+ try { await server?.stop(); } catch {}
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
+ });
+
+ it('renderiza el formulario con valores por defecto y muestra próximo recordatorio', async () => {
+ const sid = 'sid-test-pref-ui';
+ const res = await fetch(`${server!.baseUrl}/app/preferences`, {
+ headers: { Cookie: `sid=${sid}` }
+ });
+ expect(res.status).toBe(200);
+ const html = await res.text();
+
+ expect(html).toContain('Preferencias de recordatorios');
+ // control de frecuencia (radio) con opción 'off' presente
+ expect(html).toMatch(/]+type="radio"[^>]+name="freq"[^>]+value="off"/);
+ // input type="time" con valor por defecto
+ expect(html).toContain('type="time"');
+ expect(html).toContain('08:30');
+ // bloque de "Próximo recordatorio"
+ expect(html).toContain('Próximo recordatorio');
+ });
+});
diff --git a/tests/web/helpers/db.ts b/tests/web/helpers/db.ts
new file mode 100644
index 0000000..334ed15
--- /dev/null
+++ b/tests/web/helpers/db.ts
@@ -0,0 +1,18 @@
+import { mkdirSync, rmSync } from 'fs';
+import { join, resolve } from 'path';
+import Database from 'bun:sqlite';
+import { initializeDatabase } from '../../../src/db';
+
+export function createTempDb(): { path: string; db: any; cleanup: () => void } {
+ const dir = join('tmp', 'web-tests');
+ try { mkdirSync(dir, { recursive: true }); } catch {}
+ const path = join(dir, `db-${Date.now()}-${Math.random().toString(16).slice(2)}.sqlite`);
+ const absPath = resolve(path);
+ const db = new Database(absPath);
+ initializeDatabase(db);
+ const cleanup = () => {
+ try { db.close(); } catch {}
+ try { rmSync(absPath); } catch {}
+ };
+ return { path: absPath, db, cleanup };
+}
diff --git a/tests/web/helpers/server.ts b/tests/web/helpers/server.ts
new file mode 100644
index 0000000..e649ce7
--- /dev/null
+++ b/tests/web/helpers/server.ts
@@ -0,0 +1,139 @@
+import { existsSync, mkdirSync, openSync, rmSync, writeFileSync } from 'fs';
+import { join, dirname } from 'path';
+
+export async function ensureWebBuilt(): Promise {
+ const buildEntry = join('apps', 'web', 'build', 'index.js');
+ const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test';
+ if (existsSync(buildEntry) && !isTest) return;
+
+ const lockFile = join('apps', 'web', '.build.lock');
+
+ let haveLock = false;
+ try {
+ // Intentar crear lock (exclusivo). Si existe, esperar a que termine la otra build.
+ openSync(lockFile, 'wx');
+ haveLock = true;
+ } catch {
+ // Otra build en progreso o ya hecha. Esperar hasta que exista el build.
+ const timeoutMs = 60_000;
+ const start = Date.now();
+ while (!existsSync(buildEntry)) {
+ if (Date.now() - start > timeoutMs) {
+ throw new Error('Timeout esperando a que termine el build de apps/web');
+ }
+ // Dormir 100ms
+ await new Promise((res) => setTimeout(res, 100));
+ }
+ return;
+ }
+
+ try {
+ // Asegurar carpeta build
+ try {
+ mkdirSync(dirname(buildEntry), { recursive: true });
+ } catch {}
+
+ // Ejecutar "bun run build" dentro de apps/web
+ const proc = Bun.spawn({
+ cmd: [process.execPath, 'run', 'build'],
+ cwd: join('apps', 'web'),
+ stdout: 'inherit',
+ stderr: 'inherit',
+ env: {
+ ...process.env,
+ NODE_ENV: 'production'
+ }
+ });
+ const exitCode = await proc.exited;
+ if (exitCode !== 0) {
+ throw new Error(`Fallo al construir apps/web (exit ${exitCode})`);
+ }
+ } finally {
+ // Liberar lock
+ try {
+ rmSync(lockFile, { force: true });
+ } catch {}
+ }
+}
+
+export type RunningServer = {
+ baseUrl: string;
+ stop: () => Promise;
+ pid: number | null;
+ port: number;
+};
+
+export async function startWebServer(opts: {
+ port?: number;
+ env?: Record;
+} = {}): Promise {
+ await ensureWebBuilt();
+
+ const port = Number(opts.port || 19080);
+
+ // Lanzar servidor Node adapter: "bun ./build/index.js" en apps/web
+ const child = Bun.spawn({
+ cmd: [process.execPath, './build/index.js'],
+ cwd: join('apps', 'web'),
+ stdout: 'pipe',
+ stderr: 'pipe',
+ env: {
+ ...process.env,
+ PORT: String(port),
+ NODE_ENV: 'test',
+ ...(opts.env || {})
+ }
+ });
+
+ // Esperar a que esté arriba (ping a "/")
+ const baseUrl = `http://127.0.0.1:${port}`;
+ const startedAt = Date.now();
+ const timeoutMs = 30_000;
+ let lastErr: any = null;
+
+ while (Date.now() - startedAt < timeoutMs) {
+ try {
+ const res = await fetch(baseUrl + '/', { method: 'GET' });
+ if (res) break;
+ } catch (e) {
+ lastErr = e;
+ }
+ await new Promise((res) => setTimeout(res, 100));
+ }
+
+ if (Date.now() - startedAt >= timeoutMs) {
+ try { child.kill(); } catch {}
+ throw new Error(`Timeout esperando al servidor web: ${lastErr?.message || lastErr}`);
+ }
+
+ // Conectar logs a consola (opcional)
+ (async () => {
+ try {
+ for await (const chunk of child.stdout) {
+ try {
+ process.stderr.write(`[web] ${new TextDecoder().decode(chunk)}`);
+ } catch {}
+ }
+ } catch {}
+ })();
+ (async () => {
+ try {
+ for await (const chunk of child.stderr) {
+ try {
+ process.stderr.write(`[web] ${new TextDecoder().decode(chunk)}`);
+ } catch {}
+ }
+ } catch {}
+ })();
+
+ return {
+ baseUrl,
+ port,
+ pid: child.pid,
+ stop: async () => {
+ try { child.kill(); } catch {}
+ // Pequeña espera para liberar puerto
+ await new Promise((res) => setTimeout(res, 50));
+ }
+ };
+}
diff --git a/tests/web/ics.aggregate.test.ts b/tests/web/ics.aggregate.test.ts
new file mode 100644
index 0000000..c5a57c4
--- /dev/null
+++ b/tests/web/ics.aggregate.test.ts
@@ -0,0 +1,179 @@
+import { describe, it, expect, afterAll } from 'bun:test';
+import Database from 'bun:sqlite';
+import { startWebServer } from './helpers/server';
+import { createTempDb } from './helpers/db';
+
+async function sha256Hex(input: string): Promise {
+ const enc = new TextEncoder().encode(input);
+ const buf = await crypto.subtle.digest('SHA-256', enc);
+ const bytes = new Uint8Array(buf);
+ return Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
+function toIsoSql(d = new Date()): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+function ymdUTC(date = new Date()): string {
+ const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
+ const dd = String(date.getUTCDate()).padStart(2, '0');
+ return `${yyyy}-${mm}-${dd}`;
+}
+
+function addDays(date: Date, days: number): Date {
+ const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
+ d.setUTCDate(d.getUTCDate() + days);
+ return d;
+}
+
+function pad4(n: number): string {
+ const s = String(Math.floor(n));
+ return s.length >= 4 ? s : '0'.repeat(4 - s.length) + s;
+}
+
+describe('ICS - aggregate feed', () => {
+ const PORT = 19133;
+ const BASE = `http://127.0.0.1:${PORT}`;
+ const USER = '34600123456';
+ const G1 = 'g1@g.us';
+ const G2 = 'g2@g.us';
+ const G3 = 'g3@g.us'; // no permitido
+ const SID = 'sid-ics-aggregate-1';
+
+ const tmp = createTempDb();
+ const db: any = tmp.db as Database;
+
+ // Sembrar datos mínimos
+ db.exec(`INSERT OR IGNORE INTO users (id) VALUES ('${USER}')`);
+ for (const [gid, name] of [
+ [G1, 'Group A'],
+ [G2, 'Group B'],
+ [G3, 'Group C'],
+ ] as const) {
+ db.exec(
+ `INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES ('${gid}', 'comm', '${name}', 1)`
+ );
+ }
+ db.exec(
+ `INSERT OR IGNORE INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('${G1}', 'allowed', '${toIsoSql()}', '${toIsoSql()}')`
+ );
+ db.exec(
+ `INSERT OR IGNORE INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('${G2}', 'allowed', '${toIsoSql()}', '${toIsoSql()}')`
+ );
+ db.exec(
+ `INSERT OR IGNORE INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('${G3}', 'blocked', '${toIsoSql()}', '${toIsoSql()}')`
+ );
+ for (const gid of [G1, G2]) {
+ db.exec(
+ `INSERT OR IGNORE INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
+ VALUES ('${gid}', '${USER}', 0, 1, '${toIsoSql()}', '${toIsoSql()}')`
+ );
+ }
+
+ const today = new Date();
+ const dueIn3 = ymdUTC(addDays(today, 3));
+ const dueIn6 = ymdUTC(addDays(today, 6));
+ const dueFar = ymdUTC(addDays(today, 400));
+
+ const insTask = db.prepare(
+ `INSERT INTO tasks (description, due_date, group_id, created_by, completed, created_at)
+ VALUES (?, ?, ?, ?, ?, ?)`
+ );
+ const createdBy = USER;
+
+ // G1 unassigned -> incluido
+ const r1 = insTask.run('G1 unassigned', dueIn3, G1, createdBy, 0, toIsoSql());
+ const t1 = Number(r1.lastInsertRowid);
+
+ // G1 assigned -> excluido (aggregate sólo "sin responsable")
+ const r2 = insTask.run('G1 assigned', dueIn3, G1, createdBy, 0, toIsoSql());
+ const t2 = Number(r2.lastInsertRowid);
+ db.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at)
+ VALUES (?, ?, ?, ?)`).run(t2, USER, USER, toIsoSql());
+
+ // G2 unassigned -> incluido
+ const r3 = insTask.run('G2 unassigned', dueIn6, G2, createdBy, 0, toIsoSql());
+ const t3 = Number(r3.lastInsertRowid);
+
+ // G3 unassigned (no permitido) -> excluido
+ const r4 = insTask.run('G3 unassigned', dueIn6, G3, createdBy, 0, toIsoSql());
+ const t4 = Number(r4.lastInsertRowid);
+
+ // G1 far future -> excluido por horizonte
+ const r5 = insTask.run('G1 far', dueFar, G1, createdBy, 0, toIsoSql());
+ const t5 = Number(r5.lastInsertRowid);
+
+ const sidHashPromise = sha256Hex(SID);
+ const serverPromise = startWebServer({
+ port: PORT,
+ env: {
+ DB_PATH: tmp.path,
+ WEB_BASE_URL: BASE,
+ },
+ });
+
+ let server: Awaited | null = null;
+
+ afterAll(async () => {
+ try {
+ await server?.stop();
+ } catch {}
+ try {
+ tmp.cleanup();
+ } catch {}
+ });
+
+ it('serves ICS for aggregate token with correct filtering, supports ETag, and returns 410 when revoked', async () => {
+ server = await serverPromise;
+
+ const sidHash = await sidHashPromise;
+ db.exec(`
+ INSERT OR REPLACE INTO web_sessions (id, user_id, session_hash, created_at, last_seen_at, expires_at)
+ VALUES ('sess-ics-aggregate', '${USER}', '${sidHash}', '${toIsoSql()}', '${toIsoSql()}', '${toIsoSql(
+ addDays(new Date(), 1)
+ )}')
+ `);
+
+ // Obtener URLs de feeds
+ const resFeeds = await fetch(`${BASE}/api/integrations/feeds`, {
+ headers: { cookie: `sid=${SID}` },
+ });
+ expect(resFeeds.status).toBe(200);
+ const feeds = await resFeeds.json();
+ expect(feeds.aggregate && typeof feeds.aggregate.url === 'string').toBe(true);
+
+ const aggregateUrl: string = feeds.aggregate.url;
+ const token = new URL(aggregateUrl).pathname.split('/').pop()!.replace(/\.ics$/i, '');
+
+ // Primera petición ICS
+ const resIcs = await fetch(aggregateUrl);
+ expect(resIcs.status).toBe(200);
+ expect((resIcs.headers.get('content-type') || '').includes('text/calendar')).toBe(true);
+ const body1 = await resIcs.text();
+
+ // Incluidos: t1, t3
+ expect(body1.includes(`[T${pad4(t1)}]`)).toBe(true);
+ expect(body1.includes(`[T${pad4(t3)}]`)).toBe(true);
+
+ // Excluidos: t2 (assigned), t4 (no permitido), t5 (far)
+ expect(body1.includes(`[T${pad4(t2)}]`)).toBe(false);
+ expect(body1.includes(`[T${pad4(t4)}]`)).toBe(false);
+ expect(body1.includes(`[T${pad4(t5)}]`)).toBe(false);
+
+ const etag = resIcs.headers.get('etag') || '';
+ const res304 = await fetch(aggregateUrl, { headers: { 'if-none-match': etag } });
+ expect(res304.status).toBe(304);
+
+ const row = db
+ .prepare(`SELECT last_used_at FROM calendar_tokens WHERE token_plain = ?`)
+ .get(token) as any;
+ expect(row && row.last_used_at).toBeTruthy();
+
+ db.prepare(`UPDATE calendar_tokens SET revoked_at = ? WHERE token_plain = ?`).run(toIsoSql(), token);
+ const resGone = await fetch(aggregateUrl);
+ expect(resGone.status).toBe(410);
+ });
+});
diff --git a/tests/web/ics.group.test.ts b/tests/web/ics.group.test.ts
new file mode 100644
index 0000000..7b33975
--- /dev/null
+++ b/tests/web/ics.group.test.ts
@@ -0,0 +1,166 @@
+import { describe, it, expect, afterAll } from 'bun:test';
+import Database from 'bun:sqlite';
+import { startWebServer } from './helpers/server';
+import { createTempDb } from './helpers/db';
+
+async function sha256Hex(input: string): Promise {
+ const enc = new TextEncoder().encode(input);
+ const buf = await crypto.subtle.digest('SHA-256', enc);
+ const bytes = new Uint8Array(buf);
+ return Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
+function toIsoSql(d = new Date()): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+function ymdUTC(date = new Date()): string {
+ const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
+ const dd = String(date.getUTCDate()).padStart(2, '0');
+ return `${yyyy}-${mm}-${dd}`;
+}
+
+function addDays(date: Date, days: number): Date {
+ const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
+ d.setUTCDate(d.getUTCDate() + days);
+ return d;
+}
+
+function pad4(n: number): string {
+ const s = String(Math.floor(n));
+ return s.length >= 4 ? s : '0'.repeat(4 - s.length) + s;
+}
+
+describe('ICS - group feed', () => {
+ const PORT = 19131;
+ const BASE = `http://127.0.0.1:${PORT}`;
+ const USER = '34600123456';
+ const GROUP = '123@g.us';
+ const SID = 'sid-ics-group-1';
+
+ const tmp = createTempDb();
+ const db: any = tmp.db as Database;
+
+ // Sembrar datos mínimos
+ db.exec(`INSERT OR IGNORE INTO users (id) VALUES ('${USER}')`);
+ db.exec(
+ `INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES ('${GROUP}', 'comm1', 'Group One', 1)`
+ );
+ db.exec(
+ `INSERT OR IGNORE INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('${GROUP}', 'allowed', '${toIsoSql()}', '${toIsoSql()}')`
+ );
+ db.exec(
+ `INSERT OR IGNORE INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
+ VALUES ('${GROUP}', '${USER}', 0, 1, '${toIsoSql()}', '${toIsoSql()}')`
+ );
+
+ // Tareas del grupo (varias condiciones)
+ const today = new Date();
+ const dueIn2 = ymdUTC(addDays(today, 2));
+ const dueIn400 = ymdUTC(addDays(today, 400));
+
+ const insTask = db.prepare(
+ `INSERT INTO tasks (description, due_date, group_id, created_by, completed, created_at)
+ VALUES (?, ?, ?, ?, ?, ?)`
+ );
+ const createdBy = USER;
+
+ // t1: sin responsable, dentro de horizonte -> debe aparecer
+ const r1 = insTask.run('Task unassigned in range', dueIn2, GROUP, createdBy, 0, toIsoSql());
+ const t1 = Number(r1.lastInsertRowid);
+
+ // t2: con responsable -> NO debe aparecer
+ const r2 = insTask.run('Task assigned', dueIn2, GROUP, createdBy, 0, toIsoSql());
+ const t2 = Number(r2.lastInsertRowid);
+ db.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at)
+ VALUES (?, ?, ?, ?)`).run(t2, USER, USER, toIsoSql());
+
+ // t3: completada -> NO debe aparecer
+ const r3 = insTask.run('Task completed', dueIn2, GROUP, createdBy, 1, toIsoSql());
+ const t3 = Number(r3.lastInsertRowid);
+
+ // t4: muy lejos (fuera de horizonte) -> NO debe aparecer
+ const r4 = insTask.run('Task far future', dueIn400, GROUP, createdBy, 0, toIsoSql());
+ const t4 = Number(r4.lastInsertRowid);
+
+ // t5: sin due_date -> NO debe aparecer
+ const r5 = insTask.run('Task without due', null, GROUP, createdBy, 0, toIsoSql());
+ const t5 = Number(r5.lastInsertRowid);
+
+ const sidHashPromise = sha256Hex(SID);
+ const serverPromise = startWebServer({
+ port: PORT,
+ env: {
+ DB_PATH: tmp.path,
+ WEB_BASE_URL: BASE,
+ },
+ });
+
+ let server: Awaited | null = null;
+
+ afterAll(async () => {
+ try {
+ await server?.stop();
+ } catch {}
+ try {
+ tmp.cleanup();
+ } catch {}
+ });
+
+ it('serves ICS for group token with correct filtering, supports ETag, and returns 410 when revoked', async () => {
+ server = await serverPromise;
+
+ const sidHash = await sidHashPromise;
+ db.exec(`
+ INSERT OR REPLACE INTO web_sessions (id, user_id, session_hash, created_at, last_seen_at, expires_at)
+ VALUES ('sess-ics-group', '${USER}', '${sidHash}', '${toIsoSql()}', '${toIsoSql()}', '${toIsoSql(
+ addDays(new Date(), 1)
+ )}')
+ `);
+
+ // Obtener URLs de feeds (autogenera tokens)
+ const resFeeds = await fetch(`${BASE}/api/integrations/feeds`, {
+ headers: { cookie: `sid=${SID}` },
+ });
+ expect(resFeeds.status).toBe(200);
+ const feeds = await resFeeds.json();
+ const groupItem = (feeds.groups || []).find((g: any) => g.groupId === GROUP);
+ expect(groupItem && typeof groupItem.url === 'string' && groupItem.url.endsWith('.ics')).toBe(true);
+
+ const groupUrl = groupItem.url as string;
+ const token = new URL(groupUrl).pathname.split('/').pop()!.replace(/\.ics$/i, '');
+
+ // Primera petición ICS
+ const resIcs = await fetch(groupUrl);
+ expect(resIcs.status).toBe(200);
+ expect((resIcs.headers.get('content-type') || '').includes('text/calendar')).toBe(true);
+ const body1 = await resIcs.text();
+
+ // Debe contener solo t1
+ expect(body1.includes(`[T${pad4(t1)}]`)).toBe(true);
+ expect(body1.includes(`[T${pad4(t2)}]`)).toBe(false);
+ expect(body1.includes(`[T${pad4(t3)}]`)).toBe(false);
+ expect(body1.includes(`[T${pad4(t4)}]`)).toBe(false);
+ expect(body1.includes(`[T${pad4(t5)}]`)).toBe(false);
+
+ const etag = resIcs.headers.get('etag') || '';
+
+ // Segunda petición con If-None-Match -> 304
+ const res304 = await fetch(groupUrl, { headers: { 'if-none-match': etag } });
+ expect(res304.status).toBe(304);
+
+ // last_used_at actualizado
+ const row = db
+ .prepare(`SELECT last_used_at FROM calendar_tokens WHERE token_plain = ?`)
+ .get(token) as any;
+ expect(row && row.last_used_at).toBeTruthy();
+
+ // Revocar el token y comprobar 410
+ db.prepare(`UPDATE calendar_tokens SET revoked_at = ? WHERE token_plain = ?`).run(toIsoSql(), token);
+ const resGone = await fetch(groupUrl);
+ expect(resGone.status).toBe(410);
+ });
+});
diff --git a/tests/web/ics.personal.test.ts b/tests/web/ics.personal.test.ts
new file mode 100644
index 0000000..10cb466
--- /dev/null
+++ b/tests/web/ics.personal.test.ts
@@ -0,0 +1,178 @@
+import { describe, it, expect, afterAll } from 'bun:test';
+import Database from 'bun:sqlite';
+import { startWebServer } from './helpers/server';
+import { createTempDb } from './helpers/db';
+
+async function sha256Hex(input: string): Promise {
+ const enc = new TextEncoder().encode(input);
+ const buf = await crypto.subtle.digest('SHA-256', enc);
+ const bytes = new Uint8Array(buf);
+ return Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
+function toIsoSql(d = new Date()): string {
+ return d.toISOString().replace('T', ' ').replace('Z', '');
+}
+
+function ymdUTC(date = new Date()): string {
+ const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
+ const dd = String(date.getUTCDate()).padStart(2, '0');
+ return `${yyyy}-${mm}-${dd}`;
+}
+
+function addDays(date: Date, days: number): Date {
+ const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
+ d.setUTCDate(d.getUTCDate() + days);
+ return d;
+}
+
+function pad4(n: number): string {
+ const s = String(Math.floor(n));
+ return s.length >= 4 ? s : '0'.repeat(4 - s.length) + s;
+}
+
+describe('ICS - personal feed', () => {
+ const PORT = 19132;
+ const BASE = `http://127.0.0.1:${PORT}`;
+ const USER = '34600123456';
+ const GROUP_ALLOWED = '111@g.us';
+ const GROUP_BLOCKED = '222@g.us';
+ const SID = 'sid-ics-personal-1';
+
+ const tmp = createTempDb();
+ const db: any = tmp.db as Database;
+
+ // Sembrar datos mínimos
+ db.exec(`INSERT OR IGNORE INTO users (id) VALUES ('${USER}')`);
+ db.exec(
+ `INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES ('${GROUP_ALLOWED}', 'comm1', 'Allowed', 1)`
+ );
+ db.exec(
+ `INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES ('${GROUP_BLOCKED}', 'comm2', 'Blocked', 1)`
+ );
+ db.exec(
+ `INSERT OR IGNORE INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('${GROUP_ALLOWED}', 'allowed', '${toIsoSql()}', '${toIsoSql()}')`
+ );
+ db.exec(
+ `INSERT OR IGNORE INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('${GROUP_BLOCKED}', 'blocked', '${toIsoSql()}', '${toIsoSql()}')`
+ );
+ db.exec(
+ `INSERT OR IGNORE INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
+ VALUES ('${GROUP_ALLOWED}', '${USER}', 0, 1, '${toIsoSql()}', '${toIsoSql()}')`
+ );
+
+ const today = new Date();
+ const dueIn2 = ymdUTC(addDays(today, 2));
+ const dueIn5 = ymdUTC(addDays(today, 5));
+ const duePast = ymdUTC(addDays(today, -2));
+
+ const insTask = db.prepare(
+ `INSERT INTO tasks (description, due_date, group_id, created_by, completed, created_at)
+ VALUES (?, ?, ?, ?, ?, ?)`
+ );
+ const createdBy = USER;
+
+ // Privada asignada (incluida)
+ const r1 = insTask.run('Private assigned', dueIn2, null, createdBy, 0, toIsoSql());
+ const t1 = Number(r1.lastInsertRowid);
+ db.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at)
+ VALUES (?, ?, ?, ?)`).run(t1, USER, USER, toIsoSql());
+
+ // Grupo allowed asignada (incluida)
+ const r2 = insTask.run('Allowed group assigned', dueIn5, GROUP_ALLOWED, createdBy, 0, toIsoSql());
+ const t2 = Number(r2.lastInsertRowid);
+ db.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at)
+ VALUES (?, ?, ?, ?)`).run(t2, USER, USER, toIsoSql());
+
+ // Grupo blocked asignada (excluida)
+ const r3 = insTask.run('Blocked group assigned', dueIn5, GROUP_BLOCKED, createdBy, 0, toIsoSql());
+ const t3 = Number(r3.lastInsertRowid);
+ db.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at)
+ VALUES (?, ?, ?, ?)`).run(t3, USER, USER, toIsoSql());
+
+ // Grupo allowed sin due_date (excluida)
+ const r4 = insTask.run('No due date', null, GROUP_ALLOWED, createdBy, 0, toIsoSql());
+ const t4 = Number(r4.lastInsertRowid);
+ db.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at)
+ VALUES (?, ?, ?, ?)`).run(t4, USER, USER, toIsoSql());
+
+ // Grupo allowed completada (excluida)
+ const r5 = insTask.run('Completed assigned', dueIn2, GROUP_ALLOWED, createdBy, 1, toIsoSql());
+ const t5 = Number(r5.lastInsertRowid);
+ db.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at)
+ VALUES (?, ?, ?, ?)`).run(t5, USER, USER, toIsoSql());
+
+ const sidHashPromise = sha256Hex(SID);
+ const serverPromise = startWebServer({
+ port: PORT,
+ env: {
+ DB_PATH: tmp.path,
+ WEB_BASE_URL: BASE,
+ },
+ });
+
+ let server: Awaited | null = null;
+
+ afterAll(async () => {
+ try {
+ await server?.stop();
+ } catch {}
+ try {
+ tmp.cleanup();
+ } catch {}
+ });
+
+ it('serves ICS for personal token with correct filtering, supports ETag, and returns 410 when revoked', async () => {
+ server = await serverPromise;
+
+ const sidHash = await sidHashPromise;
+ db.exec(`
+ INSERT OR REPLACE INTO web_sessions (id, user_id, session_hash, created_at, last_seen_at, expires_at)
+ VALUES ('sess-ics-personal', '${USER}', '${sidHash}', '${toIsoSql()}', '${toIsoSql()}', '${toIsoSql(
+ addDays(new Date(), 1)
+ )}')
+ `);
+
+ // Obtener URLs de feeds
+ const resFeeds = await fetch(`${BASE}/api/integrations/feeds`, {
+ headers: { cookie: `sid=${SID}` },
+ });
+ expect(resFeeds.status).toBe(200);
+ const feeds = await resFeeds.json();
+ expect(feeds.personal && typeof feeds.personal.url === 'string').toBe(true);
+
+ const personalUrl: string = feeds.personal.url;
+ const token = new URL(personalUrl).pathname.split('/').pop()!.replace(/\.ics$/i, '');
+
+ // Primera petición ICS
+ const resIcs = await fetch(personalUrl);
+ expect(resIcs.status).toBe(200);
+ expect((resIcs.headers.get('content-type') || '').includes('text/calendar')).toBe(true);
+ const body1 = await resIcs.text();
+
+ // Debe contener solo t1 y t2
+ expect(body1.includes(`[T${pad4(t1)}]`)).toBe(true);
+ expect(body1.includes(`[T${pad4(t2)}]`)).toBe(true);
+
+ // Excluidos
+ expect(body1.includes(`[T${pad4(t3)}]`)).toBe(false);
+ expect(body1.includes(`[T${pad4(t4)}]`)).toBe(false);
+ expect(body1.includes(`[T${pad4(t5)}]`)).toBe(false);
+
+ const etag = resIcs.headers.get('etag') || '';
+ const res304 = await fetch(personalUrl, { headers: { 'if-none-match': etag } });
+ expect(res304.status).toBe(304);
+
+ const row = db
+ .prepare(`SELECT last_used_at FROM calendar_tokens WHERE token_plain = ?`)
+ .get(token) as any;
+ expect(row && row.last_used_at).toBeTruthy();
+
+ db.prepare(`UPDATE calendar_tokens SET revoked_at = ? WHERE token_plain = ?`).run(toIsoSql(), token);
+ const resGone = await fetch(personalUrl);
+ expect(resGone.status).toBe(410);
+ });
+});
diff --git a/tests/web/unit/groupColor.test.ts b/tests/web/unit/groupColor.test.ts
new file mode 100644
index 0000000..b546010
--- /dev/null
+++ b/tests/web/unit/groupColor.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'bun:test';
+import { colorForGroup } from '../../../apps/web/src/lib/utils/groupColor';
+
+function isHexColor(s: string): boolean {
+ return /^#[0-9A-Fa-f]{6}$/.test(s);
+}
+
+describe('groupColor - colorForGroup', () => {
+ it('devuelve null para groupId vacío o nulo', () => {
+ expect(colorForGroup(null)).toBeNull();
+ expect(colorForGroup(undefined)).toBeNull();
+ expect(colorForGroup('')).toBeNull();
+ expect(colorForGroup(' ')).toBeNull();
+ });
+
+ it('es determinista: misma entrada → misma salida', () => {
+ const a = colorForGroup('123@g.us');
+ const b = colorForGroup('123@g.us');
+ expect(a).not.toBeNull();
+ expect(b).not.toBeNull();
+ expect(a?.border).toBe(b?.border);
+ expect(a?.bg).toBe(b?.bg);
+ expect(a?.text).toBe(b?.text);
+ });
+
+ it('devuelve colores hex válidos', () => {
+ const c = colorForGroup('group-xyz@g.us');
+ expect(c).not.toBeNull();
+ expect(isHexColor(c!.border)).toBe(true);
+ expect(isHexColor(c!.bg)).toBe(true);
+ expect(isHexColor(c!.text)).toBe(true);
+ });
+
+ it('tiene distribución razonable en distintos IDs', () => {
+ const uniq = new Set();
+ for (let i = 0; i < 30; i++) {
+ const c = colorForGroup(`group-${i}@g.us`);
+ if (c) uniq.add(`${c.border}|${c.bg}|${c.text}`);
+ }
+ // Con 12 paletas, deberíamos cubrir bastantes índices con 30 IDs
+ expect(uniq.size).toBeGreaterThan(8);
+ });
+});