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 @@ +emergency-exit \ 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 @@ +svelte-logo \ 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 @@ +time-period \ 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 @@ + + + +
+ {name ?? id} +
+ abiertas: {counts.open} + sin responsable: {counts.unassigned} +
+
+ + {#if previews?.length} +
+ Sin responsable: +
    + {#each previews as t} +
  • +
    + #{t.display_code ?? t.id} — {t.description} + {#if t.due_date} (vence: {t.due_date}){/if} +
    +
    + +
    +
  • + {/each} +
+
+ {/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} +
    + +
    + {groupLabel} + {#if due_date} + + {#if !overdue && !imminent} + + {:else if imminent} + + {:else} + + {/if} + {dateDmy} + + {/if} +
    +
    + {#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)} + + {/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 @@ + + + + {#if title}{title}{/if} + + + + + 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 @@ + + +
    +
    + Tareas + +
    + +
    +
    +
    + + + + + + + + +
    + +
    + + + + + + 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}

    + +
    + Orden: + Fecha + Grupo +
    + +

    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)} + > + + {g.name ?? g.id} + + tareas: {g.counts.open} + 🙅‍♂️: {g.counts.unassigned} + + + {#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

    + + +
    +
    + + +
      +
    • Diario: cada día a la hora indicada
    • +
    • Laborables: solo de lunes a viernes
    • +
    • Semanal: los lunes
    • +
    +
    + +
    + + +

    Zona horaria: {data.tz}

    +
    + + {#if form?.error} +
    {form.error}
    + {/if} + +
    + +
    +
    +
    + +
    +

    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 @@ +emergency-exit \ 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 @@ +time-period \ 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); + }); +});