From b7ed1ad01325f59468c79744e1c3353f8c0c620b Mon Sep 17 00:00:00 2001 From: borja Date: Sat, 2 May 2026 00:01:14 +0200 Subject: [PATCH] refactor: webhook handler, group sync, command handlers, tests - Refactor webhook handler with improved error handling and auto-ensure - Break group-sync into modular services (changes, deactivation, membership, scheduler) - Add startup.ts bootstrap with health checks and metrics - Refactor command handlers (nueva, completar, tomar, soltar, ver) for gating/resilience - Remove unused Svelte UI components (Badge, Skeleton, GroupCard, etc.) - Add ICS helpers, task helpers, preferences helpers to web lib - Remove legacy help.ts message service - Restructure tests: split monolithic server.test.ts into focused files - Add server test harness and coverage/conformance tests - Update docs (commands inventory, user guide, operational docs) - Command trigger simplified to 't' and task name (no slash) - Add .gitignore entries for fallow, sift, sq artifacts --- .gitignore | 7 + Dockerfile | 7 +- README.md | 4 +- README.old.md | 293 ---- apps/web/src/lib/index.ts | 1 - apps/web/src/lib/server/datetime.ts | 14 +- apps/web/src/lib/server/db.ts | 221 +-- apps/web/src/lib/server/env.ts | 5 +- apps/web/src/lib/server/ics-helpers.ts | 92 + .../web/src/lib/server/preferences-helpers.ts | 56 + apps/web/src/lib/server/task-helpers.ts | 264 +++ apps/web/src/lib/stores/toasts.ts | 8 +- apps/web/src/lib/ui/atoms/Badge.svelte | 35 - apps/web/src/lib/ui/atoms/Skeleton.svelte | 24 - .../src/lib/ui/atoms/VisuallyHidden.svelte | 11 - apps/web/src/lib/ui/data/GroupCard.svelte | 108 -- .../src/lib/ui/feedback/ErrorBanner.svelte | 16 - apps/web/src/lib/ui/icons/Hourglass.svelte | 27 - apps/web/src/lib/ui/inputs/TextField.svelte | 32 - apps/web/src/lib/utils/date.ts | 23 +- .../routes/api/groups/[id]/tasks/+server.ts | 50 +- .../routes/api/integrations/feeds/+server.ts | 16 +- .../api/integrations/feeds/rotate/+server.ts | 16 +- apps/web/src/routes/api/me/groups/+server.ts | 14 +- .../src/routes/api/me/preferences/+server.ts | 72 +- apps/web/src/routes/api/me/tasks/+server.ts | 177 +- .../routes/api/me/tasks/overview/+server.ts | 52 +- apps/web/src/routes/api/tasks/[id]/+server.ts | 202 +-- .../routes/api/tasks/[id]/claim/+server.ts | 58 +- .../routes/api/tasks/[id]/complete/+server.ts | 219 +-- .../routes/api/tasks/[id]/unassign/+server.ts | 60 +- .../api/tasks/[id]/uncomplete/+server.ts | 91 +- .../routes/app/preferences/+page.server.ts | 54 +- .../ics/aggregate/[token].ics/+server.ts | 62 +- .../routes/ics/group/[token].ics/+server.ts | 64 +- .../ics/personal/[token].ics/+server.ts | 62 +- docs/MANUAL_TESTS.md | 14 +- docs/REQUESTED_FILES.md | 2 +- docs/USER_GUIDE.md | 44 +- docs/commands-inventory.md | 70 +- docs/golden/command.texts.json | 20 +- docs/how-to/adding-command.md | 2 +- docs/operations.md | 2 +- docs/plan-ayuda-bot.md | 46 +- docs/plan-interfaz-web.md | 22 +- docs/plan-onboarding-comandos.md | 70 +- docs/plan-reacciones-bot.md | 14 +- docs/plan-sincronizacion-miembros.md | 8 +- docs/refactor-command-service.md | 2 +- docs/whatsapp-style-guide.md | 18 +- src/clients/evolution.ts | 35 +- src/db.ts | 2 +- src/db/locator.ts | 19 +- src/db/migrator.ts | 136 +- src/env/required.ts | 7 + src/http/bootstrap.ts | 22 - src/http/metrics.ts | 83 +- src/http/webhook-handler.ts | 612 ++++--- src/server.ts | 136 +- src/services/admin.ts | 527 +++--- src/services/command.ts | 4 +- src/services/commands/handlers/completar.ts | 189 +- src/services/commands/handlers/configurar.ts | 4 +- src/services/commands/handlers/nueva.ts | 428 +++-- src/services/commands/handlers/soltar.ts | 128 +- src/services/commands/handlers/tomar.ts | 219 ++- src/services/commands/handlers/ver.ts | 263 +-- src/services/commands/handlers/web.ts | 4 +- src/services/commands/index.ts | 122 +- src/services/commands/shared.ts | 92 +- src/services/contacts.ts | 149 +- src/services/group-sync.ts | 1554 ++++++++--------- src/services/group-sync/api.ts | 133 +- src/services/group-sync/changes.ts | 60 + src/services/group-sync/deactivation.ts | 85 + src/services/group-sync/membership.ts | 75 + src/services/group-sync/scheduler.ts | 79 + src/services/messages/help.ts | 100 -- src/services/onboarding.ts | 527 +++--- src/services/reminders.ts | 431 +++-- src/services/response-queue.ts | 244 ++- src/services/webhook-manager.ts | 2 +- src/tasks/complete-reaction.ts | 155 +- src/tasks/mappers.ts | 16 + src/tasks/model.ts | 18 - src/tasks/service.ts | 182 +- src/utils/datetime.ts | 35 + src/utils/whatsapp.ts | 10 +- startup.ts | 183 ++ tests/helpers/dates.ts | 21 +- tests/helpers/db.ts | 56 + tests/helpers/server-test-harness.ts | 98 ++ tests/unit/db/locator.test.ts | 4 +- tests/unit/server.advanced-listings.test.ts | 175 ++ tests/unit/server.basic.test.ts | 264 +++ tests/unit/server.command-logging.test.ts | 228 +++ tests/unit/server.coverage.test.ts | 292 ++++ tests/unit/server.group-validation.test.ts | 97 + tests/unit/server.test.ts | 941 ---------- tests/unit/server.user-validation.test.ts | 149 ++ tests/unit/server/discovery-label.test.ts | 2 +- .../server/discovery-notify-admins.test.ts | 2 +- tests/unit/server/enforce-gating.test.ts | 4 +- .../server/unknown-group-discovery.test.ts | 2 +- .../unit/server/webhook.reactions.e2e.test.ts | 10 +- .../command.assignment-defaults.test.ts | 4 +- .../services/command.claim-unassign.test.ts | 26 +- .../services/command.date-parsing.test.ts | 32 +- .../services/command.formatting-ddmm.test.ts | 2 +- tests/unit/services/command.gating.test.ts | 4 +- tests/unit/services/command.help.test.ts | 20 +- .../services/command.listing-ddmm.test.ts | 6 +- .../services/command.nueva-assignees.test.ts | 12 +- .../command.onboarding-jit-lid.test.ts | 4 +- .../services/command.onboarding-jit.test.ts | 4 +- .../services/command.reminders-config.test.ts | 16 +- .../unit/services/command.self-assign.test.ts | 16 +- .../services/command.task-origins.test.ts | 2 +- tests/unit/services/command.test.ts | 28 +- .../services/command.unknown-help.test.ts | 12 +- tests/unit/services/command.web-login.test.ts | 12 +- tests/unit/services/help-content.test.ts | 35 - tests/unit/services/reminders.gating.test.ts | 33 +- tests/unit/tasks/service.gating.test.ts | 54 +- tests/unit/utils/whatsapp.test.ts | 32 +- tests/web/api.tasks.complete.errors.test.ts | 4 +- tests/web/api.tasks.complete.reaction.test.ts | 6 +- 127 files changed, 6379 insertions(+), 6251 deletions(-) delete mode 100644 README.old.md delete mode 100644 apps/web/src/lib/index.ts create mode 100644 apps/web/src/lib/server/ics-helpers.ts create mode 100644 apps/web/src/lib/server/preferences-helpers.ts create mode 100644 apps/web/src/lib/server/task-helpers.ts delete mode 100644 apps/web/src/lib/ui/atoms/Badge.svelte delete mode 100644 apps/web/src/lib/ui/atoms/Skeleton.svelte delete mode 100644 apps/web/src/lib/ui/atoms/VisuallyHidden.svelte delete mode 100644 apps/web/src/lib/ui/data/GroupCard.svelte delete mode 100644 apps/web/src/lib/ui/feedback/ErrorBanner.svelte delete mode 100644 apps/web/src/lib/ui/icons/Hourglass.svelte delete mode 100644 apps/web/src/lib/ui/inputs/TextField.svelte create mode 100644 src/env/required.ts create mode 100644 src/services/group-sync/changes.ts create mode 100644 src/services/group-sync/deactivation.ts create mode 100644 src/services/group-sync/membership.ts create mode 100644 src/services/group-sync/scheduler.ts delete mode 100644 src/services/messages/help.ts delete mode 100644 src/tasks/model.ts create mode 100644 startup.ts create mode 100644 tests/helpers/server-test-harness.ts create mode 100644 tests/unit/server.advanced-listings.test.ts create mode 100644 tests/unit/server.basic.test.ts create mode 100644 tests/unit/server.command-logging.test.ts create mode 100644 tests/unit/server.coverage.test.ts create mode 100644 tests/unit/server.group-validation.test.ts delete mode 100644 tests/unit/server.test.ts create mode 100644 tests/unit/server.user-validation.test.ts delete mode 100644 tests/unit/services/help-content.test.ts diff --git a/.gitignore b/.gitignore index 12ef629..b7c4502 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,13 @@ docs/evolution-api.envs .DS_Store .aider* +# Tool artifacts (fallow, sift, sq) +.fallow* +.sift* +dupes.json +tasks.jsonl +README.old.md + # DB *.db *.db-wal diff --git a/Dockerfile b/Dockerfile index dfa162b..1675996 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,9 +43,8 @@ EXPOSE 3000 # Declare volume for persistent data by default VOLUME ["/app/data"] -# Make script executable -COPY startup.sh ./ -RUN chmod +x startup.sh +# Copy startup script +COPY startup.ts ./ # Start via wrapper script -CMD ["./startup.sh"] +CMD ["bun", "run", "startup.ts"] diff --git a/README.md b/README.md index daacb86..bd32970 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen - 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. +- 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. - Acks por reacciones en WhatsApp: 🤖/⚠️ al procesar comandos y ✅ al completar tareas dentro de un TTL configurable; idempotencia y gating por grupo/alcance; requiere Evolution API sendReaction (key.fromMe=false). - Rate limiting por usuario para evitar abuso. @@ -82,7 +82,7 @@ Variables clave: - ALLOWED_GROUPS (semilla inicial), NOTIFY_ADMINS_ON_DISCOVERY. - HEALTH_CHECK_INTERVAL_MS (ms, por defecto 60000) y HEALTH_CHECK_RESTART_COOLDOWN_MS (ms, por defecto 900000). - METRICS_ENABLED, PORT. -- WEB_BASE_URL (host público de la web para generar enlaces absolutos; usado por /t web). +- 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' diff --git a/README.old.md b/README.old.md deleted file mode 100644 index b5298c0..0000000 --- a/README.old.md +++ /dev/null @@ -1,293 +0,0 @@ -# Task WhatsApp Chatbot - -Un chatbot de WhatsApp para gestionar tareas en grupos, integrado con Evolution API. Diseño “solo DM”: el bot no publica en grupos; todas las respuestas se envían por mensaje directo al autor (opcionalmente puede enviarse un breve resumen al grupo al crear, configurable). - -## Cómo se usa (mini guía para usuarios) -- Principios - - Comandos con prefijo “/t” o “/tarea”. - - En grupo: el bot responde por DM al autor (no escribe en el grupo). - - Fechas en formato dd/MM en mensajes; puedes escribir “hoy” o “mañana” al crear; la zona horaria se controla con la variable de entorno TZ (por defecto Europe/Madrid). -- Comandos y alias principales - - Crear: “/t nueva Acta reunión mañana @600123456” - - Ver pendientes del grupo: “/t ver grupo” - - Ver tus pendientes: “/t ver mis” - - Completar: “/t x 26” (alias: hecho, completar, done) - - Tomar: “/t tomar 26” - - Soltar: “/t soltar 26” - - Configurar recordatorios: “/t configurar daily|weekly|off” - - Ayuda: “/t” o “/t ayuda” -- Notas y reglas - - Si creas en grupo y no mencionas a nadie: queda “sin responsable”. - - Si creas por DM y no mencionas a nadie: se asigna al creador. - - En DM, WhatsApp no muestra chips de mención de terceros; se incluye @número como texto para acción rápida. -- Guía completa con alias, reglas y ejemplos: docs/USER_GUIDE.md - -## Características -- Crear, listar, completar, tomar/soltar tareas; ayuda por DM. -- Recordatorios por DM (daily/weekly) por usuario; evita duplicados y respeta TZ. -- Cola de respuestas persistente con reintentos (backoff exponencial + jitter) y recuperación tras reinicios. -- Nombres amigables vía caché de contactos (sin llamadas de red en tests). -- Sincronización de miembros de grupos (snapshot periódica + webhooks incrementales; tolerante a fallos). -- Mensajes compactos con emojis y cursiva; fechas dd/MM; vencidas con ⚠️. -- Observabilidad mínima: /metrics (Prometheus por defecto, JSON opcional) y /health detallado. - -## Requisitos -- Evolution API accesible (recomendado: misma red interna Docker). -- Bun para desarrollo local; Docker/CapRover para despliegue. -- SQLite embebido con persistencia en data/. - -## Variables de entorno -- Requeridas - - EVOLUTION_API_URL: URL de Evolution API (p.ej., http://evolution-api:3000). - - EVOLUTION_API_KEY: API key de Evolution. - - EVOLUTION_API_INSTANCE: nombre de la instancia en Evolution. - - WHATSAPP_COMMUNITY_ID: comunidad principal desde la que sincronizar grupos (jid @g.us). - - CHATBOT_PHONE_NUMBER: número normalizado del bot (evita auto-respuestas). - - WEBHOOK_URL: URL (interna) donde Evolution enviará webhooks. - - PORT: puerto del servidor webhook (p.ej., 3007). -- Opcionales — comportamiento - - TZ: zona horaria para “hoy/mañana” y render de fechas; por defecto Europe/Madrid. - - NOTIFY_GROUP_ON_CREATE: si “true”, envía resumen al grupo al crear (por defecto false). - - GROUP_SYNC_INTERVAL_MS: intervalo de sync de grupos; por defecto 24h (mín 10s en desarrollo). - - GROUP_MEMBERS_SYNC_INTERVAL_MS: intervalo de sync de miembros; por defecto 6h (mín 10s en desarrollo). - - MAX_MEMBERS_SNAPSHOT_AGE_MS: edad máxima (ms) para considerar "fresca" la snapshot de miembros; por defecto 24h. - - GROUP_MEMBERS_ENFORCE: si "true", aplica validación estricta de membresía cuando la snapshot es fresca; por defecto false. - - REMINDERS_INCLUDE_UNASSIGNED_FROM_MEMBERSHIP: si "true", añade sección "sin responsable" en recordatorios solo de tus grupos con membresía activa; por defecto false. - - RATE_LIMIT_PER_MIN: límite por usuario (tokens/min); por defecto 15. - - RATE_LIMIT_BURST: capacidad del bucket; por defecto = RATE_LIMIT_PER_MIN. -- Opcionales — cola de respuestas - - RQ_MAX_ATTEMPTS: reintentos máximos; por defecto 6. - - RQ_BASE_BACKOFF_MS: backoff base en ms; por defecto 5000. - - RQ_MAX_BACKOFF_MS: backoff máximo en ms; por defecto 3600000. -- Opcionales — migraciones - - MIGRATOR_CHECKSUM_STRICT: si "false" desactiva validación estricta de checksum de migraciones; por defecto "true". - - MIGRATIONS_LOG_PATH: ruta del fichero de log de migraciones; por defecto data/migrations.log. -- Opcionales — métricas y mantenimiento - - METRICS_ENABLED: "true"|"false" para habilitar /metrics; por defecto true (desactivado en test). - - METRICS_FORMAT: "prom"|"json"; por defecto "prom". - - GROUP_MEMBERS_INACTIVE_RETENTION_DAYS: días para purgar miembros inactivos; 180 por defecto; 0 desactiva. - - FORCE_SCHEDULERS: "true" para forzar arranque de jobs en NODE_ENV=test. -- Entorno - - NODE_ENV: production | development | test. - -Consulta .env.example para un listado comentado con valores de ejemplo. - -## Puesta en marcha (local) -- bun install -- Copia .env.example a .env y ajústalo. -- bun run dev (arranca servidor con recarga). -- bun test (ejecuta pruebas con SQLite en memoria). - -## Despliegue con Docker/CapRover -- Crea una app y configura: - - Variables de entorno (ver arriba). - - Health check: GET /health. - - Volumen persistente: mapea /app/data a un volumen (persistencia de SQLite). - - Red interna con Evolution API (ideal: no exponer públicamente el webhook). -- El worker de la cola arranca con el servidor (en NODE_ENV=test se desactiva). -- Plan operativo mínimo (CI/CD, healthcheck y backups): ver docs/CI-CD-PLAN.md (decisiones pendientes marcadas). - -## Seguridad y buenas prácticas -- Mantén WEBHOOK_URL accesible desde Evolution API preferiblemente en red interna; si se expone, restringe IPs o usa reverse proxy/firewall. -- Gestiona secretos (API keys) como variables en el orquestador. -- Configura backups periódicos del fichero data/tasks.db (las migraciones hacen VACUUM INTO, pero no sustituyen un backup programado). - -## Limitaciones conocidas -- Sin orden garantizado por destinatario en la cola. -- /metrics básico sin histogramas ni etiquetas; mejoras futuras. -- Permisos/roles y validación estricta de pertenencia a grupos no implementados. - -## Roadmap -- Próximos pasos y estado detallado: ver STATUS.md. - -## Testing -- Ejecuta la suite con “bun test”. Llama a Evolution API solo fuera de test. - -## Contribución -- PRs bienvenidas. Añade pruebas, ejecuta “bun test” y describe los cambios. - -6) Permisos y pertenencia a grupos -- Objetivo: control de quién puede qué, y pertenencia válida. -- Implica: roles y/o verificación de pertenencia; posibles migraciones y sincronización de miembros. - -7) Historial de tareas (auditoría ligera) -- Objetivo: trazabilidad de cambios. -- Implica: tabla task_events; eventos en crear/asignar/tomar/soltar/completar; consulta “historial”. - -8) Rate limiting (operación segura) — completado -- Objetivo: proteger ante abuso o loops. -- Implementado: token bucket por usuario con límites configurables (RATE_LIMIT_PER_MIN, RATE_LIMIT_BURST) y aviso con cooldown. - -9) Sincronización de miembros (opcional) -- Objetivo: conocer miembros activos por grupo para features avanzadas. -- Implica: endpoints Evolution API para miembros; cache/migraciones. - -10) Métricas y observabilidad -- Objetivo: visibilidad con bajo coste. -- Implementado: /metrics (Prometheus/JSON) con counters/gauges y /health detallado; pendiente: histogramas/latencias y logging estructurado avanzado. - -## 🔑 Key Considerations & Caveats -* **WhatsApp ID Normalization:** Crucial for consistently identifying users and groups. Needs careful implementation to handle edge cases. (Utility function exists). -* **Response Latency:** Sending responses requires an API call back to Evolution. Ensure the `ResponseQueue` processing is efficient. -* **Cola de respuestas:** Persistente en DB; con reintentos (backoff exponencial + jitter), recuperación tras reinicios y limpieza/retención configurables. -* **Group Sync:** The current full sync might be slow or rate-limited with many groups. Delta updates are recommended long-term. -* **Error Handling:** Failures in command processing or response sending should be logged clearly and potentially reported back to the user. Database operations should use transactions for atomicity (especially task+assignment creation). -* **State Management:** The current design is stateless. Complex interactions might require state persistence later. -* **Security:** Ensure group/user validation logic is robust once integrated. - -## 🧪 Testing -### Running Tests -```bash -bun test -``` - -### Test Coverage -- Database initialization and basic operations (`db.test.ts`). -- Webhook validation (basic) (`webhook-manager.test.ts`). -- Command parsing (basic structure) (`command.test.ts`). -- Environment checks (`server.test.ts`). -- Basic error handling (`server.test.ts`). -- WhatsApp ID normalization (`whatsapp.test.ts`). -- Group sync operations (`group-sync.test.ts`). -- Webhook handlers de membresías (alta/baja/cambio de rol) (`group-sync.*.test.ts`). -- **Needed:** Tests for `ensureUserExists` integration, `isGroupActive` integration, `CommandService` logic, `ResponseQueue` processing (mocking API), `TaskService` operations. -- All 170 unit tests passing. Added unit tests for CommandService (date parsing "hoy/mañana", DM help, dd/MM formatting, default assignment rules) y para RemindersService (daily/weekly, duplicados por día, hora/TZ, “… y X más”) y configuración de recordatorios. - -## 🧑‍💻 Contributing -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/implement-user-validation`) -3. Add/update tests for new functionality -4. Ensure tests pass (`bun test`) -5. Submit a pull request - -## 📚 Documentation -For detailed API documentation and architecture decisions, see the [docs/](docs/) directory (if created). - ---- - -## 📐 Diseño UX acordado (MVP y siguientes iteraciones) - -Este apartado documenta las decisiones de UX aprobadas para el MVP y su evolución inmediata. Objetivo: mínima fricción, cero ruido en grupos y mensajes compactos. - -### Principios -- Silencio en grupos: el bot NO publica mensajes en grupos. Cuando alguien usa un comando en un grupo, el bot responde solo por DM al autor, sin dejar mensaje en el grupo. -- Homogeneidad: mismos comandos y comportamiento tanto en la comunidad “Casa” como en la del AMPA. -- Mensajes compactos: máximo 2–3 líneas, usando emojis y formato WhatsApp (negritas, monoespacio) para legibilidad. -- Aprendizaje progresivo: alias cortos y ayuda accesible por DM. - -### Comando base y alias -- Prefijo admitido: “/t” y “/tarea”. -- Subcomandos y sinónimos (aceptar cualquiera, mapear a una acción canónica): - - Crear: n, nueva, crear, + - - Ver: ver, mostrar, listar, ls - - Completar: x, hecho, completar, done - - Tomar: tomar, claim - - Soltar: soltar, unassign - - Ayuda: ayuda, help, ? - - Configurar: configurar, config - -### Gramática de “crear tarea” -- Texto libre: descripción. -- Fecha: soportar tokens “hoy” y “mañana” (MVP). Futuro: +2d, +1w, lun/mar/… -- Menciones: “@…” y menciones reales del cliente. -- Asignación por defecto: - - En grupos: si no hay menciones → tarea queda “sin responsable”. - - En DM: si no hay menciones → asignada al creador. -- Comandos de gestión de asignación: - - /t tomar → el usuario se asigna la tarea. - - /t soltar → elimina su asignación, devolviendo la tarea a “sin responsable” si no quedan asignados. - -### Listados -- /t ver grupo → devuelve por DM las pendientes del grupo desde el que se invoca (incluye sección “sin responsable”). -- /t ver mis → devuelve por DM las pendientes del usuario agregadas de todos sus grupos. -- Listas extensas: mostrar top N (p. ej., 10) y resumen “y X más…”. - -### Completar -- /t x (alias: /t hecho , /t completar ) -- Registro de quién completó. Por ahora no se restringe a asignados (permite fluidez); política configurable en el futuro. -- Confirmación solo por DM. - -### Ayuda y onboarding -- “/t” sin parámetros o “ayuda” → siempre por DM, con guía corta y 2–3 ejemplos. -- En grupos: no se escribe nada en el grupo; únicamente el DM al autor. - -### Mensajes: plantillas compactas -- Confirmación al crear (DM al creador): - - 📝 26 _Acta de la reunión_ - - 📅 12/09 - - 🚫👤 sin responsable (Junta AMPA) — o — 👤 @Juan -- DM a asignados: - - 📬 Tarea 26 — 📅 12/09 - - _Acta de la reunión_ - - Grupo: Junta AMPA - - Completar: /t x 26 -- Listado (enviado por DM): - - Junta AMPA - - 26) _Acta…_ — 📅 12/09 — 👤 @Juan - - 27) _Carteles fiesta_ — 📅 10/09 — 🚫👤 sin responsable - - … y 3 más -- Completar (feedback por DM): - - ✅ 26 completada — _Acta…_ - - Gracias, Juan. - -### Preferencias (MVP) -- Única preferencia: frecuencia de recordatorios por DM: daily | off. -- MVP sin web: el usuario escribe “configurar” por DM y el bot le ofrece elegir “diario” u “off”. -- Por defecto: off (evitar spam). Futuro: hora y zona horaria configurables; magic link a web de configuración. - -### Recordatorios -- Resumen diario por DM (si el usuario eligió “diario”): - - ⏰ Recordatorio diario — hoy 12/09 - - 26) _Acta…_ — 📅 12/09 — Junta AMPA - - 31) _Pagar comedor_ — hoy — Casa - - 33) _…_ — 📅 15/09 — Casa - - Completar: /t x -- Un solo DM con secciones por comunidad para evitar múltiples mensajes. - -### No objetivos del MVP -- No asignar por defecto a “todo el grupo” (evita DMs masivos y responsabilidad difusa). -- No canal “Tareas” compartido por defecto (riesgo de ruido). Se considerará en el futuro solo si hay demanda y opt‑in. - -### Plan de implementación (iteraciones) -- Iteración A — UX base y silencios - - Alias de comandos y sinónimos en CommandService. - - Respuestas de todos los comandos únicamente por DM (incluido cuando se invocan en grupos). - - Mensajes compactos con plantillas. - - Soporte de “hoy/mañana”. - - Default sin dueño en grupos; asignar al creador en DMs. - - Nuevos comandos: tomar y soltar. - - Ayuda por DM. -- Iteración B — Listados y completar - - /t ver grupo, /t ver mis. - - /t x con registro de quién completa. - - Tests para alias, hoy/mañana, ver y x. -- Iteración C — Recordatorios - - Preferencia reminder_freq (daily|off) por usuario via “configurar” por DM. - - Job diario que envía el resumen (solo “tus tareas” en MVP). -- Iteración D — (Opcional) Miembros de grupo - - Sincronizar miembros si se necesita incluir “sin responsable” por grupo en recordatorios. - -### Cambios técnicos asociados (resumen) -- src/services/command.ts - - Mapeo de sinónimos a acciones canónicas. - - Parser de “hoy/mañana”. - - Subcomandos: ver grupo|mis, x, tomar, soltar. - - Render de mensajes compactos. -- src/server.ts - - Detección de contexto grupo vs DM; nunca responder en grupo (solo DM al autor). -- src/tasks/service.ts - - Permitir tareas sin asignaciones. - - Métodos: claimTask(user_id), unassignTask(user_id), completeTask(id, completed_by). -- src/services/response-queue.ts - - Envío de DMs para ayuda, confirmaciones, listados y recordatorios. -- (Futuro) Preferencias - - Tabla user_preferences(user_id PK, reminder_freq TEXT, updated_at). - - “configurar” por DM en MVP; más adelante, web con magic link (user_tokens). - -### Testing sugerido -- Alias de comandos y “/t” sin parámetros → DM de ayuda. -- Crear en grupo sin menciones → sin responsable; no hay mensaje en el grupo; DM al autor. -- Crear en DM sin menciones → asignada al creador. -- “hoy/mañana” en fechas. -- ver grupo y ver mis → DM con paginación/resumen. -- completar, tomar y soltar → reglas y feedback por DM. diff --git a/apps/web/src/lib/index.ts b/apps/web/src/lib/index.ts deleted file mode 100644 index 856f2b6..0000000 --- a/apps/web/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/apps/web/src/lib/server/datetime.ts b/apps/web/src/lib/server/datetime.ts index 80831c0..3cf3db8 100644 --- a/apps/web/src/lib/server/datetime.ts +++ b/apps/web/src/lib/server/datetime.ts @@ -1,4 +1,4 @@ -import { toIsoSqlUTC as coreToIsoSqlUTC, normalizeTime as coreNormalizeTime } from '../../../../../src/utils/datetime'; +import { toIsoSqlUTC as coreToIsoSqlUTC, normalizeTime as coreNormalizeTime, ymdUTC, ymdInTZ as coreYmdInTZ } from '../../../../../src/utils/datetime'; /** * Serializa una fecha en UTC al formato SQL ISO "YYYY-MM-DD HH:MM:SS[.SSS]". @@ -15,14 +15,10 @@ export function normalizeTime(input: string | null | undefined): string | null { return coreNormalizeTime(input); } -/** - * Devuelve YYYY-MM-DD en UTC (útil para consultas por rango de fecha). - */ -export 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}`; +export { ymdUTC }; + +export function ymdInTZ(d: Date, tz: string): string { + return coreYmdInTZ(d, tz); } /** diff --git a/apps/web/src/lib/server/db.ts b/apps/web/src/lib/server/db.ts index 79d5387..5492f46 100644 --- a/apps/web/src/lib/server/db.ts +++ b/apps/web/src/lib/server/db.ts @@ -1,4 +1,4 @@ -import { mkdirSync, existsSync } from 'fs'; +import { mkdirSync } from 'fs'; import { dirname } from 'path'; import { resolveDbAbsolutePath, isDev, DEV_AUTOSEED_DB, DEV_DEFAULT_USER } from './env'; @@ -48,139 +48,156 @@ async function importSqliteDatabase(): Promise { 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); +// --------------------------------------------------------------------------- +// DB open helpers +// --------------------------------------------------------------------------- - // Crear directorio padre si no existe +function ensureParentDir(absolutePath: string): void { try { mkdirSync(dirname(absolutePath), { recursive: true }); } catch (err: any) { if (err?.code !== 'EEXIST') throw err; } +} +async function createSqliteInstance(absolutePath: string): Promise { const DatabaseCtor = await importSqliteDatabase(); const instance = new DatabaseCtor(absolutePath); applyDefaultPragmas(instance); + return 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 {} +function hasTable(instance: any, table: string): boolean { + try { + instance.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get(); + return true; + } catch { + return false; + } +} - // 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'); - 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); - } - } +async function runDevMigrationsBun(instance: any): Promise { + try { + const dbModule = await import('../../../../../src/db'); + if (typeof (dbModule as any).initializeDatabase === 'function') { + (dbModule as any).initializeDatabase(instance); + console.info('[web/db] DEV: esquema inicializado (Bun initializeDatabase).'); } + } catch (e) { + console.warn('[web/db] No se pudo ejecutar initializeDatabase en dev (Bun):', e); + } +} - // Seed de datos de demo si la tabla está vacía (por defecto habilitado en dev) - try { - let count = 0; +async function runDevMigrationsNode(instance: any): Promise { + try { + const mod = await import('../../../../../src/db/migrations/index'); + 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 { - 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; + 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); } + } + try { + compat.prepare('SELECT 1 FROM tasks LIMIT 1').get(); + 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); + } +} + +async function initializeDevSchema(instance: any): Promise { + if (hasTable(instance, 'tasks')) return; + + const isBun = typeof (globalThis as any).Bun !== 'undefined'; + if (isBun) { + await runDevMigrationsBun(instance); + } else { + await runDevMigrationsNode(instance); + } +} - 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); - } +async function seedDevData(instance: any): Promise { + 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 { + return; // table doesn't exist yet — skip seeding + } + + const shouldSeed = typeof DEV_AUTOSEED_DB === 'boolean' ? DEV_AUTOSEED_DB : true; + if (count !== 0 || !shouldSeed) { + console.info(`[web/db] DEV: seed no aplicado (count=${count}, DEV_AUTOSEED_DB=${shouldSeed}).`); + return; + } + + 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.info(`[web/db] DEV: seed no aplicado (count=${count}, DEV_AUTOSEED_DB=${shouldSeed}).`); + console.warn('[web/db] DEV: módulo dev-seed sin función seedDev; omitiendo seed.'); } } catch (e) { - console.warn('[web/db] DEV: error al evaluar seed de demo:', e); + console.warn('[web/db] DEV: no se pudo cargar/ejecutar dev-seed; omitiendo seed. Error:', e); } + } catch (e) { + console.warn('[web/db] DEV: error al evaluar seed de demo:', e); + } +} + +// --------------------------------------------------------------------------- +// DB open +// --------------------------------------------------------------------------- + +/** + * 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); + + ensureParentDir(absolutePath); + const instance = await createSqliteInstance(absolutePath); + + if (isDev()) { + await initializeDevSchema(instance); + await seedDevData(instance); } return instance; } let _db: any | null = null; +let _dbPath: string | null = null; /** * Devuelve una única instancia compartida (lazy) de la BD. + * Si cambia el path, cierra la anterior y abre una nueva conexión. */ export async function getDb(filename: string = 'tasks.db'): Promise { - if (_db) return _db; - _db = await openDb(filename); - return _db; -} - -/** - * Cierra y resetea la instancia compartida (útil en tests para evitar manejar - * un descriptor abierto al borrar el archivo de la BD en disco). - */ -export function closeDb(): void { + const resolved = resolveDbAbsolutePath(filename); + if (_db && _dbPath === resolved) return _db; + // Path changed or first call — close previous and reconnect try { - if (_db && typeof _db.close === 'function') { - _db.close(); - } + if (_db && typeof _db.close === 'function') _db.close(); } catch {} - _db = null; + _db = await openDb(filename); + _dbPath = resolved; + return _db; } diff --git a/apps/web/src/lib/server/env.ts b/apps/web/src/lib/server/env.ts index c439d2f..3b56007 100644 --- a/apps/web/src/lib/server/env.ts +++ b/apps/web/src/lib/server/env.ts @@ -12,12 +12,11 @@ try { export { resolveDbAbsolutePath } from '../../../../../src/env/db-path'; 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(); +const NODE_ENV = (env.NODE_ENV || 'development').trim().toLowerCase(); export const isProd = () => NODE_ENV === 'production'; export const isDev = () => NODE_ENV === 'development'; @@ -37,8 +36,6 @@ 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; - // Reacciones (flags de característica para la web) const REACTIONS_TTL_DAYS_RAW = Number(env.REACTIONS_TTL_DAYS || 14); export const REACTIONS_TTL_DAYS = Math.max(1, Math.floor(REACTIONS_TTL_DAYS_RAW)); diff --git a/apps/web/src/lib/server/ics-helpers.ts b/apps/web/src/lib/server/ics-helpers.ts new file mode 100644 index 0000000..45858df --- /dev/null +++ b/apps/web/src/lib/server/ics-helpers.ts @@ -0,0 +1,92 @@ +import { sha256Hex } from '$lib/server/crypto'; +import { icsHorizonMonths } from '$lib/server/env'; +import { buildIcsCalendar, checkIcsRateLimit } from '$lib/server/ics'; +import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime'; + +type TaskRow = { id: number; description: string; due_date: string; group_name: string | null }; + +/** + * Validate the ICS token, returning the DB row or an error Response. + */ +export async function validateIcsToken( + db: any, + token: string, + expectedType: string +): Promise<{ row: any } | Response> { + 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) !== expectedType) return new Response('Not Found', { status: 404 }); + + return { row }; +} + +/** + * Build the ICS feed response: rate limit, query tasks, map events, build calendar, + * handle ETag / 304, update last_used_at, return 200 with ETag. + */ +export async function buildIcsFeed( + db: any, + tokenHash: string, + row: any, + request: Request, + title: string, + tasks: TaskRow[] +): Promise { + // Rate limit + const rl = checkIcsRateLimit(tokenHash); + if (!rl.ok) { + return new Response('Too Many Requests', { + status: 429, + headers: { 'Retry-After': String(rl.retryAfterSec || 60) } + }); + } + + // Map to events + const events = tasks.map((t) => ({ + id: t.id, + description: t.description, + due_date: t.due_date, + group_name: t.group_name || null, + prefix: 'T' as const + })); + + const { body, etag } = await buildIcsCalendar(title, events); + + // 304 if ETag matches + const inm = request.headers.get('if-none-match'); + if (inm && inm === etag) { + 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(toIsoSqlUTC(), row.id); + + return new Response(body, { + status: 200, + headers: { + 'content-type': 'text/calendar; charset=utf-8', + 'cache-control': 'public, max-age=300', + ETag: etag + } + }); +} + +/** Compute the YMD date range for ICS queries. */ +export function icsDateRange(): { startYmd: string; endYmd: string } { + const today = new Date(); + return { + startYmd: ymdUTC(today), + endYmd: ymdUTC(addMonthsUTC(today, icsHorizonMonths)) + }; +} diff --git a/apps/web/src/lib/server/preferences-helpers.ts b/apps/web/src/lib/server/preferences-helpers.ts new file mode 100644 index 0000000..e7c59f7 --- /dev/null +++ b/apps/web/src/lib/server/preferences-helpers.ts @@ -0,0 +1,56 @@ +import { normalizeTime } from '$lib/server/datetime'; + +/** + * Resolves the reminder time to save based on frequency and raw input. + * Returns { timeToSave } on success or { error } on validation failure. + * Callers must handle the error case with their own Response/fail mechanism. + */ +export function resolveReminderTime( + db: any, + userId: string, + freqRaw: string, + timeRaw: string | null +): { timeToSave: string } | { error: string } { + 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 { error: 'hora inválida' }; + return { timeToSave: norm }; + } + const row = db + .prepare( + `SELECT reminder_time AS time + FROM user_preferences + WHERE user_id = ? + LIMIT 1` + ) + .get(userId) as any; + return { timeToSave: row?.time ? String(row.time) : '08:30' }; + } + + // daily/weekly/weekdays: si no se especifica hora, usar '08:30' + if (!timeRaw || timeRaw.length === 0) { + return { timeToSave: '08:30' }; + } + const norm = normalizeTime(timeRaw); + if (!norm) return { error: 'hora inválida' }; + return { timeToSave: norm }; +} + +/** SQL for upserting user preferences (preserves last_reminded_on). */ +export function upsertPreference( + db: any, + userId: string, + freqRaw: string, + timeToSave: string +): void { + 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); +} diff --git a/apps/web/src/lib/server/task-helpers.ts b/apps/web/src/lib/server/task-helpers.ts new file mode 100644 index 0000000..83d8655 --- /dev/null +++ b/apps/web/src/lib/server/task-helpers.ts @@ -0,0 +1,264 @@ +import { getDb } from '$lib/server/db'; + +/** + * Validate session and parse JSON body for POST endpoints. + * Returns { userId, payload } on success, or a Response on failure. + * Callers should check `instanceof Response` before destructuring. + */ +export async function requireAuthAndJson(event: { + locals: { userId?: string | null }; + request: { json(): Promise }; +}): Promise<{ userId: string; payload: any } | Response> { + 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 }); + } + + return { userId, payload }; +} + +/** + * Shared auth + task loading logic used by task detail, claim, and unassign routes. + * + * Validates the user, parses the task ID from params, opens the DB, loads the task, + * and checks that it exists and is not completed. Returns the context on success + * or a Response on failure — callers should check `instanceof Response` first. + */ +export async function loadAndCheckTask(event: { + locals: { userId?: string | null }; + params: { id?: string }; +}): Promise<{ db: any; task: any; userId: string } | Response> { + const ctx = await _loadTask(event); + if (ctx instanceof Response) return ctx; + + // Additional check: reject completed tasks + const { task } = ctx; + 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' } + }); + } + + return ctx; +} + +/** + * Shared group gating check: verifies the group is allowed and the user + * is an active member. Returns a 403 Response on failure, or true to + * continue. Callers should `if (res instanceof Response) return res;`. + */ +export function checkGroupAccess( + db: any, + groupId: string | null, + userId: string +): Response | true { + if (!groupId) return true; + + 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 }); + } + return true; +} + +/** + * Auth + task load + full gating (group + personal assignment). + * Returns context or a Response on failure. Does NOT check completed status — + * callers must handle that themselves (complete vs uncomplete have opposite + * semantics). + */ +/** + * Load a task, check auth, and verify group access. + * Returns { db, task, userId } or a Response on failure. + * Does NOT check personal assignment (suitable for claim/unassign routes). + */ +export async function loadTaskAndCheckGroup(event: { + locals: { userId?: string | null }; + params: { id?: string }; +}): Promise<{ db: any; task: any; userId: string } | Response> { + const ctx = await loadAndCheckTask(event); + if (ctx instanceof Response) return ctx; + const { db, task, userId } = ctx; + + // Gating: grupo permitido + usuario miembro activo + const groupId: string | null = task.group_id ? String(task.group_id) : null; + const gating = checkGroupAccess(db, groupId, userId); + if (gating instanceof Response) return gating; + + return { db, task, userId }; +} + +/** + * Fetch allowed groups for a user where the user is an active member. + * + * @param excludeCommunityArchived - when true, also filters out + * community groups (is_community=0) and archived groups (archived=0). + * Defaults to false (includes all active allowed groups). + */ +export function fetchAllowedUserGroups( + db: any, + userId: string, + opts?: { excludeCommunityArchived?: boolean } +): Array<{ id: string; name: string | null }> { + const extraWhere = opts?.excludeCommunityArchived + ? ' AND COALESCE(g.is_community, 0) = 0 AND COALESCE(g.archived, 0) = 0' + : ''; + + return 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${extraWhere} + ORDER BY (g.name IS NULL) ASC, g.name ASC, g.id ASC` + ) + .all(userId) as Array<{ id: string; name: string | null }>; +} + +/** + * Low-level: auth + taskId parsing + DB + task load + not-found check. + * Does NOT reject completed tasks — that's up to the caller. + */ +async function _loadTask(event: { + locals: { userId?: string | null }; + params: { id?: string }; +}): Promise<{ db: any; task: any; userId: string } | Response> { + // Auth + const userId = event.locals.userId ?? null; + if (!userId) return new Response('Unauthorized', { status: 401 }); + + // Parse task ID + const idStr = event.params.id || ''; + const taskId = parseInt(idStr, 10); + if (!Number.isFinite(taskId) || taskId <= 0) return new Response('Bad Request', { status: 400 }); + + // DB + const db = await getDb(); + + // Load + 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' } + }); + } + + return { db, task, userId }; +} + +export async function loadTaskAndGating(event: { + locals: { userId?: string | null }; + params: { id?: string }; +}): Promise<{ db: any; task: any; userId: string; groupId: string | null } | Response> { + const ctx = await _loadTask(event); + if (ctx instanceof Response) return ctx; + const { db, task, userId } = ctx; + + // Gating: grupo allowed + miembro activo; si no tiene grupo, debe estar asignado + const groupId: string | null = task.group_id ? String(task.group_id) : null; + const gating = checkGroupAccess(db, groupId, userId); + if (gating instanceof Response) return gating; + if (!groupId) { + const isAssigned = db + .prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`) + .get(task.id, userId); + if (!isAssigned) { + return new Response('Forbidden', { status: 403 }); + } + } + + return { db, task, userId, groupId }; +} + +/** Convert a DB row to the standard API task shape. */ +export function formatTask(row: any): Record { + return { + id: Number(row.id), + description: String(row.description || ''), + due_date: row.due_date ? String(row.due_date) : null, + display_code: row.display_code != null ? Number(row.display_code) : null, + completed: 'completed' in (row || {}) ? Number(row.completed || 0) : undefined, + completed_at: 'completed_at' in (row || {}) ? (row.completed_at ? String(row.completed_at) : null) : undefined + }; +} + +/** Map a DB row to a task list item (id, desc, date, group, code, assignees). */ +export function mapTaskRow(r: any): Record { + return { + 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[] + }; +} + +/** + * Populate item.assignees by batch-loading task_assignments. + * Optionally computes can_unassign for the given userId (pass null to skip). + */ +export function loadAssignees(db: any, items: any[], userId: string | null): void { + if (items.length === 0) return; + 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) || []; + if (userId != null) { + const personal = it.group_id == null; + const cnt = Array.isArray(it.assignees) ? it.assignees.length : 0; + const mine = (it.assignees || []).some((uid: string) => uid === userId); + (it as any).can_unassign = !(personal && cnt === 1 && mine); + } + } +} + +/** Build a 200 JSON response { status, task }. */ +export function respondTask(status: string, task: Record): Response { + return new Response(JSON.stringify({ status, task }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); +} diff --git a/apps/web/src/lib/stores/toasts.ts b/apps/web/src/lib/stores/toasts.ts index 0e8d06b..d3b3c8d 100644 --- a/apps/web/src/lib/stores/toasts.ts +++ b/apps/web/src/lib/stores/toasts.ts @@ -2,7 +2,7 @@ import { writable } from 'svelte/store'; export type ToastType = 'info' | 'success' | 'error'; -export type ToastItem = { +type ToastItem = { id: string; type: ToastType; message: string; @@ -15,7 +15,7 @@ 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 { +function show(message: string, type: ToastType = 'info', timeout = 2500): string { const id = uid(); toasts.update((list) => [...list, { id, type, message, timeout }]); if (timeout > 0) { @@ -32,10 +32,6 @@ 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/ui/atoms/Badge.svelte b/apps/web/src/lib/ui/atoms/Badge.svelte deleted file mode 100644 index a405089..0000000 --- a/apps/web/src/lib/ui/atoms/Badge.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - - - diff --git a/apps/web/src/lib/ui/atoms/Skeleton.svelte b/apps/web/src/lib/ui/atoms/Skeleton.svelte deleted file mode 100644 index 697b046..0000000 --- a/apps/web/src/lib/ui/atoms/Skeleton.svelte +++ /dev/null @@ -1,24 +0,0 @@ - - -
- - diff --git a/apps/web/src/lib/ui/atoms/VisuallyHidden.svelte b/apps/web/src/lib/ui/atoms/VisuallyHidden.svelte deleted file mode 100644 index 72a51a9..0000000 --- a/apps/web/src/lib/ui/atoms/VisuallyHidden.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/apps/web/src/lib/ui/data/GroupCard.svelte b/apps/web/src/lib/ui/data/GroupCard.svelte deleted file mode 100644 index 40b203c..0000000 --- a/apps/web/src/lib/ui/data/GroupCard.svelte +++ /dev/null @@ -1,108 +0,0 @@ - - - -
- {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/feedback/ErrorBanner.svelte b/apps/web/src/lib/ui/feedback/ErrorBanner.svelte deleted file mode 100644 index af0cfa3..0000000 --- a/apps/web/src/lib/ui/feedback/ErrorBanner.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/apps/web/src/lib/ui/icons/Hourglass.svelte b/apps/web/src/lib/ui/icons/Hourglass.svelte deleted file mode 100644 index d5abc26..0000000 --- a/apps/web/src/lib/ui/icons/Hourglass.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - - {#if title}{title}{/if} - - - - - diff --git a/apps/web/src/lib/ui/inputs/TextField.svelte b/apps/web/src/lib/ui/inputs/TextField.svelte deleted file mode 100644 index aadcb58..0000000 --- a/apps/web/src/lib/ui/inputs/TextField.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - - - - diff --git a/apps/web/src/lib/utils/date.ts b/apps/web/src/lib/utils/date.ts index 141b25e..ca0a015 100644 --- a/apps/web/src/lib/utils/date.ts +++ b/apps/web/src/lib/utils/date.ts @@ -1,33 +1,24 @@ -export function todayYmdUTC(): string { - const d = new Date(); +function fmtYmd(d: Date): string { 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 todayYmdUTC(): string { + return fmtYmd(new Date()); +} + 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 { +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'; + return fmtYmd(d); } export function ymdToDmy(ymd: string): string { diff --git a/apps/web/src/routes/api/groups/[id]/tasks/+server.ts b/apps/web/src/routes/api/groups/[id]/tasks/+server.ts index ddacd86..5db7a60 100644 --- a/apps/web/src/routes/api/groups/[id]/tasks/+server.ts +++ b/apps/web/src/routes/api/groups/[id]/tasks/+server.ts @@ -1,5 +1,6 @@ import type { RequestHandler } from './$types'; import { getDb } from '$lib/server/db'; +import { loadAssignees, checkGroupAccess, mapTaskRow } from '$lib/server/task-helpers'; export const GET: RequestHandler = async (event) => { // Requiere sesión @@ -25,18 +26,8 @@ export const GET: RequestHandler = async (event) => { 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 accessCheck = checkGroupAccess(db, groupId, userId); + if (accessCheck instanceof Response) return accessCheck; const orderParts: string[] = []; if (unassignedFirst) { @@ -73,38 +64,11 @@ export const GET: RequestHandler = async (event) => { 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[]; + let items = rows.map(mapTaskRow); - 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) || []; - } + // Cargar asignados (skip when onlyUnassigned) + if (!onlyUnassigned) { + loadAssignees(db, items, null); } return new Response(JSON.stringify({ items }), { diff --git a/apps/web/src/routes/api/integrations/feeds/+server.ts b/apps/web/src/routes/api/integrations/feeds/+server.ts index 8cbcd2f..1187a7f 100644 --- a/apps/web/src/routes/api/integrations/feeds/+server.ts +++ b/apps/web/src/routes/api/integrations/feeds/+server.ts @@ -1,6 +1,7 @@ import type { RequestHandler } from './$types'; import { getDb } from '$lib/server/db'; import { findActiveToken, createCalendarTokenUrl, buildCalendarIcsUrl, rotateCalendarTokenUrl } from '$lib/server/calendar-tokens'; +import { fetchAllowedUserGroups } from '$lib/server/task-helpers'; export const GET: RequestHandler = async (event) => { // Requiere sesión @@ -11,19 +12,8 @@ export const GET: RequestHandler = async (event) => { 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 }>; + // Listar solo grupos permitidos donde el usuario está activo (excluye comunidad y archivados) + const groups = fetchAllowedUserGroups(db, userId, { excludeCommunityArchived: true }); // Personal const personalExisting = await findActiveToken('personal', userId, null); diff --git a/apps/web/src/routes/api/integrations/feeds/rotate/+server.ts b/apps/web/src/routes/api/integrations/feeds/rotate/+server.ts index f8f5c06..c17ddd6 100644 --- a/apps/web/src/routes/api/integrations/feeds/rotate/+server.ts +++ b/apps/web/src/routes/api/integrations/feeds/rotate/+server.ts @@ -1,20 +1,12 @@ import type { RequestHandler } from './$types'; import { getDb } from '$lib/server/db'; import { rotateCalendarTokenUrl } from '$lib/server/calendar-tokens'; +import { requireAuthAndJson } from '$lib/server/task-helpers'; 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 ctx = await requireAuthAndJson(event); + if (ctx instanceof Response) return ctx; + const { userId, payload } = ctx; const type = String(payload?.type || '').trim().toLowerCase(); const groupId = payload?.groupId ? String(payload.groupId).trim() : null; diff --git a/apps/web/src/routes/api/me/groups/+server.ts b/apps/web/src/routes/api/me/groups/+server.ts index 489c08a..8420dd9 100644 --- a/apps/web/src/routes/api/me/groups/+server.ts +++ b/apps/web/src/routes/api/me/groups/+server.ts @@ -1,5 +1,6 @@ import type { RequestHandler } from './$types'; import { getDb } from '$lib/server/db'; +import { fetchAllowedUserGroups } from '$lib/server/task-helpers'; export const GET: RequestHandler = async (event) => { // Requiere sesión @@ -11,18 +12,7 @@ export const GET: RequestHandler = async (event) => { 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[]; + const groups = fetchAllowedUserGroups(db, userId); // Preparar statements para contadores const countOpenStmt = db.prepare( diff --git a/apps/web/src/routes/api/me/preferences/+server.ts b/apps/web/src/routes/api/me/preferences/+server.ts index 7a09318..c0d3a3f 100644 --- a/apps/web/src/routes/api/me/preferences/+server.ts +++ b/apps/web/src/routes/api/me/preferences/+server.ts @@ -1,6 +1,7 @@ import type { RequestHandler } from './$types'; import { getDb } from '$lib/server/db'; -import { normalizeTime } from '$lib/server/datetime'; +import { requireAuthAndJson } from '$lib/server/task-helpers'; +import { resolveReminderTime, upsertPreference } from '$lib/server/preferences-helpers'; export const GET: RequestHandler = async (event) => { // Requiere sesión @@ -31,18 +32,10 @@ export const GET: RequestHandler = async (event) => { }; export const POST: RequestHandler = async (event) => { - // Requiere sesión - const userId = event.locals.userId ?? null; - if (!userId) { - return new Response('Unauthorized', { status: 401 }); - } + const ctx = await requireAuthAndJson(event); + if (ctx instanceof Response) return ctx; + const { userId, payload } = ctx; - let payload: unknown = null; - try { - payload = await event.request.json(); - } catch { - return new Response('Bad Request', { status: 400 }); - } const body = payload && typeof payload === 'object' ? (payload as { freq?: unknown; time?: unknown }) : null; const freqRaw = String(body?.freq ?? '').trim().toLowerCase(); @@ -59,55 +52,16 @@ export const POST: RequestHandler = async (event) => { 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; - } + const resolved = resolveReminderTime(db, userId, freqRaw, timeRaw); + if ('error' in resolved) { + return new Response(JSON.stringify({ error: resolved.error }), { + status: 400, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); } + const { timeToSave } = resolved; - // 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); + upsertPreference(db, userId, freqRaw, timeToSave); const responseBody = { freq: freqRaw, time: timeToSave }; diff --git a/apps/web/src/routes/api/me/tasks/+server.ts b/apps/web/src/routes/api/me/tasks/+server.ts index 1204514..13b6e6c 100644 --- a/apps/web/src/routes/api/me/tasks/+server.ts +++ b/apps/web/src/routes/api/me/tasks/+server.ts @@ -1,10 +1,49 @@ import type { RequestHandler } from './$types'; import { getDb } from '$lib/server/db'; +import { loadAssignees, mapTaskRow } from '$lib/server/task-helpers'; function clamp(n: number, min: number, max: number) { return Math.max(min, Math.min(max, n)); } +/** Subquery compartido: filtra a tareas visibles para el usuario (grupo permitido + miembro activo). */ +const GROUP_GATING_SQL = + `(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)))`; + +/** Añade filtro de búsqueda por descripción si procede. */ +function addSearchFilter(whereParts: string[], params: any[], search: string): void { + if (!search) return; + whereParts.push(`t.description LIKE ? ESCAPE '\\'`); + params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`); +} + +/** Ejecuta COUNT con los whereParts + params dados y devuelve el total. */ +function countTasks(db: any, whereParts: string[], params: any[]): number { + const row = 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; + return Number(row?.cnt || 0); +} + +/** Construye la respuesta JSON paginada estándar. */ +function jsonPage(items: any[], page: number, limit: number, total: number, offset: number): Response { + return new Response(JSON.stringify({ + items, + page, + limit, + total, + hasMore: offset + items.length < total + }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); +} + export const GET: RequestHandler = async (event) => { // Requiere sesión const userId = event.locals.userId ?? null; @@ -44,25 +83,11 @@ export const GET: RequestHandler = async (event) => { `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)))` + GROUP_GATING_SQL ]; 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); + addSearchFilter(whereParts, params, search); + const total = countTasks(db, whereParts, params); // Items (order by completed_at DESC) const itemsRows = db @@ -87,47 +112,8 @@ export const GET: RequestHandler = async (event) => { 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' } - }); + loadAssignees(db, items, userId); + return jsonPage(items, page, limit, total, offset); } // OPEN (comportamiento existente) @@ -136,33 +122,18 @@ export const GET: RequestHandler = async (event) => { `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)))` + GROUP_GATING_SQL ]; - const params: any[] = [userId]; - - // Añadir userId para el chequeo de membresía en el filtro de gating - params.push(userId); + const params: any[] = [userId, userId]; - if (search) { - whereParts.push(`t.description LIKE ? ESCAPE '\\'`); - params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`); - } + addSearchFilter(whereParts, params, search); 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); + const total = countTasks(db, whereParts, params); // Items const orderBy = @@ -182,54 +153,8 @@ export const GET: RequestHandler = async (event) => { ) .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 - }; + const items = itemsRows.map(mapTaskRow); - return new Response(JSON.stringify(body), { - status: 200, - headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } - }); + loadAssignees(db, items, userId); + return jsonPage(items, page, limit, total, offset); }; diff --git a/apps/web/src/routes/api/me/tasks/overview/+server.ts b/apps/web/src/routes/api/me/tasks/overview/+server.ts index 191d7f6..93b008d 100644 --- a/apps/web/src/routes/api/me/tasks/overview/+server.ts +++ b/apps/web/src/routes/api/me/tasks/overview/+server.ts @@ -1,5 +1,14 @@ import type { RequestHandler } from './$types'; import { getDb } from '$lib/server/db'; +import { loadAssignees, mapTaskRow } from '$lib/server/task-helpers'; + +/** Map a DB row to an overview item (includes group_name). */ +function mapOverviewRow(r: any) { + return { + ...mapTaskRow(r), + group_name: r.group_name != null ? String(r.group_name) : null + }; +} export const GET: RequestHandler = async (event) => { // Requiere sesión @@ -40,39 +49,10 @@ export const GET: RequestHandler = async (event) => { ) .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[] - })); + const assigned = assignedRows.map(mapOverviewRow); // 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) || []; - } - } + loadAssignees(db, assigned, null); // Orden para "unassigned" const unassignedOrder = @@ -96,15 +76,7 @@ export const GET: RequestHandler = async (event) => { ) .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 - })); + const unassigned = unassignedRows.map(mapOverviewRow); return new Response(JSON.stringify({ assigned, unassigned }), { status: 200, diff --git a/apps/web/src/routes/api/tasks/[id]/+server.ts b/apps/web/src/routes/api/tasks/[id]/+server.ts index 92900a7..5f1a708 100644 --- a/apps/web/src/routes/api/tasks/[id]/+server.ts +++ b/apps/web/src/routes/api/tasks/[id]/+server.ts @@ -1,5 +1,5 @@ import type { RequestHandler } from './$types'; -import { getDb } from '$lib/server/db'; +import { loadAndCheckTask } from '$lib/server/task-helpers'; function isValidYmd(input: string): boolean { const m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(input || ''); @@ -12,138 +12,86 @@ function isValidYmd(input: string): boolean { 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 }); - } +// --------------------------------------------------------------------------- +// Response helpers +// --------------------------------------------------------------------------- - // 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' } - }); - } +const badRequest = () => new Response('Bad Request', { status: 400 }); +const forbidden = () => new Response('Forbidden', { status: 403 }); +const json400 = (error: string) => + new Response(JSON.stringify({ error }), { + 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; - } +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- - const db = await getDb(); +function validateDueDate(raw: unknown): string | null | Response { + if (raw !== null && raw !== undefined && typeof raw !== 'string') return badRequest(); + const trimmed = raw == null ? null : String(raw).trim(); + if (!trimmed) return null; + return isValidYmd(trimmed) ? trimmed : json400('invalid_due_date'); +} - // 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; +function validateDescription(raw: unknown): string | Response { + if (raw !== null && raw !== undefined && typeof raw !== 'string') return badRequest(); + if (raw == null) return json400('invalid_description'); + const normalized = String(raw).replace(/\s+/g, ' ').trim(); + return (normalized.length >= 1 && normalized.length <= 1000) + ? normalized + : json400('invalid_description'); +} - 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' } - }); - } +// --------------------------------------------------------------------------- +// Authorization +// --------------------------------------------------------------------------- - // Gating: grupo permitido + usuario miembro activo (si tiene group_id) +function checkTaskEditAuthorization(db: any, task: any, userId: string): void | Response { 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` - ) + .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` - ) + .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 }); - } + if (!allowed || !active || !gstatus) return forbidden(); } 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); + .get(task.id, userId); const isCreator = String(task.created_by || '') === String(userId); - - if (!isAssigned && !isCreator) { - return new Response('Forbidden', { status: 403 }); - } + if (!isAssigned && !isCreator) return forbidden(); } +} - // Aplicar actualización - if (hasDescField && hasDueField) { +// --------------------------------------------------------------------------- +// DB persistence +// --------------------------------------------------------------------------- + +function applyTaskUpdate( + db: any, + taskId: number, + hasDesc: boolean, + hasDue: boolean, + description: string | undefined, + due_date: string | null, +): void { + if (hasDesc && hasDue) { db.prepare(`UPDATE tasks SET description = ?, due_date = ? WHERE id = ?`).run(description!, due_date, taskId); - } else if (hasDescField) { + } else if (hasDesc) { db.prepare(`UPDATE tasks SET description = ? WHERE id = ?`).run(description!, taskId); - } else if (hasDueField) { + } else if (hasDue) { db.prepare(`UPDATE tasks SET due_date = ? WHERE id = ?`).run(due_date, taskId); } +} +function buildUpdatedResponse(db: any, taskId: number): Response { const updated = db .prepare(`SELECT id, description, due_date, display_code FROM tasks WHERE id = ?`) .get(taskId) as any; @@ -154,12 +102,46 @@ export const PATCH: RequestHandler = async (event) => { 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 - } + 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' } + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, }); +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +export const PATCH: RequestHandler = async (event) => { + const ctx = await loadAndCheckTask(event); + if (ctx instanceof Response) return ctx; + const { db, task, userId } = ctx; + + let payload: any = null; + try { payload = await event.request.json(); } catch { return badRequest(); } + + const hasDueField = Object.prototype.hasOwnProperty.call(payload ?? {}, 'due_date'); + const hasDescField = Object.prototype.hasOwnProperty.call(payload ?? {}, 'description'); + if (!hasDueField && !hasDescField) return badRequest(); + + const due_date = hasDueField ? validateDueDate(payload?.due_date) : null; + if (due_date instanceof Response) return due_date; + + let description: string | undefined; + if (hasDescField) { + const result = validateDescription(payload?.description); + if (result instanceof Response) return result; + description = result; + } + + const auth = checkTaskEditAuthorization(db, task, userId); + if (auth instanceof Response) return auth; + + applyTaskUpdate(db, task.id, hasDescField, hasDueField, description, due_date as string | null); + + return buildUpdatedResponse(db, task.id); }; diff --git a/apps/web/src/routes/api/tasks/[id]/claim/+server.ts b/apps/web/src/routes/api/tasks/[id]/claim/+server.ts index 84e6bd6..0404de8 100644 --- a/apps/web/src/routes/api/tasks/[id]/claim/+server.ts +++ b/apps/web/src/routes/api/tasks/[id]/claim/+server.ts @@ -1,58 +1,10 @@ import type { RequestHandler } from './$types'; -import { getDb } from '$lib/server/db'; +import { loadTaskAndCheckGroup } from '$lib/server/task-helpers'; 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 }); - } - } + const ctx = await loadTaskAndCheckGroup(event); + if (ctx instanceof Response) return ctx; + const { db, task, userId } = ctx; // Asegurar existencia del usuario (best-effort) try { @@ -74,7 +26,7 @@ export const POST: RequestHandler = async (event) => { `INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?)` ) - .run(taskId, userId, userId) as any; + .run(task.id, userId, userId) as any; const status = Number(res?.changes || 0) > 0 ? 'claimed' : 'already'; diff --git a/apps/web/src/routes/api/tasks/[id]/complete/+server.ts b/apps/web/src/routes/api/tasks/[id]/complete/+server.ts index cb63441..d095292 100644 --- a/apps/web/src/routes/api/tasks/[id]/complete/+server.ts +++ b/apps/web/src/routes/api/tasks/[id]/complete/+server.ts @@ -1,87 +1,92 @@ import type { RequestHandler } from './$types'; -import { getDb } from '$lib/server/db'; +import { loadTaskAndGating, formatTask, respondTask } from '$lib/server/task-helpers'; import { REACTIONS_ENABLED, REACTIONS_TTL_DAYS, REACTIONS_SCOPE, GROUP_GATING_MODE } from '$lib/server/env'; import { toIsoSqlUTC } from '$lib/server/datetime'; -export const POST: RequestHandler = async (event) => { - const userId = event.locals.userId ?? null; - if (!userId) { - return new Response('Unauthorized', { status: 401 }); - } +// --------------------------------------------------------------------------- +// Reaction helper +// --------------------------------------------------------------------------- - const idStr = event.params.id || ''; - const taskId = parseInt(idStr, 10); - if (!Number.isFinite(taskId) || taskId <= 0) { - return new Response('Bad Request', { status: 400 }); - } +function tryEnqueueCompletionReaction(db: any, taskId: number): void { + try { + // Look up origin (fallback for missing participant/from_me columns) + let origin: any = null; + try { + origin = db.prepare(`SELECT chat_id, message_id, created_at, participant, from_me FROM task_origins WHERE task_id = ?`).get(taskId) as any; + } catch { + origin = db.prepare(`SELECT chat_id, message_id, created_at FROM task_origins WHERE task_id = ?`).get(taskId) as any; + } - const db = await getDb(); + if (!origin?.chat_id || !origin?.message_id) return; + const chatId = String(origin.chat_id); - 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; + // Scope: por defecto solo reaccionar en grupos + if (REACTIONS_SCOPE !== 'all' && !chatId.endsWith('@g.us')) return; - if (!task) { - return new Response(JSON.stringify({ status: 'not_found' }), { - status: 404, - headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } - }); - } + // TTL + const ttlMs = REACTIONS_TTL_DAYS * 24 * 60 * 60 * 1000; + const createdRaw = String(origin.created_at || ''); + const createdIso = createdRaw.includes('T') ? createdRaw : (createdRaw.replace(' ', 'T') + 'Z'); + const createdMs = Date.parse(createdIso); + if (!Number.isFinite(createdMs) || Date.now() - createdMs > ttlMs) return; - // 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 }); + // Gating 'enforce' + if (GROUP_GATING_MODE === 'enforce' && chatId.endsWith('@g.us')) { + const row = db.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`).get(chatId) as any; + if (!row) return; } - } 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 }); + + // Idempotencia 24h por metadata canónica exacta + const nowIso = toIsoSqlUTC(new Date()); + const cutoff = toIsoSqlUTC(new Date(Date.now() - 24 * 60 * 60 * 1000)); + + const meta: any = { kind: 'reaction', emoji: '✅', chatId, messageId: String(origin.message_id) }; + if (origin.from_me === 1 || origin.from_me === true) meta.fromMe = true; + if (origin.participant) meta.participant = String(origin.participant); + const metadata = JSON.stringify(meta); + + const exists = db.prepare(` + SELECT 1 + FROM response_queue + WHERE metadata = ? + AND status IN ('queued','processing','sent') + AND (updated_at > ? OR created_at > ?) + LIMIT 1 + `).get(metadata, cutoff, cutoff) as any; + + if (!exists) { + db.prepare(`INSERT INTO response_queue (recipient, message, metadata, next_attempt_at) VALUES (?, ?, ?, ?)`).run(chatId, '', metadata, nowIso); } - } + } catch {} +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +export const POST: RequestHandler = async (event) => { + const ctx = await loadTaskAndGating(event); + if (ctx instanceof Response) return ctx; + const { db, task, userId, groupId } = ctx; + // Idempotencia: ya está completada 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' } + return respondTask('already', { + ...formatTask(task), + completed: 1, + completed_at: task.completed_at ? String(task.completed_at) : null }); } // Transacción: auto-asignar si no hay responsables y completar + const taskId = task.id; 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); + if (Number(cntRow?.cnt || 0) === 0) { + db.prepare(`INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?)`) + .run(taskId, userId, userId); } db.prepare(` UPDATE tasks @@ -103,89 +108,9 @@ export const POST: RequestHandler = async (event) => { const statusStr = Number(updated.completed || 0) === 1 ? 'updated' : 'already'; - // Encolar reacción ✅ desde la web si procede (idéntico formato al bot) - try { - if (statusStr === 'updated' && REACTIONS_ENABLED) { - // Buscar origen con columnas opcionales (participant/from_me) si existen - let origin: any = null; - try { - origin = db.prepare(` - SELECT chat_id, message_id, created_at, participant, from_me - FROM task_origins - WHERE task_id = ? - `).get(taskId) as any; - } catch { - origin = db.prepare(` - SELECT chat_id, message_id, created_at - FROM task_origins - WHERE task_id = ? - `).get(taskId) as any; - } - - if (origin && origin.chat_id && origin.message_id) { - const chatId = String(origin.chat_id); - - // Scope: por defecto solo reaccionar en grupos - if (REACTIONS_SCOPE === 'all' || chatId.endsWith('@g.us')) { - // TTL (por defecto 14 días) - const ttlMs = REACTIONS_TTL_DAYS * 24 * 60 * 60 * 1000; - const createdRaw = String(origin.created_at || ''); - const createdIso = createdRaw.includes('T') ? createdRaw : (createdRaw.replace(' ', 'T') + 'Z'); - const createdMs = Date.parse(createdIso); - const withinTtl = Number.isFinite(createdMs) ? (Date.now() - createdMs <= ttlMs) : false; - - // Gating 'enforce' (solo aplica a grupos) - let allowed = true; - if (GROUP_GATING_MODE === 'enforce' && chatId.endsWith('@g.us')) { - const row = db.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`).get(chatId) as any; - allowed = !!row; - } - - if (withinTtl && allowed) { - // Idempotencia 24h por metadata canónica exacta - const nowIso = toIsoSqlUTC(new Date()); - const cutoff = toIsoSqlUTC(new Date(Date.now() - 24 * 60 * 60 * 1000)); - - const meta: any = { kind: 'reaction', emoji: '✅', chatId, messageId: String(origin.message_id) }; - if (origin && (origin.from_me === 1 || origin.from_me === true)) meta.fromMe = true; - if (origin && origin.participant) meta.participant = String(origin.participant); - const metadata = JSON.stringify(meta); - - const exists = db.prepare(` - SELECT 1 - FROM response_queue - WHERE metadata = ? - AND status IN ('queued','processing','sent') - AND (updated_at > ? OR created_at > ?) - LIMIT 1 - `).get(metadata, cutoff, cutoff) as any; - - if (!exists) { - db.prepare(` - INSERT INTO response_queue (recipient, message, metadata, next_attempt_at) - VALUES (?, ?, ?, ?) - `).run(chatId, '', metadata, nowIso); - } - } - } - } - } - } catch {} - - 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 - } - }; + if (statusStr === 'updated' && REACTIONS_ENABLED) { + tryEnqueueCompletionReaction(db, taskId); + } - return new Response(JSON.stringify(body), { - status: 200, - headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } - }); + return respondTask(statusStr, formatTask(updated)); }; diff --git a/apps/web/src/routes/api/tasks/[id]/unassign/+server.ts b/apps/web/src/routes/api/tasks/[id]/unassign/+server.ts index 9fab05c..51247f5 100644 --- a/apps/web/src/routes/api/tasks/[id]/unassign/+server.ts +++ b/apps/web/src/routes/api/tasks/[id]/unassign/+server.ts @@ -1,58 +1,12 @@ import type { RequestHandler } from './$types'; -import { getDb } from '$lib/server/db'; +import { loadTaskAndCheckGroup } from '$lib/server/task-helpers'; 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 ctx = await loadTaskAndCheckGroup(event); + if (ctx instanceof Response) return ctx; + const { db, task, userId } = ctx; - // 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 @@ -62,7 +16,7 @@ export const POST: RequestHandler = async (event) => { FROM task_assignments WHERE task_id = ? `) - .get(userId, taskId) as any; + .get(userId, task.id) as any; const cnt = Number(stats?.cnt || 0); const mine = Number(stats?.mine || 0) > 0; @@ -76,11 +30,11 @@ export const POST: RequestHandler = async (event) => { // Eliminar asignación (idempotente) const delRes = db .prepare(`DELETE FROM task_assignments WHERE task_id = ? AND user_id = ?`) - .run(taskId, userId) as any; + .run(task.id, userId) as any; const cntRow = db .prepare(`SELECT COUNT(*) AS cnt FROM task_assignments WHERE task_id = ?`) - .get(taskId) as any; + .get(task.id) as any; const remaining = Number(cntRow?.cnt || 0); const status = Number(delRes?.changes || 0) > 0 ? 'unassigned' : 'not_assigned'; diff --git a/apps/web/src/routes/api/tasks/[id]/uncomplete/+server.ts b/apps/web/src/routes/api/tasks/[id]/uncomplete/+server.ts index 7a3f9a1..933f3f8 100644 --- a/apps/web/src/routes/api/tasks/[id]/uncomplete/+server.ts +++ b/apps/web/src/routes/api/tasks/[id]/uncomplete/+server.ts @@ -1,78 +1,24 @@ import type { RequestHandler } from './$types'; -import { getDb } from '$lib/server/db'; +import { loadTaskAndGating, formatTask, respondTask } from '$lib/server/task-helpers'; import { UNCOMPLETE_WINDOW_MIN } from '$lib/server/env'; import { toIsoSqlUTC } from '$lib/server/datetime'; 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' } - }); - } + const ctx = await loadTaskAndGating(event); + if (ctx instanceof Response) return ctx; + const { db, task, userId } = ctx; - // Si ya está sin completar, es idempotente + // Idempotencia: ya está sin completar 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' } + return respondTask('already', { + ...formatTask(task), + completed: 0, + completed_at: null }); } - // 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 + const taskId = task.id; if (!task.completed_at) { return new Response('Forbidden', { status: 403 }); } @@ -95,20 +41,5 @@ export const POST: RequestHandler = async (event) => { 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' } - }); + return respondTask('updated', formatTask(updated)); }; diff --git a/apps/web/src/routes/app/preferences/+page.server.ts b/apps/web/src/routes/app/preferences/+page.server.ts index dde52ce..b1f92cc 100644 --- a/apps/web/src/routes/app/preferences/+page.server.ts +++ b/apps/web/src/routes/app/preferences/+page.server.ts @@ -1,18 +1,8 @@ import type { PageServerLoad, Actions } from './$types'; import { getDb } from '$lib/server/db'; import { redirect, fail } from '@sveltejs/kit'; -import { normalizeTime } from '$lib/server/datetime'; - -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')}`; -} +import { normalizeTime, ymdInTZ } from '$lib/server/datetime'; +import { resolveReminderTime, upsertPreference } from '$lib/server/preferences-helpers'; function hmInTZ(d: Date, tz: string): string { const parts = new Intl.DateTimeFormat('en-GB', { @@ -115,42 +105,12 @@ export const actions: Actions = { 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); + const resolved = resolveReminderTime(db, userId, freqRaw, timeRaw); + if ('error' in resolved) return fail(400, { error: resolved.error }); + const { timeToSave } = resolved; + + upsertPreference(db, userId, freqRaw, timeToSave); return { success: true, pref: { freq: freqRaw, time: timeToSave } }; } diff --git a/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts b/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts index dea5c66..ba8ab2c 100644 --- a/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts +++ b/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts @@ -1,39 +1,18 @@ 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, checkIcsRateLimit } from '$lib/server/ics'; -import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime'; +import { validateIcsToken, buildIcsFeed, icsDateRange } from '$lib/server/ics-helpers'; 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 rl = checkIcsRateLimit(tokenHash); - if (!rl.ok) { - return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': String(rl.retryAfterSec || 60) } }); - } + const res = await validateIcsToken(db, params.token || '', 'aggregate'); + if (res instanceof Response) return res; + const { row } = res; - const today = new Date(); - const startYmd = ymdUTC(today); - const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); + const tokenHash = await sha256Hex(params.token || ''); + const { startYmd, endYmd } = icsDateRange(); - // 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 @@ -47,32 +26,7 @@ export const GET: RequestHandler = async ({ params, request }) => { 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('Wtask.org Tareas – Agregado', events); - - // 304 si ETag coincide - const inm = request.headers.get('if-none-match'); - if (inm && inm === etag) { - 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(toIsoSqlUTC(), row.id); + .all(row.user_id, startYmd, endYmd) as any[]; - return new Response(body, { - status: 200, - headers: { - 'content-type': 'text/calendar; charset=utf-8', - 'cache-control': 'public, max-age=300', - ETag: etag - } - }); + return buildIcsFeed(db, tokenHash, row, request, 'Wtask.org Tareas – Agregado', tasks); }; diff --git a/apps/web/src/routes/ics/group/[token].ics/+server.ts b/apps/web/src/routes/ics/group/[token].ics/+server.ts index fdc7917..66f7504 100644 --- a/apps/web/src/routes/ics/group/[token].ics/+server.ts +++ b/apps/web/src/routes/ics/group/[token].ics/+server.ts @@ -1,30 +1,17 @@ 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, checkIcsRateLimit } from '$lib/server/ics'; -import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime'; +import { validateIcsToken, buildIcsFeed, icsDateRange } from '$lib/server/ics-helpers'; 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 }); + const res = await validateIcsToken(db, params.token || '', 'group'); + if (res instanceof Response) return res; + const { row } = res; + if (!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 + // Validar estado del grupo 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; @@ -32,14 +19,8 @@ export const GET: RequestHandler = async ({ params, request }) => { return new Response('Gone', { status: 410 }); } - const rl = checkIcsRateLimit(tokenHash); - if (!rl.ok) { - return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': String(rl.retryAfterSec || 60) } }); - } - - const today = new Date(); - const startYmd = ymdUTC(today); - const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); + const tokenHash = await sha256Hex(params.token || ''); + const { startYmd, endYmd } = icsDateRange(); const tasks = db .prepare( @@ -53,32 +34,7 @@ export const GET: RequestHandler = async ({ params, request }) => { 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('Wtask.org Tareas – Grupo', events); - - // 304 si ETag coincide - const inm = request.headers.get('if-none-match'); - if (inm && inm === etag) { - 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(toIsoSqlUTC(), row.id); + .all(row.group_id, startYmd, endYmd) as any[]; - return new Response(body, { - status: 200, - headers: { - 'content-type': 'text/calendar; charset=utf-8', - 'cache-control': 'public, max-age=300', - ETag: etag - } - }); + return buildIcsFeed(db, tokenHash, row, request, 'Wtask.org Tareas – Grupo', tasks); }; diff --git a/apps/web/src/routes/ics/personal/[token].ics/+server.ts b/apps/web/src/routes/ics/personal/[token].ics/+server.ts index 28c2986..d3d3160 100644 --- a/apps/web/src/routes/ics/personal/[token].ics/+server.ts +++ b/apps/web/src/routes/ics/personal/[token].ics/+server.ts @@ -1,39 +1,18 @@ 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, checkIcsRateLimit } from '$lib/server/ics'; -import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime'; +import { validateIcsToken, buildIcsFeed, icsDateRange } from '$lib/server/ics-helpers'; 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 rl = checkIcsRateLimit(tokenHash); - if (!rl.ok) { - return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': String(rl.retryAfterSec || 60) } }); - } + const res = await validateIcsToken(db, params.token || '', 'personal'); + if (res instanceof Response) return res; + const { row } = res; - const today = new Date(); - const startYmd = ymdUTC(today); - const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); + const tokenHash = await sha256Hex(params.token || ''); + const { startYmd, endYmd } = icsDateRange(); - // "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 @@ -48,32 +27,7 @@ export const GET: RequestHandler = async ({ params, request }) => { 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('Wtask.org Tareas – Personal', events); - - // 304 si ETag coincide - const inm = request.headers.get('if-none-match'); - if (inm && inm === etag) { - 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(toIsoSqlUTC(), row.id); + .all(row.user_id, startYmd, endYmd, row.user_id) as any[]; - return new Response(body, { - status: 200, - headers: { - 'content-type': 'text/calendar; charset=utf-8', - 'cache-control': 'public, max-age=300', - ETag: etag - } - }); + return buildIcsFeed(db, tokenHash, row, request, 'Wtask.org Tareas – Personal', tasks); }; diff --git a/docs/MANUAL_TESTS.md b/docs/MANUAL_TESTS.md index 79b2a47..8ac9880 100644 --- a/docs/MANUAL_TESTS.md +++ b/docs/MANUAL_TESTS.md @@ -3,13 +3,13 @@ Ejecuta el servidor (entorno de desarrollo) y usa un cliente WhatsApp conectado a Evolution API. 1) Comando base y ayuda -- En un grupo activo: enviar “/t” o “/t ayuda”. +- En un grupo activo: enviar “t” o “t ayuda”. - Esperado: no aparece nada en el grupo; recibes un DM con la guía rápida. -- En DM al bot: enviar “/t”. +- En DM al bot: enviar “t”. - Esperado: recibes el mismo DM de ayuda. 2) Crear tarea en grupo (sin menciones) -- Enviar en el grupo: “/t n Comprar leche mañana”. +- Enviar en el grupo: “t n Comprar leche mañana”. - Esperado: - Se crea la tarea con due_date = YYYY-MM-DD (mañana). - No se asigna a nadie (sin dueño). @@ -20,7 +20,7 @@ Ejecuta el servidor (entorno de desarrollo) y usa un cliente WhatsApp conectado 👥 sin dueño () 3) Crear tarea en DM (sin menciones) -- Enviar al bot por DM: “/t n Pagar comedor hoy”. +- Enviar al bot por DM: “t n Pagar comedor hoy”. - Esperado: - Se crea la tarea con due_date = YYYY-MM-DD (hoy). - Tarea asignada a ti (creador). @@ -28,7 +28,7 @@ Ejecuta el servidor (entorno de desarrollo) y usa un cliente WhatsApp conectado - No se envía nada a ningún grupo. 4) Crear tarea con menciones en grupo -- Enviar: “/t n Acta de la reunión 2025-09-12 @34611122233”. +- Enviar: “t n Acta de la reunión 2025-09-12 @34611122233”. - Esperado: - Se crea la tarea con due_date 2025-09-12. - Se asigna a 34611122233 (normalizado). @@ -41,10 +41,10 @@ Ejecuta el servidor (entorno de desarrollo) y usa un cliente WhatsApp conectado 🔔 — 📅 2025-09-12 “*Acta de la reunión*” Grupo: - Completar: /t x + Completar: t x 5) Prefijos aceptados -- Repetir 2–4 usando “/tarea n ...” (debe comportarse igual que “/t ...”). +- Repetir 2–4 usando “tarea n ...” (debe comportarse igual que “t ...”). Notas - En el log del servidor verás “✅ Sent message to with this as payload: ...” por cada DM encolado y enviado por Evolution API. diff --git a/docs/REQUESTED_FILES.md b/docs/REQUESTED_FILES.md index bec17ac..25ce793 100644 --- a/docs/REQUESTED_FILES.md +++ b/docs/REQUESTED_FILES.md @@ -8,7 +8,7 @@ Por favor, añade a este chat los siguientes archivos (exactos y completos) para - src/services/response-queue.ts Resumen de los cambios planificados (breve): -- Alias y sinónimos: agregar soporte para `/t` además de `/tarea`, y mapear subcomandos (n/nueva/crear/+), (ver/listar/ls), (x/hecho/completar), (tomar/claim), (soltar/unassign), (ayuda/help/?). +- Alias y sinónimos: agregar soporte para `t` además de `tarea`, y mapear subcomandos (n/nueva/crear/+), (ver/listar/ls), (x/hecho/completar), (tomar/claim), (soltar/unassign), (ayuda/help/?). - Silencio en grupos: no publicar nunca en el grupo; todas las respuestas se enviarán por DM al autor. No mostraremos el mensaje “Te envié la info por DM”. - Crear tarea: en grupos sin menciones → tarea “sin dueño”; en DM sin menciones → asignada al creador. Añadir soporte de fechas “hoy” y “mañana”. - Nuevos comandos: `tomar ` y `soltar ` (se implementan en TaskService con métodos claim/unassign). diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 5291d6d..0953216 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1,7 +1,7 @@ # Guía de uso — Task WhatsApp Chatbot Principios -- Prefijo de comandos: “/t” o “/tarea”. +- Prefijo de comandos: “t” o “tarea”. - Respuestas “solo DM”: el bot no publica en grupos; siempre envía un mensaje directo al autor (salvo resumen opcional al crear si se activa). - Fechas: puedes escribir “hoy” o “mañana” y también YYYY-MM-DD. La zona horaria se configura con la variable de entorno TZ (por defecto Europe/Madrid). - Límite de uso: rate limit por usuario (15/min por defecto); si lo superas, verás un aviso (acotado a 1/min). @@ -10,9 +10,9 @@ Comandos y alias - Crear - Aliases: n, nueva, crear, + - Ejemplos: - - /t nueva Acta de la reunión mañana - - /t n Carteles fiesta 2025-09-12 @600123456 - - /t + Preparar dossier @600111111 @600222222 + - t nueva Acta de la reunión mañana + - t n Carteles fiesta 2025-09-12 @600123456 + - t + Preparar dossier @600111111 @600222222 - Reglas: - En grupo: si no mencionas a nadie → “sin responsable”. - En DM: si no mencionas a nadie → se asigna al creador. @@ -24,33 +24,33 @@ Comandos y alias - sin — pendientes sin responsable (según contexto). - todos — visión general (según permisos futuros). - Ejemplos: - - /t ver grupo - - /t ver mis + - t ver grupo + - t ver mis - Completar - Aliases: x, hecho, completar, done - Ejemplos: - - /t x 26 - - /t hecho 31 + - t x 26 + - t hecho 31 - Notas: registra quién completa; no restringido solo a asignados (por fluidez). - Tomar - Aliases: tomar, claim - - Ejemplo: /t tomar 26 + - Ejemplo: t tomar 26 - Idempotente: si ya eres asignado, lo indica sin error. - Soltar - Aliases: soltar, unassign - - Ejemplo: /t soltar 26 + - Ejemplo: t soltar 26 - Idempotente: si no estabas asignado, lo indica sin error. La tarea puede quedar “sin responsable” si no quedan asignados. - Configurar recordatorios - Aliases: configurar, config - Opciones: daily | weekly | off - Ejemplos: - - /t configurar daily - - /t configurar weekly - - /t configurar off + - t configurar daily + - t configurar weekly + - t configurar off - Notas: resumen diario/weekly por DM; weekly los lunes a la hora configurada (por defecto 08:30 si aplica); se evita duplicar en el mismo día y no se envía si no hay tareas. - Ayuda - Aliases: ayuda, help, ? - - Ejemplos: /t, /t ayuda + - Ejemplos: t, t ayuda Gramática y formato - Menciones @@ -67,20 +67,20 @@ Gramática y formato Ejemplos prácticos - Crear en grupo sin menciones (queda sin responsable): - - /t nueva Revisión presupuesto mañana + - t nueva Revisión presupuesto mañana - Crear en DM (se asigna a ti): - - /t nueva Preparar documento hoy + - t nueva Preparar documento hoy - Crear con varios asignados: - - /t nueva Carteles @600111111 @600222222 2025-10-10 + - t nueva Carteles @600111111 @600222222 2025-10-10 - Ver tus tareas: - - /t ver mis + - t ver mis - Completar: - - /t x 42 + - t x 42 - Tomar y soltar: - - /t tomar 42 - - /t soltar 42 + - t tomar 42 + - t soltar 42 - Configurar recordatorios: - - /t configurar weekly + - t configurar weekly Limitaciones y notas - El bot no publica en grupos por diseño. diff --git a/docs/commands-inventory.md b/docs/commands-inventory.md index f41905f..67fc85c 100644 --- a/docs/commands-inventory.md +++ b/docs/commands-inventory.md @@ -12,10 +12,10 @@ Notas generales --- -## /t nueva (crear) +## t nueva (crear) Alias: `n`, `nueva`, `crear`, `+` -Sintaxis: `/t n [fecha] [@menciones...]` +Sintaxis: `t n [fecha] [@menciones...]` Parámetros - descripción: texto libre. @@ -34,17 +34,17 @@ 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 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) +## t ver (listar) Alias: `ver`, `mostrar`, `listar`, `ls` -Sintaxis: `/t ver [grupo|mis|todos|sin]` (el alcance es opcional) +Sintaxis: `t ver [grupo|mis|todos|sin]` (el alcance es opcional) Alcances - `grupo`: lista pendientes del grupo actual (solo desde grupo activo). @@ -62,15 +62,15 @@ 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` +- En grupo: `t ver` (equivale a `grupo`), `t ver sin` +- Por DM: `t ver mis`, `t ver todos` --- -## /t x (completar) +## t x (completar) Alias: `x`, `hecho`, `completar`, `done` -Sintaxis: `/t x ` +Sintaxis: `t x ` Soporta múltiples IDs separados por espacios y/o comas. Máx. 10 IDs. Resolución de ID @@ -80,46 +80,46 @@ 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 x 26` +- `t x 14 19 24` +- `t x 14,19,24` --- -## /t tomar (asumir) +## t tomar (asumir) Alias: `tomar`, `claim`, `asumir`, `asumo` -Sintaxis: `/t tomar ` +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 tomar 12` +- `t tomar 12 19 50` +- `t tomar 12,19,50` --- -## /t soltar (unassign) +## t soltar (unassign) Alias: `soltar`, `unassign`, `dejar`, `liberar`, `renunciar` -Sintaxis: `/t soltar ` +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 soltar 26` --- -## /t configurar (recordatorios) +## t configurar (recordatorios) Alias: `config`, `configurar` -Sintaxis: `/t configurar diario|l-v|semanal|off [HH:MM]` +Sintaxis: `t configurar diario|l-v|semanal|off [HH:MM]` Valores admitidos y alias - `diario`/`diaria` → recordatorio diario (se guarda como `daily`). @@ -135,17 +135,17 @@ 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 configurar diaria 09:00` +- `t configurar l-v 08:30` +- `t configurar semanal` (→ lunes 08:30) +- `t configurar off` --- -## /t ayuda +## t ayuda Alias: `ayuda`, `help`, `?` -Sintaxis: `/t ayuda` | `/t ayuda avanzada` +Sintaxis: `t ayuda` | `t ayuda avanzada` Comportamiento actual - Ayuda rápida con comandos básicos, límites y ejemplos cortos. @@ -156,22 +156,22 @@ Nota --- -## /t web +## t web -Sintaxis: `/t web` (solo por DM) +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`.” + “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. +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 diff --git a/docs/golden/command.texts.json b/docs/golden/command.texts.json index 820fa29..a6b2686 100644 --- a/docs/golden/command.texts.json +++ b/docs/golden/command.texts.json @@ -9,18 +9,18 @@ "{bot}": "Número del bot sin prefijo" }, "cta": [ - "ℹ️ Tus tareas: `/t mias` · Todas: `/t todas` · Info: `/t info` · Web: `/t web`" + "ℹ️ Tus tareas: `t mias` · Todas: `t todas` · Info: `t info` · Web: `t web`" ], "help": { - "transition_group_ver": "No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web", - "advanced_hint": "Ayuda avanzada: `/t ayuda avanzada`", + "transition_group_ver": "No respondo en grupos. Tus tareas: t mias · Todas: t todas · Info: t info · Web: t web", + "advanced_hint": "Ayuda avanzada: `t ayuda avanzada`", "legacy_quick_title": "Guía rápida:" }, "usage": [ - "ℹ️ Uso: `/t x 26` o múltiples: `/t x 14 19 24` o `/t x 14,19,24` (máx. 10)", - "ℹ️ Uso: `/t tomar 26` o múltiples: `/t tomar 12 19 50` o `/t tomar 12,19,50` (máx. 10)", - "ℹ️ Uso: `/t soltar 26`", - "ℹ️ Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`" + "ℹ️ Uso: `t x 26` o múltiples: `t x 14 19 24` o `t x 14,19,24` (máx. 10)", + "ℹ️ Uso: `t tomar 26` o múltiples: `t tomar 12 19 50` o `t tomar 12,19,50` (máx. 10)", + "ℹ️ Uso: `t soltar 26`", + "ℹ️ Uso: `t configurar diario|l-v|semanal|off [HH:MM]`" ], "errors": [ "⚠️ Tarea {id} no encontrada.", @@ -33,11 +33,11 @@ "Acción {rawAction} no implementada aún" ], "info": [ - "ℹ️ Este comando se usa por privado. Envíame `/t web` por DM.", + "ℹ️ Este comando se usa por privado. Envíame `t web` por DM.", "No tienes tareas pendientes.", "⚠️ Se procesarán solo los primeros 10 IDs.", "Resumen: ", - "ℹ️ Para ver tareas sin responsable, escribe por privado `/t todas` o usa `/t web`.", + "ℹ️ Para ver tareas sin responsable, escribe por privado `t todas` o usa `t web`.", "✅ Recordatorios: {label}" ], "states": [ @@ -52,6 +52,6 @@ "No puedo asignar a {list} aún. Pídele que toque este enlace y diga 'activar': https://wa.me/{bot}" ], "web": [ - "Acceso web: {url}\nVálido durante 10 minutos. Si caduca, vuelve a enviar \"/t web\"." + "Acceso web: {url}\nVálido durante 10 minutos. Si caduca, vuelve a enviar \"t web\"." ] } diff --git a/docs/how-to/adding-command.md b/docs/how-to/adding-command.md index 7c69b32..5cf9c09 100644 --- a/docs/how-to/adding-command.md +++ b/docs/how-to/adding-command.md @@ -6,7 +6,7 @@ Objetivo Pasos 1) Parseo en CommandService - Ubicación: src/services/command.ts - - Añade lógica para detectar el patrón (p.ej., "/t listar", "/t info 0012"). + - Añade lógica para detectar el patrón (p.ej., "t listar", "t info 0012"). - Normaliza IDs con utils/whatsapp.normalizeWhatsAppId cuando corresponda. - Registra métricas si procede (Metrics.inc). diff --git a/docs/operations.md b/docs/operations.md index 8b09e9c..8cc2e41 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -29,7 +29,7 @@ Variables de entorno (principales) - 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' +- 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' diff --git a/docs/plan-ayuda-bot.md b/docs/plan-ayuda-bot.md index c88ac0a..dfcb7f2 100644 --- a/docs/plan-ayuda-bot.md +++ b/docs/plan-ayuda-bot.md @@ -17,38 +17,38 @@ Objetivo: hacer la ayuda consistente, útil ante comandos desconocidos, visible ## Inventario de comandos actual (derivado de src/services/command.ts) - Crear - - Comandos: `/t n`, `/t nueva`, `/t crear`, `/t +` + - 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`) + - 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` + - 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` + - 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` + - 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]` + - 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” + - Comandos: `t ayuda`, `t help`, `t ?`, y variante “ayuda avanzada” - Actualmente genera mensajes en línea (no centralizados) - Web - - Comando: `/t 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) @@ -96,7 +96,7 @@ Objetivo: hacer la ayuda consistente, útil ante comandos desconocidos, visible - 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` + - 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”. @@ -107,7 +107,7 @@ Objetivo: hacer la ayuda consistente, útil ante comandos desconocidos, visible - 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. + - `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) @@ -116,7 +116,7 @@ Objetivo: hacer la ayuda consistente, útil ante comandos desconocidos, visible - 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`” + - 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: @@ -124,14 +124,14 @@ Objetivo: hacer la ayuda consistente, útil ante comandos desconocidos, visible - `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`) + - 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) +### Fase 4 — Unificar el comando t ayuda (completado) -- Objetivo: que `/t ayuda` y “ayuda avanzada” usen el módulo centralizado. +- 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” @@ -143,8 +143,8 @@ Objetivo: hacer la ayuda consistente, útil ante comandos desconocidos, visible - `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 + - “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. @@ -153,7 +153,7 @@ Objetivo: hacer la ayuda consistente, útil ante comandos desconocidos, visible - 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`. + - 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?`) @@ -217,8 +217,8 @@ Cuando ejecutemos las fases de código/tests, si estos archivos no están en el ## 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. +- 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. @@ -230,7 +230,7 @@ Cuando ejecutemos las fases de código/tests, si estos archivos no están en el - 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. + - 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. @@ -241,10 +241,10 @@ Cuando ejecutemos las fases de código/tests, si estos archivos no están en el 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. +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`. +Incluye validación manual: probar `t ayuda`, `t ayuda avanzada`, un comando desconocido y `t web`. --- diff --git a/docs/plan-interfaz-web.md b/docs/plan-interfaz-web.md index c1997a3..1faa3f7 100644 --- a/docs/plan-interfaz-web.md +++ b/docs/plan-interfaz-web.md @@ -36,7 +36,7 @@ Este documento define el plan para añadir una interfaz web al sistema, mantenie - 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. +- 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). @@ -57,7 +57,7 @@ Este documento define el plan para añadir una interfaz web al sistema, mantenie ## 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. + - 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. @@ -65,7 +65,7 @@ Este documento define el plan para añadir una interfaz web al sistema, mantenie - 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. + - 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. @@ -149,7 +149,7 @@ Notas: - 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. + - Estado de sesión (2h de inactividad): al expirar, mostrar mensaje con instrucción de enviar t web. ## 10) Seguridad @@ -172,7 +172,7 @@ Notas: - 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). + - 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. @@ -206,7 +206,7 @@ Etapa 0 — Preparación 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 +- 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. @@ -245,7 +245,7 @@ Implementado: suite web con bun:test y build programático (helpers en tests/web - 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. + - 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. @@ -259,7 +259,7 @@ Implementado: suite web con bun:test y build programático (helpers en tests/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. + - t web es rápido; expiración 10 min adecuada; mensajes claros si expira. ## 16) Variables de entorno (propuestas, apps/web) @@ -339,7 +339,7 @@ Objetivo - Toast/Snackbar (store global; auto-dismiss; role="status"). - ConfirmDialog (portal sencillo con focus trap básico). - Skeleton (rectángulos/filas). - - EmptyState y ErrorBanner. + - EmptyState. - Datos - TaskItem (fila) con: [id], descripción, fecha (badge), grupo, asignación (solo lectura). - GroupCard con nombre, contadores open/unassigned. @@ -362,7 +362,7 @@ Objetivo 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. + - 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). @@ -413,7 +413,7 @@ Objetivo - 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/feedback: Toast.svelte (+ store), ConfirmDialog.svelte, EmptyState.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) diff --git a/docs/plan-onboarding-comandos.md b/docs/plan-onboarding-comandos.md index 40f6e5c..6ca917f 100644 --- a/docs/plan-onboarding-comandos.md +++ b/docs/plan-onboarding-comandos.md @@ -40,14 +40,14 @@ Archivos a consultar - tests relacionados con comandos de listado (no incluidos aquí) Overview de cambios (sin código) -- Definir que en DM “/t ver” (sin argumentos) se comporte como “todas”. -- Mantener compatibilidad con “/t ayuda”, pero comunicar “/t info” como alias preferido. -- Aceptar “/t mias” y “/t todas” como atajos (alias de “ver mis” y “ver todas”). +- Definir que en DM “t ver” (sin argumentos) se comporte como “todas”. +- Mantener compatibilidad con “t ayuda”, pero comunicar “t info” como alias preferido. +- Aceptar “t mias” y “t todas” como atajos (alias de “ver mis” y “ver todas”). - En contexto de grupo, cualquier intento de “ver …” no debe listar en el grupo; se responderá por DM (mensaje breve de transición). - Flags/env de onboarding (ver Fase 4). Criterios de aceptación -- Decisión documentada: “/t ver” en DM => “todas”. +- Decisión documentada: “t ver” en DM => “todas”. - Alias permitidos y comunicados en help. - Confirmación de “cero mensajes en grupo”. @@ -57,7 +57,7 @@ Criterios de aceptación Objetivos - Añadir y mapear alias “info” → “ayuda”; “mias” → “ver mis”; “todas” → “ver todas”. -- Cambiar default de “/t ver” (en DM) a “todas”. +- Cambiar default de “t ver” (en DM) a “todas”. - En grupo, redirigir listados a DM con mensaje corto de transición (sin listar en el grupo). Archivos a modificar @@ -67,11 +67,11 @@ Archivos a modificar Overview de cambios - Extender ACTION_ALIASES y/o routing para nuevas acciones y scopes. -- Detectar contexto de grupo y bloquear listados en el grupo (enviar por DM un mensaje de transición: “No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web”). -- Help v2: mostrar “/t mias”, “/t todas”, “/t info”; retirar “ver grupo” de la guía básica y sugerir web para ver todo el grupo. +- Detectar contexto de grupo y bloquear listados en el grupo (enviar por DM un mensaje de transición: “No respondo en grupos. Tus tareas: t mias · Todas: t todas · Info: t info · Web: t web”). +- Help v2: mostrar “t mias”, “t todas”, “t info”; retirar “ver grupo” de la guía básica y sugerir web para ver todo el grupo. Impacto en tests -- Actualizar tests que esperen “/t ver” => “mis” en DM. +- Actualizar tests que esperen “t ver” => “mis” en DM. - Añadir tests de alias (“mias”, “todas”, “info”). - Tests de transición desde grupo (no hay listados en el grupo; respuesta por DM). @@ -81,8 +81,8 @@ Impacto en tests Objetivos - Enviar un paquete de 2 DMs (Mensaje 1 + Mensaje 2) por usuario cuando se crea una tarea en un grupo. - - Mensaje 1: CTA “/t tomar {CÓDIGO}” + “/t info”. - - Mensaje 2: mini‑chuleta (“/t mias”, “/t todas”, “/t configurar …”, “/t web”), 5–10 s después del Mensaje 1. + - Mensaje 1: CTA “t tomar {CÓDIGO}” + “t info”. + - Mensaje 2: mini‑chuleta (“t mias”, “t todas”, “t configurar …”, “t web”), 5–10 s después del Mensaje 1. - Repetir el mismo paquete una única vez más si pasan ≥ 14 días sin interacción del usuario (si hubo interacción, no se envía el segundo paquete). - Cap por evento; sin mensajes en grupos. @@ -100,7 +100,7 @@ Overview de cambios - enqueueOnboarding(recipient, message, metadata) con metadata canónica: { kind: 'onboarding', variant: 'initial'|'reminder', part: 1|2, bundle_id, group_id, task_id, display_code }. - getOnboardingStats(recipient): { total, lastSentAt, lastVariant?: 'initial'|'reminder' } consultando response_queue por metadata.kind='onboarding'. - Soportar programar el segundo DM del paquete con un retraso aleatorio de 5000–10000ms. -- CommandService (en /t nueva): +- CommandService (en t nueva): - Tras crear la tarea en grupo, construir candidatos: - miembros activos del grupo (GroupSync.listActiveMemberIds), - excluir creador, asignados y el número del bot, @@ -116,13 +116,13 @@ Overview de cambios Copys de onboarding (exactos) - Mensaje 1 (en ambos disparos): - “Hola, soy el bot de tareas. En ‘{Grupo}’ acaban de crear una tarea: #{CÓDIGO} {descripción corta} - Encárgate: /t tomar {CÓDIGO} · Más info: /t info + Encárgate: t tomar {CÓDIGO} · Más info: t info Nota: nunca respondo en grupos; solo por privado.” - Mensaje 2 (mini‑chuleta; se envía tras 5–10 s, en ambos disparos): - “Guía rápida (este es un mensaje único): - · Tus tareas: /t mias · Todas: /t todas - · Recordatorios: /t configurar diario | l‑v | semanal | off - · Web: /t web” + · Tus tareas: t mias · Todas: t todas + · Recordatorios: t configurar diario | l‑v | semanal | off + · Web: t web” Impacto en tests - Tests unitarios para: @@ -150,13 +150,13 @@ Archivos a modificar Overview de cambios - Help rápido: - - “Ver mis: /t mias (por privado)” - - “Ver todas: /t todas (por privado)” - - “Más info: /t info” - - Retirar “ver grupo” de la guía básica; sugerir web (“/t web”). + - “Ver mis: t mias (por privado)” + - “Ver todas: t todas (por privado)” + - “Más info: t info” + - Retirar “ver grupo” de la guía básica; sugerir web (“t web”). - Help completo: reflejar “mias/todas/info” y la política de “no responder en grupos”. - CTAs discretos al final de DMs operativos: - - “Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web” + - “Tus tareas: t mias · Todas: t todas · Info: t info · Web: t web” Impacto en tests - Actualizar snapshots/expectativas del help y de los DMs. @@ -207,10 +207,10 @@ Objetivos Áreas de test - Alias: - - “/t info” → ayuda - - “/t mias” → listado de asignadas - - “/t todas” → listado combinado (DM) - - “/t ver” (DM) → “todas” + - “t info” → ayuda + - “t mias” → listado de asignadas + - “t todas” → listado combinado (DM) + - “t ver” (DM) → “todas” - Contexto grupo: - Invocar listados desde un grupo responde por DM con transición (no lista en el grupo). - Onboarding: @@ -234,8 +234,8 @@ Checklist de despliegue - Validar alias y help actualizados. - Monitorizar: - onboarding_dm_sent_total vs skipped. - - Uso de “/t mias”, “/t todas”, “/t info”. - - web_tokens_issued_total (por “/t web”). + - Uso de “t mias”, “t todas”, “t info”. + - web_tokens_issued_total (por “t web”). - Habilitar en producción. Ajustar ONBOARDING_EVENT_CAP si hay grupos muy grandes. --- @@ -246,21 +246,21 @@ Ideas a evaluar después - “Bienvenida al primer DM inbound” (mensaje corto de bienvenida una única vez cuando el usuario inicia chat). - Ventanas horarias: programar next_attempt_at si se detecta horario nocturno (requiere mínima lógica extra). - Tabla explícita de onboarding (si se quiere persistir fuera de response_queue), p. ej. user_onboarding con timestamps y contadores. -- Resumen semanal opt‑in (ya soportado con “/t configurar …”): medir retención y satisfacción. +- Resumen semanal opt‑in (ya soportado con “t configurar …”): medir retención y satisfacción. --- ## Resumen de archivos a cambiar (referencia) - src/services/command.ts - - Alias y routing: “info”, “mias”, “todas”; “/t ver” en DM => “todas”. + - Alias y routing: “info”, “mias”, “todas”; “t ver” en DM => “todas”. - Mensaje de transición al detectar listados desde grupo (solo DM). - Disparo de onboarding tras crear tarea en grupo (con caps y cooldown). - CTAs discretos al final de acks y DMs al asignado. - Instrumentación de métricas. - src/services/messages/help.ts - - Actualizar copys a “/t mias”, “/t todas”, “/t info”. + - Actualizar copys a “t mias”, “t todas”, “t info”. - Retirar “ver grupo” del básico y empujar web para ver todo el grupo. - src/services/group-sync.ts @@ -285,20 +285,20 @@ Ideas a evaluar después 1) Onboarding — Mensaje 1 (initial) - “Hola, soy el bot de tareas. En ‘{Grupo}’ acaban de crear una tarea: #{CÓDIGO} {descripción corta} - Encárgate: /t tomar {CÓDIGO} · Más info: /t info + Encárgate: t tomar {CÓDIGO} · Más info: t info Nota: nunca respondo en grupos; solo por privado.” 2) Onboarding — Mensaje 2 (reminder, único) - “Guía rápida (este es un mensaje único): - · Tus tareas: /t mias · Todas: /t todas - · Recordatorios: /t configurar diario | l‑v | semanal | off - · Web: /t web” + · Tus tareas: t mias · Todas: t todas + · Recordatorios: t configurar diario | l‑v | semanal | off + · Web: t web” 3) Transición cuando se intenta listar desde grupo (responder por DM) -- “No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web” +- “No respondo en grupos. Tus tareas: t mias · Todas: t todas · Info: t info · Web: t web” 4) Línea discreta al final de DMs operativos (ack creación y DM a asignados) -- “Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web” +- “Tus tareas: t mias · Todas: t todas · Info: t info · Web: t web” --- diff --git a/docs/plan-reacciones-bot.md b/docs/plan-reacciones-bot.md index f9cc4a4..cab46ef 100644 --- a/docs/plan-reacciones-bot.md +++ b/docs/plan-reacciones-bot.md @@ -1,6 +1,6 @@ # Plan de reacciones del bot de tareas (WhatsApp) -Objetivo: añadir un “ack” visual de bajo ruido en grupos, usando reacciones a los mensajes con comandos `/t`. Alcance inicial: +Objetivo: añadir un “ack” visual de bajo ruido en grupos, usando reacciones a los mensajes con comandos `t`. Alcance inicial: - Reaccionar 1 sola vez por comando: - Éxito (comando procesado): 🤖 - Error (uso inválido, permisos, no encontrada…): ⚠️ @@ -115,7 +115,7 @@ Fase 1 — Infra y reacción final por comando - Idempotencia: consulta previa antes de insertar. - src/services/command.ts - Ampliar `CommandContext` con `messageId: string`. - - En la rama `/t nueva`, tras crear la tarea: + - En la rama `t nueva`, tras crear la tarea: - Si `isGroupId(context.groupId)` y `context.messageId`, insertar fila en `task_origins (task_id, chat_id, message_id)`. - (Recomendado) Añadir `handleWithOutcome` para clasificar ok/error sin depender del texto. - src/db/migrations/index.ts @@ -136,7 +136,7 @@ Fase 2 — Reacción tardía (✅) al completar ## 6) Flujo E2E (grupo permitido) -1) Usuario envía mensaje con `/t nueva …` en un grupo. +1) Usuario envía mensaje con `t nueva …` en un grupo. 2) WebhookServer: - Obtiene `remoteJid`, `messageId`. - Construye `CommandContext` con `sender`, `groupId`, `message`, `mentions`, `messageId`. @@ -179,12 +179,12 @@ Fase 2 — Reacción tardía (✅) al completar Unitarias: - Reacción final: - - Grupo allowed, `REACTIONS_ENABLED=true`, `/t nueva …` → se encola 🤖 (1 job con metadata.kind='reaction', emoji='🤖', chatId=grupo, messageId capturado). - - Comando inválido (p. ej. `/t x` sin IDs) → se encola ⚠️. + - Grupo allowed, `REACTIONS_ENABLED=true`, `t nueva …` → se encola 🤖 (1 job con metadata.kind='reaction', emoji='🤖', chatId=grupo, messageId capturado). + - Comando inválido (p. ej. `t x` sin IDs) → se encola ⚠️. - DM con `REACTIONS_SCOPE=groups` → no se encola. - `REACTIONS_ENABLED=false` → no se encola. - task_origins: - - Tras `/t nueva` en grupo, existe `task_origins(task_id, chat_id, message_id)`. + - Tras `t nueva` en grupo, existe `task_origins(task_id, chat_id, message_id)`. - Completar → ✅: - Dentro de TTL → se encola ✅ con el `messageId` de origen. - Fuera de TTL → no se encola. @@ -194,7 +194,7 @@ Unitarias: - Manejo de 4xx/5xx conforme a política de reintentos. Integración simulada: -- Flujo feliz: `/t nueva` → 🤖; `completeTask` → ✅. +- Flujo feliz: `t nueva` → 🤖; `completeTask` → ✅. - Error: comando desconocido o “Uso:” → ⚠️. - Grupo bloqueado en enforce → no reacción. diff --git a/docs/plan-sincronizacion-miembros.md b/docs/plan-sincronizacion-miembros.md index d96c473..3801c37 100644 --- a/docs/plan-sincronizacion-miembros.md +++ b/docs/plan-sincronizacion-miembros.md @@ -63,7 +63,7 @@ Este documento define el plan para implementar una sincronización robusta de mi - Logs con contexto: group_id, user_id, evento/tipo, resultado. ## Uso en la aplicación (consumidores) -- “/t ver todo” y recordatorios: +- “t ver todo” y recordatorios: - Incluir “sin responsable” únicamente de grupos donde el usuario sea miembro activo. - Fallback: si aún no hay snapshot de membresías, usar heurística (grupos con tareas del usuario). - Validación (opcional por fase): @@ -93,7 +93,7 @@ Etapa 2 — Integración con Evolution API — COMPLETADA - Reintentos/backoff en errores de red/5xx. Etapa 3 — Consumidores (comandos y recordatorios) — COMPLETADA -- “/t ver todo” y RemindersService usan membership real. +- “t ver todo” y RemindersService usan membership real. - Fallback heurístico si no hay snapshot aún. - Validaciones opcionales de pertenencia. - Tests: @@ -110,7 +110,7 @@ Etapa 4 — Observabilidad y mantenimiento — COMPLETADA ## Criterios de aceptación - Tras una full sync, group_members refleja fielmente miembros activos por grupo. - Webhooks de alta/baja actualizan el estado en <1s y son idempotentes. -- “/t ver todo” y recordatorios respetan la membresía y no rompen UX preexistente. +- “t ver todo” y recordatorios respetan la membresía y no rompen UX preexistente. - 100% de tests existentes siguen pasando; nuevos tests cubren sync/reconciliación/handlers. ## Métricas y trazas sugeridas @@ -136,6 +136,6 @@ Total: 2–3.5 días netos. - src/db/migrations/index.ts (migración up-only: tablas e índices). - src/services/webhook-manager.ts (registro y handlers de eventos). - src/services/group-sync.ts (full sync y reconciliación). -- src/services/command.ts (consumo en “/t ver todo”; validaciones opcionales). +- src/services/command.ts (consumo en “t ver todo”; validaciones opcionales). - src/services/reminders.ts y src/tasks/service.ts (consultas usando membresía). - tests/unit/services/* (sync, reconciliación, webhooks, consumidores). diff --git a/docs/refactor-command-service.md b/docs/refactor-command-service.md index 30633ce..e4a4672 100644 --- a/docs/refactor-command-service.md +++ b/docs/refactor-command-service.md @@ -97,7 +97,7 @@ Etapa 6 — Handler “nueva” y Onboarding Etapa 7 — Limpieza - Reducir `CommandService` a: - - parseo de trigger (/t), registro de `last_command_at` y gating global inicial. + - parseo de trigger (t), registro de `last_command_at` y gating global inicial. - delegación al router y clasificación de outcome (ok/error) como ahora. - Centralizar CTA y textos estáticos compartidos si aplica. - Opcional: centralizar flags en un `config.ts` liviano. diff --git a/docs/whatsapp-style-guide.md b/docs/whatsapp-style-guide.md index d51129a..7d617c8 100644 --- a/docs/whatsapp-style-guide.md +++ b/docs/whatsapp-style-guide.md @@ -9,7 +9,7 @@ Principios - 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`”). +- 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 @@ -17,7 +17,7 @@ Componentes de formato - Patrón: `*${TÍTULO EN MAYÚSCULAS}*` - Ej.: `*COMANDOS BÁSICOS*` - Comandos: - - Siempre en backticks: `` `/t ver mis` `` + - Siempre en backticks: `` `t ver mis` `` - IDs: - Mostrar con 4 dígitos entre backticks: `` `0026` `` (usar `codeId()`). - Fechas: @@ -46,7 +46,7 @@ Patrones comunes - 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` `` + - 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”). @@ -61,7 +61,7 @@ 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)` + - 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. @@ -74,11 +74,11 @@ 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` +- `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._ ``` diff --git a/src/clients/evolution.ts b/src/clients/evolution.ts index 5cef4a5..7e5c0ba 100644 --- a/src/clients/evolution.ts +++ b/src/clients/evolution.ts @@ -1,20 +1,21 @@ export type EvolutionResult = { ok: boolean; status?: number; error?: string }; -export function buildHeaders(): HeadersInit { +function buildHeaders(): HeadersInit { return { apikey: process.env.EVOLUTION_API_KEY || '', 'Content-Type': 'application/json' }; } -export async function sendText(payload: { number: string; text: string; mentioned?: string[] }): Promise { +/** Shared helper: POST to the Evolution API endpoint, returning a standard result. */ +async function evolutionPost(endpoint: string, payload: unknown): Promise { const baseUrl = process.env.EVOLUTION_API_URL; const instance = process.env.EVOLUTION_API_INSTANCE; if (!baseUrl || !instance) { const msg = 'Missing EVOLUTION_API_URL or EVOLUTION_API_INSTANCE'; return { ok: false, error: msg }; } - const url = `${baseUrl}/message/sendText/${instance}`; + const url = `${baseUrl}/message/${endpoint}/${instance}`; try { const res = await fetch(url, { method: 'POST', @@ -33,31 +34,13 @@ export async function sendText(payload: { number: string; text: string; mentione } } +export async function sendText(payload: { number: string; text: string; mentioned?: string[] }): Promise { + return evolutionPost('sendText', payload); +} + export async function sendReaction(payload: { key: { remoteJid: string; id: string; fromMe: boolean; participant?: string }; reaction: string; }): Promise { - const baseUrl = process.env.EVOLUTION_API_URL; - const instance = process.env.EVOLUTION_API_INSTANCE; - if (!baseUrl || !instance) { - const msg = 'Missing EVOLUTION_API_URL or EVOLUTION_API_INSTANCE'; - return { ok: false, error: msg }; - } - const url = `${baseUrl}/message/sendReaction/${instance}`; - try { - const res = await fetch(url, { - method: 'POST', - headers: buildHeaders(), - body: JSON.stringify(payload) - }); - if (!res.ok) { - const body = await res.text().catch(() => ''); - const errTxt = body?.slice(0, 200) || `HTTP ${res.status}`; - return { ok: false, status: res.status, error: errTxt }; - } - return { ok: true, status: res.status }; - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - return { ok: false, error: errMsg }; - } + return evolutionPost('sendReaction', payload); } diff --git a/src/db.ts b/src/db.ts index 9f62427..879b621 100644 --- a/src/db.ts +++ b/src/db.ts @@ -21,7 +21,7 @@ function applyDefaultPragmas(instance: Database): void { } // Function to get a database instance. Defaults to 'tmp/tasks.db' in dev and '/app/data/tasks.db' in prod (overridable via DB_PATH/DATA_DIR) -export function getDb(filename: string = 'tasks.db'): Database { +function getDb(filename: string = 'tasks.db'): Database { const absolutePath = resolveDbAbsolutePath(filename); // Asegurar directorio padre diff --git a/src/db/locator.ts b/src/db/locator.ts index 9a1be67..84e93ac 100644 --- a/src/db/locator.ts +++ b/src/db/locator.ts @@ -1,15 +1,5 @@ import type { Database } from 'bun:sqlite'; -/** - * Error específico cuando se intenta acceder a la DB sin haberla configurado. - */ -export class DbNotConfiguredError extends Error { - constructor(message: string = 'Database has not been configured. Call setDb(db) before using getDb().') { - super(message); - this.name = 'DbNotConfiguredError'; - } -} - let currentDb: Database | null = null; /** @@ -25,7 +15,7 @@ export function setDb(db: Database): void { */ export function getDb(): Database { if (currentDb) return currentDb; - throw new DbNotConfiguredError('Database has not been configured. Call setDb(db) before using getDb().'); + throw new Error('Database has not been configured. Call setDb(db) before using getDb().'); } /** @@ -35,13 +25,6 @@ export function resetDb(): void { currentDb = null; } -/** - * Alias de resetDb() por ergonomía en tests. - */ -export function clearDb(): void { - currentDb = null; -} - /** * Ejecuta una función con la DB actual (sync o async) y devuelve su resultado. */ diff --git a/src/db/migrator.ts b/src/db/migrator.ts index 209ebb4..d16f284 100644 --- a/src/db/migrator.ts +++ b/src/db/migrator.ts @@ -78,6 +78,78 @@ function backupDatabaseIfNeeded(db: Database): string | null { } } +// --------------------------------------------------------------------------- +// Migration runner helpers +// --------------------------------------------------------------------------- + +function validateMigrationsChecksums( + applied: Map, +): void { + const strict = (process.env.MIGRATOR_CHECKSUM_STRICT ?? 'true').toLowerCase() !== 'false'; + for (const [version, info] of applied) { + const codeMig = migrations.find(m => m.version === version); + if (!codeMig || codeMig.checksum === info.checksum) continue; + + const msg = `❌ Checksum mismatch en migración v${version}: aplicado=${info.checksum}, código=${codeMig.checksum}`; + console.error(msg); + try { logEvent('error', 'checksum_mismatch', { version, applied_checksum: info.checksum, code_checksum: codeMig.checksum }); } catch {} + if (strict) throw new Error(msg); + } +} + +function logStartupSummary( + db: Database, + applied: Map, + pending: Migration[], +): void { + const jmRow = db.query(`PRAGMA journal_mode`).get() as Record | undefined; + const journalMode = jmRow ? String((jmRow['journal_mode'] ?? jmRow['value'] ?? jmRow['mode'] ?? 'unknown')) : 'unknown'; + const currentVersion = applied.size ? Math.max(...Array.from(applied.keys())) : 0; + 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 {} +} + +function maybeApplyBaseline(db: Database): void { + const v1 = migrations.find(m => m.version === 1)!; + db.transaction(() => { + insertMigrationRow(db, v1); + })(); + if (!MIGRATIONS_QUIET) console.log('ℹ️ Baseline aplicado: schema_migrations marcada en v1 (sin ejecutar up)'); + try { logEvent('info', 'baseline_applied', { version: 1 }); } catch {} +} + +function applyMigration(db: Database, mig: Migration): void { + 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(); + db.transaction(() => { + const res = mig.up(db); + if (res instanceof Promise) { + throw new Error('Las migraciones up no deben ser asíncronas en este migrador'); + } + insertMigrationRow(db, mig); + })(); + const ms = Date.now() - t0; + 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); + try { logEvent('error', 'apply_error', { version: mig.version, name: mig.name, checksum: mig.checksum, error: String(e) }); } catch {} + throw e; + } +} + +function storeBackupIfNeeded(db: Database, withBackup: boolean): void { + if (!withBackup) return; + const backupPath = backupDatabaseIfNeeded(db); + try { logEvent('info', 'backup', { path: backupPath }); } catch {} +} + +// --------------------------------------------------------------------------- +// Migrator object +// --------------------------------------------------------------------------- + export const Migrator = { ensureMigrationsTable, getAppliedVersions, @@ -88,37 +160,16 @@ export const Migrator = { ensureMigrationsTable(db); const applied = getAppliedVersions(db); - const pending = migrations.filter(m => !applied.has(m.version)).sort((a, b) => a.version - b.version); - - // Validación de checksum (estricta por defecto, configurable) - const strict = (process.env.MIGRATOR_CHECKSUM_STRICT ?? 'true').toLowerCase() !== 'false'; - for (const [version, info] of applied) { - const codeMig = migrations.find(m => m.version === version); - if (codeMig && codeMig.checksum !== info.checksum) { - const msg = `❌ Checksum mismatch en migración v${version}: aplicado=${info.checksum}, código=${codeMig.checksum}`; - console.error(msg); - try { logEvent('error', 'checksum_mismatch', { version, applied_checksum: info.checksum, code_checksum: codeMig.checksum }); } catch {} - if (strict) throw new Error(msg); - } - } + let pending = migrations + .filter(m => !applied.has(m.version)) + .sort((a, b) => a.version - b.version); - // Resumen inicial - const jmRow = db.query(`PRAGMA journal_mode`).get() as Record | undefined; - const journalMode = jmRow ? String((jmRow['journal_mode'] ?? jmRow['value'] ?? jmRow['mode'] ?? 'unknown')) : 'unknown'; - const currentVersion = applied.size ? Math.max(...Array.from(applied.keys())) : 0; - 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 {} + validateMigrationsChecksums(applied); + logStartupSummary(db, applied, pending); if (applied.size === 0 && allowBaseline && detectExistingSchema(db)) { - // Baseline a v1 si ya existe el esquema pero no hay registro - const v1 = migrations.find(m => m.version === 1)!; - db.transaction(() => { - insertMigrationRow(db, v1); - })(); - 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)); + maybeApplyBaseline(db); + pending = migrations.filter(m => m.version > 1); } if (pending.length === 0) { @@ -127,33 +178,10 @@ export const Migrator = { return; } - if (withBackup) { - const backupPath = backupDatabaseIfNeeded(db); - try { logEvent('info', 'backup', { path: backupPath }); } catch {} - } + storeBackupIfNeeded(db, withBackup); for (const mig of pending) { - 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(); - db.transaction(() => { - // Ejecutar up - const res = mig.up(db); - if (res instanceof Promise) { - throw new Error('Las migraciones up no deben ser asíncronas en este migrador'); - } - // Registrar - insertMigrationRow(db, mig); - })(); - const ms = Date.now() - t0; - 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); - try { logEvent('error', 'apply_error', { version: mig.version, name: mig.name, checksum: mig.checksum, error: String(e) }); } catch {} - throw e; - } + applyMigration(db, mig); } - } + }, }; diff --git a/src/env/required.ts b/src/env/required.ts new file mode 100644 index 0000000..446b5fd --- /dev/null +++ b/src/env/required.ts @@ -0,0 +1,7 @@ +export const REQUIRED_ENV = [ + 'EVOLUTION_API_URL', + 'EVOLUTION_API_KEY', + 'EVOLUTION_API_INSTANCE', + 'CHATBOT_PHONE_NUMBER', + 'WEBHOOK_URL' +]; diff --git a/src/http/bootstrap.ts b/src/http/bootstrap.ts index d7ad7fc..af2c945 100644 --- a/src/http/bootstrap.ts +++ b/src/http/bootstrap.ts @@ -70,25 +70,3 @@ export async function startServices(_db: Database): Promise { } } -export function stopServices(): void { - try { - WebhookManager.stopAutoEnsure(); - } catch {} - try { - ResponseQueue.stopCleanupScheduler(); - } catch {} - try { - // No existe un "stop" público de workers; paramos el lazo - (ResponseQueue as any).stop?.(); - } catch {} - try { - RemindersService.stop(); - } catch {} - try { - GroupSyncService.stopGroupsScheduler(); - GroupSyncService.stopMembersScheduler(); - } catch {} - try { - MaintenanceService.stop(); - } catch {} -} diff --git a/src/http/metrics.ts b/src/http/metrics.ts index c0f9280..be98540 100644 --- a/src/http/metrics.ts +++ b/src/http/metrics.ts @@ -2,15 +2,11 @@ import type { Database } from 'bun:sqlite'; import { Metrics } from '../services/metrics'; import { GroupSyncService } from '../services/group-sync'; -export async function handleMetricsRequest(request: Request, db: Database): Promise { - if (request.method !== 'GET') { - return new Response('🚫 Method not allowed', { status: 405 }); - } - if (!Metrics.enabled()) { - return new Response('Metrics disabled', { status: 404 }); - } +// --------------------------------------------------------------------------- +// Metric collectors +// --------------------------------------------------------------------------- - // Gauges de allowed_groups por estado (best-effort) +function collectAllowedGroupMetrics(db: Database): void { try { const rows = db .prepare(`SELECT status, COUNT(*) AS c FROM allowed_groups GROUP BY status`) @@ -27,10 +23,10 @@ export async function handleMetricsRequest(request: Request, db: Database): Prom Metrics.set('allowed_groups_total_allowed', allowed); Metrics.set('allowed_groups_total_blocked', blocked); } catch {} +} - // Métricas de grupos y usuarios (gauges derivadas desde BD) +function collectGroupAndUserMetrics(db: Database): void { try { - // Grupos: totales, activos y archivados (siempre excluyendo comunidades) const groupRow = db .prepare(` SELECT @@ -54,16 +50,11 @@ export async function handleMetricsRequest(request: Request, db: Database): Prom .get() as any; if (groupRow) { - const groupsTotal = Number(groupRow.total ?? 0); - const groupsActive = Number(groupRow.active ?? 0); - const groupsArchived = Number(groupRow.archived ?? 0); - - Metrics.set('groups_total', groupsTotal); - Metrics.set('groups_active_total', groupsActive); - Metrics.set('groups_archived_total', groupsArchived); + Metrics.set('groups_total', Number(groupRow.total ?? 0)); + Metrics.set('groups_active_total', Number(groupRow.active ?? 0)); + Metrics.set('groups_archived_total', Number(groupRow.archived ?? 0)); } - // Miembros de grupos: solo miembros activos en grupos activos, no comunidad, no archivados const gmRow = db .prepare(` SELECT COUNT(*) AS total @@ -77,22 +68,17 @@ export async function handleMetricsRequest(request: Request, db: Database): Prom .get() as any; if (gmRow) { - const gmTotal = Number(gmRow.total ?? 0); - Metrics.set('group_members_total', gmTotal); + Metrics.set('group_members_total', Number(gmRow.total ?? 0)); } - // Usuarios totales - const usersRow = db - .prepare(`SELECT COUNT(*) AS total FROM users;`) - .get() as any; - + const usersRow = db.prepare(`SELECT COUNT(*) AS total FROM users;`).get() as any; if (usersRow) { - const usersTotal = Number(usersRow.total ?? 0); - Metrics.set('users_total', usersTotal); + Metrics.set('users_total', Number(usersRow.total ?? 0)); } } catch {} +} - // Métricas de tareas (gauges derivadas desde BD) +function collectTaskMetrics(db: Database): void { try { const row = db .prepare(` @@ -114,19 +100,15 @@ export async function handleMetricsRequest(request: Request, db: Database): Prom .get() as any; if (row) { - const total = Number(row.total ?? 0); - const completed = Number(row.completed ?? 0); - const active = Number(row.active ?? 0); - const overdue = Number(row.overdue ?? 0); - - Metrics.set('tasks_created_total', total); - Metrics.set('tasks_completed_total', completed); - Metrics.set('tasks_active', active); - Metrics.set('tasks_overdue', overdue); + Metrics.set('tasks_created_total', Number(row.total ?? 0)); + Metrics.set('tasks_completed_total', Number(row.completed ?? 0)); + Metrics.set('tasks_active', Number(row.active ?? 0)); + Metrics.set('tasks_overdue', Number(row.overdue ?? 0)); } } catch {} +} - // Métricas de cola de respuestas (gauges derivadas desde BD) +function collectResponseQueueMetrics(db: Database): void { try { const row = db .prepare(` @@ -160,13 +142,32 @@ export async function handleMetricsRequest(request: Request, db: Database): Prom Metrics.set('response_queue_oldest_age_seconds', ageSeconds); } } catch {} +} - // Exponer métrica con el tiempo restante hasta el próximo group sync (o -1 si scheduler inactivo) +function collectGroupSyncMetric(): void { try { const secs = GroupSyncService.getSecondsUntilNextGroupSync(); - const val = (secs == null || !Number.isFinite(secs)) ? -1 : secs; - Metrics.set('group_sync_seconds_until_next', val); + Metrics.set('group_sync_seconds_until_next', (secs == null || !Number.isFinite(secs)) ? -1 : secs); } catch {} +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +export async function handleMetricsRequest(request: Request, db: Database): Promise { + if (request.method !== 'GET') { + return new Response('🚫 Method not allowed', { status: 405 }); + } + if (!Metrics.enabled()) { + return new Response('Metrics disabled', { status: 404 }); + } + + collectAllowedGroupMetrics(db); + collectGroupAndUserMetrics(db); + collectTaskMetrics(db); + collectResponseQueueMetrics(db); + collectGroupSyncMetric(); const format = (process.env.METRICS_FORMAT || 'prom').toLowerCase() === 'json' ? 'json' : 'prom'; const body = Metrics.render(format as any); diff --git a/src/http/webhook-handler.ts b/src/http/webhook-handler.ts index 2feb641..2f99319 100644 --- a/src/http/webhook-handler.ts +++ b/src/http/webhook-handler.ts @@ -11,7 +11,17 @@ import { TaskService } from '../tasks/service'; import { RateLimiter } from '../services/rate-limit'; import { Metrics } from '../services/metrics'; -function getMessageText(message: any): string { +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const isTest = () => process.env.NODE_ENV === 'test'; +const log = (msg: string, ...args: unknown[]) => { if (!isTest()) console.log(msg, ...args); }; +const logErr = (msg: string, ...args: unknown[]) => { if (!isTest()) console.error(msg, ...args); }; +const logWarn = (msg: string, ...args: unknown[]) => { if (!isTest()) console.warn(msg, ...args); }; +const logDebug = (msg: string, ...args: unknown[]) => { if (!isTest()) console.debug(msg, ...args); }; + +export function getMessageText(message: any): string { if (!message || typeof message !== 'object') return ''; const text = message.conversation || @@ -22,294 +32,392 @@ function getMessageText(message: any): string { return typeof text === 'string' ? text.trim() : ''; } -export async function handleMessageUpsert(data: any, db: Database): Promise { - if (!data?.key?.remoteJid || !data.message) { - if (process.env.NODE_ENV !== 'test') { - console.log('⚠️ Invalid message format - missing required fields'); - console.log(data); - } - return; - } +function isEnvTrue(key: string): boolean { + return ['true', '1', 'yes', 'on'].includes(String(process.env[key] || 'false').toLowerCase()); +} - const messageText = getMessageText(data.message); - if (!messageText) { - if (process.env.NODE_ENV !== 'test') { - console.log('⚠️ Empty or unsupported message content'); - } - return; +function resolveRateLimitPerMin(): number { + const v = Number(process.env.RATE_LIMIT_PER_MIN); + return Number.isFinite(v) && v > 0 ? v : 15; +} + +// --------------------------------------------------------------------------- +// Message validation +// --------------------------------------------------------------------------- + +/** Returns false if the message payload is missing required fields. */ +function isValidMessageShape(data: any): boolean { + if (!data?.key?.remoteJid || !data.message) { + log('⚠️ Invalid message format - missing required fields', data); + return false; } + return true; +} - // Determine sender depending on context (group vs DM) and ignore non-user messages - const remoteJid = data.key.remoteJid; - const participant = data.key.participant; - const fromMe = !!data.key.fromMe; +// --------------------------------------------------------------------------- +// Sender resolution +// --------------------------------------------------------------------------- - // Ignore broadcasts/status +/** Returns true when the message should be discarded (broadcast or self). */ +function shouldSkipBroadcastOrSelf(data: any, remoteJid: string): boolean { if (remoteJid === 'status@broadcast' || (typeof remoteJid === 'string' && remoteJid.endsWith('@broadcast'))) { - if (process.env.NODE_ENV !== 'test') { - console.log('ℹ️ Ignoring broadcast/status message'); - } - return; + log('ℹ️ Ignoring broadcast/status message'); + return true; } - - // Ignore our own messages - if (fromMe) { - if (process.env.NODE_ENV !== 'test') { - console.log('ℹ️ Ignoring message sent by the bot (fromMe=true)'); - } - return; + if (data.key.fromMe) { + log('ℹ️ Ignoring message sent by the bot (fromMe=true)'); + return true; } + return false; +} - // Compute sender JID based on chat type (prefer participantAlt when available due to Baileys change) - const senderRaw = isGroupId(remoteJid) - ? (data.key.participantAlt || participant) - : remoteJid; - - // Aprender mapping alias→número cuando vienen ambos y difieren (participant vs participantAlt) +/** Prefers participantAlt (Baileys change) in groups, falls back to remoteJid. */ +function resolveSenderJid(data: any, remoteJid: string): string { if (isGroupId(remoteJid)) { - const pAlt = typeof data.key.participantAlt === 'string' ? data.key.participantAlt : null; - const p = typeof participant === 'string' ? participant : null; - if (pAlt && p) { - 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'); - } - } catch {} - } + return data.key.participantAlt || data.key.participant || remoteJid; } + return remoteJid; +} - // Normalize sender ID for consistency and validation - const normalizedSenderId = normalizeWhatsAppId(senderRaw); - if (!normalizedSenderId) { - if (process.env.NODE_ENV !== 'test') { - console.debug('⚠️ Invalid sender ID, ignoring message', { remoteJid, participant, fromMe }); +/** Learns participant ↔ participantAlt alias when both differ. */ +function learnAliasFromKey(data: any, remoteJid: string): void { + if (!isGroupId(remoteJid)) return; + const pAlt = typeof data.key.participantAlt === 'string' ? data.key.participantAlt : null; + const p = typeof data.key.participant === 'string' ? data.key.participant : null; + if (!pAlt || !p) return; + try { + const nAlt = normalizeWhatsAppId(pAlt); + const n = normalizeWhatsAppId(p); + if (nAlt && n && nAlt !== n) { + log('[A0] message.key participants', { + participant: p, + participantAlt: pAlt, + normalized_participant: n, + normalized_participantAlt: nAlt, + alias_upsert: true, + }); + IdentityService.upsertAlias(p, pAlt, 'message.key'); } - return; + } catch { /* best-effort */ } +} + +/** Returns the normalized WhatsApp ID, or null if invalid. */ +function normalizeSender(senderRaw: string, remoteJid: string, participant: string, fromMe: boolean): string | null { + const id = normalizeWhatsAppId(senderRaw); + if (!id) { + logDebug('⚠️ Invalid sender ID, ignoring message', { remoteJid, participant, fromMe }); + return null; } + return id; +} - // Avoid processing messages from the bot number - if (process.env.CHATBOT_PHONE_NUMBER && normalizedSenderId === process.env.CHATBOT_PHONE_NUMBER) { - if (process.env.NODE_ENV !== 'test') { - console.log('ℹ️ Ignoring message from the bot number'); - } - return; +/** True if the sender matches the configured bot phone number. */ +function isOwnBotNumber(normalizedId: string): boolean { + if (process.env.CHATBOT_PHONE_NUMBER && normalizedId === process.env.CHATBOT_PHONE_NUMBER) { + log('ℹ️ Ignoring message from the bot number'); + return true; } + return false; +} + +// --------------------------------------------------------------------------- +// User / activation +// --------------------------------------------------------------------------- - // Ensure user exists in database (swallow DB errors to keep webhook 200) - let userId: string | null = null; +/** Ensures the sender exists in the database. Returns the userId or null on failure. */ +function ensureUserOrBail(senderRaw: string, db: Database): string | null { try { - userId = ensureUserExists(senderRaw, db); + const userId = ensureUserExists(senderRaw, db); + if (!userId) log('⚠️ Failed to ensure user exists, ignoring message'); + return userId || null; } catch (e) { - if (process.env.NODE_ENV !== 'test') { - console.error('⚠️ Error ensuring user exists, ignoring message:', e); + logErr('⚠️ Error ensuring user exists, ignoring message:', e); + return null; + } +} + +/** Handles the "activar" DM flow. Returns true if the message was consumed. */ +async function handleActivationDM( + remoteJid: string, + senderId: string, + text: string, +): Promise { + if (isGroupId(remoteJid) || text !== 'activar') return false; + 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: senderId, message: msg }]); } catch {} + return true; +} + +// --------------------------------------------------------------------------- +// Group gating +// --------------------------------------------------------------------------- + +/** + * Discover mode: when an unknown group receives a message, register it as + * pending and optionally notify admins. + * + * Returns true if the message was consumed (not an admin command → bail out). + */ +async function handleGroupDiscovery( + db: Database, + groupId: string, + senderId: string, + isAdminCmd: boolean, +): Promise { + const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + if (mode !== 'discover') return false; + + try { + const exists = db.prepare('SELECT 1 FROM allowed_groups WHERE group_id = ? LIMIT 1').get(groupId); + if (exists) return false; // already known — continue normally + } catch { + // Table may not exist yet — fall through to discovery + } + + // Register unknown group + return await registerDiscoveredGroup(groupId, senderId, isAdminCmd); +} + +/** + * Enforce mode: silently drop messages from groups that haven't been + * approved by an admin (unless the message itself is an /admin command). + * + * Returns true if the message was blocked. + */ +function handleGroupEnforcement(groupId: string, isAdminCmd: boolean): boolean { + const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + if (mode !== 'enforce') return false; + + try { + if (!AllowedGroups.isAllowed(groupId) && !isAdminCmd) { + try { Metrics.inc('messages_blocked_group_total'); } catch {} + return true; } - return; + } catch { + // If the check fails, be permissive } - if (!userId) { - if (process.env.NODE_ENV !== 'test') { - console.log('⚠️ Failed to ensure user exists, ignoring message'); + return false; +} + +/** Shared discovery registration & admin notification. */ +async function registerDiscoveredGroup( + groupId: string, + senderId: string, + isAdminCmd: boolean, +): Promise { + try { await GroupSyncService.ensureGroupLabelAndName(groupId); } catch {} + try { AllowedGroups.upsertPending(groupId, GroupSyncService.activeGroupsCache.get(groupId) || null, senderId); } catch {} + try { Metrics.inc('unknown_groups_discovered_total'); } catch {} + + if (isEnvTrue('NOTIFY_ADMINS_ON_DISCOVERY') && !isAdminCmd) { + const admins = AdminService.getAdmins(); + if (admins.length > 0) { + const msg = `🔎 Nuevo grupo detectado: ${groupId}\nEstado: pending.\nUsa /admin habilitar-aquí desde el grupo o /admin allow-group ${groupId}.`; + try { await ResponseQueue.add(admins.map(a => ({ recipient: a, message: msg }))); } catch {} } - return; } - const messageTextTrimmed = messageText.trim(); - const isAdminCmd = messageTextTrimmed.startsWith('/admin'); + // Admin commands pass through; everything else stops here + return !isAdminCmd; +} - // 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; +// --------------------------------------------------------------------------- +// Admin commands +// --------------------------------------------------------------------------- + +/** Routes /admin commands. Returns true if the message was an admin command. */ +async function handleAdminCommand( + senderId: string, + groupId: string, + messageText: string, +): Promise { + if (!messageText.trim().startsWith('/admin')) return false; + + const responses = await AdminService.handle({ + sender: senderId, + groupId, + message: messageText, + }); + if (responses.length > 0) { + await ResponseQueue.add(responses); } + return true; +} - // Etapa 2: Descubrimiento seguro de grupos (modo 'discover') - if (isGroupId(remoteJid)) { - const gatingMode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); - if (gatingMode === 'discover') { - try { - const exists = db - .prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? LIMIT 1`) - .get(remoteJid); - if (!exists) { - try { await GroupSyncService.ensureGroupLabelAndName(remoteJid); } catch {} - try { AllowedGroups.upsertPending(remoteJid, (GroupSyncService.activeGroupsCache.get(remoteJid) || null), normalizedSenderId); } catch {} - try { Metrics.inc('unknown_groups_discovered_total'); } catch {} - try { - const notify = String(process.env.NOTIFY_ADMINS_ON_DISCOVERY || 'false').toLowerCase() === 'true'; - if (notify && !isAdminCmd) { - const admins = AdminService.getAdmins(); - if (admins.length > 0) { - const info = remoteJid; - const msg = `🔎 Nuevo grupo detectado: ${info}\nEstado: pending.\nUsa /admin habilitar-aquí desde el grupo o /admin allow-group ${info}.`; - await ResponseQueue.add(admins.map(a => ({ recipient: a, message: msg }))); - } - } - } catch {} - if (!isAdminCmd) return; - } - } catch { - // Si la tabla no existe por alguna razón, intentar upsert y retornar igualmente - try { await GroupSyncService.ensureGroupLabelAndName(remoteJid); } catch {} - try { AllowedGroups.upsertPending(remoteJid, (GroupSyncService.activeGroupsCache.get(remoteJid) || null), normalizedSenderId); } catch {} - try { Metrics.inc('unknown_groups_discovered_total'); } catch {} - try { - const notify = String(process.env.NOTIFY_ADMINS_ON_DISCOVERY || 'false').toLowerCase() === 'true'; - if (notify && !isAdminCmd) { - const admins = AdminService.getAdmins(); - if (admins.length > 0) { - const info = remoteJid; - const msg = `🔎 Nuevo grupo detectado: ${info}\nEstado: pending.\nUsa /admin habilitar-aquí desde el grupo o /admin allow-group ${info}.`; - await ResponseQueue.add(admins.map(a => ({ recipient: a, message: msg }))); - } - } - } catch {} - if (!isAdminCmd) return; - } - } +// --------------------------------------------------------------------------- +// Group cache +// --------------------------------------------------------------------------- + +/** + * Ensures the group is present in the active-groups cache (lazy registration). + * In test mode, inactive groups cause an early bail; in production they are + * registered on the fly. + * + * Returns true when the message should be dropped (tests only). + */ +function ensureGroupActive(db: Database, groupId: string, senderId: string): boolean { + if (!isGroupId(groupId) || GroupSyncService.isGroupActive(groupId)) return false; + + if (isTest()) return true; // tests: bail on inactive groups + + log('ℹ️ Group not active in cache — ensuring group (no immediate members sync)'); + try { + GroupSyncService.ensureGroupExists(groupId); + try { GroupSyncService.upsertMemberSeen(groupId, senderId); } catch {} + } catch (e) { + logErr('⚠️ Failed to ensure group on-the-fly:', e); } + return false; +} - // Etapa 3: Gating en modo 'enforce' — ignorar mensajes de grupos no permitidos - if (isGroupId(remoteJid)) { - const gatingMode2 = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); - if (gatingMode2 === 'enforce') { - try { - const allowed = AllowedGroups.isAllowed(remoteJid); - if (!allowed && !isAdminCmd) { - try { Metrics.inc('messages_blocked_group_total'); } catch {} - return; - } - } catch { - // Si falla el check por cualquier motivo, ser conservadores y permitir - } - } +// --------------------------------------------------------------------------- +// Task command routing (t, tarea) +// --------------------------------------------------------------------------- + +/** Extracts mentioned JIDs across all known message contexts. */ +function extractMentions(data: any): string[] { + return data.message?.contextInfo?.mentionedJid + || data.message?.extendedTextMessage?.contextInfo?.mentionedJid + || data.message?.imageMessage?.contextInfo?.mentionedJid + || data.message?.videoMessage?.contextInfo?.mentionedJid + || []; +} + +/** Rate-limits the sender. Returns false (and optionally notifies) if over the limit. */ +async function applyRateLimit(senderId: string): Promise { + if (isTest()) return true; + + if (RateLimiter.checkAndConsume(senderId)) return true; + + if (RateLimiter.shouldNotify(senderId)) { + await ResponseQueue.add([{ + recipient: senderId, + message: `Has superado el límite de ${resolveRateLimitPerMin()} comandos por minuto. Inténtalo de nuevo en un momento.`, + }]); } + return false; +} - // Manejo de comandos de administración (/admin) antes de cualquier otra lógica de grupo - if (messageTextTrimmed.startsWith('/admin')) { - const adminResponses = await AdminService.handle({ - sender: normalizedSenderId, - groupId: remoteJid, - message: messageText - }); - if (adminResponses.length > 0) { - await ResponseQueue.add(adminResponses); - } - return; +/** + * Adds a success/error emoji reaction to the command message when reactions + * are enabled and in scope. + */ +async function maybeReactToCommand(data: any, outcome: { ok: boolean }, messageId: string): Promise { + if (!isEnvTrue('REACTIONS_ENABLED')) return; + + const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase(); + const isGroup = isGroupId(data.key.remoteJid); + if (scope !== 'all' && !isGroup) return; + + // Respect enforce gating for reactions + const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + if (mode === 'enforce' && isGroup) { + try { if (!AllowedGroups.isAllowed(data.key.remoteJid)) return; } catch {} } - // Check/ensure group exists (allow DMs always) - if (isGroupId(data.key.remoteJid) && !GroupSyncService.isGroupActive(data.key.remoteJid)) { - // En tests, mantener comportamiento anterior: ignorar mensajes de grupos inactivos - if (process.env.NODE_ENV === 'test') { - return; - } - if (process.env.NODE_ENV !== 'test') { - console.log('ℹ️ Group not active in cache — ensuring group (no immediate members sync)'); - } + const emoji = outcome.ok ? '🤖' : '⚠️'; + const participant = typeof data?.key?.participantAlt === 'string' + ? data.key.participantAlt + : (typeof data?.key?.participant === 'string' ? data.key.participant : undefined); + + await ResponseQueue.enqueueReaction(data.key.remoteJid, messageId, emoji, { + participant, + fromMe: !!data?.key?.fromMe, + }); +} + +/** Routes t and tarea commands (with trailing space) through the command service. */ +async function handleTaskCommand( + data: any, + db: Database, + senderId: string, + messageText: string, +): Promise { + if (!await applyRateLimit(senderId)) return; + + const mentions = extractMentions(data); + + const messageId = typeof data?.key?.id === 'string' ? data.key.id : null; + const participantForKey = typeof data?.key?.participantAlt === 'string' + ? data.key.participantAlt + : (typeof data?.key?.participant === 'string' ? data.key.participant : null); + + const outcome = await CommandService.handleWithOutcome({ + sender: senderId, + groupId: data.key.remoteJid, + message: messageText, + mentions, + messageId: messageId || undefined, + participant: participantForKey || undefined, + fromMe: !!data?.key?.fromMe, + }); + + if (outcome.responses.length > 0) { + await ResponseQueue.add(outcome.responses); + } + + if (messageId) { try { - GroupSyncService.ensureGroupExists(data.key.remoteJid); - try { GroupSyncService.upsertMemberSeen(data.key.remoteJid, normalizedSenderId); } catch {} + await maybeReactToCommand(data, outcome, messageId); } catch (e) { - if (process.env.NODE_ENV !== 'test') { - console.error('⚠️ Failed to ensure group on-the-fly:', e); - } + logWarn('⚠️ Reaction enqueue failed:', e); } } +} - // Forward to command service only if it's a text-ish message and starts with /t or /tarea - if (messageTextTrimmed.startsWith('/tarea') || messageTextTrimmed.startsWith('/t')) { - // Rate limiting básico por usuario (desactivado en tests) - if (process.env.NODE_ENV !== 'test') { - const allowed = RateLimiter.checkAndConsume(normalizedSenderId); - if (!allowed) { - // Notificar como máximo una vez por minuto - if (RateLimiter.shouldNotify(normalizedSenderId)) { - await ResponseQueue.add([{ - recipient: normalizedSenderId, - message: `Has superado el límite de ${((() => { const v = Number(process.env.RATE_LIMIT_PER_MIN); return Number.isFinite(v) && v > 0 ? v : 15; })())} comandos por minuto. Inténtalo de nuevo en un momento.` - }]); - } - return; - } - } - // Extraer menciones desde el mensaje (varios formatos) - const mentions = data.message?.contextInfo?.mentionedJid - || data.message?.extendedTextMessage?.contextInfo?.mentionedJid - || data.message?.imageMessage?.contextInfo?.mentionedJid - || data.message?.videoMessage?.contextInfo?.mentionedJid - || []; - - // Asegurar que CommandService y TaskService usen la misma DB (tests/producción) - - // Delegar el manejo del comando - const messageId = typeof data?.key?.id === 'string' ? data.key.id : null; - const participantForKey = typeof data?.key?.participantAlt === 'string' - ? data.key.participantAlt - : (typeof data?.key?.participant === 'string' ? data.key.participant : null); - const outcome = await CommandService.handleWithOutcome({ - sender: normalizedSenderId, - groupId: data.key.remoteJid, - message: messageText, - mentions, - messageId: messageId || undefined, - participant: participantForKey || undefined, - fromMe: !!data?.key?.fromMe - }); - const responses = outcome.responses; - - // Encolar respuestas si las hay - if (responses.length > 0) { - await ResponseQueue.add(responses); - } +/** True when the message should be forwarded to the command service. */ +function isTaskCommand(text: string): boolean { + return text.startsWith('tarea ') || text.startsWith('t '); +} - // Reaccionar al mensaje del comando con outcome explícito - try { - const reactionsEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase(); - const enabled = ['true','1','yes','on'].includes(reactionsEnabled); - if (!enabled) return; - - if (!messageId) return; - - const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase(); - const isGroup = isGroupId(data.key.remoteJid); - if (scope !== 'all' && !isGroup) return; - - // Respetar gating 'enforce' - const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); - if (mode === 'enforce' && isGroup) { - try { - if (!AllowedGroups.isAllowed(data.key.remoteJid)) { - return; - } - } catch {} - } - - const emoji = outcome.ok ? '🤖' : '⚠️'; - const participant = typeof data?.key?.participantAlt === 'string' - ? data.key.participantAlt - : (typeof data?.key?.participant === 'string' ? data.key.participant : undefined); - await ResponseQueue.enqueueReaction(data.key.remoteJid, messageId, emoji, { participant, fromMe: !!data?.key?.fromMe }); - } catch (e) { - // No romper el flujo por errores de reacción - if (process.env.NODE_ENV !== 'test') { - console.warn('⚠️ Reaction enqueue failed:', e); - } - } +// --------------------------------------------------------------------------- +// Main handler +// --------------------------------------------------------------------------- + +export async function handleMessageUpsert(data: any, db: Database): Promise { + // 1. Validate message shape + if (!isValidMessageShape(data)) return; + + const messageText = getMessageText(data.message); + if (!messageText) { log('⚠️ Empty or unsupported message content'); return; } + + const remoteJid: string = data.key.remoteJid; + + // 2. Broadcast / self-message guard + if (shouldSkipBroadcastOrSelf(data, remoteJid)) return; + + // 3. Resolve & normalize sender + const senderRaw = resolveSenderJid(data, remoteJid); + learnAliasFromKey(data, remoteJid); + + const normalizedSenderId = normalizeSender(senderRaw, remoteJid, data.key.participant, !!data.key.fromMe); + if (!normalizedSenderId) return; + if (isOwnBotNumber(normalizedSenderId)) return; + + // 4. Ensure user exists + if (!ensureUserOrBail(senderRaw, db)) return; + + const text = messageText.trim(); + const isAdminCmd = text.startsWith('/admin'); + + // 5. Activation DM + if (await handleActivationDM(remoteJid, normalizedSenderId, text)) return; + + // 6. Group gating (discover → enforce) + if (await handleGroupDiscovery(db, remoteJid, normalizedSenderId, isAdminCmd)) return; + if (handleGroupEnforcement(remoteJid, isAdminCmd)) return; + + // 7. Admin commands (before any other group logic) + if (await handleAdminCommand(normalizedSenderId, remoteJid, messageText)) return; + + // 8. Ensure group is active (lazy registration in prod, bail in tests) + if (ensureGroupActive(db, remoteJid, normalizedSenderId)) return; + + // 9. Task commands (t, tarea) + if (isTaskCommand(text)) { + await handleTaskCommand(data, db, normalizedSenderId, messageText); } } diff --git a/src/server.ts b/src/server.ts index e6b11e3..ea6c186 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,17 +8,10 @@ import { AllowedGroups } from './services/allowed-groups'; import { db } from './db'; import { handleMetricsRequest } from './http/metrics'; import { handleHealthRequest } from './http/health'; +import { REQUIRED_ENV } from './env/required'; import { startServices } from './http/bootstrap'; import { handleMessageUpsert as handleMessageUpsertFn } from './http/webhook-handler'; -export const REQUIRED_ENV = [ - 'EVOLUTION_API_URL', - 'EVOLUTION_API_KEY', - 'EVOLUTION_API_INSTANCE', - 'CHATBOT_PHONE_NUMBER', - 'WEBHOOK_URL' -]; - type WebhookPayload = { event: string; instance: string; @@ -36,19 +29,61 @@ export class WebhookServer { return `${proto}://${host}`; } - private static getMessageText(message: any): string { - if (!message || typeof message !== 'object') return ''; - const text = - message.conversation || - message?.extendedTextMessage?.text || - message?.imageMessage?.caption || - message?.videoMessage?.caption || - ''; - return typeof text === 'string' ? text.trim() : ''; + private static shouldVerifyInstance(): boolean { + return process.env.NODE_ENV !== 'test' || !!process.env.TEST_VERIFY_INSTANCE; + } + + private static async handleGroupsUpsert(): Promise { + await GroupSyncService.syncGroups(); + GroupSyncService.refreshActiveGroupsCache(); + const changed = GroupSyncService.getLastChangedActive(); + if (changed.length > 0) { + await GroupSyncService.syncMembersForGroups(changed); + } else { + await GroupSyncService.syncMembersForActiveGroups(); + } + } + + /** Route a parsed webhook payload to the appropriate handler. */ + private static async routeWebhookEvent(data: any, evt: string): Promise { + const evtNorm = evt.toLowerCase().replace(/_/g, '.'); + + try { + Metrics.inc(`webhook_events_total_${evtNorm.replace(/\./g, '_')}`); + } catch {} + + switch (evtNorm) { + case 'messages.upsert': + if (process.env.NODE_ENV !== 'test') { + console.log('ℹ️ Handling message upsert:', { + groupId: data?.key?.remoteJid, + message: data?.message?.conversation, + rawEvent: evt + }); + } + await WebhookServer.handleMessageUpsert(data); + break; + case 'contacts.update': + case 'chats.update': + if (process.env.NODE_ENV !== 'test') { + console.log('ℹ️ Handling contacts/chats update event:', { rawEvent: evt }); + } + ContactsService.updateFromWebhook(data); + break; + case 'groups.upsert': + if (process.env.NODE_ENV !== 'test') { + console.log('ℹ️ Handling groups upsert event:', { rawEvent: evt }); + } + try { + await WebhookServer.handleGroupsUpsert(); + } catch (e) { + console.error('❌ Error handling groups.upsert:', e); + } + break; + } } static async handleRequest(request: Request): Promise { - // Health check endpoint y métricas const url = new URL(request.url); if (url.pathname.endsWith('/metrics')) { return await handleMetricsRequest(request, WebhookServer.dbInstance); @@ -61,88 +96,27 @@ export class WebhookServer { console.log('ℹ️ Incoming webhook request:'); } - // 1. Method validation if (request.method !== 'POST') { return new Response('🚫 Method not allowed', { status: 405 }); } - // 2. Content-Type validation const contentType = request.headers.get('content-type'); if (!contentType?.includes('application/json')) { return new Response('🚫 Invalid content type', { status: 400 }); } try { - // 3. Parse and validate payload const payload = (await request.json()) as WebhookPayload; if (!payload.event || !payload.instance) { return new Response('🚫 Invalid payload', { status: 400 }); } - // 4. Verify instance matches (skip in test environment unless TEST_VERIFY_INSTANCE is set) - if ( - (process.env.NODE_ENV !== 'test' || process.env.TEST_VERIFY_INSTANCE) && - payload.instance !== process.env.EVOLUTION_API_INSTANCE - ) { + if (WebhookServer.shouldVerifyInstance() && payload.instance !== process.env.EVOLUTION_API_INSTANCE) { return new Response('🚫 Invalid instance', { status: 403 }); } - // 5. Route events - // console.log('ℹ️ Webhook event received:', { - // event: payload.event, - // instance: payload.instance, - // data: payload.data ? '[...]' : null - // }); - - // Normalize event name to handle different casing/format (e.g., MESSAGES_UPSERT) - const evt = String(payload.event); - const evtNorm = evt.toLowerCase().replace(/_/g, '.'); - - // Contabilizar evento de webhook por tipo - try { - Metrics.inc(`webhook_events_total_${evtNorm.replace(/\./g, '_')}`); - } catch {} - - switch (evtNorm) { - case 'messages.upsert': - if (process.env.NODE_ENV !== 'test') { - console.log('ℹ️ Handling message upsert:', { - groupId: payload.data?.key?.remoteJid, - message: payload.data?.message?.conversation, - rawEvent: evt - }); - } - await WebhookServer.handleMessageUpsert(payload.data); - break; - case 'contacts.update': - case 'chats.update': - if (process.env.NODE_ENV !== 'test') { - console.log('ℹ️ Handling contacts/chats update event:', { - rawEvent: evt - }); - } - ContactsService.updateFromWebhook(payload.data); - break; - case 'groups.upsert': - if (process.env.NODE_ENV !== 'test') { - console.log('ℹ️ Handling groups upsert event:', { rawEvent: evt }); - } - try { - const res = await GroupSyncService.syncGroups(); - GroupSyncService.refreshActiveGroupsCache(); - const changed = GroupSyncService.getLastChangedActive(); - if (changed.length > 0) { - await GroupSyncService.syncMembersForGroups(changed); - } else { - await GroupSyncService.syncMembersForActiveGroups(); - } - } catch (e) { - console.error('❌ Error handling groups.upsert:', e); - } - break; - // Other events will be added later - } + await WebhookServer.routeWebhookEvent(payload.data, String(payload.event)); return new Response('OK', { status: 200 }); } catch (error) { diff --git a/src/services/admin.ts b/src/services/admin.ts index 3e4594d..cfc3271 100644 --- a/src/services/admin.ts +++ b/src/services/admin.ts @@ -8,15 +8,270 @@ import { codeId, formatDDMM } from '../utils/formatting'; import { getDb } from '../db/locator'; type AdminContext = { - sender: string; // normalized user id (digits only) - groupId: string; // raw JID (group or DM) - message: string; // raw message text + sender: string; + groupId: string; + message: string; }; type AdminResponse = { recipient: string; message: string }; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Shared "must be used in a group" guard. */ +function requireIsGroup(groupId: string, sender: string): AdminResponse[] | null { + if (isGroupId(groupId)) return null; + return [{ recipient: sender, message: 'ℹ️ Este comando se debe usar dentro de un grupo.' }]; +} + +/** Parses a group_id argument from rest after one of the given prefixes. */ +function parseGroupArg(rest: string, ...prefixes: string[]): string | null { + for (const p of prefixes) { + if (rest.startsWith(p)) return rest.slice(p.length).trim(); + } + return null; +} + +/** Checks the parsed group arg is valid, returning an error response if not. */ +function validateGroupArg(arg: string | null, sender: string): AdminResponse[] | null { + if (!arg) return null; + if (!isGroupId(arg)) return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }]; + return null; +} + +/** Sets a group's status and returns a response. */ +function setGroupStatusAndRespond( + groupId: string, + status: 'allowed' | 'blocked', + metricKey: string, + label: string, + sender: string, +): AdminResponse[] { + const changed = AllowedGroups.setStatus(groupId, status); + try { if (changed) Metrics.inc(metricKey); } catch {} + return [{ recipient: sender, message: `✅ Grupo ${label}: ${groupId}` }]; +} + +// --------------------------------------------------------------------------- +// Database operations +// --------------------------------------------------------------------------- + +function archiveGroupInDb(db: Database, groupId: string): void { + db.transaction(() => { + db.prepare(` + UPDATE groups SET active = 0, archived = 1, last_verified = strftime('%Y-%m-%d %H:%M:%f','now') + WHERE id = ? + `).run(groupId); + db.prepare(` + UPDATE calendar_tokens SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now') + WHERE group_id = ? AND revoked_at IS NULL + `).run(groupId); + db.prepare(` + UPDATE group_members SET is_active = 0 WHERE group_id = ? AND is_active = 1 + `).run(groupId); + })(); +} + +function deleteGroupInDb(db: Database, groupId: string): void { + db.transaction(() => { + db.prepare(`DELETE FROM tasks WHERE group_id = ?`).run(groupId); + db.prepare(`DELETE FROM groups WHERE id = ?`).run(groupId); + try { db.prepare(`DELETE FROM allowed_groups WHERE group_id = ?`).run(groupId); } catch {} + })(); +} + +// --------------------------------------------------------------------------- +// Command handlers +// --------------------------------------------------------------------------- + +function handlePending(sender: string): AdminResponse[] { + const rows = AllowedGroups.listByStatus('pending'); + if (!rows || rows.length === 0) { + return [{ recipient: sender, message: '✅ No hay grupos pendientes.' }]; + } + const list = rows.map(r => `- ${r.group_id}${r.label ? ` (${r.label})` : ''}`).join('\n'); + return [{ recipient: sender, message: `Grupos pendientes (${rows.length}):\n${list}` }]; +} + +function handleEnableHere(ctx: AdminContext, sender: string): AdminResponse[] { + const err = requireIsGroup(ctx.groupId, sender); + if (err) return err; + return setGroupStatusAndRespond(ctx.groupId, 'allowed', 'admin_actions_total_allow', 'habilitado', sender); +} + +function handleDisableHere(ctx: AdminContext, sender: string): AdminResponse[] { + const err = requireIsGroup(ctx.groupId, sender); + if (err) return err; + return setGroupStatusAndRespond(ctx.groupId, 'blocked', 'admin_actions_total_block', 'deshabilitado', sender); +} + +function handleArchiveHere(ctx: AdminContext, sender: string, db: Database): AdminResponse[] { + const err = requireIsGroup(ctx.groupId, sender); + if (err) return err; + archiveGroupInDb(db, ctx.groupId); + try { AllowedGroups.setStatus(ctx.groupId, 'blocked'); } catch {} + return [{ recipient: sender, message: `📦 Grupo archivado: ${ctx.groupId}` }]; +} + +function handleArchiveGroup(rest: string, sender: string, db: Database): AdminResponse[] { + const arg = parseGroupArg(rest, 'archivar-grupo ', 'archive-group '); + const err = validateGroupArg(arg, sender); + if (err) return err; + archiveGroupInDb(db, arg!); + try { AllowedGroups.setStatus(arg!, 'blocked'); } catch {} + return [{ recipient: sender, message: `📦 Grupo archivado: ${arg}` }]; +} + +function handleDeleteHere(ctx: AdminContext, sender: string, db: Database): AdminResponse[] { + const err = requireIsGroup(ctx.groupId, sender); + if (err) return err; + deleteGroupInDb(db, ctx.groupId); + return [{ recipient: sender, message: `🗑️ Grupo borrado y datos asociados eliminados: ${ctx.groupId}` }]; +} + +function handleDeleteGroup(rest: string, sender: string, db: Database): AdminResponse[] { + const arg = parseGroupArg(rest, 'borrar-grupo ', 'delete-group '); + const err = validateGroupArg(arg, sender); + if (err) return err; + deleteGroupInDb(db, arg!); + return [{ recipient: sender, message: `🗑️ Grupo borrado y datos asociados eliminados: ${arg}` }]; +} + +function handleAllowAll(sender: string): AdminResponse[] { + 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) { + if (AllowedGroups.setStatus(r.group_id, 'allowed', r.label ?? null)) changed++; + try { Metrics.inc('admin_actions_total_allow'); } catch {} + } + return [{ recipient: sender, message: `✅ Grupos habilitados: ${changed}` }]; +} + +function handleSetGroupArg( + rest: string, + prefixes: string[], + sender: string, + status: 'allowed' | 'blocked', + metricKey: string, + label: string, +): AdminResponse[] { + const arg = parseGroupArg(rest, ...prefixes); + if (!arg) return []; + const err = validateGroupArg(arg, sender); + if (err) return err; + return setGroupStatusAndRespond(arg, status, metricKey, label, sender); +} + +function handleAllowGroup(rest: string, sender: string): AdminResponse[] { + // Must not catch the "allow all" variants + if (rest === 'allow all' || rest === 'allow-all') return []; + return handleSetGroupArg(rest, ['allow-group ', 'allow '], sender, 'allowed', 'admin_actions_total_allow', 'habilitado'); +} + +function handleBlockGroup(rest: string, sender: string): AdminResponse[] { + return handleSetGroupArg(rest, ['block-group ', 'block '], sender, 'blocked', 'admin_actions_total_block', 'bloqueado'); +} + +async function handleSyncGroups(sender: string): Promise { + try { + const r = await GroupSyncService.syncGroups(true); + return [{ recipient: sender, message: `✅ Sync de grupos ejecutado: ${r.added} añadidos, ${r.updated} actualizados.` }]; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return [{ recipient: sender, message: `❌ Error al ejecutar sync de grupos: ${msg}` }]; + } +} + +function handleListAll(rest: string, sender: string): AdminResponse[] { + const DEFAULT_LIMIT = 50; + const maybeNum = parseInt(rest.split(/\s+/).pop() || '', 10); + const limit = (Number.isFinite(maybeNum) && maybeNum > 0) ? Math.min(maybeNum, 500) : DEFAULT_LIMIT; + + const tasks = TaskService.listAllActive(limit); + const total = TaskService.countAllActive(); + + if (!tasks || tasks.length === 0) { + return [{ recipient: sender, message: '✅ No hay tareas activas.' }]; + } + + const lines = tasks.map(t => { + const ddmm = formatDDMM(t.due_date); + const groupLabel = t.group_name || t.group_id || 'DM'; + const parts: string[] = [ + `${codeId(t.id, t.display_code)}`, + String(t.description || '').trim(), + ]; + if (ddmm) parts.push(`vence ${ddmm}`); + if (groupLabel) parts.push(`[${groupLabel}]`); + return `- ${parts.join(' · ')}`; + }); + + const header = total > limit + ? `Tus tareas — Tareas activas (${total}) — mostrando ${tasks.length} primeras:` + : `Tus tareas — Tareas activas (${total}):`; + const footer = `ℹ️ Para ver tareas sin responsable de un grupo, pide el listado desde ese grupo.`; + + try { Metrics.inc('admin_actions_total_list'); } catch {} + + return [{ recipient: sender, message: `${header}\n${lines.join('\n')}\n\n${footer}` }]; +} + +function handleHelp(sender: string): AdminResponse[] { + return [{ + recipient: sender, + message: [ + 'Comandos de administración:', + '- /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)', + '- /admin ver todos (alias: listar, list all)', + ].join('\n'), + }]; +} + +// --------------------------------------------------------------------------- +// Command router +// --------------------------------------------------------------------------- + +/** Exact-match commands. */ +const EXACT_COMMANDS: Record AdminResponse[] | Promise)> = { + pendientes: (_, s) => handlePending(s), + pending: (_, s) => handlePending(s), + pend: (_, s) => handlePending(s), + 'habilitar-aquí': (c, s) => handleEnableHere(c, s), + 'habilitar-aqui': (c, s) => handleEnableHere(c, s), + enable: (c, s) => handleEnableHere(c, s), + 'deshabilitar-aquí': (c, s) => handleDisableHere(c, s), + 'deshabilitar-aqui': (c, s) => handleDisableHere(c, s), + disable: (c, s) => handleDisableHere(c, s), + 'archivar-aquí': (c, s, d) => handleArchiveHere(c, s, d), + 'archivar-aqui': (c, s, d) => handleArchiveHere(c, s, d), + 'archive here': (c, s, d) => handleArchiveHere(c, s, d), + 'archive-aquí': (c, s, d) => handleArchiveHere(c, s, d), + 'archive-aqui': (c, s, d) => handleArchiveHere(c, s, d), + 'borrar-aquí': (c, s, d) => handleDeleteHere(c, s, d), + 'borrar-aqui': (c, s, d) => handleDeleteHere(c, s, d), + 'delete here': (c, s, d) => handleDeleteHere(c, s, d), + 'delete-here': (c, s, d) => handleDeleteHere(c, s, d), + 'allow all': (_, s) => handleAllowAll(s), + 'allow-all': (_, s) => handleAllowAll(s), + 'habilitar-todos': (_, s) => handleAllowAll(s), + 'permitir todos': (_, s) => handleAllowAll(s), + 'enable all': (_, s) => handleAllowAll(s), + 'sync-grupos': async (_, s) => handleSyncGroups(s), + 'group-sync': async (_, s) => handleSyncGroups(s), + syncgroups: async (_, s) => handleSyncGroups(s), +}; + export class AdminService { - private static admins(): Set { const raw = String(process.env.ADMIN_USERS || ''); @@ -38,258 +293,62 @@ export class AdminService { return this.admins().has(n); } - private static help(): string { - return [ - 'Comandos de administración:', - '- /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)', - '- /admin ver todos (alias: listar, list all)', - ].join('\n'); + private static checkAdminAccess(sender: string): AdminResponse[] | null { + if (!this.isAdmin(sender)) { + return [{ recipient: sender, message: '🚫 No estás autorizado para usar /admin.' }]; + } + return null; } + /** Routes an /admin command to the appropriate handler. */ static async handle(ctx: AdminContext): Promise { const sender = normalizeWhatsAppId(ctx.sender); if (!sender) return []; - if (!this.isAdmin(sender)) { - return [{ recipient: sender, message: '🚫 No estás autorizado para usar /admin.' }]; - } + const accessDenied = this.checkAdminAccess(sender); + if (accessDenied) return accessDenied; - const instanceDb = getDb() as Database; - // Asegurar acceso a la misma DB para AllowedGroups + const raw = String(ctx.message || '').trim().toLowerCase(); + if (!raw.startsWith('/admin')) return []; - const raw = String(ctx.message || '').trim(); - const lower = raw.toLowerCase(); - if (!lower.startsWith('/admin')) { - return []; - } + const rest = raw.slice('/admin'.length).trim(); + const db = getDb() as Database; - const rest = lower.slice('/admin'.length).trim(); - - // /admin pendientes - if (rest === 'pendientes' || rest === 'pending' || rest === 'pend') { - const rows = AllowedGroups.listByStatus('pending'); - if (!rows || rows.length === 0) { - return [{ recipient: sender, message: '✅ No hay grupos pendientes.' }]; - } - const list = rows.map(r => `- ${r.group_id}${r.label ? ` (${r.label})` : ''}`).join('\n'); - return [{ - recipient: sender, - message: `Grupos pendientes (${rows.length}):\n${list}` - }]; + // 1. Try exact-routed commands first + const exactHandler = EXACT_COMMANDS[rest]; + if (typeof exactHandler === 'function') { + return await exactHandler(ctx, sender, db); } - - // /admin habilitar-aquí - if (rest === 'habilitar-aquí' || rest === 'habilitar-aqui' || rest === 'enable') { - if (!isGroupId(ctx.groupId)) { - return [{ recipient: sender, message: 'ℹ️ Este comando se debe usar dentro de un grupo.' }]; - } - const changed = AllowedGroups.setStatus(ctx.groupId, 'allowed'); - try { if (changed) Metrics.inc('admin_actions_total_allow'); } catch {} - return [{ recipient: sender, message: `✅ Grupo habilitado: ${ctx.groupId}` }]; + if (exactHandler) { + return exactHandler; } - // /admin deshabilitar-aquí - if (rest === 'deshabilitar-aquí' || rest === 'deshabilitar-aqui' || rest === 'disable') { - if (!isGroupId(ctx.groupId)) { - return [{ recipient: sender, message: 'ℹ️ Este comando se debe usar dentro de un grupo.' }]; - } - const changed = AllowedGroups.setStatus(ctx.groupId, 'blocked'); - try { if (changed) Metrics.inc('admin_actions_total_block'); } catch {} - return [{ recipient: sender, message: `✅ Grupo deshabilitado: ${ctx.groupId}` }]; + // 2. Prefix-routed commands (take a group_id argument) + if (rest.startsWith('allow-group ') || rest.startsWith('allow ')) { + const r = handleAllowGroup(rest, sender); + if (r.length > 0) return r; } - - // /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.' }]; - } - instanceDb.transaction(() => { - instanceDb.prepare(` - UPDATE groups - SET active = 0, archived = 1, last_verified = strftime('%Y-%m-%d %H:%M:%f','now') - WHERE id = ? - `).run(ctx.groupId); - instanceDb.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); - instanceDb.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}` }]; + if (rest.startsWith('block-group ') || rest.startsWith('block ')) { + return handleBlockGroup(rest, sender); } - - // /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' }]; - } - instanceDb.transaction(() => { - instanceDb.prepare(` - UPDATE groups - SET active = 0, archived = 1, last_verified = strftime('%Y-%m-%d %H:%M:%f','now') - WHERE id = ? - `).run(arg); - instanceDb.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); - instanceDb.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.' }]; - } - instanceDb.transaction(() => { - instanceDb.prepare(`DELETE FROM tasks WHERE group_id = ?`).run(ctx.groupId); - instanceDb.prepare(`DELETE FROM groups WHERE id = ?`).run(ctx.groupId); - try { instanceDb.prepare(`DELETE FROM allowed_groups WHERE group_id = ?`).run(ctx.groupId); } catch {} - })(); - return [{ recipient: sender, message: `🗑️ Grupo borrado y datos asociados eliminados: ${ctx.groupId}` }]; + return handleArchiveGroup(rest, sender, db); } - - // /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' }]; - } - instanceDb.transaction(() => { - instanceDb.prepare(`DELETE FROM tasks WHERE group_id = ?`).run(arg); - instanceDb.prepare(`DELETE FROM groups WHERE id = ?`).run(arg); - try { instanceDb.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 ') && 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' }]; - } - const changed = AllowedGroups.setStatus(arg, 'allowed'); - try { if (changed) Metrics.inc('admin_actions_total_allow'); } catch {} - return [{ recipient: sender, message: `✅ Grupo habilitado: ${arg}` }]; - } - - // /admin block-group - if (rest.startsWith('block-group ') || rest.startsWith('block ')) { - const arg = (rest.startsWith('block-group ') ? rest.slice('block-group '.length) : rest.slice('block '.length)).trim(); - if (!isGroupId(arg)) { - return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }]; - } - const changed = AllowedGroups.setStatus(arg, 'blocked'); - try { if (changed) Metrics.inc('admin_actions_total_block'); } catch {} - return [{ recipient: sender, message: `✅ Grupo bloqueado: ${arg}` }]; - } - - // /admin sync-grupos - if (rest === 'sync-grupos' || rest === 'group-sync' || rest === 'syncgroups') { - try { - const r = await GroupSyncService.syncGroups(true); - return [{ recipient: sender, message: `✅ Sync de grupos ejecutado: ${r.added} añadidos, ${r.updated} actualizados.` }]; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return [{ recipient: sender, message: `❌ Error al ejecutar sync de grupos: ${msg}` }]; - } + return handleDeleteGroup(rest, sender, db); } - // /admin ver todos [] + // 3. "ver todos" with optional limit if ( - rest === 'ver todos' || - rest.startsWith('ver todos ') || - rest === 'listar' || - rest.startsWith('listar ') || - rest === 'list all' || - rest.startsWith('list all ') || - rest === 'list-all' || - rest.startsWith('list-all ') + rest === 'ver todos' || rest.startsWith('ver todos ') || + rest === 'listar' || rest.startsWith('listar ') || + rest === 'list all' || rest.startsWith('list all ') || + rest === 'list-all' || rest.startsWith('list-all ') ) { - // Asegurar acceso a la misma DB para TaskService - - const DEFAULT_LIMIT = 50; - let limit = DEFAULT_LIMIT; - const maybeNum = parseInt(rest.split(/\s+/).pop() || '', 10); - if (Number.isFinite(maybeNum) && maybeNum > 0) { - limit = Math.min(maybeNum, 500); // tope razonable - } - - const tasks = TaskService.listAllActive(limit); - const total = TaskService.countAllActive(); - - if (!tasks || tasks.length === 0) { - return [{ recipient: sender, message: '✅ No hay tareas activas.' }]; - } - - const lines = tasks.map(t => { - const ddmm = formatDDMM(t.due_date); - const groupLabel = t.group_name || t.group_id || 'DM'; - const parts: string[] = [ - `${codeId(t.id, t.display_code)}`, - String(t.description || '').trim() - ]; - if (ddmm) parts.push(`vence ${ddmm}`); - if (groupLabel) parts.push(`[${groupLabel}]`); - return `- ${parts.join(' · ')}`; - }); - const header = total > limit - ? `Tus tareas — Tareas activas (${total}) — mostrando ${tasks.length} primeras:` - : `Tus tareas — Tareas activas (${total}):`; - const footer = `ℹ️ Para ver tareas sin responsable de un grupo, pide el listado desde ese grupo.`; - - try { Metrics.inc('admin_actions_total_list'); } catch {} - - return [{ - recipient: sender, - message: `${header}\n${lines.join('\n')}\n\n${footer}` - }]; + return handleListAll(rest, sender); } - // Ayuda por defecto - return [{ recipient: sender, message: this.help() }]; + // 4. Default → help + return handleHelp(sender); } } diff --git a/src/services/command.ts b/src/services/command.ts index c074eeb..788377c 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -39,11 +39,11 @@ export class CommandService { static async handleWithOutcome(context: CommandContext): Promise { const msg = (context.message || '').trim(); const instanceDb = getDb() as Database; - if (!/^\/(tarea|t)\b/i.test(msg)) { + if (!/^(tarea|t) /i.test(msg)) { return { responses: [], ok: true }; } - // Registrar interacción del usuario (last_command_at) para cualquier comando /t … + // Registrar interacción del usuario (last_command_at) para cualquier comando t … try { let usersTableExists = false; try { diff --git a/src/services/commands/handlers/completar.ts b/src/services/commands/handlers/completar.ts index 34237ca..6534e25 100644 --- a/src/services/commands/handlers/completar.ts +++ b/src/services/commands/handlers/completar.ts @@ -1,7 +1,11 @@ import { TaskService } from '../../../tasks/service'; import { ICONS } from '../../../utils/icons'; -import { codeId, formatDDMM } from '../../../utils/formatting'; -import { parseMultipleIds, resolveTaskIdFromInput, enforceMembership } from '../shared'; +import { codeId } from '../../../utils/formatting'; +import { + parseMultipleIds, resolveAndValidate, + formatDue, handleBatch +} from '../shared'; +import type { BatchOutcome } from '../shared'; type Ctx = { sender: string; @@ -15,118 +19,101 @@ type Msg = { mentions?: string[]; }; -export async function handleCompletar(context: Ctx): Promise { - const tokens = (context.message || '').trim().split(/\s+/); +// --------------------------------------------------------------------------- +// Single completion outcome +// --------------------------------------------------------------------------- - const { ids, truncated } = parseMultipleIds(tokens.slice(2), 10); +function completeOne(idInput: number, sender: string): BatchOutcome { + const rv = resolveAndValidate(idInput, sender); - // Sin IDs: ayuda de uso - if (ids.length === 0) { - return [{ - recipient: context.sender, - message: 'ℹ️ Uso: `/t x 26` o múltiples: `/t x 14 19 24` o `/t x 14,19,24` (máx. 10)' - }]; + if ('error' in rv) { + const isNotFound = rv.error.includes('no encontrada'); + return { + status: isNotFound ? 'notFound' : 'blocked', + line: rv.error, + }; } - // Caso de 1 ID: mantener comportamiento actual - if (ids.length === 1) { - const idInput = ids[0]; - const resolvedId = resolveTaskIdFromInput(idInput); - if (!resolvedId) { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(idInput)} no encontrada.` - }]; - } - - const task = TaskService.getTaskById(resolvedId); - if (!task) { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` - }]; - } - - if (!enforceMembership(context.sender, task)) { - return [{ - recipient: context.sender, - message: 'No puedes completar esta tarea porque no eres de este grupo.' - }]; - } - - const res = TaskService.completeTask(resolvedId, context.sender); - const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; - - if (res.status === 'not_found') { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` - }]; - } - if (res.status === 'already') { - return [{ - recipient: context.sender, - message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} _Ya estaba completada_ — ${res.task?.description || '(sin descripción)'}${due}` - }]; - } + const { resolvedId } = rv; + const res = TaskService.completeTask(resolvedId, sender); + const due = formatDue(res.task); + const desc = res.task?.description || '(sin descripción)'; + const dc = res.task?.display_code; + + switch (res.status) { + case 'already': + return { + status: 'already', + line: `ℹ️ ${codeId(resolvedId, dc)} ya estaba completada — ${desc}${due}`, + }; + case 'updated': + return { + status: 'updated', + line: `${ICONS.complete} ${codeId(resolvedId, dc)} completada — ${desc}${due}`, + }; + default: + return { + status: 'notFound', + line: `⚠️ ${codeId(resolvedId)} no encontrada.`, + }; + } +} + +// --------------------------------------------------------------------------- +// Single-ID mode +// --------------------------------------------------------------------------- + +function handleSingleComplete(idInput: number, sender: string): Msg[] { + const rv = resolveAndValidate(idInput, sender); + if ('error' in rv) { + return [{ recipient: sender, message: rv.error }]; + } + const { resolvedId } = rv; + const res = TaskService.completeTask(resolvedId, sender); + const due = formatDue(res.task); + + if (res.status === 'not_found') { + return [{ recipient: sender, message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` }]; + } + if (res.status === 'already') { return [{ - recipient: context.sender, - message: `${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} _completada_ — ${res.task?.description || '(sin descripción)'}${due}` + recipient: sender, + message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} _Ya estaba completada_ — ${res.task?.description || '(sin descripción)'}${due}`, }]; } - // Modo múltiple - let cntUpdated = 0, cntAlready = 0, cntNotFound = 0, cntBlocked = 0; - const lines: string[] = []; + return [{ + recipient: sender, + message: `${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} _completada_ — ${res.task?.description || '(sin descripción)'}${due}`, + }]; +} - if (truncated) { - lines.push('⚠️ Se procesarán solo los primeros 10 IDs.'); - } +// --------------------------------------------------------------------------- +// Main handler +// --------------------------------------------------------------------------- + +export async function handleCompletar(context: Ctx): Promise { + const tokens = (context.message || '').trim().split(/\s+/); + const { ids, truncated } = parseMultipleIds(tokens.slice(2), 10); - for (const idInput of ids) { - const resolvedId = resolveTaskIdFromInput(idInput); - if (!resolvedId) { - lines.push(`⚠️ ${codeId(idInput)} no encontrada.`); - cntNotFound++; - continue; - } - - const task = TaskService.getTaskById(resolvedId); - if (task && !enforceMembership(context.sender, task)) { - lines.push(`🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).`); - cntBlocked++; - continue; - } - - const res = TaskService.completeTask(resolvedId, context.sender); - const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; - - if (res.status === 'already') { - lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`); - cntAlready++; - } else if (res.status === 'updated') { - lines.push(`${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} completada — ${res.task?.description || '(sin descripción)'}${due}`); - cntUpdated++; - } else if (res.status === 'not_found') { - lines.push(`⚠️ ${codeId(resolvedId)} no encontrada.`); - cntNotFound++; - } + if (ids.length === 0) { + return [{ + recipient: context.sender, + message: 'ℹ️ Uso: `t x 26` o múltiples: `t x 14 19 24` o `t x 14,19,24` (máx. 10)', + }]; } - // Resumen final - const summary: string[] = []; - if (cntUpdated) summary.push(`completadas ${cntUpdated}`); - if (cntAlready) summary.push(`ya estaban ${cntAlready}`); - if (cntNotFound) summary.push(`no encontradas ${cntNotFound}`); - if (cntBlocked) summary.push(`bloqueadas ${cntBlocked}`); - if (summary.length) { - lines.push(''); - lines.push(`Resumen: ${summary.join(', ')}.`); + if (ids.length === 1 && ids[0] != null) { + return handleSingleComplete(ids[0], context.sender); } - return [{ - recipient: context.sender, - message: lines.join('\n') - }]; + return handleBatch( + context.sender, + ids, + truncated, + completeOne, + { updated: 'completadas', already: 'ya estaban', notFound: 'no encontradas', blocked: 'bloqueadas' }, + '', + ) as Msg[]; } diff --git a/src/services/commands/handlers/configurar.ts b/src/services/commands/handlers/configurar.ts index b494f31..b9ee90b 100644 --- a/src/services/commands/handlers/configurar.ts +++ b/src/services/commands/handlers/configurar.ts @@ -41,7 +41,7 @@ export function handleConfigurar(context: Ctx, deps: { db: Database }): Msg[] { if (!m) { return [{ recipient: context.sender, - message: 'ℹ️ Uso: `/t configurar diario|l-v|semanal|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))); @@ -51,7 +51,7 @@ export function handleConfigurar(context: Ctx, deps: { db: Database }): Msg[] { if (!freq) { return [{ recipient: context.sender, - message: 'ℹ️ Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`' + message: 'ℹ️ Uso: `t configurar diario|l-v|semanal|off [HH:MM]`' }]; } diff --git a/src/services/commands/handlers/nueva.ts b/src/services/commands/handlers/nueva.ts index 47ac48e..56d95a9 100644 --- a/src/services/commands/handlers/nueva.ts +++ b/src/services/commands/handlers/nueva.ts @@ -28,233 +28,291 @@ type Msg = { mentions?: string[]; }; +// --------------------------------------------------------------------------- +// Config & helpers +// --------------------------------------------------------------------------- -export async function handleNueva(context: Ctx, deps: { db: Database }): Promise { - const tokens = (context.message || '').trim().split(/\s+/); +type FailReason = 'non_numeric' | 'too_short' | 'too_long' | 'from_lid' | 'invalid'; - // 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 { } +function getFallbackDigitLimits(): { min: number; max: number } { + const min = parseInt((process.env.ONBOARDING_FALLBACK_MIN_DIGITS || '8').trim(), 10); + const max = parseInt((process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '15').trim(), 10); + return { + min: Number.isFinite(min) && min > 0 ? min : 8, + max: Number.isFinite(max) && max > 0 ? max : 15, }; +} - // 1) Menciones aportadas por el backend (JIDs crudos) - const unresolvedAssigneeDisplays: string[] = []; - const mentionsNormalizedFromContext = Array.from(new Set( - (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) - )); - - // 2) Tokens de texto que empiezan por '@' como posibles asignados - const atTokenCandidates = tokens.slice(2) - .filter(t => t.startsWith('@')) - .map(t => t.replace(/^@+/, '').replace(/^\+/, '').replace(/[.,;:!?)\]}¿¡"'’”]+$/, '')); - const normalizedFromAtTokens = Array.from(new Set( - atTokenCandidates.map((v) => { - // Token especial: '@yo' → autoasignación; no cuenta como fallo - if (String(v).toLowerCase() === 'yo') { - return null; - } - 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 - ])); +function isDigits(s: string): boolean { return /^\d+$/.test(s); } - const { description, dueDate, selfAssign } = parseNueva((context.message || '').trim(), mentionsNormalizedFromContext); +function checkPlausibility(s: string, limits: { min: number; max: number }, fromLid: boolean): { ok: boolean; reason?: FailReason } { + if (!s) return { ok: false, reason: 'invalid' }; + if (fromLid) return { ok: false, reason: 'from_lid' }; + if (!isDigits(s)) return { ok: false, reason: 'non_numeric' }; + if (s.length < limits.min) return { ok: false, reason: 'too_short' }; + if (s.length >= limits.max) return { ok: false, reason: 'too_long' }; + return { ok: true }; +} - // Asegurar creador - const createdBy = ensureUserExists(context.sender, deps.db); - if (!createdBy) { - throw new Error('No se pudo asegurar el usuario creador'); - } +function recordOnboardingFailure(groupId: string, source: 'mentions' | 'tokens', reason: FailReason): void { + try { + const gid = isGroupId(groupId) ? groupId : 'dm'; + Metrics.inc('onboarding_assign_failures_total', 1, { group_id: String(gid), source, reason }); + } catch {} +} - // Normalizar menciones y excluir duplicados y el número del bot - const botNumber = process.env.CHATBOT_PHONE_NUMBER || ''; - const assigneesNormalized = Array.from(new Set( - [ - ...(selfAssign ? [context.sender] : []), - ...combinedAssigneeCandidates - ].filter(id => !botNumber || id !== botNumber) - )); - - // Asegurar usuarios asignados - const ensuredAssignees = assigneesNormalized - .map(id => ensureUserExists(id, deps.db)) - .filter((id): id is string => !!id); +// --------------------------------------------------------------------------- +// Mention processing +// --------------------------------------------------------------------------- - // Asignación por defecto según contexto: - // - En grupos: si no hay menciones → sin dueño (ningún asignado) - // - En DM: si no hay menciones → asignada al creador - let assignmentUserIds: string[] = []; - if (ensuredAssignees.length > 0) { - assignmentUserIds = ensuredAssignees; - } else { - assignmentUserIds = (context.groupId && isGroupId(context.groupId)) ? [] : [createdBy]; +interface MentionResult { + ids: string[]; + unresolved: string[]; +} + +/** Extracts a display string from a raw JID (strips @domain, @, +). */ +function displayFromJid(raw: string): string { + return raw.split('@')[0].split(':')[0].replace(/^@+/, '').replace(/^\+/, ''); +} + +function processContextMentions(mentions: string[], limits: { min: number; max: number }, groupId: string): MentionResult { + const ids: string[] = []; + const unresolved: string[] = []; + + for (const j of new Set(mentions)) { + const norm = normalizeWhatsAppId(j); + if (!norm) { + const disp = displayFromJid(j); + if (disp) unresolved.push(disp); + recordOnboardingFailure(groupId, 'mentions', 'invalid'); + continue; + } + + const resolved = IdentityService.resolveAliasOrNull(norm); + if (resolved) { ids.push(resolved); continue; } + + const dom = String(j).split('@')[1]?.toLowerCase() || ''; + const fromLid = dom.includes('lid'); + const p = checkPlausibility(norm, limits, fromLid); + + if (p.ok) { ids.push(norm); continue; } + + unresolved.push(norm); + recordOnboardingFailure(groupId, 'mentions', p.reason!); } - // Definir group_id solo si el grupo está activo - const groupIdToUse = (context.groupId && GroupSyncService.isGroupActive(context.groupId)) - ? context.groupId - : null; + return { ids, unresolved }; +} - // Crear tarea y asignaciones - const taskId = TaskService.createTask( - { - description: description || '', - due_date: dueDate ?? null, - group_id: groupIdToUse, - created_by: createdBy, - }, - assignmentUserIds.map(uid => ({ - user_id: uid, - assigned_by: createdBy, - })) - ); +function processAtTokens(tokens: string[], limits: { min: number; max: number }, groupId: string): MentionResult { + const candidates = tokens + .filter(t => t.startsWith('@')) + .map(t => t.replace(/^@+/, '').replace(/^\+/, '').replace(/[.,;:!?)\]}¿¡"'’”]+$/, '')); - // Registrar origen del comando para esta tarea (si aplica) - try { - if (groupIdToUse && isGroupId(groupIdToUse) && context.messageId) { - const participant = typeof context.participant === 'string' ? context.participant : null; - const fromMe = typeof context.fromMe === 'boolean' ? (context.fromMe ? 1 : 0) : null; - try { - deps.db.prepare(` - INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id, participant, from_me) - VALUES (?, ?, ?, ?, ?) - `).run(taskId, groupIdToUse, context.messageId, participant, fromMe); - } catch { - deps.db.prepare(` - INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id) - VALUES (?, ?, ?) - `).run(taskId, groupIdToUse, context.messageId); - } + const ids: string[] = []; + const unresolved: string[] = []; + + for (const v of new Set(candidates)) { + // '@yo' → self-assignment marker, not an actual user + if (String(v).toLowerCase() === 'yo') continue; + + const norm = normalizeWhatsAppId(v); + if (!norm) { + if (v) unresolved.push(v); + recordOnboardingFailure(groupId, 'tokens', 'invalid'); + continue; } - } catch { } - // Recuperar la tarea creada para obtener display_code asignado - const createdTask = TaskService.getTaskById(taskId); + const resolved = IdentityService.resolveAliasOrNull(norm); + if (resolved) { ids.push(resolved); continue; } + + const p = checkPlausibility(norm, limits, false); + if (p.ok) { ids.push(norm); continue; } - const mentionsForSending = assignmentUserIds.map(uid => `${uid}@s.whatsapp.net`); + unresolved.push(v); + recordOnboardingFailure(groupId, 'tokens', p.reason!); + } - // Resolver nombres útiles - const groupName = groupIdToUse ? GroupSyncService.activeGroupsCache.get(groupIdToUse) : null; + return { ids, unresolved }; +} - const assignedDisplayNames = await Promise.all( - assignmentUserIds.map(async uid => { - const name = await ContactsService.getDisplayName(uid); - return name || uid; - }) - ); +// --------------------------------------------------------------------------- +// Assignment resolution +// --------------------------------------------------------------------------- - const responses: Msg[] = []; +function resolveFinalAssignees( + candidates: string[], + selfAssign: boolean, + sender: string, + groupId: string, + db: Database, +): { ensured: string[]; userIds: string[] } { + const botNumber = process.env.CHATBOT_PHONE_NUMBER || ''; + const source = Array.from(new Set([ + ...(selfAssign ? [sender] : []), + ...candidates, + ].filter(id => !botNumber || id !== botNumber))); - // 1) Ack al creador con formato compacto + const ensured = source + .map(id => ensureUserExists(id, db)) + .filter((id): id is string => !!id); + + // Default: in groups → no assignment; in DMs → assign to creator + const userIds = ensured.length > 0 + ? ensured + : (isGroupId(groupId) ? [] : [sender]); + + return { ensured, userIds }; +} + +// --------------------------------------------------------------------------- +// Response building +// --------------------------------------------------------------------------- + +function buildAcknowledgement( + taskId: number, + displayCode: number | null, + description: string, + dueDate: string | null, + assignmentUserIds: string[], + assignedDisplayNames: string[], + groupName: string | null, + createdBy: string, +): Msg { const dueFmt = formatDDMM(dueDate); const ownerPart = assignmentUserIds.length === 0 ? `${ICONS.unassigned} ${groupName ? ` (${groupName})` : ''}` : `${assignmentUserIds.length > 1 ? '👥' : '👤'} ${assignedDisplayNames.join(', ')}`; - const ackLines = [ - `${ICONS.create} ${codeId(taskId, createdTask?.display_code)} ${description || '(sin descripción)'}`, + + const lines = [ + `${ICONS.create} ${codeId(taskId, displayCode)} ${description || '(sin descripción)'}`, dueFmt ? `${ICONS.date} ${dueFmt}` : null, - ownerPart + ownerPart, ].filter(Boolean); - responses.push({ + + return { recipient: createdBy, - message: [ackLines.join('\n'), '', CTA_HELP].join('\n'), - ...(mentionsForSending.length > 0 ? { mentions: mentionsForSending } : {}) - }); + message: [lines.join('\n'), '', CTA_HELP].join('\n'), + ...(assignmentUserIds.length > 0 ? { mentions: assignmentUserIds.map(uid => `${uid}@s.whatsapp.net`) } : {}), + }; +} - // 2) DM a cada asignado (excluyendo al creador para evitar duplicados) +function buildAssigneeNotices( + taskId: number, + displayCode: number | null, + description: string, + dueDate: string | null, + assignmentUserIds: string[], + createdBy: string, + groupName: string | null, +): Msg[] { + const notices: Msg[] = []; for (const uid of assignmentUserIds) { if (uid === createdBy) continue; - responses.push({ + notices.push({ recipient: uid, message: [ - `${ICONS.assignNotice} ${codeId(taskId, createdTask?.display_code)}`, - `${description || '(sin descripción)'}`, + `${ICONS.assignNotice} ${codeId(taskId, displayCode)}`, + description || '(sin descripción)', formatDDMM(dueDate) ? `${ICONS.date} ${formatDDMM(dueDate)}` : null, groupName ? `Grupo: ${groupName}` : null, - `- Completar: \`/t x ${createdTask?.display_code}\``, - `- Soltar: \`/t soltar ${createdTask?.display_code}\`` + `- Completar: \`t x ${displayCode}\``, + `- Soltar: \`t soltar ${displayCode}\``, ].filter(Boolean).join('\n') + '\n\n' + CTA_HELP, - mentions: [`${createdBy}@s.whatsapp.net`] + mentions: [`${createdBy}@s.whatsapp.net`], }); } + return notices; +} + +function recordTaskOrigin(db: Database, taskId: number, groupId: string, ctx: Ctx): void { + if (!isGroupId(groupId) || !ctx.messageId) return; + try { + const participant = typeof ctx.participant === 'string' ? ctx.participant : null; + const fromMe = typeof ctx.fromMe === 'boolean' ? (ctx.fromMe ? 1 : 0) : null; + try { + db.prepare(` + INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id, participant, from_me) + VALUES (?, ?, ?, ?, ?) + `).run(taskId, groupId, ctx.messageId, participant, fromMe); + } catch { + db.prepare(` + INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id) + VALUES (?, ?, ?) + `).run(taskId, groupId, ctx.messageId); + } + } catch {} +} + +// --------------------------------------------------------------------------- +// Main handler +// --------------------------------------------------------------------------- + +export async function handleNueva(context: Ctx, deps: { db: Database }): Promise { + const tokens = (context.message || '').trim().split(/\s+/); + const limits = getFallbackDigitLimits(); - // A4: DM JIT al asignador si quedaron menciones/tokens irrecuperables - responses.push(...buildJitAssigneePrompt(createdBy, context.groupId, unresolvedAssigneeDisplays)); + // 1. Process mentions from context and @tokens from text + const mentionResult = processContextMentions(context.mentions || [], limits, context.groupId); + const tokenResult = processAtTokens(tokens.slice(2), limits, context.groupId); - // Fase 2: disparar paquete de onboarding (2 DMs) tras crear tarea en grupo + const combinedCandidates = Array.from(new Set([ + ...mentionResult.ids, + ...tokenResult.ids, + ])); + const unresolvedDisplays = Array.from(new Set([ + ...mentionResult.unresolved, + ...tokenResult.unresolved, + ])); + + // 2. Parse command + const { description, dueDate, selfAssign } = parseNueva( + (context.message || '').trim(), + mentionResult.ids, + ); + + // 3. Ensure creator + const createdBy = ensureUserExists(context.sender, deps.db); + if (!createdBy) throw new Error('No se pudo asegurar el usuario creador'); + + // 4. Resolve assignees + const { ensured: ensuredAssignees, userIds: assignmentUserIds } = resolveFinalAssignees( + combinedCandidates, selfAssign, context.sender, context.groupId, deps.db, + ); + + // 5. Determine group + const groupIdToUse = (context.groupId && GroupSyncService.isGroupActive(context.groupId)) + ? context.groupId : null; + + // 6. Create task + const taskId = TaskService.createTask( + { description: description || '', due_date: dueDate ?? null, group_id: groupIdToUse, created_by: createdBy }, + assignmentUserIds.map(uid => ({ user_id: uid, assigned_by: createdBy })), + ); + + // 7. Record origin + if (groupIdToUse) recordTaskOrigin(deps.db, taskId, groupIdToUse, context); + + // 8. Fetch created task & display names + const createdTask = TaskService.getTaskById(taskId); + const groupName = groupIdToUse ? (GroupSyncService.activeGroupsCache.get(groupIdToUse) ?? null) : null; + const displayNames = await Promise.all( + assignmentUserIds.map(uid => ContactsService.getDisplayName(uid).then(n => n || uid)), + ); + + // 9. Build responses + const responses: Msg[] = []; + responses.push(buildAcknowledgement(taskId, createdTask?.display_code ?? null, description || '', dueDate, assignmentUserIds, displayNames, groupName, createdBy)); + responses.push(...buildAssigneeNotices(taskId, createdTask?.display_code ?? null, description || '', dueDate, assignmentUserIds, createdBy, groupName)); + responses.push(...buildJitAssigneePrompt(createdBy, context.groupId, unresolvedDisplays)); + + // 10. Onboarding bundle try { const gid = groupIdToUse || (isGroupId(context.groupId) ? context.groupId : null); maybeEnqueueOnboardingBundle(deps.db, { - gid, - createdBy, - assignmentUserIds, - taskId, + gid, createdBy, assignmentUserIds, taskId, displayCode: createdTask?.display_code ?? null, - description: description || '' + description: description || '', }); } catch {} diff --git a/src/services/commands/handlers/soltar.ts b/src/services/commands/handlers/soltar.ts index 431eb89..980dca0 100644 --- a/src/services/commands/handlers/soltar.ts +++ b/src/services/commands/handlers/soltar.ts @@ -1,7 +1,7 @@ import { TaskService } from '../../../tasks/service'; import { ICONS } from '../../../utils/icons'; import { codeId, formatDDMM, italic } from '../../../utils/formatting'; -import { resolveTaskIdFromInput, enforceMembership } from '../shared'; +import { resolveAndValidate, formatDue } from '../shared'; type Ctx = { sender: string; @@ -15,90 +15,68 @@ type Msg = { mentions?: string[]; }; -export async function handleSoltar(context: Ctx): Promise { - const tokens = (context.message || '').trim().split(/\s+/); +// --------------------------------------------------------------------------- +// Message builder +// --------------------------------------------------------------------------- - const idToken = tokens[2]; - const idInput = idToken ? parseInt(idToken, 10) : NaN; - if (!idInput || Number.isNaN(idInput)) { - return [{ - recipient: context.sender, - message: 'ℹ️ Uso: `/t soltar 26`' - }]; - } +function unassignMessage( + res: ReturnType, + resolvedId: number, +): string { + const label = codeId(resolvedId, res.task?.display_code); + const desc = res.task?.description || '(sin descripción)'; + const due = formatDue(res.task); - const resolvedId = resolveTaskIdFromInput(idInput); - if (!resolvedId) { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(idInput)} no encontrada.` - }]; - } + switch (res.status) { + case 'forbidden_personal': + return '⚠️ No puedes soltar una tarea personal. Márcala como completada para eliminarla'; - const task = TaskService.getTaskById(resolvedId); - if (!task) { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` - }]; - } + case 'not_found': + return `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`; - if (!enforceMembership(context.sender, task)) { - return [{ - recipient: context.sender, - message: '⚠️ No puedes soltar esta tarea porque no eres de este grupo.' - }]; - } + case 'completed': + return `ℹ️ ${label} ya estaba completada — ${desc}${due}`; - const res = TaskService.unassignTask(resolvedId, context.sender); - const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; + case 'not_assigned': + return `ℹ️ ${label} no la tenías asignada — ${desc}${due}`; - if (res.status === 'forbidden_personal') { - return [{ - recipient: context.sender, - message: '⚠️ No puedes soltar una tarea personal. Márcala como completada para eliminarla' - }]; - } + case 'unassigned': { + if (res.now_unassigned) { + return [ + `${ICONS.unassigned} ${label}`, + desc, + res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task!.due_date)}` : '', + italic('queda sin responsable.'), + ].filter(Boolean).join('\n'); + } + return [ + `${ICONS.unassign} ${label}`, + desc, + res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task!.due_date)}` : '', + ].filter(Boolean).join('\n'); + } - if (res.status === 'not_found') { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` - }]; + default: + return '⚠️ Estado inesperado al soltar la tarea.'; } - if (res.status === 'completed') { - return [{ - recipient: context.sender, - message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}` - }]; - } - if (res.status === 'not_assigned') { - return [{ - recipient: context.sender, - message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} no la tenías asignada — ${res.task?.description || '(sin descripción)'}${due}` - }]; +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +export async function handleSoltar(context: Ctx): Promise { + const tokens = (context.message || '').trim().split(/\s+/); + const idInput = parseInt(tokens[2], 10); + if (!idInput || Number.isNaN(idInput)) { + return [{ recipient: context.sender, message: 'ℹ️ Uso: `t soltar 26`' }]; } - if (res.now_unassigned) { - const lines = [ - `${ICONS.unassigned} ${codeId(resolvedId, res.task?.display_code)}`, - `${res.task?.description || '(sin descripción)'}`, - res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '', - italic('queda sin responsable.') - ].filter(Boolean); - return [{ - recipient: context.sender, - message: lines.join('\n') - }]; + const rv = resolveAndValidate(idInput, context.sender); + if ('error' in rv) { + return [{ recipient: context.sender, message: rv.error }]; } - const lines = [ - `${ICONS.unassign} ${codeId(resolvedId, res.task?.display_code)}`, - `${res.task?.description || '(sin descripción)'}`, - res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '' - ].filter(Boolean); - return [{ - recipient: context.sender, - message: lines.join('\n') - }]; + const res = TaskService.unassignTask(rv.resolvedId, context.sender); + return [{ recipient: context.sender, message: unassignMessage(res, rv.resolvedId) }]; } diff --git a/src/services/commands/handlers/tomar.ts b/src/services/commands/handlers/tomar.ts index c46420d..713fc85 100644 --- a/src/services/commands/handlers/tomar.ts +++ b/src/services/commands/handlers/tomar.ts @@ -1,7 +1,11 @@ import { TaskService } from '../../../tasks/service'; import { ICONS } from '../../../utils/icons'; import { codeId, formatDDMM, italic } from '../../../utils/formatting'; -import { parseMultipleIds, resolveTaskIdFromInput, enforceMembership } from '../shared'; +import { + parseMultipleIds, resolveAndValidate, + formatDue, handleBatch +} from '../shared'; +import type { BatchOutcome } from '../shared'; type Ctx = { sender: string; @@ -15,134 +19,117 @@ type Msg = { mentions?: string[]; }; -export async function handleTomar(context: Ctx): Promise { - const tokens = (context.message || '').trim().split(/\s+/); +// --------------------------------------------------------------------------- +// Single claim outcome +// --------------------------------------------------------------------------- - const { ids, truncated } = parseMultipleIds(tokens.slice(2), 10); +function claimOne(idInput: number, sender: string): BatchOutcome { + const rv = resolveAndValidate(idInput, sender); - // Sin IDs: ayuda de uso - if (ids.length === 0) { - return [{ - recipient: context.sender, - message: 'ℹ️ Uso: `/t tomar 26` o múltiples: `/t tomar 12 19 50` o `/t tomar 12,19,50` (máx. 10)' - }]; + if ('error' in rv) { + const isNotFound = rv.error.includes('no encontrada'); + return { + status: isNotFound ? 'notFound' : 'blocked', + line: rv.error, + }; } - // Caso de 1 ID: mantener comportamiento actual - if (ids.length === 1) { - const idInput = ids[0]; - const resolvedId = resolveTaskIdFromInput(idInput); - if (!resolvedId) { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(idInput)} no encontrada.` - }]; - } - - const task = TaskService.getTaskById(resolvedId); - if (!task) { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` - }]; - } - - if (!enforceMembership(context.sender, task)) { - return [{ - recipient: context.sender, - message: 'No puedes tomar esta tarea porque no eres de este grupo.' - }]; - } - - const res = TaskService.claimTask(resolvedId, context.sender); - const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; - - if (res.status === 'not_found') { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` - }]; - } - if (res.status === 'completed') { - return [{ - recipient: context.sender, - message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}` - }]; - } - if (res.status === 'already') { - return [{ - recipient: context.sender, - message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}` - }]; - } - - const lines = [ - italic(`${ICONS.take} Has tomado ${codeId(resolvedId, res.task?.display_code)}`), - `${res.task?.description || '(sin descripción)'}`, - res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '' - ].filter(Boolean); + const { resolvedId } = rv; + const res = TaskService.claimTask(resolvedId, sender); + const due = formatDue(res.task); + const desc = res.task?.description || '(sin descripción)'; + const dc = res.task?.display_code; + + switch (res.status) { + case 'claimed': + return { + status: 'claimed', + line: `${ICONS.take} ${codeId(resolvedId, dc)} tomada — ${desc}${due}`, + }; + case 'already': + return { + status: 'already', + line: `ℹ️ ${codeId(resolvedId, dc)} ya la tenías — ${desc}${due}`, + }; + case 'completed': + return { + status: 'completed', + line: `ℹ️ ${codeId(resolvedId, dc)} ya estaba completada — ${desc}${due}`, + }; + default: + return { + status: 'notFound', + line: `⚠️ ${codeId(resolvedId)} no encontrada.`, + }; + } +} + +// --------------------------------------------------------------------------- +// Single-ID mode +// --------------------------------------------------------------------------- + +function handleSingleClaim(idInput: number, sender: string): Msg[] { + const rv = resolveAndValidate(idInput, sender); + if ('error' in rv) { + return [{ recipient: sender, message: rv.error }]; + } + const { resolvedId } = rv; + const res = TaskService.claimTask(resolvedId, sender); + const due = formatDue(res.task); + const dc = res.task?.display_code; + + if (res.status === 'not_found') { + return [{ recipient: sender, message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` }]; + } + if (res.status === 'completed') { return [{ - recipient: context.sender, - message: lines.join('\n') + recipient: sender, + message: `ℹ️ ${codeId(resolvedId, dc)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`, + }]; + } + if (res.status === 'already') { + return [{ + recipient: sender, + message: `ℹ️ ${codeId(resolvedId, dc)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}`, }]; } - // Modo múltiple - let cntClaimed = 0, cntAlready = 0, cntCompleted = 0, cntNotFound = 0, cntBlocked = 0; - const lines: string[] = []; + // Success + const lines = [ + italic(`${ICONS.take} Has tomado ${codeId(resolvedId, dc)}`), + res.task?.description || '(sin descripción)', + res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '', + ].filter(Boolean); - if (truncated) { - lines.push('⚠️ Se procesarán solo los primeros 10 IDs.'); - } + return [{ recipient: sender, message: lines.join('\n') }]; +} + +// --------------------------------------------------------------------------- +// Main handler +// --------------------------------------------------------------------------- + +export async function handleTomar(context: Ctx): Promise { + const tokens = (context.message || '').trim().split(/\s+/); + const { ids, truncated } = parseMultipleIds(tokens.slice(2), 10); - for (const idInput of ids) { - const resolvedId = resolveTaskIdFromInput(idInput); - if (!resolvedId) { - lines.push(`⚠️ ${codeId(idInput)} no encontrada.`); - cntNotFound++; - continue; - } - - const task = TaskService.getTaskById(resolvedId); - if (task && !enforceMembership(context.sender, task)) { - lines.push(`🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).`); - cntBlocked++; - continue; - } - - const res = TaskService.claimTask(resolvedId, context.sender); - const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; - - if (res.status === 'already') { - lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}`); - cntAlready++; - } else if (res.status === 'claimed') { - lines.push(`${ICONS.take} ${codeId(resolvedId, res.task?.display_code)} tomada — ${res.task?.description || '(sin descripción)'}${due}`); - cntClaimed++; - } else if (res.status === 'completed') { - lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`); - cntCompleted++; - } else if (res.status === 'not_found') { - lines.push(`⚠️ ${codeId(resolvedId)} no encontrada.`); - cntNotFound++; - } + if (ids.length === 0) { + return [{ + recipient: context.sender, + message: 'ℹ️ Uso: `t tomar 26` o múltiples: `t tomar 12 19 50` o `t tomar 12,19,50` (máx. 10)', + }]; } - // Resumen final - const summary: string[] = []; - if (cntClaimed) summary.push(`tomadas ${cntClaimed}`); - if (cntAlready) summary.push(`ya las tenías ${cntAlready}`); - if (cntCompleted) summary.push(`ya completadas ${cntCompleted}`); - if (cntNotFound) summary.push(`no encontradas ${cntNotFound}`); - if (cntBlocked) summary.push(`bloqueadas ${cntBlocked}`); - if (summary.length) { - lines.push(''); - lines.push(`Resumen: ${summary.join(', ')}.`); + if (ids.length === 1 && ids[0] != null) { + return handleSingleClaim(ids[0], context.sender); } - return [{ - recipient: context.sender, - message: lines.join('\n') - }]; + return handleBatch( + context.sender, + ids, + truncated, + claimOne, + { claimed: 'tomadas', already: 'ya las tenías', completed: 'ya completadas', notFound: 'no encontradas', blocked: 'bloqueadas' }, + '', + ) as Msg[]; } diff --git a/src/services/commands/handlers/ver.ts b/src/services/commands/handlers/ver.ts index bcf4407..20475c4 100644 --- a/src/services/commands/handlers/ver.ts +++ b/src/services/commands/handlers/ver.ts @@ -2,8 +2,8 @@ import { TaskService } from '../../../tasks/service'; import { GroupSyncService } from '../../group-sync'; import { ContactsService } from '../../contacts'; import { ICONS } from '../../../utils/icons'; -import { codeId, formatDDMM, bold, italic } from '../../../utils/formatting'; -import { SCOPE_ALIASES, todayYMD } from '../shared'; +import { codeId, bold, italic } from '../../../utils/formatting'; +import { SCOPE_ALIASES, todayYMD, formatTaskLine } from '../shared'; type Ctx = { sender: string; @@ -17,124 +17,17 @@ type Msg = { mentions?: string[]; }; -export async function handleVer(context: Ctx): Promise { - const trimmed = (context.message || '').trim(); - const tokens = trimmed.split(/\s+/); - const rawAction = (tokens[1] || '').toLowerCase(); - - const scopeRaw = (tokens[2] || '').toLowerCase(); - const scope = scopeRaw - ? (SCOPE_ALIASES[scopeRaw] || scopeRaw) - : ((rawAction === 'mias' || rawAction === 'mías') ? 'mis' : ((rawAction === 'todas' || rawAction === 'todos') ? 'todos' : 'todos')); - - const LIMIT = 10; - const today = todayYMD(); - - if (scope === 'todos') { - const sections: string[] = []; - - // Encabezado fijo para la sección de tareas del usuario - sections.push(bold('Tus tareas')); - - // Tus tareas (mis) - const myItems = TaskService.listUserPending(context.sender, LIMIT); - if (myItems.length > 0) { - // Agrupar por grupo como en "ver mis" - const byGroup = new Map(); - for (const t of myItems) { - const key = t.group_id || '(sin grupo)'; - const arr = byGroup.get(key) || []; - arr.push(t); - byGroup.set(key, arr); - } - - for (const [groupId, arr] of byGroup.entries()) { - const groupName = - (groupId && GroupSyncService.activeGroupsCache.get(groupId)) || - (groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo'); - - sections.push(groupName); - const rendered = await Promise.all(arr.map(async (t) => { - const names = await Promise.all( - (t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid) - ); - const owner = - (t.assignees?.length || 0) === 0 - ? `${ICONS.unassigned} sin responsable` - : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; - const isOverdue = t.due_date ? t.due_date < today : false; - const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - const dc = (t as any)?.display_code as number | undefined; - return `- ${codeId(t.id, dc)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`; - })); - sections.push(...rendered); - sections.push(''); - } - - // Quitar línea en blanco final si procede - if (sections.length > 0 && sections[sections.length - 1] === '') { - sections.pop(); - } - - const totalMy = TaskService.countUserPending(context.sender); - if (totalMy > myItems.length) { - sections.push(`… y ${totalMy - myItems.length} más`); - } - } else { - sections.push(italic('_No tienes tareas pendientes._')); - } +const LIMIT = 10; - // En DM: usar membresía real (snapshot fresca) para incluir "sin responsable" por grupo - const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(context.sender); - if (memberGroups.length > 0) { - const perGroup = TaskService.listUnassignedByGroups(memberGroups, LIMIT); - for (const gid of perGroup.keys()) { - const unassigned = perGroup.get(gid)!; - const groupName = - (gid && GroupSyncService.activeGroupsCache.get(gid)) || - gid; - - if (unassigned.length > 0) { - if (sections.length && sections[sections.length - 1] !== '') sections.push(''); - sections.push(`${groupName} — Sin responsable`); - const renderedUnassigned = unassigned.map((t) => { - const isOverdue = t.due_date ? t.due_date < today : false; - const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - const dc = (t as any)?.display_code as number | undefined; - return `- ${codeId(t.id, dc)} ${t.description || '(sin descripción)'}${datePart} — ${ICONS.unassigned}`; - }); - sections.push(...renderedUnassigned); - - const totalUnassigned = TaskService.countGroupUnassigned(gid); - if (totalUnassigned > unassigned.length) { - sections.push(`… y ${totalUnassigned - unassigned.length} más`); - } - } - } - } else { - // Si no hay snapshot fresca de membresía, nota instructiva - sections.push('ℹ️ Para ver tareas sin responsable, escribe por privado `/t todas` o usa `/t web`.'); - } - - return [{ - recipient: context.sender, - message: sections.join('\n') - }]; - } +// --------------------------------------------------------------------------- +// Shared: group tasks by group_id and render +// --------------------------------------------------------------------------- - // Ver mis - const items = TaskService.listUserPending(context.sender, LIMIT); - if (items.length === 0) { - return [{ - recipient: context.sender, - message: italic('No tienes tareas pendientes.') - }]; - } - - const total = TaskService.countUserPending(context.sender); - - // Agrupar por grupo - const byGroup = new Map(); +async function groupAndRenderTasks( + items: any[], + todayYMD: string, +): Promise { + const byGroup = new Map(); for (const t of items) { const key = t.group_id || '(sin grupo)'; const arr = byGroup.get(key) || []; @@ -142,7 +35,7 @@ export async function handleVer(context: Ctx): Promise { byGroup.set(key, arr); } - const sections: string[] = [bold('Tus tareas')]; + const sections: string[] = []; for (const [groupId, arr] of byGroup.entries()) { const groupName = (groupId && GroupSyncService.activeGroupsCache.get(groupId)) || @@ -151,32 +44,144 @@ export async function handleVer(context: Ctx): Promise { sections.push(groupName); const rendered = await Promise.all(arr.map(async (t) => { const names = await Promise.all( - (t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid) + (t.assignees || []).map(async (uid: string) => (await ContactsService.getDisplayName(uid)) || uid), ); const owner = (t.assignees?.length || 0) === 0 ? `${ICONS.unassigned}` : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; - const isOverdue = t.due_date ? t.due_date < today : false; - const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - const dc = (t as any)?.display_code as number | undefined; - return `- ${codeId(t.id, dc)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`; + return formatTaskLine(t, owner, todayYMD); })); sections.push(...rendered); sections.push(''); } - // Quitar línea en blanco final si procede + // Remove trailing blank line if (sections.length > 0 && sections[sections.length - 1] === '') { sections.pop(); } + return sections; +} +// --------------------------------------------------------------------------- +// Build user's own tasks section (used by both "mis" and "todos") +// --------------------------------------------------------------------------- + +async function buildUserTasksSection(sender: string, todayYMD: string): Promise { + const sections: string[] = [bold('Tus tareas')]; + + const items = TaskService.listUserPending(sender, LIMIT); + if (items.length === 0) { + sections.push(italic('_No tienes tareas pendientes._')); + return sections; + } + + sections.push(...await groupAndRenderTasks(items, todayYMD)); + + const total = TaskService.countUserPending(sender); if (total > items.length) { sections.push(`… y ${total - items.length} más`); } + return sections; +} + +// --------------------------------------------------------------------------- +// Unassigned tasks from membership (used by "todos") +// --------------------------------------------------------------------------- - return [{ - recipient: context.sender, - message: sections.join('\n') - }]; +async function buildUnassignedSections(sender: string, todayYMD: string): Promise { + const sections: string[] = []; + const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(sender); + + if (memberGroups.length === 0) { + sections.push('ℹ️ Para ver tareas sin responsable, escribe por privado `t todas` o usa `t web`.'); + return sections; + } + + const perGroup = TaskService.listUnassignedByGroups(memberGroups, LIMIT); + for (const gid of perGroup.keys()) { + const unassigned = perGroup.get(gid)!; + if (unassigned.length === 0) continue; + + const groupName = GroupSyncService.activeGroupsCache.get(gid) || gid; + if (sections.length && sections[sections.length - 1] !== '') sections.push(''); + sections.push(`${groupName} — Sin responsable`); + + const rendered = unassigned.map(t => + formatTaskLine(t, `${ICONS.unassigned}`, todayYMD), + ); + sections.push(...rendered); + + const total = TaskService.countGroupUnassigned(gid); + if (total > unassigned.length) { + sections.push(`… y ${total - unassigned.length} más`); + } + } + return sections; +} + +// --------------------------------------------------------------------------- +// Scope: "todos" — user tasks + unassigned from member groups +// --------------------------------------------------------------------------- + +async function handleVerTodos(sender: string): Promise { + const today = todayYMD(); + const sections = await buildUserTasksSection(sender, today); + + // Add unassigned section + const unassignedSections = await buildUnassignedSections(sender, today); + sections.push(...unassignedSections); + + return [{ recipient: sender, message: sections.join('\n') }]; +} + +// --------------------------------------------------------------------------- +// Scope: "mis" — user's own tasks only +// --------------------------------------------------------------------------- + +async function handleVerMis(sender: string): Promise { + const today = todayYMD(); + const items = TaskService.listUserPending(sender, LIMIT); + + if (items.length === 0) { + return [{ recipient: sender, message: italic('No tienes tareas pendientes.') }]; + } + + const sections: string[] = [bold('Tus tareas')]; + sections.push(...await groupAndRenderTasks(items, today)); + + const total = TaskService.countUserPending(sender); + if (total > items.length) { + sections.push(`… y ${total - items.length} más`); + } + + return [{ recipient: sender, message: sections.join('\n') }]; +} + +// --------------------------------------------------------------------------- +// Scope resolution +// --------------------------------------------------------------------------- + +function resolveScope(tokens: string[]): string { + const rawAction = (tokens[1] || '').toLowerCase(); + const scopeRaw = (tokens[2] || '').toLowerCase(); + if (scopeRaw) return SCOPE_ALIASES[scopeRaw] || scopeRaw; + + // Guess scope from action word + if (rawAction === 'mias' || rawAction === 'mías') return 'mis'; + return 'todos'; +} + +// --------------------------------------------------------------------------- +// Main handler +// --------------------------------------------------------------------------- + +export async function handleVer(context: Ctx): Promise { + const tokens = (context.message || '').trim().split(/\s+/); + const scope = resolveScope(tokens); + + if (scope === 'todos') { + return handleVerTodos(context.sender); + } + return handleVerMis(context.sender); } diff --git a/src/services/commands/handlers/web.ts b/src/services/commands/handlers/web.ts index 7145c5f..2f41249 100644 --- a/src/services/commands/handlers/web.ts +++ b/src/services/commands/handlers/web.ts @@ -22,7 +22,7 @@ export async function handleWeb(context: Ctx, deps: { db: Database }): Promise { const trimmed = (context.message || '').trim(); const tokens = trimmed.split(/\s+/); const rawAction = (tokens[1] || '').toLowerCase(); const action = ACTION_ALIASES[rawAction] || rawAction; - // Ayuda (no requiere DB) + // --- ayuda (no requiere DB) --- if (action === 'ayuda') { - // Métrica de alias "info" (compatibilidad con legacy) - try { - if (rawAction === 'info' || rawAction === '?') { - Metrics.inc('commands_alias_used_total', 1, { action: 'info' }); - } - } catch {} - try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} + if (rawAction === 'info' || rawAction === '?') trackAlias('info'); + trackOnboarding(); const isAdvanced = (tokens[2] || '').toLowerCase() === 'avanzada'; const message = isAdvanced ? getFullHelp() - : [getQuickHelp(), '', 'Ayuda avanzada: `/t ayuda avanzada`'].join('\n'); - return [{ - recipient: context.sender, - message - }]; + : [getQuickHelp(), '', 'Ayuda avanzada: `t ayuda avanzada`'].join('\n'); + return [{ recipient: context.sender, message }]; } - // Requiere db inyectada para poder operar (CommandService la inyecta) + // --- resto de comandos requieren DB --- const database = deps?.db; if (!database) return null; + // --- nueva --- if (action === 'nueva') { - try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} + trackOnboarding(); return await handleNueva(context as unknown as NuevaCtx, { db: database }); } + // --- ver --- if (action === 'ver') { - // Métricas de alias (mias/todas) como en el código actual - try { - if (rawAction === 'mias' || rawAction === 'mías') { - Metrics.inc('commands_alias_used_total', 1, { action: 'mias' }); - } else if (rawAction === 'todas' || rawAction === 'todos') { - Metrics.inc('commands_alias_used_total', 1, { action: 'todas' }); - } - } catch {} - - try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} + if (rawAction === 'mias' || rawAction === 'mías') trackAlias('mias'); + else if (rawAction === 'todas' || rawAction === 'todos') trackAlias('todas'); + trackOnboarding(); // En grupo: transición a DM if (isGroupId(context.groupId)) { try { Metrics.inc('ver_dm_transition_total'); } catch {} return [{ recipient: context.sender, - message: 'No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web' + message: 'No respondo en grupos. Tus tareas: t mias · Todas: t todas · Info: t info · Web: t web', }]; } return await handleVer(context as unknown as VerCtx); } - if (action === 'completar') { - try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} - return await handleCompletar(context as unknown as CompletarCtx); - } + // --- completar / tomar / soltar --- + if (action === 'completar') { trackOnboarding(); return await handleCompletar(context as unknown as CompletarCtx); } + if (action === 'tomar') { trackOnboarding(); return await handleTomar(context as unknown as TomarCtx); } + if (action === 'soltar') { trackOnboarding(); return await handleSoltar(context as unknown as SoltarCtx); } - if (action === 'tomar') { - try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} - return await handleTomar(context as unknown as TomarCtx); - } + // --- configurar / web --- + if (action === 'configurar') { trackOnboarding(); return handleConfigurar(context as unknown as ConfigurarCtx, { db: database }); } + if (action === 'web') { trackOnboarding(); return await handleWeb(context as unknown as WebCtx, { db: database }); } - if (action === 'soltar') { - try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} - return await handleSoltar(context as unknown as SoltarCtx); - } - - if (action === 'configurar') { - try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} - return handleConfigurar(context as unknown as ConfigurarCtx, { db: database }); - } - - if (action === 'web') { - try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} - return await handleWeb(context as unknown as WebCtx, { db: database }); - } - - // Desconocido → ayuda rápida - try { Metrics.inc('commands_unknown_total'); } catch {} - return [{ - recipient: context.sender, - message: buildUnknownHelp() - }]; + // --- desconocido --- + trackUknown(); + return [{ recipient: context.sender, message: buildUnknownHelp() }]; } diff --git a/src/services/commands/shared.ts b/src/services/commands/shared.ts index 30c50e1..a25f598 100644 --- a/src/services/commands/shared.ts +++ b/src/services/commands/shared.ts @@ -5,6 +5,8 @@ import { TaskService } from '../../tasks/service'; import { GroupSyncService } from '../group-sync'; +import { ICONS } from '../../utils/icons'; +import { codeId, formatDDMM } from '../../utils/formatting'; export const ACTION_ALIASES: Record = { 'n': 'nueva', @@ -51,12 +53,12 @@ export const SCOPE_ALIASES: Record = { 'yo': 'mis' }; -export const CTA_HELP = 'ℹ️ Tus tareas: `/t mias` · Todas: `/t todas` · Info: `/t info` · Web: `/t web`'; +export const CTA_HELP = 'ℹ️ Tus tareas: `t mias` · Todas: `t todas` · Info: `t info` · Web: `t web`'; /** * Formatea un Date a YYYY-MM-DD respetando TZ (por defecto Europe/Madrid). */ -export function ymdInTZ(d: Date, tz?: string): string { +function ymdInTZ(d: Date, tz?: string): string { const TZ = (tz && tz.trim()) || (process.env.TZ && process.env.TZ.trim()) || 'Europe/Madrid'; const parts = new Intl.DateTimeFormat('en-GB', { timeZone: TZ, @@ -123,3 +125,89 @@ export function enforceMembership(sender: string, task: { group_id?: string | nu return GroupSyncService.isUserActiveInGroup(sender, gid); } + +/** Formatea el sufijo de fecha de vencimiento para una respuesta de tarea. */ +export function formatDue(task: { due_date?: string | null } | null | undefined): string { + return task?.due_date ? ` — ${ICONS.date} ${formatDDMM(task.due_date)}` : ''; +} + +/** Construye el texto de resumen para procesamiento por lotes. */ +export function buildSummary(counts: Record, labels: Record): string { + const parts: string[] = []; + for (const key of Object.keys(counts)) { + if (counts[key]) parts.push(`${labels[key]} ${counts[key]}`); + } + return parts.length ? `Resumen: ${parts.join(', ')}.` : ''; +} + +/** Construye el fragmento " — ⚠️ 📅 DD/MM" o vacío para una tarea según si está vencida. */ +export function formatDatePart(due_date: string | null | undefined, todayYMD: string): string { + if (!due_date) return ''; + const overdue = due_date < todayYMD; + return ` — ${overdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(due_date)}`; +} + +/** Renderiza una línea de tarea con su código, descripción, fecha y dueño. */ +export function formatTaskLine( + t: { id: number; description?: string | null; due_date?: string | null; display_code?: number | null }, + owner: string, + todayYMD: string +): string { + const dc = (t as any).display_code as number | undefined; + const datePart = formatDatePart(t.due_date, todayYMD); + return `- ${codeId(t.id, dc)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`; +} + +/** Outcome of a single action in a multi-ID batch. */ +export interface BatchOutcome { + status: string; + line: string; +} + +/** + * Generic multi-ID batch handler. + * + * Iterates over IDs, calls `action` for each, collects outcomes, + * counts statuses, builds a summary and returns a single Msg. + */ +export function handleBatch( + sender: string, + ids: number[], + truncated: boolean, + action: (idInput: number, sender: string) => BatchOutcome, + statusLabels: Record, + usageMessage: string, +): { recipient: string; message: string }[] { + if (ids.length === 0) { + return [{ recipient: sender, message: usageMessage }]; + } + + const lines: string[] = []; + if (truncated) lines.push('⚠️ Se procesarán solo los primeros 10 IDs.'); + + const counts: Record = {}; + + for (const idInput of ids) { + const outcome = action(idInput, sender); + lines.push(outcome.line); + counts[outcome.status] = (counts[outcome.status] || 0) + 1; + } + + const summary = buildSummary(counts, statusLabels); + if (summary) { lines.push(''); lines.push(summary); } + + return [{ recipient: sender, message: lines.join('\n') }]; +} + +/** Resuelve un ID de entrada, carga la tarea y aplica membresía. Retorna error o {resolvedId, task}. */ +export function resolveAndValidate( + idInput: number, + sender: string +): { resolvedId: number; task: any } | { error: string } { + const resolvedId = resolveTaskIdFromInput(idInput); + if (!resolvedId) return { error: `⚠️ Tarea ${codeId(idInput)} no encontrada.` }; + const task = TaskService.getTaskById(resolvedId); + if (!task) return { error: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` }; + if (!enforceMembership(sender, task)) return { error: `🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).` }; + return { resolvedId, task }; +} diff --git a/src/services/contacts.ts b/src/services/contacts.ts index 7515d6b..6533cba 100644 --- a/src/services/contacts.ts +++ b/src/services/contacts.ts @@ -1,4 +1,4 @@ -import { normalizeWhatsAppId, isUserJid } from '../utils/whatsapp'; +import { normalizeWhatsAppId } from '../utils/whatsapp'; import { IdentityService } from './identity'; type CacheEntry = { @@ -6,8 +6,76 @@ type CacheEntry = { expiresAt: number; }; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const ID_FIELDS = ['id', 'jid', 'user', 'remoteJid', 'wid'] as const; + +function extractIdCandidate(rec: any): string | null { + for (const field of ID_FIELDS) { + const val = rec?.[field]; + if (typeof val === 'string' && val) return val; + } + // wid._serialized (nested) + const widSerialized = rec?.wid?._serialized; + if (typeof widSerialized === 'string' && widSerialized) return widSerialized; + return null; +} + +function tryLearnAlias(rec: any): void { + try { + const rawId = typeof rec?.id === 'string' ? rec.id : null; + 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 { /* best-effort */ } +} + +function extractRecordArrays(data: any): any[][] { + if (Array.isArray(data)) return [data]; + + if (!data || typeof data !== 'object') return []; + + const arrays: any[][] = []; + const knownKeys = ['contacts', 'chats', 'data', 'payload', 'updates', 'results']; + for (const key of knownKeys) { + if (Array.isArray(data[key])) arrays.push(data[key]); + } + + // Also try wrapping the single object + arrays.push([data]); + return arrays; +} + +/** Processes a single contact/channel record and caches its display name. */ +function processRecord(rec: any, cache: Map, ttlMs: number): void { + const idCandidate = extractIdCandidate(rec); + if (!idCandidate) return; + + tryLearnAlias(rec); + + // Skip groups + if (typeof idCandidate === 'string' && idCandidate.endsWith('@g.us')) return; + + const normalized = normalizeWhatsAppId(String(idCandidate)); + if (!normalized) return; + + const name = ContactsService.extractName(rec); + if (!name) return; + + cache.set(normalized, { name, expiresAt: Date.now() + ttlMs }); +} + +// --------------------------------------------------------------------------- +// ContactsService +// --------------------------------------------------------------------------- + export class ContactsService { - // Caché en memoria: userId(normalizado, solo dígitos) -> nombre private static readonly cache = new Map(); private static readonly TTL_MS = 12 * 60 * 60 * 1000; // 12h @@ -18,23 +86,12 @@ export class ContactsService { }; } - private static now() { - return Date.now(); - } - - private static extractName(obj: any): string | null { + static extractName(obj: any): string | null { if (!obj || typeof obj !== 'object') return null; - // Intentar múltiples campos posibles que suelen aparecer en catálogos/contactos const candidates = [ - obj.name, - obj.pushname, - obj.verifiedName, - obj.notify, - obj.shortName, - obj.displayName, - obj.formattedName, - obj.subject, // a veces para grupos; pero evitaremos grupos abajo - obj.contactName + obj.name, obj.pushname, obj.verifiedName, obj.notify, + obj.shortName, obj.displayName, obj.formattedName, obj.subject, + obj.contactName, ].filter(Boolean) as string[]; const name = candidates.find(v => typeof v === 'string' && v.trim().length > 0); return name ? name.trim() : null; @@ -42,47 +99,10 @@ export class ContactsService { static updateFromWebhook(data: any): void { try { - // Aceptar varios formatos posibles - const tryArrays: any[] = []; - if (Array.isArray(data)) { - tryArrays.push(data); - } else if (data && typeof data === 'object') { - for (const key of ['contacts', 'chats', 'data', 'payload', 'updates', 'results']) { - if (Array.isArray((data as any)[key])) { - tryArrays.push((data as any)[key]); - } - } - // Algunos eventos pueden traer una sola entrada - tryArrays.push([data]); - } - - for (const arr of tryArrays) { + const arrays = extractRecordArrays(data); + for (const arr of arrays) { for (const rec of arr) { - const idCandidate = rec?.id ?? rec?.jid ?? rec?.user ?? rec?.remoteJid ?? rec?.wid ?? rec?.wid?._serialized; - if (!idCandidate) continue; - - // Aprender mapping alias→número si vienen ambos (id con @lid y jid de usuario) - try { - const rawId = typeof rec?.id === 'string' ? rec.id : null; - 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 {} - - // Evitar grupos - if (typeof idCandidate === 'string' && idCandidate.endsWith('@g.us')) continue; - - const normalized = normalizeWhatsAppId(String(idCandidate)); - if (!normalized) continue; - - const name = this.extractName(rec); - if (!name) continue; - - this.cache.set(normalized, { name, expiresAt: this.now() + this.TTL_MS }); + processRecord(rec, this.cache, this.TTL_MS); } } } catch (e) { @@ -100,9 +120,7 @@ export class ContactsService { body: JSON.stringify(body), }); - if (!res.ok) { - return null; - } + if (!res.ok) return null; const data = await res.json().catch(() => null); const arrayCandidates: any[] = Array.isArray(data) @@ -118,13 +136,13 @@ export class ContactsService { : []; for (const rec of arrayCandidates) { - const recId = rec?.id ?? rec?.jid ?? rec?.user ?? rec?.remoteJid ?? rec?.wid ?? rec?.wid?._serialized; + const recId = extractIdCandidate(rec); if (!recId) continue; const norm = normalizeWhatsAppId(String(recId)); if (!norm || norm !== digitsId) continue; const name = this.extractName(rec); if (name) { - this.cache.set(digitsId, { name, expiresAt: this.now() + this.TTL_MS }); + this.cache.set(digitsId, { name, expiresAt: Date.now() + this.TTL_MS }); return name; } } @@ -135,7 +153,6 @@ export class ContactsService { } private static async maybeFetchFromApi(digitsId: string): Promise { - // Evitar llamadas de red en entorno de test if (process.env.NODE_ENV === 'test') return null; const baseUrl = process.env.EVOLUTION_API_URL; @@ -147,7 +164,6 @@ export class ContactsService { const jid = `${digitsId}@s.whatsapp.net`; const body = { where: { id: jid } }; - // Probar múltiples rutas conocidas según versión de Evolution const candidates = [ `${baseUrl}/chat/findContacts/${instance}`, `${baseUrl}/contact/findContacts/${instance}`, @@ -168,11 +184,8 @@ export class ContactsService { if (!normalized) return null; const cached = this.cache.get(normalized); - if (cached && cached.expiresAt > this.now()) { - return cached.name; - } + if (cached && cached.expiresAt > Date.now()) return cached.name; - // Intento de fetch perezoso const fetched = await this.maybeFetchFromApi(normalized); return fetched || null; } diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index 22bd7d0..c795319 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -5,824 +5,760 @@ import { normalizeWhatsAppId } from '../utils/whatsapp'; import { Metrics } from './metrics'; import { IdentityService } from './identity'; import { AllowedGroups } from './allowed-groups'; -import { ResponseQueue } from './response-queue'; import { toIsoSqlUTC } from '../utils/datetime'; import { publishGroupCoveragePrompt } from './onboarding'; import { fetchGroupsFromAPI as apiFetchGroups, fetchGroupMembersFromAPI as apiFetchMembers } from './group-sync/api'; import { upsertGroups as repoUpsertGroups } from './group-sync/repo'; import { cacheActiveGroups as computeActiveCache } from './group-sync/cache'; import { reconcileGroupMembers as reconcileMembers } from './group-sync/reconcile'; +import { detectGroupChanges, type GroupRow } from './group-sync/changes'; +import { handleDeactivatedGroups } from './group-sync/deactivation'; +import { + isSnapshotFresh as _isSnapshotFresh, + isUserActiveInGroup as _isUserActiveInGroup, + getActiveGroupIdsForUser as _getActiveGroupIdsForUser, + getFreshMemberGroupsForUser as _getFreshMemberGroupsForUser, +} from './group-sync/membership'; +import { + resolveInterval, + createSchedulerState, + startScheduler, + stopScheduler, + secondsUntilNextTick, + type SchedulerState, +} from './group-sync/scheduler'; -// In-memory cache for active groups -// const activeGroupsCache = new Map(); // groupId -> groupName - -/** - * Represents a group from the Evolution API response - * - * API returns an array of groups in this format: - * [ - * { - * id: string, // Group ID in @g.us format (primary key) - * subject: string, // Group name (displayed to users) - * linkedParent?: string, // Parent community ID if group belongs to one - * size?: number, // Current member count (unused in our system) - * creation?: number, // Unix timestamp of group creation (unused) - * desc?: string, // Group description text (unused) - * // ...other fields exist but are ignored by our implementation - * } - * ] - * - * Required fields for our implementation: - * - id (used as database primary key) - * - subject (used as group display name) - * - linkedParent (used for community filtering) - */ type EvolutionGroup = { - id: string; - subject: string; - linkedParent?: string; + id: string; + subject: string; + linkedParent?: string; }; +// --------------------------------------------------------------------------- +// Helpers (file-private) +// --------------------------------------------------------------------------- + +const activeQuery = `id, active, COALESCE(archived,0) AS archived, COALESCE(is_community,0) AS is_community, name`; + +function loadGroupRows(db: Database): GroupRow[] { + return db + .prepare(`SELECT ${activeQuery} FROM groups`) + .all() as GroupRow[]; +} + +function isCommunityFlag(g: any): boolean { + return !!( + g?.isCommunity || + g?.is_community || + g?.isCommunityAnnounce || + g?.is_community_announce + ); +} + +function updateCacheAndMetrics(db: Database, cache: Map): void { + computeActiveCacheInto(db, cache); + Metrics.set('active_groups', cache.size); + const mRow = db + .prepare(`SELECT COUNT(*) AS c FROM group_members WHERE is_active = 1`) + .get() as { c?: number } | undefined; + Metrics.set('active_members', Number(mRow?.c || 0)); +} + +function computeActiveCacheInto( + db: Database, + cache: Map +): void { + const map = computeActiveCache(db); + cache.clear(); + for (const [id, name] of map) cache.set(id, name); + console.log(`Cached ${cache.size} active groups`); +} + +// --------------------------------------------------------------------------- +// GroupSyncService +// --------------------------------------------------------------------------- + export class GroupSyncService { - // Static property for DB instance injection (with fallback to global locator) - private static _dbInstance: Database | null = null; - static get dbInstance(): Database { - return (this._dbInstance as Database) ?? getGlobalDb(); - } - static set dbInstance(value: Database) { - this._dbInstance = value; - } - - // In-memory cache for active groups (made public for tests) - public static readonly activeGroupsCache = new Map(); // groupId -> groupName - - /** - * Gets the sync interval duration in milliseconds. - * - * Priority: - * 1. GROUP_SYNC_INTERVAL_MS environment variable if set - * 2. Default 24 hour interval - * - * In development mode, enforces minimum 10 second interval - * to prevent accidental excessive API calls. - * - * @returns {number} Sync interval in milliseconds - */ - private static get SYNC_INTERVAL_MS(): number { - const interval = process.env.GROUP_SYNC_INTERVAL_MS - ? Number(process.env.GROUP_SYNC_INTERVAL_MS) - : 24 * 60 * 60 * 1000; // Default 24 hours - - // Ensure minimum 10 second interval in development - if (process.env.NODE_ENV === 'development' && interval < 10000) { - console.warn(`Sync interval too low (${interval}ms), using 10s minimum`); - return 10000; - } - return interval; - } - private static lastSyncAttempt = 0; - private static _groupsTimer: any = null; - private static _groupsSchedulerRunning = false; - private static _membersTimer: any = null; - private static _membersSchedulerRunning = false; - private static _groupsIntervalMs: number | null = null; - private static _groupsNextTickAt: number | null = null; - private static _lastChangedActive: string[] = []; - - static async syncGroups(force: boolean = false): Promise<{ added: number; updated: number }> { - if (!this.shouldSync(force)) { - return { added: 0, updated: 0 }; - } - const startedAt = Date.now(); - Metrics.inc('sync_runs_total'); - - let newlyActivatedIds: string[] = []; - try { - const groups = await this.fetchGroupsFromAPI(); - 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, COALESCE(archived,0) AS archived, COALESCE(is_community,0) AS is_community, name FROM groups').all() as Array<{ id: string; active: number; archived: number; is_community: number; name?: string | null }>; - console.log('ℹ️ Grupos en DB antes de upsert:', dbGroupsBefore); - - const result = await this.upsertGroups(groups); - - const dbGroupsAfter = this.dbInstance.prepare('SELECT id, active, COALESCE(archived,0) AS archived, COALESCE(is_community,0) AS is_community, name FROM groups').all() as Array<{ id: string; active: number; archived: number; is_community: number; name?: string | null }>; - 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) { - beforeMap.set(String(r.id), { active: Number(r.active || 0), archived: Number(r.archived || 0), is_community: Number(r.is_community || 0), name: r.name ? String(r.name) : null }); - } - const afterMap = new Map(); - for (const r of dbGroupsAfter) { - afterMap.set(String(r.id), { active: Number(r.active || 0), archived: Number(r.archived || 0), is_community: Number(r.is_community || 0), name: r.name ? String(r.name) : null }); - } - - // Determinar grupos que pasaron a estar activos (nuevos o reactivados) - const newlyActivatedLocal: string[] = []; - for (const [id, a] of afterMap.entries()) { - const b = beforeMap.get(id); - const becameActive = Number(a.active) === 1 && Number(a.archived) === 0 && Number(a.is_community || 0) === 0; - if (becameActive && (!b || Number(b.active) !== 1)) { - newlyActivatedLocal.push(id); - } - } - newlyActivatedIds = newlyActivatedLocal; - - 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 { this.fillMissingAllowedGroupLabels(groups); } catch {} - - // Actualizar métricas - this.cacheActiveGroups(); - Metrics.set('active_groups', this.activeGroupsCache.size); - const rowM = this.dbInstance.prepare(`SELECT COUNT(*) AS c FROM group_members WHERE is_active = 1`).get() as { c?: number } | undefined; - Metrics.set('active_members', Number(rowM?.c || 0)); - Metrics.set('last_sync_timestamp_seconds', Math.floor(Date.now() / 1000)); - Metrics.set('last_sync_ok', 1); - // Duración opcional - Metrics.set('last_sync_duration_ms', Date.now() - (typeof startedAt !== 'undefined' ? startedAt : Date.now())); - - // Guardar lista de grupos que han pasado a activos para consumo externo - this._lastChangedActive = Array.isArray(newlyActivatedIds) ? newlyActivatedIds : []; - return result; - } catch (error) { - console.error('Group sync failed:', error); - Metrics.inc('sync_errors_total'); - Metrics.set('last_sync_ok', 0); - throw error; - } finally { - this.lastSyncAttempt = Date.now(); - } - } - - private static shouldSync(force: boolean = false): boolean { - if (force) return true; - const timeSinceLastSync = Date.now() - this.lastSyncAttempt; - const shouldSync = timeSinceLastSync > this.SYNC_INTERVAL_MS; - - if (!shouldSync) { - const nextSyncIn = this.SYNC_INTERVAL_MS - timeSinceLastSync; - console.debug(`Next sync available in ${Math.round(nextSyncIn / 1000)} seconds`); - } - - return shouldSync; - } - - private static async fetchGroupsFromAPI(): Promise { - return await apiFetchGroups() as unknown as EvolutionGroup[]; - } - - private static cacheActiveGroups(): void { - const map = computeActiveCache(this.dbInstance); - this.activeGroupsCache.clear(); - for (const [id, name] of map.entries()) { - this.activeGroupsCache.set(id, name); - } - console.log(`Cached ${this.activeGroupsCache.size} active groups`); - } - - // Rellena labels faltantes en allowed_groups a partir de los grupos devueltos por la API. - private static fillMissingAllowedGroupLabels(allGroups: EvolutionGroup[]): number { - try { - 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; - nameById.set(String(g.id), name); - } - if (nameById.size === 0) return 0; - - const rows = this.dbInstance.prepare(` - SELECT group_id AS id - FROM allowed_groups - WHERE label IS NULL OR TRIM(label) = '' - `).all() as Array<{ id: string }>; - if (!rows || rows.length === 0) return 0; - - let filled = 0; - for (const r of rows) { - const id = r?.id ? String(r.id) : null; - if (!id) continue; - const label = nameById.get(id); - if (label) { - try { AllowedGroups.upsertPending(id, label, null); } catch {} - filled++; - } - } - if (filled > 0) { - try { Metrics.inc('allowed_groups_labels_filled_total', filled); } catch {} - console.log(`ℹ️ Rellenadas ${filled} labels faltantes en allowed_groups`); - } - return filled; - } catch (e) { - console.warn('⚠️ No se pudieron rellenar labels faltantes en allowed_groups:', e); - return 0; - } - } - - private static getActiveGroupsCount(): number { - 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() as { count?: number } | undefined; - return Number(result?.count || 0); - } - - static async checkInitialGroups(): Promise { - const count = this.getActiveGroupsCount(); - if (count > 0) { - this.cacheActiveGroups(); - console.log(`✅ Using ${count} existing groups from database`); - return; - } - - const communityId = process.env.WHATSAPP_COMMUNITY_ID; - if (!communityId) { - console.log('ℹ️ WHATSAPP_COMMUNITY_ID no definido - mostrando comunidades disponibles'); - try { - const allGroups = await this.fetchGroupsFromAPI(); - const communities = allGroups.filter(g => g.linkedParent); - - if (communities.length === 0) { - console.log('❌ No se encontraron comunidades (grupos con linkedParent)'); - } else { - console.log('\n📋 Comunidades disponibles (copia el ID completo):'); - console.log('='.repeat(80)); - console.log('Nombre'.padEnd(30), 'ID Comunidad'); - console.log('-'.repeat(30), '-'.repeat(48)); - communities.forEach(c => { - console.log(c.subject.padEnd(30), c.id); - }); - console.log('='.repeat(80)); - console.log('⚠️ ATENCIÓN: Estos IDs son sensibles. No los compartas públicamente.'); - console.log(`\n⏳ El proceso terminará automáticamente en 120 segundos...`); - - // Cuenta regresiva de 120 segundos - await new Promise(resolve => { - setTimeout(resolve, 120000); - const interval = setInterval(() => { - const remaining = Math.ceil((120000 - (Date.now() - startTime)) / 1000); - process.stdout.write(`\r⏳ Tiempo restante: ${remaining}s `); - }, 1000); - const startTime = Date.now(); - }); - - console.log('\n\n✅ Listado completado. Por favor configura WHATSAPP_COMMUNITY_ID'); - } - process.exit(0); - } catch (error) { - console.error('❌ Error al obtener comunidades:', error instanceof Error ? error.message : error); - process.exit(1); - } - } - - console.log('⚠️ No groups found in database - performing initial sync'); - try { - const { added } = await this.syncGroups(); - if (added === 0) { - throw new Error('Initial group sync completed but no groups were added'); - } - this.cacheActiveGroups(); - console.log(`✅ Initial group sync completed - added ${added} groups`); - } catch (error) { - console.error('❌ Critical: Initial group sync failed - no groups available'); - console.error(error instanceof Error ? error.message : 'Unknown error'); - process.exit(1); - } - } - - private static async upsertGroups(groups: EvolutionGroup[]): Promise<{ added: number; updated: number }> { - try { - return await repoUpsertGroups(this.dbInstance, groups as any); - } catch (error) { - console.error('Error in upsertGroups:', error); - throw error; - } - } - - /** - * Checks if a given group ID is active based on the in-memory cache. - * - * @param groupId The group ID to check (e.g., '123456789@g.us'). - * @returns True if the group is active, false otherwise. - */ - static isGroupActive(groupId: string): boolean { - return this.activeGroupsCache.has(groupId); - } - - // Fetch members for a single group from Evolution API. Uses a robust parser to accept multiple payload shapes. - private static async fetchGroupMembersFromAPI(groupId: string): Promise> { - return await apiFetchMembers(groupId); - } - - /** - * Upsert optimista de la membresía a partir de un mensaje recibido en el grupo. - * Marca al usuario como activo y actualiza last_seen_at sin consultar Evolution API. - */ - static upsertMemberSeen(groupId: string, userId: string, nowIso?: string): void { - if (!groupId || !userId) return; - const now = nowIso || toIsoSqlUTC(new Date()); - try { ensureUserExists(userId, this.dbInstance); } catch {} - this.dbInstance.prepare(` - INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) - VALUES (?, ?, 0, 1, ?, ?) - ON CONFLICT(group_id, user_id) DO UPDATE SET - is_active = 1, - last_seen_at = excluded.last_seen_at - `).run(groupId, userId, now, now); - } - - /** - * Reconciles current DB membership state for a group with a fresh snapshot. - * Idempotente y atómico por grupo. - */ - static reconcileGroupMembers(groupId: string, snapshot: Array<{ userId: string; isAdmin: boolean }>, nowIso?: string): { added: number; updated: number; deactivated: number } { - const res = reconcileMembers(this.dbInstance, groupId, snapshot, nowIso || toIsoSqlUTC(new Date())); - try { this.computeAndPublishAliasCoverage(groupId); } catch {} - return res; - } - - 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 {} - - // Delegar publicación del prompt de onboarding a OnboardingService (consulta DB directamente) - try { publishGroupCoveragePrompt(this.dbInstance, groupId, ratio); } catch {} - } 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. - */ - static async syncMembersForActiveGroups(): Promise<{ groups: number; added: number; updated: number; deactivated: number }> { - if (process.env.NODE_ENV === 'test') { - return { groups: 0, added: 0, updated: 0, deactivated: 0 }; - } - // ensure cache is populated - if (this.activeGroupsCache.size === 0) { - this.cacheActiveGroups(); - } - - // Etapa 3: gating también en el scheduler masivo - const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); - const enforce = mode === 'enforce'; - if (enforce) { - // no-op - } - - let groups = 0, added = 0, updated = 0, deactivated = 0; - for (const [groupId] of this.activeGroupsCache.entries()) { - try { - if (enforce) { - try { - if (!AllowedGroups.isAllowed(groupId)) { - // Saltar grupos no permitidos en modo enforce - try { Metrics.inc('sync_skipped_group_total'); } catch {} - continue; - } - } catch { - // Si falla el check, no bloquear el grupo - } - } - - const snapshot = await this.fetchGroupMembersFromAPI(groupId); - const res = this.reconcileGroupMembers(groupId, snapshot); - groups++; - added += res.added; - updated += res.updated; - deactivated += res.deactivated; - } catch (e) { - console.error(`❌ Failed to sync members for group ${groupId}:`, e instanceof Error ? e.message : String(e)); - } - } - console.log('ℹ️ Members sync summary:', { groups, added, updated, deactivated }); - return { groups, added, updated, deactivated }; - } - - static async syncMembersForGroups(ids: string[]): Promise<{ groups: number; added: number; updated: number; deactivated: number }> { - if (process.env.NODE_ENV === 'test') { - return { groups: 0, added: 0, updated: 0, deactivated: 0 }; - } - if (!Array.isArray(ids) || ids.length === 0) { - return { groups: 0, added: 0, updated: 0, deactivated: 0 }; - } - - const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); - const enforce = mode === 'enforce'; - if (enforce) { - // no-op - } - - let groups = 0, added = 0, updated = 0, deactivated = 0; - for (const groupId of ids) { - try { - if (enforce) { - try { - if (!AllowedGroups.isAllowed(groupId)) { - try { Metrics.inc('sync_skipped_group_total'); } catch {} - continue; - } - } catch {} - } - const snapshot = await this.fetchGroupMembersFromAPI(groupId); - const res = this.reconcileGroupMembers(groupId, snapshot); - groups++; - added += res.added; - updated += res.updated; - deactivated += res.deactivated; - } catch (e) { - console.error(`❌ Failed to sync members for group ${groupId}:`, e instanceof Error ? e.message : String(e)); - } - } - console.log('ℹ️ Targeted members sync summary:', { groups, added, updated, deactivated }); - return { groups, added, updated, deactivated }; - } - - public static refreshActiveGroupsCache(): void { - this.cacheActiveGroups(); - } - - public static getLastChangedActive(): string[] { - try { - return Array.from(this._lastChangedActive || []); - } catch { - return []; - } - } - - public static startGroupsScheduler(): void { - if (process.env.NODE_ENV === 'test') return; - if (this._groupsSchedulerRunning) return; - this._groupsSchedulerRunning = true; - - // Intervalo de grupos configurable; mínimo 10s en desarrollo - let interval = Number(process.env.GROUP_SYNC_INTERVAL_MS); - if (!Number.isFinite(interval) || interval <= 0) { - interval = 24 * 60 * 60 * 1000; // 24h por defecto - } - if (process.env.NODE_ENV === 'development' && interval < 10000) { - interval = 10000; - } - this._groupsIntervalMs = interval; - this._groupsNextTickAt = Date.now() + interval; - - this._groupsTimer = setInterval(() => { - // Programar el siguiente tick antes de ejecutar la sincronización - this._groupsNextTickAt = Date.now() + (this._groupsIntervalMs ?? interval); - this.syncGroups().catch(err => { - console.error('❌ Groups scheduler run error:', err); - }); - }, interval); - } - - public static stopGroupsScheduler(): void { - this._groupsSchedulerRunning = false; - if (this._groupsTimer) { - clearInterval(this._groupsTimer); - this._groupsTimer = null; - } - this._groupsIntervalMs = null; - this._groupsNextTickAt = null; - } - - public static getSecondsUntilNextGroupSync(nowMs: number = Date.now()): number | null { - const next = this._groupsNextTickAt; - if (next == null) return null; - const secs = (next - nowMs) / 1000; - return secs > 0 ? secs : 0; - } - - public static startMembersScheduler(): void { - if (process.env.NODE_ENV === 'test') return; - if (this._membersSchedulerRunning) return; - this._membersSchedulerRunning = true; - - // Intervalo por defecto 6h; configurable por env; mínimo 10s en desarrollo - const raw = process.env.GROUP_MEMBERS_SYNC_INTERVAL_MS; - let interval = Number.isFinite(Number(raw)) && Number(raw) > 0 ? Number(raw) : 6 * 60 * 60 * 1000; - if (process.env.NODE_ENV === 'development' && interval < 10000) { - interval = 10000; - } - - this._membersTimer = setInterval(() => { - this.syncMembersForActiveGroups().catch(err => { - console.error('❌ Members scheduler run error:', err); - }); - }, interval); - } - - public static stopMembersScheduler(): void { - this._membersSchedulerRunning = false; - if (this._membersTimer) { - clearInterval(this._membersTimer); - this._membersTimer = null; - } - } - - // ===== Helpers de membresía y snapshot (Etapa 3) ===== - - private static get MAX_SNAPSHOT_AGE_MS(): number { - const raw = Number(process.env.MAX_MEMBERS_SNAPSHOT_AGE_MS); - return Number.isFinite(raw) && raw > 0 ? raw : 24 * 60 * 60 * 1000; // 24h por defecto - } - - /** - * Devuelve true si la snapshot de un grupo es "fresca" según MAX_SNAPSHOT_AGE_MS. - * Considera no fresca si no hay registro/fecha. - */ - public static isSnapshotFresh(groupId: string, nowMs: number = Date.now()): boolean { - try { - const row = this.dbInstance.prepare(`SELECT last_verified FROM groups WHERE id = ?`).get(groupId) as { last_verified?: string | null } | undefined; - const lv = row?.last_verified ? String(row.last_verified) : null; - if (!lv) return false; - // Persistimos 'YYYY-MM-DD HH:MM:SS[.mmm]'. Convertimos a ISO-like para Date.parse - const iso = lv.includes('T') ? lv : (lv.replace(' ', 'T') + 'Z'); - const ms = Date.parse(iso); - if (!Number.isFinite(ms)) return false; - return (nowMs - ms) <= this.MAX_SNAPSHOT_AGE_MS; - } catch { - return false; - } - } - - /** - * ¿El usuario figura como miembro activo del grupo? - */ - public static isUserActiveInGroup(userId: string, groupId: string): boolean { - if (!userId || !groupId) return false; - const row = this.dbInstance.prepare(` - SELECT 1 - FROM group_members - WHERE group_id = ? AND user_id = ? AND is_active = 1 - LIMIT 1 - `).get(groupId, userId); - return !!row; - } - - /** - * Devuelve todos los group_ids activos donde el usuario figura activo. - * Filtra también por grupos activos en la tabla groups. - */ - public static getActiveGroupIdsForUser(userId: string): string[] { - if (!userId) return []; - const rows = this.dbInstance.prepare(` - SELECT gm.group_id AS id - 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 Array<{ id: string }>; - const set = new Set(); - for (const r of rows) { - if (r?.id) set.add(String(r.id)); - } - return Array.from(set); - } - - /** - * Devuelve los group_ids donde el usuario es miembro activo y cuya snapshot es fresca. - */ - public static getFreshMemberGroupsForUser(userId: string): string[] { - const gids = this.getActiveGroupIdsForUser(userId); - return gids.filter(gid => this.isSnapshotFresh(gid)); - } - - /** - * Asegura un registro de grupo activo en la base de datos (upsert idempotente). - * Si no existe, lo crea con active=1. Si existe y estaba inactivo, lo reactiva. - * Puede actualizar el nombre si se proporciona. - */ - public static ensureGroupExists(groupId: string, name?: string | null): { created: boolean; reactivated: boolean; updatedName: boolean } { - if (!groupId) return { created: false, reactivated: false, updatedName: false }; - let created = false, reactivated = false, updatedName = false; - - this.dbInstance.transaction(() => { - const row = this.dbInstance.prepare(`SELECT id, active, name FROM groups WHERE id = ?`).get(groupId) as any; - if (!row) { - const community = process.env.WHATSAPP_COMMUNITY_ID || ''; - this.dbInstance.prepare(` - INSERT INTO groups (id, community_id, name, active, last_verified) - VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP) - `).run(groupId, community, name || null); - created = true; - } else { - // Reactivar si estaba inactivo y opcionalmente actualizar nombre - const shouldUpdateName = (typeof name === 'string' && name.trim().length > 0 && name !== row.name); - if (row.active !== 1 || shouldUpdateName) { - this.dbInstance.prepare(` - UPDATE groups - SET active = 1, - name = COALESCE(?, name), - last_verified = CURRENT_TIMESTAMP - WHERE id = ? - `).run(shouldUpdateName ? name : null, groupId); - reactivated = row.active !== 1; - updatedName = shouldUpdateName; - } - } - })(); - - // Actualizar caché - this.cacheActiveGroups(); - Metrics.set('active_groups', this.activeGroupsCache.size); - - return { created, reactivated, updatedName }; - } - - /** - * Asegura tener el nombre/label de un grupo (cache/DB/API) y lo persiste tanto en groups como en allowed_groups. - * Devuelve el nombre si se pudo resolver, o null en caso contrario. - */ - public static async ensureGroupLabelAndName(groupId: string): Promise { - try { - if (!groupId) return null; - - // 1) Cache en memoria - const cached = this.activeGroupsCache.get(groupId); - if (cached && cached.trim()) { - try { this.ensureGroupExists(groupId, cached); } catch {} - try { AllowedGroups.upsertPending(groupId, cached, null); } catch {} - this.cacheActiveGroups(); - return cached; - } - - // 2) DB (tabla groups) - try { - const row = this.dbInstance.prepare('SELECT name FROM groups WHERE id = ?').get(groupId) as { name?: string | null } | undefined; - const name = row?.name ? String(row.name).trim() : ''; - if (name) { - try { this.ensureGroupExists(groupId, name); } catch {} - try { AllowedGroups.upsertPending(groupId, name, null); } catch {} - this.cacheActiveGroups(); - return name; - } - } catch {} - - // 3) API (evitar en tests) - if (process.env.NODE_ENV !== 'test') { - const groups = await this.fetchGroupsFromAPI(); - const g = groups.find((gg) => gg?.id === groupId); - const subject = g?.subject ? String(g.subject).trim() : ''; - if (subject) { - try { this.ensureGroupExists(groupId, subject); } catch {} - try { AllowedGroups.upsertPending(groupId, subject, null); } catch {} - this.cacheActiveGroups(); - return subject; - } - } - - return null; - } catch { - return null; - } - } - - /** - * Sincroniza miembros para un grupo concreto (útil tras detectar un grupo nuevo). - */ - public static async syncMembersForGroup(groupId: string): Promise<{ added: number; updated: number; deactivated: number }> { - // Gating en modo 'enforce': solo sincronizar miembros para grupos permitidos - try { - const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); - if (mode === 'enforce') { - try { - // no-op - if (!AllowedGroups.isAllowed(groupId)) { - try { Metrics.inc('sync_skipped_group_total'); } catch {} - return { added: 0, updated: 0, deactivated: 0 }; - } - } catch { - // Si el check falla, seguimos sin bloquear - } - } - } catch {} - - try { - // Asegurar existencia del grupo en DB (FKs) antes de reconciliar - this.ensureGroupExists(groupId); - const snapshot = await this.fetchGroupMembersFromAPI(groupId); - return this.reconcileGroupMembers(groupId, snapshot); - } catch (e) { - console.error(`❌ Failed to sync members for group ${groupId}:`, e instanceof Error ? e.message : String(e)); - return { added: 0, updated: 0, deactivated: 0 }; - } - } - - /** - * Devuelve los IDs de usuario activos del grupo, filtrados a dígitos puros con longitud < 14. - * No devuelve duplicados. - */ - public static listActiveMemberIds(groupId: string): string[] { - if (!groupId) return []; - 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 out = new Set(); - for (const r of rows) { - const uid = String(r.user_id || '').trim(); - if (/^\d+$/.test(uid) && uid.length < 14) { - out.add(uid); - } - } - return Array.from(out); - } catch { - return []; - } - } + // ---- DB instance injection ---- + private static _dbInstance: Database | null = null; + static get dbInstance(): Database { + return (this._dbInstance as Database) ?? getGlobalDb(); + } + static set dbInstance(value: Database) { + this._dbInstance = value; + } + + // ---- In-memory cache ---- + public static readonly activeGroupsCache = new Map(); + + // ---- Scheduler state ---- + private static lastSyncAttempt = 0; + private static _lastChangedActive: string[] = []; + private static _groupsScheduler: SchedulerState = createSchedulerState(); + private static _membersScheduler: SchedulerState = createSchedulerState(); + + // =================================================================== + // Sync groups + // =================================================================== + + static async syncGroups( + force = false + ): Promise<{ added: number; updated: number }> { + if (!this.shouldSync(force)) return { added: 0, updated: 0 }; + + const startedAt = Date.now(); + Metrics.inc('sync_runs_total'); + + try { + const groups = (await this.fetchGroupsFromAPI()) as unknown as EvolutionGroup[]; + console.log( + 'ℹ️ Sin filtrar por comunidad (modo multicomunidad). Total grupos:', + groups.length + ); + + const before = loadGroupRows(this.dbInstance); + const result = await repoUpsertGroups(this.dbInstance, groups as any); + const after = loadGroupRows(this.dbInstance); + + // Detect changes + const { newlyActivated, newlyDeactivated } = + detectGroupChanges(before, after); + this._lastChangedActive = newlyActivated; + + // Handle deactivated groups + await handleDeactivatedGroups(this.dbInstance, newlyDeactivated); + + // Fill missing labels + try { + this.fillMissingAllowedGroupLabels(groups); + } catch {} + + // Update cache & metrics + updateCacheAndMetrics(this.dbInstance, this.activeGroupsCache); + Metrics.set('last_sync_timestamp_seconds', Math.floor(Date.now() / 1000)); + Metrics.set('last_sync_ok', 1); + Metrics.set('last_sync_duration_ms', Date.now() - startedAt); + + return result; + } catch (error) { + console.error('Group sync failed:', error); + Metrics.inc('sync_errors_total'); + Metrics.set('last_sync_ok', 0); + throw error; + } finally { + this.lastSyncAttempt = Date.now(); + } + } + + private static shouldSync(force = false): boolean { + if (force) return true; + const elapsed = Date.now() - this.lastSyncAttempt; + const interval = resolveInterval( + 'GROUP_SYNC_INTERVAL_MS', + 24 * 60 * 60 * 1000 + ); + if (elapsed <= interval) { + console.debug( + `Next sync available in ${Math.round((interval - elapsed) / 1000)} seconds` + ); + return false; + } + return true; + } + + // =================================================================== + // Initial group check + // =================================================================== + + static async checkInitialGroups(): Promise { + const count = this.getActiveGroupsCount(); + if (count > 0) { + computeActiveCacheInto(this.dbInstance, this.activeGroupsCache); + console.log(`✅ Using ${count} existing groups from database`); + return; + } + + const communityId = process.env.WHATSAPP_COMMUNITY_ID; + if (!communityId) { + await this.listAndExitWithCommunityInfo(); + return; // unreachable — listAndExitWithCommunityInfo calls process.exit + } + + console.log('⚠️ No groups found in database - performing initial sync'); + try { + const { added } = await this.syncGroups(); + if (added === 0) { + throw new Error( + 'Initial group sync completed but no groups were added' + ); + } + computeActiveCacheInto(this.dbInstance, this.activeGroupsCache); + console.log(`✅ Initial group sync completed - added ${added} groups`); + } catch (error) { + console.error( + '❌ Critical: Initial group sync failed - no groups available' + ); + console.error( + error instanceof Error ? error.message : 'Unknown error' + ); + process.exit(1); + } + } + + private static async listAndExitWithCommunityInfo(): Promise { + console.log( + 'ℹ️ WHATSAPP_COMMUNITY_ID no definido - mostrando comunidades disponibles' + ); + try { + const allGroups = (await this.fetchGroupsFromAPI()) as unknown as EvolutionGroup[]; + const communities = allGroups.filter(g => g.linkedParent); + + if (communities.length === 0) { + console.log( + '❌ No se encontraron comunidades (grupos con linkedParent)' + ); + } else { + console.log( + '\n📋 Comunidades disponibles (copia el ID completo):' + ); + console.log('='.repeat(80)); + console.log('Nombre'.padEnd(30), 'ID Comunidad'); + console.log('-'.repeat(30), '-'.repeat(48)); + for (const c of communities) { + console.log(c.subject.padEnd(30), c.id); + } + console.log('='.repeat(80)); + console.log( + '⚠️ ATENCIÓN: Estos IDs son sensibles. No los compartas públicamente.' + ); + console.log( + `\n⏳ El proceso terminará automáticamente en 120 segundos...` + ); + + const start = Date.now(); + await new Promise(resolve => { + const timer = setInterval(() => { + const remaining = Math.ceil((120_000 - (Date.now() - start)) / 1000); + process.stdout.write(`\r⏳ Tiempo restante: ${remaining}s `); + }, 1000); + setTimeout(() => { + clearInterval(timer); + resolve(); + }, 120_000); + }); + + console.log( + '\n\n✅ Listado completado. Por favor configura WHATSAPP_COMMUNITY_ID' + ); + } + process.exit(0); + } catch (error) { + console.error( + '❌ Error al obtener comunidades:', + error instanceof Error ? error.message : error + ); + process.exit(1); + } + } + + private static getActiveGroupsCount(): number { + const row = this.dbInstance + .prepare( + `SELECT COUNT(*) AS count + FROM groups + WHERE active = TRUE + AND COALESCE(is_community,0) = 0 + AND COALESCE(archived,0) = 0` + ) + .get() as { count?: number } | undefined; + return Number(row?.count || 0); + } + + // =================================================================== + // In-memory cache helpers + // =================================================================== + + static isGroupActive(groupId: string): boolean { + return this.activeGroupsCache.has(groupId); + } + + static refreshActiveGroupsCache(): void { + computeActiveCacheInto(this.dbInstance, this.activeGroupsCache); + } + + static getLastChangedActive(): string[] { + return Array.from(this._lastChangedActive || []); + } + + // =================================================================== + // Label filling + // =================================================================== + + private static fillMissingAllowedGroupLabels( + allGroups: EvolutionGroup[] + ): number { + try { + if (!Array.isArray(allGroups) || allGroups.length === 0) return 0; + + const nameById = new Map(); + for (const g of allGroups) { + if (isCommunityFlag(g) || !g?.id) continue; + const name = String(g.subject || '').trim(); + if (name) nameById.set(String(g.id), name); + } + if (nameById.size === 0) return 0; + + const rows = this.dbInstance + .prepare( + `SELECT group_id AS id + FROM allowed_groups + WHERE label IS NULL OR TRIM(label) = ''` + ) + .all() as Array<{ id: string }>; + if (!rows || rows.length === 0) return 0; + + let filled = 0; + for (const r of rows) { + const id = r?.id ? String(r.id) : null; + if (!id) continue; + const label = nameById.get(id); + if (label) { + try { + AllowedGroups.upsertPending(id, label, null); + } catch {} + filled++; + } + } + if (filled > 0) { + Metrics.inc('allowed_groups_labels_filled_total', filled); + console.log( + `ℹ️ Rellenadas ${filled} labels faltantes en allowed_groups` + ); + } + return filled; + } catch (e) { + console.warn( + '⚠️ No se pudieron rellenar labels faltantes en allowed_groups:', + e + ); + return 0; + } + } + + // =================================================================== + // Upsert helpers + // =================================================================== + + private static async upsertGroups( + groups: EvolutionGroup[] + ): Promise<{ added: number; updated: number }> { + return repoUpsertGroups(this.dbInstance, groups as any); + } + + static upsertMemberSeen( + groupId: string, + userId: string, + nowIso?: string + ): void { + if (!groupId || !userId) return; + const now = nowIso || toIsoSqlUTC(new Date()); + try { + ensureUserExists(userId, this.dbInstance); + } catch {} + this.dbInstance + .prepare( + `INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) + VALUES (?, ?, 0, 1, ?, ?) + ON CONFLICT(group_id, user_id) DO UPDATE SET + is_active = 1, + last_seen_at = excluded.last_seen_at` + ) + .run(groupId, userId, now, now); + } + + // =================================================================== + // Member reconciliation + // =================================================================== + + static reconcileGroupMembers( + groupId: string, + snapshot: Array<{ userId: string; isAdmin: boolean }>, + nowIso?: string + ): { added: number; updated: number; deactivated: number } { + const res = reconcileMembers( + this.dbInstance, + groupId, + snapshot, + nowIso || toIsoSqlUTC(new Date()) + ); + try { + this.computeAndPublishAliasCoverage(groupId); + } catch {} + return res; + } + + 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) { + Metrics.set('alias_coverage_ratio', 1, { group_id: groupId }); + 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) + ); + Metrics.set('alias_coverage_ratio', ratio, { group_id: groupId }); + + try { + publishGroupCoveragePrompt(this.dbInstance, groupId, ratio); + } catch {} + } catch (e) { + console.warn( + '⚠️ No se pudo calcular alias_coverage_ratio para', + groupId, + e + ); + } + } + + // =================================================================== + // API fetch wrappers (kept as class methods for test stubbing) + // =================================================================== + + private static async fetchGroupsFromAPI(): Promise { + return (await apiFetchGroups()) as unknown as EvolutionGroup[]; + } + + private static cacheActiveGroups(): void { + computeActiveCacheInto(this.dbInstance, this.activeGroupsCache); + } + + // =================================================================== + // Members sync loop + // =================================================================== + + private static async fetchGroupMembersFromAPI( + groupId: string + ): Promise> { + return apiFetchMembers(groupId); + } + + private static async _syncMembersLoop( + ids: Iterable, + label: string + ): Promise<{ + groups: number; + added: number; + updated: number; + deactivated: number; + }> { + if (process.env.NODE_ENV === 'test') { + return { groups: 0, added: 0, updated: 0, deactivated: 0 }; + } + + const enforce = + String(process.env.GROUP_GATING_MODE || 'off').toLowerCase() === + 'enforce'; + + let groups = 0, + added = 0, + updated = 0, + deactivated = 0; + + for (const groupId of ids) { + try { + if (enforce && !this.isGroupAllowedSafe(groupId)) continue; + const snapshot = await this.fetchGroupMembersFromAPI(groupId); + const res = this.reconcileGroupMembers(groupId, snapshot); + groups++; + added += res.added; + updated += res.updated; + deactivated += res.deactivated; + } catch (e) { + console.error( + `❌ Failed to sync members for group ${groupId}:`, + e instanceof Error ? e.message : String(e) + ); + } + } + console.log(`ℹ️ ${label}:`, { groups, added, updated, deactivated }); + return { groups, added, updated, deactivated }; + } + + private static isGroupAllowedSafe(groupId: string): boolean { + try { + if (!AllowedGroups.isAllowed(groupId)) { + Metrics.inc('sync_skipped_group_total'); + return false; + } + } catch {} + return true; + } + + static async syncMembersForActiveGroups(): Promise<{ + groups: number; + added: number; + updated: number; + deactivated: number; + }> { + if (process.env.NODE_ENV === 'test') { + return { groups: 0, added: 0, updated: 0, deactivated: 0 }; + } + if (this.activeGroupsCache.size === 0) { + computeActiveCacheInto(this.dbInstance, this.activeGroupsCache); + } + return this._syncMembersLoop( + Array.from(this.activeGroupsCache.keys()), + 'Members sync summary' + ); + } + + static async syncMembersForGroups( + ids: string[] + ): Promise<{ + groups: number; + added: number; + updated: number; + deactivated: number; + }> { + if (process.env.NODE_ENV === 'test') { + return { groups: 0, added: 0, updated: 0, deactivated: 0 }; + } + if (!Array.isArray(ids) || ids.length === 0) { + return { groups: 0, added: 0, updated: 0, deactivated: 0 }; + } + return this._syncMembersLoop(ids, 'Targeted members sync summary'); + } + + // =================================================================== + // Schedulers + // =================================================================== + + static startGroupsScheduler(): void { + if (process.env.NODE_ENV === 'test') return; + const interval = resolveInterval( + 'GROUP_SYNC_INTERVAL_MS', + 24 * 60 * 60 * 1000 + ); + startScheduler( + this._groupsScheduler, + interval, + async () => { await this.syncGroups(); }, + 'Groups' + ); + } + + static stopGroupsScheduler(): void { + stopScheduler(this._groupsScheduler); + } + + static getSecondsUntilNextGroupSync( + nowMs: number = Date.now() + ): number | null { + return secondsUntilNextTick(this._groupsScheduler, nowMs); + } + + static startMembersScheduler(): void { + if (process.env.NODE_ENV === 'test') return; + const interval = resolveInterval( + 'GROUP_MEMBERS_SYNC_INTERVAL_MS', + 6 * 60 * 60 * 1000 + ); + startScheduler( + this._membersScheduler, + interval, + async () => { await this.syncMembersForActiveGroups(); }, + 'Members' + ); + } + + static stopMembersScheduler(): void { + stopScheduler(this._membersScheduler); + } + + // =================================================================== + // Membership queries (thin wrappers) + // =================================================================== + + static isSnapshotFresh( + groupId: string, + nowMs: number = Date.now() + ): boolean { + return _isSnapshotFresh(this.dbInstance, groupId, nowMs); + } + + static isUserActiveInGroup(userId: string, groupId: string): boolean { + return _isUserActiveInGroup(this.dbInstance, userId, groupId); + } + + static getActiveGroupIdsForUser(userId: string): string[] { + return _getActiveGroupIdsForUser(this.dbInstance, userId); + } + + static getFreshMemberGroupsForUser(userId: string): string[] { + return _getFreshMemberGroupsForUser(this.dbInstance, userId); + } + + // =================================================================== + // Group existence & label resolution + // =================================================================== + + static ensureGroupExists( + groupId: string, + name?: string | null + ): { created: boolean; reactivated: boolean; updatedName: boolean } { + if (!groupId) + return { created: false, reactivated: false, updatedName: false }; + + let created = false, + reactivated = false, + updatedName = false; + + this.dbInstance.transaction(() => { + const row = this.dbInstance + .prepare(`SELECT id, active, name FROM groups WHERE id = ?`) + .get(groupId) as any; + + if (!row) { + const community = process.env.WHATSAPP_COMMUNITY_ID || ''; + this.dbInstance + .prepare( + `INSERT INTO groups (id, community_id, name, active, last_verified) + VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP)` + ) + .run(groupId, community, name || null); + created = true; + } else { + const hasNewName = + typeof name === 'string' && + name.trim().length > 0 && + name !== row.name; + if (row.active !== 1 || hasNewName) { + this.dbInstance + .prepare( + `UPDATE groups + SET active = 1, + name = COALESCE(?, name), + last_verified = CURRENT_TIMESTAMP + WHERE id = ?` + ) + .run(hasNewName ? name : null, groupId); + reactivated = row.active !== 1; + updatedName = hasNewName; + } + } + })(); + + updateCacheAndMetrics(this.dbInstance, this.activeGroupsCache); + return { created, reactivated, updatedName }; + } + + /** + * Resolve a group's display name through a 3-tier fallback: + * 1. In-memory cache + * 2. Database (groups.name) + * 3. Evolution API (only outside test mode) + * Side-effect: persists the resolved label to both groups and + * allowed_groups at whichever tier succeeds. + */ + static async ensureGroupLabelAndName( + groupId: string + ): Promise { + if (!groupId) return null; + + // Tier 1 — memory cache + const cached = this.activeGroupsCache.get(groupId); + if (cached?.trim()) { + this.persistLabelAndRefresh(groupId, cached); + return cached; + } + + // Tier 2 — DB + try { + const row = this.dbInstance + .prepare('SELECT name FROM groups WHERE id = ?') + .get(groupId) as { name?: string | null } | undefined; + const dbName = row?.name ? String(row.name).trim() : ''; + if (dbName) { + this.persistLabelAndRefresh(groupId, dbName); + return dbName; + } + } catch {} + + // Tier 3 — API (skip in tests) + if (process.env.NODE_ENV !== 'test') { + try { + const groups = (await this.fetchGroupsFromAPI()) as unknown as EvolutionGroup[]; + const g = groups.find(gg => gg?.id === groupId); + const subject = g?.subject ? String(g.subject).trim() : ''; + if (subject) { + this.persistLabelAndRefresh(groupId, subject); + return subject; + } + } catch {} + } + + return null; + } + + private static persistLabelAndRefresh( + groupId: string, + label: string + ): void { + try { + this.ensureGroupExists(groupId, label); + } catch {} + try { + AllowedGroups.upsertPending(groupId, label, null); + } catch {} + computeActiveCacheInto(this.dbInstance, this.activeGroupsCache); + } + + // =================================================================== + // Sync members for a single group + // =================================================================== + + static async syncMembersForGroup( + groupId: string + ): Promise<{ added: number; updated: number; deactivated: number }> { + if ( + String(process.env.GROUP_GATING_MODE || 'off').toLowerCase() === + 'enforce' && + !this.isGroupAllowedSafe(groupId) + ) { + return { added: 0, updated: 0, deactivated: 0 }; + } + + try { + this.ensureGroupExists(groupId); + const snapshot = await this.fetchGroupMembersFromAPI(groupId); + return this.reconcileGroupMembers(groupId, snapshot); + } catch (e) { + console.error( + `❌ Failed to sync members for group ${groupId}:`, + e instanceof Error ? e.message : String(e) + ); + return { added: 0, updated: 0, deactivated: 0 }; + } + } } diff --git a/src/services/group-sync/api.ts b/src/services/group-sync/api.ts index d49c59f..12eeb39 100644 --- a/src/services/group-sync/api.ts +++ b/src/services/group-sync/api.ts @@ -1,6 +1,54 @@ import { normalizeWhatsAppId } from '../../utils/whatsapp'; import { IdentityService } from '../identity'; +/** + * Parse a participants array (strings or objects) from Evolution API into + * normalized {userId, isAdmin} entries, including identity resolution. + */ +function parseParticipants(participants: any[]): Array<{ userId: string; isAdmin: boolean }> { + const result: Array<{ userId: string; isAdmin: boolean }> = []; + for (const p of participants) { + let jid: string | null = null; + let isAdmin = false; + + if (typeof p === 'string') { + jid = p; + } else if (p && typeof p === 'object') { + const rawId = p.id || p?.user?.id || p.user || null; + const rawJid = p.jid || null; + jid = rawJid || rawId || null; + + if (rawId && rawJid) { + try { IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); } catch {} + } + + if (typeof p.isAdmin === 'boolean') { + isAdmin = p.isAdmin; + } else if (typeof p.admin === 'string') { + isAdmin = p.admin === 'admin' || p.admin === 'superadmin'; + } else if (typeof p.role === 'string') { + isAdmin = p.role.toLowerCase().includes('admin'); + } + } + + let norm = normalizeWhatsAppId(jid); + if (!norm) { + const digits = (jid || '').replace(/\D+/g, ''); + norm = digits || null; + } + if (!norm) continue; + result.push({ userId: norm, isAdmin }); + } + + // Resolve identities + try { + const map = IdentityService.resolveMany(result.map(r => r.userId)); + return result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin })); + } catch { + return result; + } +} + export type ApiEvolutionGroup = { id: string; subject: string; @@ -120,47 +168,7 @@ export async function fetchGroupMembersFromAPI(groupId: string): Promise = []; - for (const p of participantsArr) { - let jid: string | null = null; - let isAdmin = false; - - if (typeof p === 'string') { - jid = p; - } else if (p && typeof p === 'object') { - const rawId = p.id || p?.user?.id || p.user || null; - const rawJid = p.jid || null; - jid = rawJid || rawId || null; - - if (rawId && rawJid) { - try { IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); } catch {} - } - - if (typeof p.isAdmin === 'boolean') { - isAdmin = p.isAdmin; - } else if (typeof p.admin === 'string') { - isAdmin = p.admin === 'admin' || p.admin === 'superadmin'; - } else if (typeof p.role === 'string') { - isAdmin = p.role.toLowerCase().includes('admin'); - } - } - - let norm = normalizeWhatsAppId(jid); - if (!norm) { - const digits = (jid || '').replace(/\D+/g, ''); - norm = digits || null; - } - if (!norm) continue; - result.push({ userId: norm, isAdmin }); - } - let resolved: Array<{ userId: string; isAdmin: boolean }>; - try { - const map = IdentityService.resolveMany(result.map(r => r.userId)); - resolved = result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin })); - } catch { - resolved = result; - } - return resolved; + return parseParticipants(participantsArr); } console.warn('⚠️ /group/participants responded without participants array, falling back to fetchAllGroups'); } else { @@ -219,46 +227,5 @@ export async function fetchGroupMembersFromAPI(groupId: string): Promise = []; - for (const p of participants) { - let jid: string | null = null; - let isAdmin = false; - - if (typeof p === 'string') { - jid = p; - } else if (p && typeof p === 'object') { - const rawId = p.id || p?.user?.id || p.user || null; - const rawJid = p.jid || null; - jid = rawJid || rawId || null; - - if (rawId && rawJid) { - try { IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); } catch {} - } - - if (typeof p.isAdmin === 'boolean') { - isAdmin = p.isAdmin; - } else if (typeof p.admin === 'string') { - isAdmin = p.admin === 'admin' || p.admin === 'superadmin'; - } else if (typeof p.role === 'string') { - isAdmin = p.role.toLowerCase().includes('admin'); - } - } - - let norm = normalizeWhatsAppId(jid); - if (!norm) { - const digits = (jid || '').replace(/\D+/g, ''); - norm = digits || null; - } - if (!norm) continue; - result.push({ userId: norm, isAdmin }); - } - let resolved: Array<{ userId: string; isAdmin: boolean }>; - try { - const map = IdentityService.resolveMany(result.map(r => r.userId)); - resolved = result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin })); - } catch { - resolved = result; - } - return resolved; + return parseParticipants(participants); } diff --git a/src/services/group-sync/changes.ts b/src/services/group-sync/changes.ts new file mode 100644 index 0000000..3850be0 --- /dev/null +++ b/src/services/group-sync/changes.ts @@ -0,0 +1,60 @@ +import type { Database } from 'bun:sqlite'; + +/** Row shape used by the change detector (subset of groups columns). */ +export interface GroupRow { + id: string; + active: number; + archived: number; + is_community: number; + name?: string | null; +} + +export interface GroupChanges { + newlyActivated: string[]; + newlyDeactivated: Array<{ id: string; name: string | null }>; +} + +/** + * Compare before/after snapshots of the groups table and detect: + * - Groups that became active (new or reactivated, excluding communities/archived) + * - Groups that went from active → inactive (excluding communities/archived) + */ +export function detectGroupChanges( + before: GroupRow[], + after: GroupRow[] +): GroupChanges { + const beforeMap = new Map(); + for (const r of before) beforeMap.set(String(r.id), r); + + const afterMap = new Map(); + for (const r of after) afterMap.set(String(r.id), r); + + const newlyActivated: string[] = []; + for (const [id, a] of afterMap) { + const b = beforeMap.get(id); + const active = + Number(a.active) === 1 && + Number(a.archived) === 0 && + Number(a.is_community) === 0; + if (active && (!b || Number(b.active) !== 1)) { + newlyActivated.push(id); + } + } + + const newlyDeactivated: Array<{ id: string; name: string | null }> = []; + for (const [id, b] of beforeMap) { + 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 && + Number(b.is_community) === 0 + ) { + newlyDeactivated.push({ id, name: a.name ?? b.name ?? null }); + } + } + + return { newlyActivated, newlyDeactivated }; +} diff --git a/src/services/group-sync/deactivation.ts b/src/services/group-sync/deactivation.ts new file mode 100644 index 0000000..354de18 --- /dev/null +++ b/src/services/group-sync/deactivation.ts @@ -0,0 +1,85 @@ +import type { Database } from 'bun:sqlite'; +import { normalizeWhatsAppId } from '../../utils/whatsapp'; +import { ResponseQueue } from '../response-queue'; +import { Metrics } from '../metrics'; + +/** + * Revoke calendar tokens and deactivate memberships for groups that have + * been deactivated (no longer present in the API snapshot). + */ +export function revokeTokensAndDeactivateMembers( + db: Database, + deactivatedIds: string[], + txn: (fn: () => void) => void = fn => fn() +): void { + if (deactivatedIds.length === 0) return; + txn(() => { + for (const groupId of deactivatedIds) { + db.prepare(` + UPDATE calendar_tokens + SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now') + WHERE group_id = ? AND revoked_at IS NULL + `).run(groupId); + db.prepare(` + UPDATE group_members + SET is_active = 0 + WHERE group_id = ? AND is_active = 1 + `).run(groupId); + } + }); +} + +/** + * Queue admin notifications about deactivated groups. + * Skips in test mode. + */ +export async function notifyAdminsAboutDeactivated( + deactivated: Array<{ id: string; name: string | null }> +): Promise { + if (deactivated.length === 0) return; + if (String(process.env.NODE_ENV || '').toLowerCase() === 'test') return; + + 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) return; + + const messages: Array<{ recipient: string; message: string }> = []; + for (const g of deactivated) { + const label = g.name ? `${g.name} (${g.id})` : g.id; + const msg = + `⚠️ El grupo ${label} parece haber dejado de existir o no está disponible.\n\n` + + `Acciones disponibles:\n- Archivar (recomendado): /admin archivar-grupo ${g.id}\n` + + `- Borrar definitivamente: /admin borrar-grupo ${g.id}`; + for (const admin of admins) { + messages.push({ recipient: admin, message: msg }); + } + } + + if (messages.length > 0) { + try { + await ResponseQueue.add(messages as any); + Metrics.inc('admin_deactivation_notifications', messages.length); + } catch (e) { + console.warn('No se pudo encolar notificación a admins:', e); + } + } +} + +/** + * Full deactivation pipeline: revoke tokens, deactivate members, notify admins. + */ +export async function handleDeactivatedGroups( + db: Database, + deactivated: Array<{ id: string; name: string | null }> +): Promise { + if (deactivated.length === 0) return; + + const ids = deactivated.map(g => g.id); + revokeTokensAndDeactivateMembers(db, ids, fn => db.transaction(fn)()); + await notifyAdminsAboutDeactivated(deactivated); +} diff --git a/src/services/group-sync/membership.ts b/src/services/group-sync/membership.ts new file mode 100644 index 0000000..882e044 --- /dev/null +++ b/src/services/group-sync/membership.ts @@ -0,0 +1,75 @@ +import type { Database } from 'bun:sqlite'; + +// --------------------------------------------------------------------------- +// Snapshot freshness +// --------------------------------------------------------------------------- + +function maxSnapshotAgeMs(): number { + const raw = Number(process.env.MAX_MEMBERS_SNAPSHOT_AGE_MS); + return Number.isFinite(raw) && raw > 0 ? raw : 24 * 60 * 60 * 1000; // 24h default +} + +export function isSnapshotFresh( + db: Database, + groupId: string, + nowMs: number = Date.now() +): boolean { + try { + const row = db + .prepare(`SELECT last_verified FROM groups WHERE id = ?`) + .get(groupId) as { last_verified?: string | null } | undefined; + const lv = row?.last_verified ? String(row.last_verified) : null; + if (!lv) return false; + const iso = lv.includes('T') ? lv : lv.replace(' ', 'T') + 'Z'; + const ms = Date.parse(iso); + if (!Number.isFinite(ms)) return false; + return nowMs - ms <= maxSnapshotAgeMs(); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Membership queries +// --------------------------------------------------------------------------- + +export function isUserActiveInGroup( + db: Database, + userId: string, + groupId: string +): boolean { + if (!userId || !groupId) return false; + const row = db + .prepare( + `SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1` + ) + .get(groupId, userId); + return !!row; +} + +export function getActiveGroupIdsForUser( + db: Database, + userId: string +): string[] { + if (!userId) return []; + const rows = db + .prepare( + `SELECT gm.group_id AS id + 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 Array<{ id: string }>; + return [...new Set(rows.map(r => String(r.id)))]; +} + +export function getFreshMemberGroupsForUser( + db: Database, + userId: string +): string[] { + return getActiveGroupIdsForUser(db, userId).filter(gid => + isSnapshotFresh(db, gid) + ); +} diff --git a/src/services/group-sync/scheduler.ts b/src/services/group-sync/scheduler.ts new file mode 100644 index 0000000..4321a00 --- /dev/null +++ b/src/services/group-sync/scheduler.ts @@ -0,0 +1,79 @@ +/** + * Resolve a schedule interval in milliseconds. + * + * Priority: + * 1. env var if set and valid + * 2. fallbackMs default + * + * In development mode, enforces a minimum of 10s to avoid accidental API spam. + */ +export function resolveInterval( + envVar: string, + fallbackMs: number +): number { + const raw = Number(process.env[envVar]); + let interval = Number.isFinite(raw) && raw > 0 ? raw : fallbackMs; + if (process.env.NODE_ENV === 'development' && interval < 10_000) { + console.warn( + `Sync interval from ${envVar} too low (${interval}ms), using 10s minimum` + ); + interval = 10_000; + } + return interval; +} + +// --------------------------------------------------------------------------- +// Scheduler state holders (mutable, per-scheduler) +// --------------------------------------------------------------------------- + +export interface SchedulerState { + running: boolean; + timer: ReturnType | null; + intervalMs: number | null; + nextTickAt: number | null; +} + +export function createSchedulerState(): SchedulerState { + return { running: false, timer: null, intervalMs: null, nextTickAt: null }; +} + +export function startScheduler( + state: SchedulerState, + intervalMs: number, + task: () => Promise, + label: string +): void { + if (process.env.NODE_ENV === 'test') return; + if (state.running) return; + + state.running = true; + state.intervalMs = intervalMs; + state.nextTickAt = Date.now() + intervalMs; + + state.timer = setInterval(() => { + state.nextTickAt = Date.now() + (state.intervalMs ?? intervalMs); + task().catch(err => + console.error(`❌ ${label} scheduler run error:`, err) + ); + }, intervalMs); +} + +export function stopScheduler(state: SchedulerState): void { + state.running = false; + if (state.timer) { + clearInterval(state.timer); + state.timer = null; + } + state.intervalMs = null; + state.nextTickAt = null; +} + +export function secondsUntilNextTick( + state: SchedulerState, + nowMs: number = Date.now() +): number | null { + const next = state.nextTickAt; + if (next == null) return null; + const secs = (next - nowMs) / 1000; + return secs > 0 ? secs : 0; +} diff --git a/src/services/messages/help.ts b/src/services/messages/help.ts deleted file mode 100644 index 0cadb15..0000000 --- a/src/services/messages/help.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * 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([ - `Crear: ${code('/t n Descripción 27-11-14 @Ana')}`, - `Ver mis: ${code('/t mias')} _por privado_`, - `Ver todas: ${code('/t todas')} _por privado_`, - `Más info: ${code('/t info')}`, - `Completar: ${code('/t x 26')} _(máx. 10 a la vez)_`, - `Tomar: ${code('/t tomar 12')} _(máx. 10 a la vez)_`, - `Soltar: ${code('/t soltar 26')} _(máx. 10 a la vez)_`, - `Recordatorios: ${code('/t configurar diario|l-v|semanal|off [HH:MM]')} _por privado_`, - `Versión web: ${code('/t web')}`, - ]) - ); - - parts.push( - italic('El bot responde por privado, 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 privado: 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 mias')} tus pendientes (por privado).`, - `${code('/t todas')} tus pendientes + “sin responsable”.`, - 'Nota: no respondo en grupos; usa estos comandos por privado.', - '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([ - 'Puedes escribir fechas en formato `2027-09-04` o `27-09-04`', - '`hoy` y `mañana` también son expresiones válidas', - ]) - ); - - // 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 (dura 10 min, una vez entras dura 2 horas).`, - ]) - ); - - // 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.', - ]) - ); - - return out.join('\n'); -} diff --git a/src/services/onboarding.ts b/src/services/onboarding.ts index 1f7646d..6f32eef 100644 --- a/src/services/onboarding.ts +++ b/src/services/onboarding.ts @@ -12,41 +12,123 @@ type CommandResponse = { mentions?: string[]; }; -/** - * Construye (si aplica) el DM JIT al creador cuando hay menciones/tokens irrecuperables. - * Aplica flags y métricas exactamente como en CommandService. - */ +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const isTest = () => process.env.NODE_ENV === 'test'; +const isEnvTrue = (key: string) => ['true','1','yes','on'].includes(String(process.env[key] || '').toLowerCase()); +const envNum = (key: string, fallback: number): number => { + const v = Number(process.env[key]); + return Number.isFinite(v) && v > 0 ? v : fallback; +}; + +function skipMetric(reason: string, gid: string): void { + try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason, group_id: String(gid || '') }); } catch {} +} + +function sentMetric(source: string, gid: string, extra?: Record): void { + try { Metrics.inc('onboarding_bundle_sent_total', 1, { ...extra, group_id: String(gid) }); } catch {} +} + +function promoSkipMetric(reason: string, groupId: string): void { + try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason }); } catch {} +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function isPromptsEnabled(): boolean { + if (isTest()) return 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()); +} + +function checkCoverageBelowThreshold(ratio: number, groupId: string): boolean { + const thrRaw = Number(process.env.ONBOARDING_COVERAGE_THRESHOLD); + const threshold = Number.isFinite(thrRaw) ? Math.min(1, Math.max(0, thrRaw)) : 1; + if (!(ratio < threshold)) { + promoSkipMetric('coverage_100', groupId); + return false; + } + return true; +} + +function checkCoverageGracePeriod(db: Database, groupId: string, nowMs: number): boolean { + const graceRaw = Number(process.env.ONBOARDING_GRACE_SECONDS); + const graceSec = Number.isFinite(graceRaw) && graceRaw >= 0 ? Math.floor(graceRaw) : 90; + + const row = db.prepare('SELECT last_verified FROM groups WHERE id = ?').get(groupId) as any; + const lv = row?.last_verified ? String(row.last_verified) : null; + if (!lv) return true; + + const ms = parseIsoMs(lv); + if (!Number.isFinite(ms)) return true; + + if (Math.floor((nowMs - ms) / 1000) < graceSec) { + promoSkipMetric('grace_period', groupId); + return false; + } + return true; +} + +function checkCoverageCooldown(db: Database, groupId: string, nowMs: number): boolean { + const cdRaw = Number(process.env.ONBOARDING_COOLDOWN_DAYS); + const cdDays = Number.isFinite(cdRaw) && cdRaw >= 0 ? Math.floor(cdRaw) : 7; + + const row = db.prepare('SELECT onboarding_prompted_at FROM groups WHERE id = ?').get(groupId) as any; + const promptedAt = row?.onboarding_prompted_at ? String(row.onboarding_prompted_at) : null; + if (!promptedAt) return true; + + const ms = parseIsoMs(promptedAt); + if (!Number.isFinite(ms)) return true; + + if ((nowMs - ms) < cdDays * 24 * 60 * 60 * 1000) { + promoSkipMetric('cooldown_active', groupId); + return false; + } + return true; +} + +function validateBotNumber(groupId: string): string | null { + const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim(); + if (!bot || !/^\d+$/.test(bot)) { + promoSkipMetric('missing_bot_number', groupId); + return null; + } + return bot; +} + +function enqueueCoveragePrompt(db: Database, groupId: string, bot: string): void { + 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}`; + db.transaction(() => { + db.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); + db.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 {} +} + +// --------------------------------------------------------------------------- +// JIT assignee prompt (unchanged except for minor cleanup) +// --------------------------------------------------------------------------- + export function buildJitAssigneePrompt(createdBy: string, groupId: string, unresolvedAssigneeDisplays: string[]): CommandResponse[] { const responses: CommandResponse[] = []; const unresolvedList = Array.from(new Set((unresolvedAssigneeDisplays || []).filter(Boolean))); if (unresolvedList.length === 0) return responses; - 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 = String(groupId && groupId.includes('@g.us') ? groupId : 'dm'); - if (!enabled) { - try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'disabled' }); } catch { } - return responses; - } + if (!isPromptsEnabled()) { promoSkipMetric('disabled', groupLabel); return responses; } 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 { } - return responses; - } + if (!bot || !/^\d+$/.test(bot)) { promoSkipMetric('missing_bot_number', groupLabel); return responses; } const list = unresolvedList.join(', '); - let groupCtx = ''; - if (groupId && groupId.includes('@g.us')) { - const name = groupId; - groupCtx = ` (en el grupo ${name})`; - } + const groupCtx = (groupId && groupId.includes('@g.us')) ? ` (en el grupo ${groupId})` : ''; 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 { } @@ -54,250 +136,227 @@ export function buildJitAssigneePrompt(createdBy: string, groupId: string, unres return responses; } -/** - * Encola el paquete de 2 DMs de onboarding para miembros del grupo (si aplica). - * Respeta gating AllowedGroups, cap, cooldown, delays y métricas. - */ -export function maybeEnqueueOnboardingBundle(db: Database, params: { +// --------------------------------------------------------------------------- +// Onboarding bundle — extracted pieces +// --------------------------------------------------------------------------- + +interface OnboardingBundleParams { gid: string | null; createdBy: string; assignmentUserIds: string[]; taskId: number; displayCode: number | null; description: string; -}): void { - const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test'; - const enabledBase = ['true','1','yes','on'].includes(String(process.env.ONBOARDING_DM_ENABLED || '').toLowerCase()); - const enabled = enabledBase && (!isTest || String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true'); - const gid = params.gid; - - if (!enabled) { - try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'disabled', group_id: String(gid || '') }); } catch {} - return; - } - if (!gid) { - try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_group', group_id: '' }); } catch {} - return; - } +} - // Gating enforce - let allowed = true; - try { - const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); - if (mode === 'enforce') { - allowed = AllowedGroups.isAllowed(gid); - } - } catch {} - if (!allowed) { - try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'not_allowed', group_id: String(gid) }); } catch {} - return; - } +/** Returns false (and records a skip metric) when onboarding should not proceed. */ +function checkOnboardingEnabled(params: OnboardingBundleParams): boolean { + const baseEnabled = isEnvTrue('ONBOARDING_DM_ENABLED'); + const enabled = baseEnabled && (!isTest() || isEnvTrue('ONBOARDING_ENABLE_IN_TEST')); + if (!enabled) { skipMetric('disabled', String(params.gid || '')); return false; } + if (!params.gid) { skipMetric('no_group', ''); return false; } + return true; +} - const displayCode = params.displayCode; - if (!(typeof displayCode === 'number' && Number.isFinite(displayCode))) { - try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_display_code', group_id: String(gid) }); } catch {} - return; - } +function checkGroupGating(gid: string): boolean { + if (String(process.env.GROUP_GATING_MODE || 'off').toLowerCase() !== 'enforce') return true; + try { return AllowedGroups.isAllowed(gid); } catch { return false; } +} + +function checkDisplayCode(params: OnboardingBundleParams): boolean { + if (typeof params.displayCode === 'number' && Number.isFinite(params.displayCode)) return true; + skipMetric('no_display_code', String(params.gid || '')); + return false; +} - // Candidatos +/** Fetches active group members, excluding the creator, assignees, and the bot. */ +function fetchEligibleMembers(db: Database, gid: string, params: OnboardingBundleParams): string[] { let members: string[] = []; try { - const rows = db.prepare(`SELECT user_id FROM group_members WHERE group_id = ? AND is_active = 1`).all(gid) as Array<{ user_id: string }>; - for (const r of rows) { - const uid = String(r.user_id || '').trim(); - if (/^\d+$/.test(uid) && uid.length < 14) members.push(uid); - } + const rows = db.prepare('SELECT user_id FROM group_members WHERE group_id = ? AND is_active = 1').all(gid) as Array<{ user_id: string }>; + members = rows.map(r => String(r.user_id || '').trim()).filter(id => /^\d+$/.test(id) && id.length < 14); } catch {} + const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim(); const exclude = new Set([params.createdBy, ...params.assignmentUserIds]); - members = members - .filter(id => /^\d+$/.test(id) && id.length < 14) - .filter(id => !exclude.has(id)) - .filter(id => !bot || id !== bot); - - if (members.length === 0) { - try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_members', group_id: String(gid) }); } catch {} - return; - } + return members.filter(id => !exclude.has(id) && (!bot || id !== bot)); +} - const capRaw = Number(process.env.ONBOARDING_EVENT_CAP); - const cap = Number.isFinite(capRaw) && capRaw > 0 ? Math.floor(capRaw) : 30; - let recipients = members; - if (recipients.length > cap) { - try { Metrics.inc('onboarding_recipients_capped_total', recipients.length - cap, { group_id: String(gid) }); } catch {} - recipients = recipients.slice(0, cap); - } +function applyRecipientCap(recipients: string[], gid: string): string[] { + const cap = Math.floor(envNum('ONBOARDING_EVENT_CAP', 30)); + if (recipients.length <= cap) return recipients; - const cooldownRaw = Number(process.env.ONBOARDING_DM_COOLDOWN_DAYS); - const cooldownDays = Number.isFinite(cooldownRaw) && cooldownRaw >= 0 ? Math.floor(cooldownRaw) : 14; - const delayEnv = Number(process.env.ONBOARDING_BUNDLE_DELAY_MS); - const delay2 = Number.isFinite(delayEnv) && delayEnv >= 0 ? Math.floor(delayEnv) : 5000 + Math.floor(Math.random() * 5001); // 5–10s + try { Metrics.inc('onboarding_recipients_capped_total', recipients.length - cap, { group_id: String(gid) }); } catch {} + return recipients.slice(0, cap); +} - let groupLabel = gid; +function resolveGroupLabel(db: Database, gid: string): string { try { - const row = db.prepare(`SELECT name FROM groups WHERE id = ?`).get(gid) as any; + const row = db.prepare('SELECT name FROM groups WHERE id = ?').get(gid) as any; const name = row?.name ? String(row.name).trim() : ''; - if (name) groupLabel = name; + if (name) return name; } catch {} - const codeStr = String(displayCode); - const desc = (params.description || '(sin descripción)').trim(); - const shortDesc = desc.length > 100 ? (desc.slice(0, 100) + '…') : desc; - - const codeInline = codeId(params.taskId, displayCode); - const cmdTake = code(`/t tomar ${padTaskId(displayCode)}`); - const cmdInfo = code(`/t info`); - const groupBold = bold(`‘${groupLabel}’`); + return gid; +} - const msg1 = `¡Hola!, soy el bot de tareas. En ${groupBold} acaban de crear una tarea: ${codeInline} _${shortDesc}_ -- Para asignártela, escríbeme ${cmdTake} por privado -- Más info: ${cmdInfo} (por privado también) +function buildOnboardingMessage1(taskId: number, displayCode: string, description: string, groupLabel: string): string { + const shortDesc = description.length > 100 ? (description.slice(0, 100) + '…') : description; + return `¡Hola!, soy el bot de tareas. En ${bold(`'${groupLabel}'`)} acaban de crear una tarea: ${codeId(taskId, Number(displayCode))} _${shortDesc}_ +- Para asignártela, escríbeme ${code(`t tomar ${padTaskId(Number(displayCode))}`)} por privado +- Más info: ${code('t info')} (por privado también) ${ICONS.info} Nunca escribo en grupos. ${ICONS.info} Cuando reciba tu primer mensaje no te enviaré más este recordatorio`; +} - const msg2 = `*GUÍA RÁPIDA* +function buildOnboardingMessage2(): string { + return `*GUÍA RÁPIDA* Puedes interactuar con el bot escribiéndome por privado: -- Ver todas las tareas: ${code('/t todas')} -- Ver solo tus tareas: ${code('/t mias')} -- ¿Quieres recordatorios?: ${code('/t configurar diario|l-v|semanal|off')} -- Web: ${code('/t web')}`; +- Ver todas las tareas: ${code('t todas')} +- Ver solo tus tareas: ${code('t mias')} +- ¿Quieres recordatorios?: ${code('t configurar diario|l-v|semanal|off')} +- Web: ${code('t web')}`; +} - for (const rcpt of recipients) { - const stats = ResponseQueue.getOnboardingStats(rcpt); - let variant: 'initial' | 'reminder' | null = null; - - if (!stats || (stats.total || 0) === 0) { - variant = 'initial'; - } else if (stats.firstInitialAt) { - let firstMs = NaN; - try { - const s = String(stats.firstInitialAt); - const iso = s.includes('T') ? s : (s.replace(' ', 'T') + 'Z'); - firstMs = Date.parse(iso); - } catch {} - const nowMs = Date.now(); - const okCooldown = Number.isFinite(firstMs) ? (nowMs - firstMs) >= cooldownDays * 24 * 60 * 60 * 1000 : false; - - // Interacción del usuario desde el primer paquete - let hadInteraction = false; - try { - const row = db.prepare(`SELECT last_command_at FROM users WHERE id = ?`).get(rcpt) as any; - const lcRaw = row?.last_command_at ? String(row.last_command_at) : null; - if (lcRaw) { - const lcIso = lcRaw.includes('T') ? lcRaw : (lcRaw.replace(' ', 'T') + 'Z'); - const lcMs = Date.parse(lcIso); - hadInteraction = Number.isFinite(lcMs) && Number.isFinite(firstMs) && lcMs > firstMs; - } - } catch {} - - if (okCooldown && !hadInteraction) { - variant = 'reminder'; - } else { - try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: hadInteraction ? 'had_interaction' : 'cooldown_active', group_id: String(gid) }); } catch {} - } - } +// --------------------------------------------------------------------------- +// Cooldown & variant logic +// --------------------------------------------------------------------------- - if (!variant) continue; +type OnboardingVariant = 'initial' | 'reminder'; + +function cooldownDays(): number { + const raw = Number(process.env.ONBOARDING_DM_COOLDOWN_DAYS); + return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 14; +} + +function bundleDelayMs(): number { + const delayEnv = Number(process.env.ONBOARDING_BUNDLE_DELAY_MS); + return Number.isFinite(delayEnv) && delayEnv >= 0 ? Math.floor(delayEnv) : 5000 + Math.floor(Math.random() * 5001); +} + +function parseIsoMs(s: string): number { + const iso = s.includes('T') ? s : (s.replace(' ', 'T') + 'Z'); + return Date.parse(iso); +} + +function userHadInteractionSince(db: Database, rcpt: string, firstMs: number): boolean { + try { + const row = db.prepare('SELECT last_command_at FROM users WHERE id = ?').get(rcpt) as any; + const lcRaw = row?.last_command_at ? String(row.last_command_at) : null; + if (!lcRaw) return false; + const lcMs = parseIsoMs(lcRaw); + return Number.isFinite(lcMs) && lcMs > firstMs; + } catch { return false; } +} - const bundleId = randomTokenBase64Url(12); +/** + * Determines the onboarding variant for a recipient. + * Returns null when the recipient should be skipped. + */ +function determineOnboardingVariant( + db: Database, + rcpt: string, + cooldownDays: number, + gid: string, +): OnboardingVariant | null { + const stats = ResponseQueue.getOnboardingStats(rcpt); + + // Never received onboarding → initial + if (!stats || (stats.total || 0) === 0) return 'initial'; + + // No firstInitialAt → can't determine cooldown + if (!stats.firstInitialAt) return null; + + const firstMs = parseIsoMs(String(stats.firstInitialAt)); + if (!Number.isFinite(firstMs)) return null; + + const cooldownMs = cooldownDays * 24 * 60 * 60 * 1000; + const okCooldown = (Date.now() - firstMs) >= cooldownMs; + const hadInteraction = userHadInteractionSince(db, rcpt, firstMs); + + if (okCooldown && !hadInteraction) return 'reminder'; + + skipMetric(hadInteraction ? 'had_interaction' : 'cooldown_active', gid); + return null; +} + +// --------------------------------------------------------------------------- +// Bundle enqueue +// --------------------------------------------------------------------------- + +function enqueueBundleForRecipient( + rcpt: string, + msg1: string, + msg2: string, + variant: OnboardingVariant, + params: OnboardingBundleParams, + delay2: number, +): void { + const bundleId = randomTokenBase64Url(12); + const gid = String(params.gid || ''); + const meta = { variant, group_id: gid, task_id: params.taskId, display_code: params.displayCode }; + + ResponseQueue.enqueueOnboarding(rcpt, msg1, { ...meta, part: 1, bundle_id: bundleId }, 0); + ResponseQueue.enqueueOnboarding(rcpt, msg2, { ...meta, part: 2, bundle_id: bundleId }, delay2); + sentMetric(params.gid || '', gid, { variant }); +} + +// --------------------------------------------------------------------------- +// Main export +// --------------------------------------------------------------------------- + +export function maybeEnqueueOnboardingBundle(db: Database, params: OnboardingBundleParams): void { + // 1. Guard clauses + if (!checkOnboardingEnabled(params)) return; + const gid = params.gid!; + if (!checkGroupGating(gid)) { skipMetric('not_allowed', gid); return; } + if (!checkDisplayCode(params)) return; + + // 2. Fetch & filter recipients + const members = fetchEligibleMembers(db, gid, params); + if (members.length === 0) { skipMetric('no_members', gid); return; } + + const recipients = applyRecipientCap(members, gid); + + // 3. Build messages (once) + const groupLabel = resolveGroupLabel(db, gid); + const displayCodeStr = String(params.displayCode!); + const desc = (params.description || '(sin descripción)').trim(); + const msg1 = buildOnboardingMessage1(params.taskId, displayCodeStr, desc, groupLabel); + const msg2 = buildOnboardingMessage2(); + + // 4. Cooldown & delay config + const cd = cooldownDays(); + const delay2 = bundleDelayMs(); + + // 5. Enqueue for each eligible recipient + for (const rcpt of recipients) { + const variant = determineOnboardingVariant(db, rcpt, cd, gid); + if (!variant) continue; try { - ResponseQueue.enqueueOnboarding(rcpt, msg1, { variant, part: 1, bundle_id: bundleId, group_id: gid, task_id: params.taskId, display_code: displayCode }, 0); - ResponseQueue.enqueueOnboarding(rcpt, msg2, { variant, part: 2, bundle_id: bundleId, group_id: gid, task_id: params.taskId, display_code: displayCode }, delay2); - try { Metrics.inc('onboarding_bundle_sent_total', 1, { variant, group_id: String(gid) }); } catch {} - } catch {} + enqueueBundleForRecipient(rcpt, msg1, msg2, variant, params, delay2); + } catch { /* best effort */ } } } +// --------------------------------------------------------------------------- +// Group coverage prompt (unchanged) +// --------------------------------------------------------------------------- + export function publishGroupCoveragePrompt(db: Database, groupId: string, ratio: number): void { try { - 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; - } - - // Umbral de cobertura: publicar solo si ratio < threshold (por defecto 1.0) - 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 enforce - try { - const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); - if (mode === 'enforce') { - if (!AllowedGroups.isAllowed(groupId)) { - try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'not_allowed' }); } catch {} - return; - } - } - } catch {} - - // Grace y cooldown - const rowG = db.prepare(`SELECT last_verified, onboarding_prompted_at FROM groups WHERE id = ?`).get(groupId) as any; + if (!isPromptsEnabled()) { promoSkipMetric('disabled', groupId); return; } + if (!checkCoverageBelowThreshold(ratio, groupId)) return; + if (!checkGroupGating(groupId)) { promoSkipMetric('not_allowed', groupId); return; } + 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; - } - } - } - - 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; - } - - 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}`; - db.transaction(() => { - db.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); - db.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 {} + if (!checkCoverageGracePeriod(db, groupId, nowMs)) return; + if (!checkCoverageCooldown(db, groupId, nowMs)) return; + + const bot = validateBotNumber(groupId); + if (!bot) return; + + enqueueCoveragePrompt(db, groupId, bot); } catch (e) { - if (process.env.NODE_ENV !== 'test') { - console.warn('⚠️ Onboarding prompt skipped due to internal error for', groupId, e); - } + if (process.env.NODE_ENV !== 'test') console.warn('⚠️ Onboarding prompt skipped due to internal error for', groupId, e); } } diff --git a/src/services/reminders.ts b/src/services/reminders.ts index f95d465..a42e1a2 100644 --- a/src/services/reminders.ts +++ b/src/services/reminders.ts @@ -5,6 +5,7 @@ import { ContactsService } from './contacts'; import { GroupSyncService } from './group-sync'; import { ICONS } from '../utils/icons'; import { codeId, formatDDMM, bold, italic } from '../utils/formatting'; +import { formatTaskLine, formatDatePart } from './commands/shared'; import { AllowedGroups } from './allowed-groups'; import { Metrics } from './metrics'; import { getDb } from '../db/locator'; @@ -12,12 +13,207 @@ import { getDb } from '../db/locator'; type UserPreference = { user_id: string; reminder_freq: 'daily' | 'weekly' | 'weekdays' | 'off'; - reminder_time: string; // 'HH:MM' - last_reminded_on: string | null; // 'YYYY-MM-DD' + reminder_time: string; + last_reminded_on: string | null; }; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseHmMinutes(hm: string): number { + const [h, m] = String(hm).split(':'); + return (parseInt(h || '0', 10) || 0) * 60 + (parseInt(m || '0', 10) || 0); +} + +function graceMinutes(): number { + const raw = Number(process.env.REMINDERS_GRACE_MINUTES); + return Number.isFinite(raw) && raw >= 0 ? Math.min(Math.floor(raw), 180) : 60; +} + +function enforceGatingEnabled(): boolean { + return String(process.env.GROUP_GATING_MODE || 'off').toLowerCase() === 'enforce'; +} + +function filterTasksByGating(tasks: any[], enforce: boolean): any[] { + if (!enforce) return tasks; + return tasks.filter(t => !t.group_id || AllowedGroups.isAllowed(t.group_id)); +} + +// --------------------------------------------------------------------------- +// Preference evaluation +// --------------------------------------------------------------------------- + +type SkipReason = 'already_reminded' | 'before_time' | 'wrong_day' | 'outside_window'; + +/** + * Evaluates whether a reminder should be sent for this preference+time. + * Returns null if we should proceed, or a SkipReason if we should not. + */ +function evaluatePreference( + pref: UserPreference, + todayYMD: string, + nowMin: number, + weekday: string, +): { skip: SkipReason } | null { + // Already reminded today + if (pref.last_reminded_on === todayYMD) return { skip: 'already_reminded' }; + if (!pref.reminder_time) return { skip: 'already_reminded' }; + + const cfgMin = parseHmMinutes(pref.reminder_time); + + // Before scheduled time + if (nowMin < cfgMin) return { skip: 'before_time' }; + + const grace = graceMinutes(); + + // Outside grace window → skip with metric + if (nowMin > cfgMin + grace) return { skip: 'outside_window' }; + + // Weekdays: Mon-Fri only + if (pref.reminder_freq === 'weekdays' && (weekday === 'Sat' || weekday === 'Sun')) { + return { skip: 'wrong_day' }; + } + + // Weekly: Monday only + if (pref.reminder_freq === 'weekly' && weekday !== 'Mon') { + return { skip: 'wrong_day' }; + } + + return null; +} + +// --------------------------------------------------------------------------- +// Message building +// --------------------------------------------------------------------------- + +async function buildTaskSections( + items: any[], + todayYMD: string, +): Promise { + const byGroup = new Map(); + for (const t of items) { + const key = t.group_id || '(sin grupo)'; + const arr = byGroup.get(key) || []; + arr.push(t); + byGroup.set(key, arr); + } + + const sections: string[] = []; + for (const [groupId, arr] of byGroup.entries()) { + const groupName = + (groupId && GroupSyncService.activeGroupsCache.get(groupId)) || + (groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo'); + + sections.push(bold(groupName)); + const rendered = await Promise.all(arr.map(async (t) => { + const names = await Promise.all( + (t.assignees || []).map(async (uid: string) => (await ContactsService.getDisplayName(uid)) || uid), + ); + const owner = + (t.assignees?.length || 0) === 0 + ? `${ICONS.unassigned} sin responsable` + : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; + return formatTaskLine(t, owner, todayYMD); + })); + sections.push(...rendered); + } + return sections; +} + +async function buildUnassignedSections( + userId: string, + enforce: boolean, + todayYMD: string, +): Promise { + const sections: string[] = []; + let memberGroups = GroupSyncService.getFreshMemberGroupsForUser(userId); + if (enforce) memberGroups = memberGroups.filter(gid => AllowedGroups.isAllowed(gid)); + + for (const gid of memberGroups) { + const unassigned = TaskService.listGroupUnassigned(gid, 10); + if (unassigned.length === 0) continue; + + const groupName = GroupSyncService.activeGroupsCache.get(gid) || gid; + sections.push(bold(`${groupName} — Sin responsable`)); + + const rendered = unassigned.map(t => + formatTaskLine(t, `${ICONS.unassigned} sin responsable`, todayYMD), + ); + sections.push(...rendered); + + const total = TaskService.countGroupUnassigned(gid); + if (total > unassigned.length) { + sections.push(italic(`… y ${total - unassigned.length} más`)); + } + } + return sections; +} + +async function buildReminderMessage( + pref: UserPreference, + items: any[], + total: number, + todayYMD: string, +): Promise { + const sections: string[] = []; + + // Header + sections.push( + pref.reminder_freq === 'weekly' + ? `${ICONS.reminder} RECORDATORIO SEMANAL — TUS TAREAS` + : `${ICONS.reminder} RECORDATORIO DIARIO — TUS TAREAS`, + ); + + // User's tasks grouped by group + sections.push(...await buildTaskSections(items, todayYMD)); + + // Overflow note + if (total > items.length) { + sections.push(italic(`… y ${total - items.length} más`)); + } + + // Optional: unassigned tasks from user's groups + const includeUnassigned = String( + process.env.REMINDERS_INCLUDE_UNASSIGNED_FROM_MEMBERSHIP || '', + ).toLowerCase() === 'true'; + if (includeUnassigned) { + const enforce = enforceGatingEnabled(); + sections.push(...await buildUnassignedSections(pref.user_id, enforce, todayYMD)); + } + + return sections.join('\n'); +} + +// --------------------------------------------------------------------------- +// Database +// --------------------------------------------------------------------------- + +function fetchPreferences(db: Database): UserPreference[] { + return db.prepare(` + SELECT user_id, reminder_freq, reminder_time, last_reminded_on + FROM user_preferences + WHERE reminder_freq IN ('daily', 'weekly', 'weekdays') + `).all() as UserPreference[]; +} + +function markReminded(db: Database, userId: string, todayYMD: string): void { + db.prepare(` + INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at) + VALUES (?, COALESCE((SELECT reminder_freq FROM user_preferences WHERE user_id = ?), 'daily'), + COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30'), + ?, strftime('%Y-%m-%d %H:%M:%f', 'now')) + ON CONFLICT(user_id) DO UPDATE SET + last_reminded_on = excluded.last_reminded_on, + updated_at = excluded.updated_at + `).run(userId, userId, userId, todayYMD); +} + +// --------------------------------------------------------------------------- +// RemindersService +// --------------------------------------------------------------------------- + export class RemindersService { - private static _running = false; private static _timer: any = null; @@ -29,9 +225,7 @@ export class RemindersService { private static ymdInTZ(d: Date): string { const parts = new Intl.DateTimeFormat('en-GB', { timeZone: this.TZ, - year: 'numeric', - month: '2-digit', - day: '2-digit', + 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')}`; @@ -40,9 +234,7 @@ export class RemindersService { private static hmInTZ(d: Date): string { const parts = new Intl.DateTimeFormat('en-GB', { timeZone: this.TZ, - hour: '2-digit', - minute: '2-digit', - hour12: false, + 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')}`; @@ -52,194 +244,87 @@ export class RemindersService { return new Intl.DateTimeFormat('en-GB', { timeZone: this.TZ, weekday: 'short', - }).format(d); // e.g., 'Mon', 'Tue', ... + }).format(d); } - static start() { - if (process.env.NODE_ENV === 'test') return; - if (this._running) return; + static start(): void { + if (process.env.NODE_ENV === 'test' || this._running) return; this._running = true; - - // Arranca un tick cada minuto this._timer = setInterval(() => { - this.runOnce().catch(err => { - console.error('RemindersService runOnce error:', err); - }); + this.runOnce().catch(err => console.error('RemindersService runOnce error:', err)); }, 60_000); - - // Primer tick diferido para no bloquear el arranque setTimeout(() => this.runOnce().catch(() => {}), 5_000); } - static stop() { + static stop(): void { this._running = false; - if (this._timer) { - clearInterval(this._timer); - this._timer = null; - } + if (this._timer) { clearInterval(this._timer); this._timer = null; } } static async runOnce(now: Date = new Date()): Promise { - const instanceDb = getDb() as Database; + const db = getDb() as Database; const todayYMD = this.ymdInTZ(now); const nowHM = this.hmInTZ(now); - const weekday = this.weekdayShortInTZ(now); // 'Mon'..'Sun' - const graceRaw = Number(process.env.REMINDERS_GRACE_MINUTES); - const GRACE_MIN = Number.isFinite(graceRaw) && graceRaw >= 0 ? Math.min(Math.floor(graceRaw), 180) : 60; - - const rows = instanceDb.prepare(` - SELECT user_id, reminder_freq, reminder_time, last_reminded_on - FROM user_preferences - WHERE reminder_freq IN ('daily', 'weekly', 'weekdays') - `).all() as UserPreference[]; - - // Determinar si aplicar gating por grupos - const enforce = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase() === 'enforce'; + const weekday = this.weekdayShortInTZ(now); + + const enforce = enforceGatingEnabled(); if (enforce) { - try { - // Evitar falsos positivos por caché obsoleta entre operaciones previas del test - AllowedGroups.clearCache?.(); - } catch {} + try { AllowedGroups.clearCache?.(); } catch {} } - for (const pref of rows) { - // Evitar duplicado el mismo día - if (pref.last_reminded_on === todayYMD) continue; - - // Verificar hora alcanzada y ventana de gracia - if (!pref.reminder_time) continue; - const [nowH, nowM] = String(nowHM).split(':'); - const [cfgH, cfgM] = String(pref.reminder_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 prefs = fetchPreferences(db); - // Antes de la hora programada - if (nowMin < cfgMin) continue; + for (const pref of prefs) { + try { + await processOnePreference(db, pref, todayYMD, nowHM, weekday, enforce); + } catch (e) { + console.error('RemindersService: error al procesar usuario', pref.user_id, e); + } + } + } +} - // Sólo incrementar métrica si es un día válido para el usuario +// --------------------------------------------------------------------------- +// Per-preference processing (extracted from the loop) +// --------------------------------------------------------------------------- + +async function processOnePreference( + db: Database, + pref: UserPreference, + todayYMD: string, + nowHM: string, + weekday: string, + enforce: boolean, +): Promise { + const nowMin = parseHmMinutes(nowHM); + const evalResult = evaluatePreference(pref, todayYMD, nowMin, weekday); + + if (evalResult) { + // Track metric for outside-window skips on valid weekdays + if (evalResult.skip === 'outside_window') { + const isWeekend = weekday === 'Sat' || weekday === 'Sun'; const isValidDay = !( - (pref.reminder_freq === 'weekdays' && (weekday === 'Sat' || weekday === 'Sun')) || + (pref.reminder_freq === 'weekdays' && isWeekend) || (pref.reminder_freq === 'weekly' && weekday !== 'Mon') ); - - // Fuera de ventana de gracia: saltar - if (nowMin > cfgMin + GRACE_MIN) { - try { if (isValidDay) Metrics.inc('reminders_skipped_outside_window_total'); } catch {} - continue; + if (isValidDay) { + try { Metrics.inc('reminders_skipped_outside_window_total'); } catch {} } + } + return; + } - // Laborables: solo de lunes a viernes - if (pref.reminder_freq === 'weekdays' && (weekday === 'Sat' || weekday === 'Sun')) continue; + // Fetch and filter tasks + const allPending = TaskService.listUserPending(pref.user_id, 1000); + const filtered = filterTasksByGating(allPending, enforce); + if (filtered.length === 0) return; // No tasks yet — don't mark as reminded - // Semanal: solo lunes (Mon) - if (pref.reminder_freq === 'weekly' && weekday !== 'Mon') continue; + const items = filtered.slice(0, 10); - try { - // Obtener una lista amplia para filtrar correctamente por grupos permitidos - const allPending = TaskService.listUserPending(pref.user_id, 1000); - const filtered = enforce ? allPending.filter(t => !t.group_id || AllowedGroups.isAllowed(t.group_id)) : allPending; - const total = filtered.length; - const items = filtered.slice(0, 10); - if (items.length === 0) { - // No enviar si no hay tareas; no marcamos last_reminded_on para permitir enviar si aparecen más tarde hoy - continue; - } - - // Construir mensaje similar a "/t ver mis" - const formatDDMM = (ymd?: string | null): string | null => { - if (!ymd) return null; - const parts = String(ymd).split('-'); - if (parts.length >= 3) { - const [Y, M, D] = parts; - if (D && M) return `${D}/${M}`; - } - return String(ymd); - }; - - const byGroup = new Map(); - for (const t of items) { - const key = t.group_id || '(sin grupo)'; - const arr = byGroup.get(key) || []; - arr.push(t); - byGroup.set(key, arr); - } - - const sections: string[] = []; - 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 = - (groupId && GroupSyncService.activeGroupsCache.get(groupId)) || - (groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo'); - - sections.push(bold(groupName)); - const rendered = await Promise.all(arr.map(async (t) => { - const names = await Promise.all( - (t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid) - ); - const owner = - (t.assignees?.length || 0) === 0 - ? `${ICONS.unassigned} sin responsable` - : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; - const isOverdue = t.due_date ? t.due_date < todayYMD : false; - const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`; - })); - sections.push(...rendered); - } - - // Si hay más tareas de las listadas (tope), añadir resumen - if (total > items.length) { - sections.push(italic(`… y ${total - items.length} más`)); - } - - // (Etapa 3) Sección opcional de "sin responsable" filtrada por membresía activa + snapshot fresca. - const includeUnassigned = String(process.env.REMINDERS_INCLUDE_UNASSIGNED_FROM_MEMBERSHIP || '').toLowerCase() === 'true'; - if (includeUnassigned) { - let memberGroups = GroupSyncService.getFreshMemberGroupsForUser(pref.user_id); - if (enforce) { - memberGroups = memberGroups.filter(gid => AllowedGroups.isAllowed(gid)); - } - for (const gid of memberGroups) { - const unassigned = TaskService.listGroupUnassigned(gid, 10); - if (unassigned.length > 0) { - const groupName = - (gid && GroupSyncService.activeGroupsCache.get(gid)) || - gid; - sections.push(bold(`${groupName} — Sin responsable`)); - const renderedUnassigned = unassigned.map((t) => { - const isOverdue = t.due_date ? t.due_date < todayYMD : false; - const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${ICONS.unassigned} sin responsable`; - }); - sections.push(...renderedUnassigned); - - const totalUnassigned = TaskService.countGroupUnassigned(gid); - if (totalUnassigned > unassigned.length) { - sections.push(italic(`… y ${totalUnassigned - unassigned.length} más`)); - } - } - } - } - - await ResponseQueue.add([{ - recipient: pref.user_id, - message: sections.join('\n') - }]); - - // Marcar como enviado hoy - instanceDb.prepare(` - INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at) - VALUES (?, COALESCE((SELECT reminder_freq FROM user_preferences WHERE user_id = ?), 'daily'), - COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30'), - ?, strftime('%Y-%m-%d %H:%M:%f', 'now')) - ON CONFLICT(user_id) DO UPDATE SET - last_reminded_on = excluded.last_reminded_on, - updated_at = excluded.updated_at - `).run(pref.user_id, pref.user_id, pref.user_id, todayYMD); - } catch (e) { - console.error('RemindersService: error al procesar usuario', pref.user_id, e); - } - } - } + // Build and send message + const message = await buildReminderMessage(pref, items, filtered.length, todayYMD); + await ResponseQueue.add([{ recipient: pref.user_id, message }]); + + // Mark as sent + markReminded(db, pref.user_id, todayYMD); } diff --git a/src/services/response-queue.ts b/src/services/response-queue.ts index 839c434..6462be1 100644 --- a/src/services/response-queue.ts +++ b/src/services/response-queue.ts @@ -3,10 +3,10 @@ import { getDb } from '../db/locator'; import { IdentityService } from './identity'; import { normalizeWhatsAppId } from '../utils/whatsapp'; import { Metrics } from './metrics'; -import { toIsoSqlUTC } from '../utils/datetime'; +import { toIsoSqlUTC, toIsoUTC } from '../utils/datetime'; import * as EvolutionClient from '../clients/evolution'; import { runCleanupOnce as cleanupRunOnce } from './queue/cleanup'; -import { parseQueueMetadata, isReactionMeta } from './queue/metadata'; +import { parseQueueMetadata, isReactionMeta, type ReactionMeta } from './queue/metadata'; const MAX_FALLBACK_DIGITS = (() => { const raw = (process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '').trim(); @@ -16,6 +16,99 @@ const MAX_FALLBACK_DIGITS = (() => { const isDigits = (s: string) => /^\d+$/.test(s); +// ── Recipient resolution (extracted from sendOne) ──────────────────── + +type RecipientResult = + | { ok: true; number: string } + | { ok: false; status: 422; error: string }; + +function resolveRecipient(raw: string): RecipientResult { + const recipient = String(raw || ''); + + if (!recipient.includes('@')) { + const resolved = IdentityService.resolveAliasOrNull(recipient) || recipient; + 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' }; + } + return { ok: true, number: resolved }; + } + + if (recipient.endsWith('@g.us')) return { ok: true, number: recipient }; + + if (recipient.endsWith('@s.whatsapp.net')) { + const n = normalizeWhatsAppId(recipient); + 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' }; + } + return { ok: true, number: n }; + } + + try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'bad_domain' }); } catch {} + return { ok: false, status: 422, error: 'unresolvable_recipient_bad_domain' }; +} + +// ── Mention resolution (extracted from sendOne) ────────────────────── + +function resolveMentions(metadata: string | null | undefined): string[] | undefined { + if (!metadata) return undefined; + let parsed: any; + try { parsed = JSON.parse(metadata); } catch { return undefined; } + if (!parsed || !Array.isArray(parsed.mentioned) || parsed.mentioned.length === 0) return undefined; + + const resolved: string[] = []; + for (const m of parsed.mentioned) { + const n = normalizeWhatsAppId(String(m)); + if (!n) continue; + const r = IdentityService.resolveAliasOrNull(n) || n; + if (!/^\d+$/.test(r)) continue; + resolved.push(`${r}@s.whatsapp.net`); + } + return resolved.length > 0 ? Array.from(new Set(resolved)) : undefined; +} + +// ── Reaction job sender (extracted from sendOne) ───────────────────── + +async function sendReactionJob(meta: ReactionMeta): Promise<{ ok: boolean; status?: number; error?: string }> { + const chatId = String(meta.chatId || ''); + const messageId = String(meta.messageId || ''); + const emoji = String(meta.emoji || ''); + const emojiLabel = emoji === '✅' ? 'check' : (emoji === '🤖' ? 'robot' : (emoji === '⚠️' ? 'warn' : 'other')); + + if (!chatId || !messageId || !emoji) { + return { ok: false, error: 'invalid_reaction_metadata' }; + } + + const key: any = { remoteJid: chatId, fromMe: !!meta.fromMe, id: messageId }; + if (meta.participant) key.participant = String(meta.participant); + + const result = await EvolutionClient.sendReaction({ key, reaction: emoji }); + + if (!result.ok) { + const errTxt = result.error || (typeof result.status === 'number' ? `HTTP ${result.status}` : 'unknown_error'); + console.warn('Send reaction failed:', { status: result.status, body: errTxt }); + try { Metrics.inc('reactions_failed_total', 1, { emoji: emojiLabel }); } catch {} + const out: { ok: false; error: string; status?: number } = { ok: false, error: errTxt }; + if (typeof result.status === 'number') out.status = result.status; + return out; + } + + console.log(`✅ Sent reaction: ${emoji} on ${chatId}/${messageId}`); + try { Metrics.inc('reactions_sent_total', 1, { emoji: emojiLabel }); } catch {} + const out: { ok: true; status?: number } = { ok: true }; + if (typeof result.status === 'number') out.status = result.status; + return out; +} + type QueuedResponse = { recipient: string; message: string; @@ -33,9 +126,6 @@ type ClaimedItem = { export const ResponseQueue = { - // Conservamos la cola en memoria por compatibilidad, aunque no se usa para persistencia - queue: [] as QueuedResponse[], - // Configuración fija (MVP) WORKERS: 2, BATCH_SIZE: 10, @@ -174,26 +264,22 @@ export const ResponseQueue = { // Elegir timestamp de referencia const tRaw = (r.updated_at || r.created_at || '').toString(); - const iso = tRaw.includes('T') ? tRaw : (tRaw.replace(' ', 'T') + 'Z'); - const ts = Date.parse(iso); + const ts = Date.parse(toIsoUTC(tRaw)); if (Number.isFinite(ts) && ts > lastTsMs) { lastTsMs = ts; lastSentAt = tRaw || null; lastVariant = (meta.variant === 'reminder' ? 'reminder' : 'initial'); } - // Primer initial (preferimos part=1) + // Primer initial (mantener el más antiguo) if (meta.variant === 'initial') { const created = (r.created_at || '').toString(); if (!firstInitialAt) { firstInitialAt = created || null; } else { - // mantener el más antiguo try { - const curIso = (firstInitialAt as string).includes('T') ? firstInitialAt as string : ((firstInitialAt as string).replace(' ', 'T') + 'Z'); - const curMs = Date.parse(curIso); - const newIso = created.includes('T') ? created : (created.replace(' ', 'T') + 'Z'); - const newMs = Date.parse(newIso); + const curMs = Date.parse(toIsoUTC(String(firstInitialAt))); + const newMs = Date.parse(toIsoUTC(created)); if (Number.isFinite(newMs) && (!Number.isFinite(curMs) || newMs < curMs)) { firstInitialAt = created || null; } @@ -249,13 +335,6 @@ export const ResponseQueue = { } }, - getHeaders(): HeadersInit { - return { - apikey: process.env.EVOLUTION_API_KEY || '', - 'Content-Type': 'application/json', - }; - }, - async sendOne(item: ClaimedItem): Promise<{ ok: boolean; status?: number; error?: string }> { const baseUrl = process.env.EVOLUTION_API_URL; const instance = process.env.EVOLUTION_API_INSTANCE; @@ -265,120 +344,35 @@ export const ResponseQueue = { return { ok: false, error: msg }; } - // Detectar jobs de reacción + // Reaction jobs: delegate to standalone sender const meta = parseQueueMetadata(item.metadata); if (isReactionMeta(meta)) { - const chatId = String(meta.chatId || ''); - const messageId = String(meta.messageId || ''); - const emoji = String(meta.emoji || ''); - const emojiLabel = emoji === '✅' ? 'check' : (emoji === '🤖' ? 'robot' : (emoji === '⚠️' ? 'warn' : 'other')); - if (!chatId || !messageId || !emoji) { - return { ok: false, error: 'invalid_reaction_metadata' }; - } - const fromMe = !!meta.fromMe; - const key: any = { remoteJid: chatId, fromMe, id: messageId }; - if (meta.participant) { - key.participant = String(meta.participant); - } - const payload = { key, reaction: emoji }; - const result = await EvolutionClient.sendReaction(payload); + return sendReactionJob(meta); + } + + // Resolve recipient + const resolved = resolveRecipient(item.recipient); + if (!resolved.ok) return resolved; + + // Build payload + const payload: any = { number: resolved.number, text: item.message }; + const mentions = resolveMentions(item.metadata); + if (mentions) payload.mentioned = mentions; + + // Send + try { + const result = await EvolutionClient.sendText(payload); if (!result.ok) { const errTxt = result.error || (typeof result.status === 'number' ? `HTTP ${result.status}` : 'unknown_error'); - console.warn('Send reaction failed:', { status: result.status, body: errTxt }); - try { Metrics.inc('reactions_failed_total', 1, { emoji: emojiLabel }); } catch {} - const out: { ok: false; error: string } & { status?: number } = { ok: false, error: errTxt }; + console.warn('Send failed:', { status: result.status, body: errTxt }); + const out: { ok: false; error: string; status?: number } = { ok: false, error: errTxt }; if (typeof result.status === 'number') out.status = result.status; return out; } - console.log(`✅ Sent reaction with payload: ${JSON.stringify(payload)}`); - try { Metrics.inc('reactions_sent_total', 1, { emoji: emojiLabel }); } catch {} - const okOut: { ok: true } & { status?: number } = { ok: true }; + console.log(`✅ Sent message to: ${resolved.number}`); + const okOut: { ok: true; status?: number } = { ok: true }; if (typeof result.status === 'number') okOut.status = result.status; return okOut; - } - - // Endpoint típico de Evolution API para texto simple - 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: numberOrJid, - text: item.message, - }; - - if (item.metadata) { - try { - const parsed = JSON.parse(item.metadata); - if (parsed && Array.isArray(parsed.mentioned) && parsed.mentioned.length > 0) { - const resolved: string[] = []; - for (const m of parsed.mentioned) { - const n = normalizeWhatsAppId(String(m)); - if (!n) continue; - const r = IdentityService.resolveAliasOrNull(n) || n; - if (!/^\d+$/.test(r)) continue; - resolved.push(`${r}@s.whatsapp.net`); - } - // Eliminar duplicados - payload.mentioned = Array.from(new Set(resolved)); - } - } catch { - // ignore bad metadata - } - } - - { - const result = await EvolutionClient.sendText(payload); - if (!result.ok) { - const errTxt = result.error || (typeof result.status === 'number' ? `HTTP ${result.status}` : 'unknown_error'); - console.warn('Send failed:', { status: result.status, body: errTxt }); - const out: { ok: false; error: string } & { status?: number } = { ok: false, error: errTxt }; - if (typeof result.status === 'number') out.status = result.status; - return out; - } - console.log(`✅ Sent message with payload: ${JSON.stringify(payload)}`); - const okOut: { ok: true } & { status?: number } = { ok: true }; - if (typeof result.status === 'number') okOut.status = result.status; - return okOut; - } } catch (err) { const errMsg = (err instanceof Error ? err.message : String(err)); console.error('Network error sending message:', errMsg); diff --git a/src/services/webhook-manager.ts b/src/services/webhook-manager.ts index 994bd55..ccac46c 100644 --- a/src/services/webhook-manager.ts +++ b/src/services/webhook-manager.ts @@ -1,4 +1,4 @@ -import { REQUIRED_ENV } from '../server'; +import { REQUIRED_ENV } from '../env/required'; type WebhookConfig = { url: string; diff --git a/src/tasks/complete-reaction.ts b/src/tasks/complete-reaction.ts index 73a7f8b..84d5abb 100644 --- a/src/tasks/complete-reaction.ts +++ b/src/tasks/complete-reaction.ts @@ -1,8 +1,95 @@ import type { Database } from 'bun:sqlite'; import { isGroupId } from '../utils/whatsapp'; +import { toIsoUTC } from '../utils/datetime'; import { AllowedGroups } from '../services/allowed-groups'; import { ResponseQueue } from '../services/response-queue'; +// ── Env helpers ────────────────────────────────────────────── + +const DEFAULT_TTL_DAYS = 14; + +function isEnvFlagEnabled(envKey: string, defaultVal = false): boolean { + const raw = String(process.env[envKey] || String(defaultVal)).toLowerCase(); + return ['true', '1', 'yes', 'on'].includes(raw); +} + +function envNumber(envKey: string, fallback: number): number { + const n = Number(process.env[envKey]); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function envString(envKey: string, fallback: string): string { + const v = (process.env[envKey] ?? '').trim(); + return v || fallback; +} + +// ── DB helpers ─────────────────────────────────────────────── + +interface TaskOrigin { + chat_id?: string; + message_id?: string; + created_at?: string; + participant?: string | null; + from_me?: number | boolean | null; +} + +/** Query con fallback: si la columna participant/from_me no existe aún (schema antiguo), reintenta sin ellas. */ +function getTaskOrigin(db: Database, taskId: number): TaskOrigin | null { + try { + const row = db.prepare(` + SELECT chat_id, message_id, created_at, participant, from_me + FROM task_origins WHERE task_id = ? + `).get(taskId) as TaskOrigin | undefined; + return row ?? null; + } catch { + const row = db.prepare(` + SELECT chat_id, message_id, created_at + FROM task_origins WHERE task_id = ? + `).get(taskId) as TaskOrigin | undefined; + return row ?? null; + } +} + +// ── Eligibility checks ─────────────────────────────────────── + +function isScopeEligible(chatId: string): boolean { + const scope = envString('REACTIONS_SCOPE', 'groups').toLowerCase(); + return scope === 'all' || isGroupId(chatId); +} + +function isWithinTtl(origin: TaskOrigin): boolean { + const ttlDays = envNumber('REACTIONS_TTL_DAYS', DEFAULT_TTL_DAYS); + const maxAgeMs = ttlDays * 24 * 60 * 60 * 1000; + + const createdMs = Date.parse(toIsoUTC(String(origin.created_at || ''))); + return Number.isFinite(createdMs) && (Date.now() - createdMs <= maxAgeMs); +} + +function isGatingAllowed(chatId: string): boolean { + if (!isGroupId(chatId)) return true; + const mode = envString('GROUP_GATING_MODE', 'off').toLowerCase(); + if (mode !== 'enforce') return true; + try { + return AllowedGroups.isAllowed(chatId); + } catch { + return true; // fail open + } +} + +// ── Reaction options builder ───────────────────────────────── + +function buildReactionOpts(origin: TaskOrigin): { participant?: string; fromMe?: boolean } { + const participant = origin.participant ? String(origin.participant) : undefined; + const fromMe = (origin.from_me === 1 || origin.from_me === true) ? true : undefined; + + const opts: { participant?: string; fromMe?: boolean } = {}; + if (participant !== undefined) opts.participant = participant; + if (typeof fromMe === 'boolean') opts.fromMe = fromMe; + return Object.keys(opts).length > 0 ? opts : {}; +} + +// ── Public API ─────────────────────────────────────────────── + /** * Publica una reacción ✅ al mensaje origen de la tarea si: * - REACTIONS_ENABLED está activado, @@ -14,61 +101,23 @@ import { ResponseQueue } from '../services/response-queue'; */ export function enqueueCompletionReactionIfEligible(db: Database, taskId: number): void { try { - const rxEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase(); - const enabled = ['true', '1', 'yes', 'on'].includes(rxEnabled); - if (!enabled) return; - - let origin: any = null; - try { - origin = db.prepare(` - SELECT chat_id, message_id, created_at, participant, from_me - FROM task_origins - WHERE task_id = ? - `).get(taskId) as { chat_id?: string; message_id?: string; created_at?: string; participant?: string | null; from_me?: number | boolean | null } | undefined; - } catch { - origin = db.prepare(` - SELECT chat_id, message_id, created_at - FROM task_origins - WHERE task_id = ? - `).get(taskId) as { chat_id?: string; message_id?: string; created_at?: string } | undefined; - } - - if (!origin || !origin.chat_id || !origin.message_id) return; + if (!isEnvFlagEnabled('REACTIONS_ENABLED')) return; + + const origin = getTaskOrigin(db, taskId); + if (!origin?.chat_id || !origin.message_id) return; const chatId = String(origin.chat_id); - const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase(); - if (!(scope === 'all' || isGroupId(chatId))) return; - - // TTL desde REACTIONS_TTL_DAYS (default 14 si inválido) - const ttlDaysEnv = Number(process.env.REACTIONS_TTL_DAYS); - const ttlDays = Number.isFinite(ttlDaysEnv) && ttlDaysEnv > 0 ? ttlDaysEnv : 14; - const maxAgeMs = ttlDays * 24 * 60 * 60 * 1000; - - const createdRaw = String(origin.created_at || ''); - const createdIso = createdRaw.includes('T') ? createdRaw : (createdRaw.replace(' ', 'T') + 'Z'); - const createdMs = Date.parse(createdIso); - const withinTtl = Number.isFinite(createdMs) ? (Date.now() - createdMs <= maxAgeMs) : false; - if (!withinTtl) return; - - // Gating 'enforce' para grupos - if (isGroupId(chatId)) { - const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); - if (mode === 'enforce') { - let allowed = true; - try { allowed = AllowedGroups.isAllowed(chatId); } catch { allowed = true; } - if (!allowed) return; - } - } - - // Encolar reacción ✅ con idempotencia; no bloquear si falla - const participant = origin && origin.participant ? String(origin.participant) : undefined; - const fromMe = (origin && (origin.from_me === 1 || origin.from_me === true)) ? true : undefined; - const rxOpts: { participant?: string; fromMe?: boolean } = {}; - if (participant !== undefined) rxOpts.participant = participant; - if (typeof fromMe === 'boolean') rxOpts.fromMe = fromMe; - - ResponseQueue.enqueueReaction(chatId, String(origin.message_id), '✅', rxOpts).catch(() => {}); + if (!isScopeEligible(chatId)) return; + if (!isWithinTtl(origin)) return; + if (!isGatingAllowed(chatId)) return; + + ResponseQueue.enqueueReaction( + chatId, + String(origin.message_id), + '✅', + buildReactionOpts(origin), + ).catch(() => {}); } catch { - // no-op + // no-op: nunca bloquear el flujo de completado } } diff --git a/src/tasks/mappers.ts b/src/tasks/mappers.ts index 16e4813..0f9f236 100644 --- a/src/tasks/mappers.ts +++ b/src/tasks/mappers.ts @@ -3,6 +3,22 @@ * Mantienen las mismas formas que consumen comandos, recordatorios y API web. */ +export function mapTaskBasicRow( + row: { id?: number | null; description?: string | null; due_date?: string | null; display_code?: number | null } +): { + id: number; + description: string; + due_date: string | null; + display_code: number | null; +} { + return { + id: Number(row.id), + description: String(row.description || ''), + due_date: row.due_date ? String(row.due_date) : null, + display_code: row.display_code != null ? Number(row.display_code) : null, + }; +} + export function mapTaskListItem( row: { id: number; description: string; due_date: string | null; group_id: string | null; display_code: number | null }, assignees: string[] diff --git a/src/tasks/model.ts b/src/tasks/model.ts deleted file mode 100644 index d050e3f..0000000 --- a/src/tasks/model.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface Task { - id: number; - description: string; - created_at: Date; - due_date: Date | null; - completed: boolean; - completed_at: Date | null; - completed_by: string | null; - group_id: string; // WhatsApp group ID where task was created - created_by: string; // WhatsApp user ID of task creator -} - -export interface TaskAssignment { - task_id: number; - user_id: string; // Normalized phone number - assigned_by: string; // Who assigned this - assigned_at: Date; -} diff --git a/src/tasks/service.ts b/src/tasks/service.ts index abdf784..9ef32e1 100644 --- a/src/tasks/service.ts +++ b/src/tasks/service.ts @@ -5,7 +5,7 @@ import { AllowedGroups } from '../services/allowed-groups'; import { isGroupId } from '../utils/whatsapp'; import { pickNextDisplayCode } from './display-code'; import { enqueueCompletionReactionIfEligible } from './complete-reaction'; -import { mapTaskListItem, mapTaskWithGroupNameRow, mapTaskDetailsRow } from './mappers'; +import { mapTaskListItem, mapTaskWithGroupNameRow, mapTaskDetailsRow, mapTaskBasicRow } from './mappers'; type CreateTaskInput = { description: string; @@ -97,42 +97,6 @@ export class TaskService { return runTx(); } - // Listar pendientes del grupo (limite por defecto 10) - static listGroupPending(groupId: string, limit: number = 10): Array<{ - id: number; - description: string; - due_date: string | null; - group_id: string | null; - display_code: number | null; - assignees: string[]; - }> { - const rows = this.getDb() - .prepare(` - SELECT id, description, due_date, group_id, display_code - FROM tasks - WHERE group_id = ? - AND COALESCE(completed, 0) = 0 AND completed_at IS NULL - ORDER BY - CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, - due_date ASC, - id ASC - LIMIT ? - `) - .all(groupId, limit) as Array<{ id: number; description: string; due_date: string | null; group_id: string | null; display_code: number | null }>; - - const getAssignees = this.getDb().prepare(` - SELECT user_id FROM task_assignments - WHERE task_id = ? - ORDER BY assigned_at ASC - `); - - return rows.map((r) => { - const assigneesRows = getAssignees.all(r.id) as Array<{ user_id: string }>; - const assignees = assigneesRows.map((a) => String(a.user_id)); - return mapTaskListItem(r, assignees); - }); - } - // Listar pendientes asignadas al usuario (limite por defecto 10) static listUserPending(userId: string, limit: number = 10): Array<{ id: number; @@ -170,19 +134,6 @@ export class TaskService { }); } - // Contar pendientes del grupo (sin límite) - static countGroupPending(groupId: string): number { - const row = this.getDb() - .prepare(` - SELECT COUNT(*) as cnt - FROM tasks - WHERE group_id = ? - AND COALESCE(completed, 0) = 0 AND completed_at IS NULL - `) - .get(groupId) as { cnt?: number } | undefined; - return Number(row?.cnt || 0); - } - // Contar pendientes asignadas al usuario (sin límite) static countUserPending(userId: string): number { const row = this.getDb() @@ -369,6 +320,39 @@ export class TaskService { }; } + // Construye el objeto task para respuestas de unassign/claim/complete + private static buildTaskResult(existing: any): { + id: number; + description: string; + due_date: string | null; + display_code: number | null; + } { + return { + 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, + }; + } + + // Verifica si soltar sería prohibido (tarea personal con único asignatario) + private static isForbiddenPersonal(db: Database, taskId: number, userId: string, groupId: string | null): boolean { + if (groupId != null) return false; + try { + 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 { cnt?: number; mine?: number } | undefined; + const cnt = Number(stats?.cnt || 0); + const mine = Number(stats?.mine || 0) > 0; + return cnt === 1 && mine; + } catch { + return false; + } + } + // Soltar tarea (unassign): idempotente static unassignTask(taskId: number, userId: string): { status: 'unassigned' | 'not_assigned' | 'not_found' | 'completed' | 'forbidden_personal'; @@ -376,91 +360,38 @@ export class TaskService { now_unassigned?: boolean; // true si tras soltar no quedan asignados } { const ensuredUser = ensureUserExists(userId, this.getDb()); - if (!ensuredUser) { - throw new Error('No se pudo asegurar el usuario'); - } + if (!ensuredUser) throw new Error('No se pudo asegurar el usuario'); const existing = this.getDb() - .prepare(` - SELECT id, description, due_date, group_id, completed, completed_at, display_code - FROM tasks - WHERE id = ? - `) + .prepare(`SELECT id, description, due_date, group_id, completed, completed_at, display_code FROM tasks WHERE id = ?`) .get(taskId) as any; - if (!existing) { - return { status: 'not_found' }; - } - + // --- guard clauses --- + if (!existing) return { status: 'not_found' }; if (existing.completed || existing.completed_at) { + return { status: 'completed', task: TaskService.buildTaskResult(existing) }; + } + if (TaskService.isForbiddenPersonal(this.getDb(), taskId, ensuredUser, existing.group_id)) { return { - status: 'completed', - 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, - }, + status: 'forbidden_personal', + task: TaskService.buildTaskResult(existing), + now_unassigned: false, }; } - // Regla: no permitir soltar si es tarea personal y el usuario es el único asignatario - try { - const stats = this.getDb().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 { cnt?: number; mine?: number } | undefined; - const cnt = Number(stats?.cnt || 0); - const mine = Number(stats?.mine || 0) > 0; - if (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 {} + // --- execute unassign & count remaining --- + const result = this.getDb() + .prepare(`DELETE FROM task_assignments WHERE task_id = ? AND user_id = ?`) + .run(taskId, ensuredUser) as { changes?: number }; - const deleteStmt = this.getDb().prepare(` - DELETE FROM task_assignments - WHERE task_id = ? AND user_id = ? - `); - - const result = deleteStmt.run(taskId, ensuredUser) as { changes?: number }; - - const cntRow = this.getDb() + const remaining = Number((this.getDb() .prepare(`SELECT COUNT(*) as cnt FROM task_assignments WHERE task_id = ?`) - .get(taskId) as { cnt?: number } | undefined; - const remaining = Number(cntRow?.cnt || 0); - - if (result.changes && result.changes > 0) { - return { - status: 'unassigned', - 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: remaining === 0, - }; - } + .get(taskId) as { cnt?: number } | undefined)?.cnt || 0); + const status = result.changes && result.changes > 0 ? 'unassigned' : 'not_assigned'; return { - status: 'not_assigned', - 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, - }, + status, + task: TaskService.buildTaskResult(existing), now_unassigned: remaining === 0, }; } @@ -502,12 +433,7 @@ export class TaskService { LIMIT 1 `).get(displayCode) as any; if (!row) return null; - return { - id: Number(row.id), - description: String(row.description || ''), - due_date: row.due_date ? String(row.due_date) : null, - display_code: row.display_code != null ? Number(row.display_code) : null, - }; + return mapTaskBasicRow(row); } // Lista tareas sin responsable para múltiples grupos. diff --git a/src/utils/datetime.ts b/src/utils/datetime.ts index c631b7e..835d48b 100644 --- a/src/utils/datetime.ts +++ b/src/utils/datetime.ts @@ -2,6 +2,41 @@ export function toIsoSqlUTC(d: Date = new Date()): string { return d.toISOString().replace('T', ' ').replace('Z', ''); } +/** + * Devuelve YYYY-MM-DD en UTC (útil para consultas por rango de fecha). + */ +export function ymdUTC(date: 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}`; +} + +/** + * Convierte un string de timestamp (ISO 8601 o SQLite "YYYY-MM-DD HH:MM:SS") a ISO-8601. + * Si ya contiene 'T' se devuelve tal cual; si no, reemplaza espacio por 'T' y añade 'Z'. + */ +export function toIsoUTC(raw: string): string { + const s = String(raw ?? '').trim(); + if (!s) return ''; + if (s.includes('T')) return s; + return s.replace(' ', 'T') + 'Z'; +} + +/** + * Formats a Date as YYYY-MM-DD in the given IANA timezone. + */ +export 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')}`; +} + export function normalizeTime(input: string | null | undefined): string | null { const s = (input ?? '').trim(); const m = /^(\d{1,2}):(\d{1,2})$/.exec(s); diff --git a/src/utils/whatsapp.ts b/src/utils/whatsapp.ts index 3422955..a307fa8 100644 --- a/src/utils/whatsapp.ts +++ b/src/utils/whatsapp.ts @@ -50,12 +50,4 @@ export function isGroupId(jid: string | null | undefined): boolean { return !!jid && jid.endsWith('@g.us'); } -/** - * Checks if a given raw JID represents a standard user chat. - * - * @param jid The raw JID string (e.g., '123456@s.whatsapp.net'). - * @returns True if the JID ends with '@s.whatsapp.net', false otherwise. - */ -export function isUserJid(jid: string | null | undefined): boolean { - return !!jid && jid.endsWith('@s.whatsapp.net'); -} + diff --git a/startup.ts b/startup.ts new file mode 100644 index 0000000..3636228 --- /dev/null +++ b/startup.ts @@ -0,0 +1,183 @@ +/** + * startup.ts — TypeScript equivalent of startup.sh + * + * Normalizes DB paths, starts the bot (index.ts) and web app (SvelteKit) + * in the background, waits for the database and auth tables to be ready, + * then runs the proxy router in the foreground. + */ + +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Track spawned children so we can clean them up on exit. */ +const children: { proc: ReturnType }[] = []; + +function cleanup(): void { + for (const { proc } of children) { + try { + proc.kill(); + } catch { + // Process already dead – ignore + } + } +} + +process.on("SIGTERM", () => { + cleanup(); + process.exit(0); +}); +process.on("SIGINT", () => { + cleanup(); + process.exit(0); +}); + +// --------------------------------------------------------------------------- +// 1. Normalize DB paths to absolute (readlink -f equivalent) +// --------------------------------------------------------------------------- + +let dbPath = process.env.DB_PATH; +let dataDir = process.env.DATA_DIR || "/app/data"; + +if (dbPath) { + try { + dbPath = resolve(dbPath); + process.env.DB_PATH = dbPath; + } catch { + // Equivalent to `readlink -f "$DB_PATH" || true` + } +} else { + try { + dataDir = resolve(dataDir); + process.env.DATA_DIR = dataDir; + } catch { + // Equivalent to `readlink -f "$DATA_DIR" || true` + } +} + +// --------------------------------------------------------------------------- +// 2. Determine the DB file path for wait checks +// --------------------------------------------------------------------------- + +const dbFile: string = process.env.DB_PATH + ? process.env.DB_PATH + : `${process.env.DATA_DIR || "/app/data"}/tasks.db`; + +// --------------------------------------------------------------------------- +// 3. Start the bot in the background (default port 3007) +// --------------------------------------------------------------------------- + +const botPort = process.env.BOT_PORT || "3007"; +const botProc = Bun.spawn({ + cmd: ["bun", "run", "index.ts"], + env: { ...process.env, PORT: botPort }, + stdout: "inherit", + stderr: "inherit", +}); +children.push({ proc: botProc }); + +// --------------------------------------------------------------------------- +// 4. Wait for the DB file to exist (max ~30 s) +// --------------------------------------------------------------------------- + +console.log(`[startup] Waiting for database at: ${dbFile}`); +for (let i = 0; i < 150; i++) { + if (existsSync(dbFile)) break; + await Bun.sleep(200); +} + +// --------------------------------------------------------------------------- +// 5. Wait for auth tables (web_tokens, web_sessions) — max ~30 s +// --------------------------------------------------------------------------- + +// Check if sqlite3 CLI is available (equivalent to `command -v sqlite3`) +let hasSqlite3 = false; +try { + const check = Bun.spawn({ + cmd: ["sqlite3", "--version"], + stdout: "pipe", + stderr: "pipe", + }); + await check.exited; + hasSqlite3 = check.exitCode === 0; +} catch { + hasSqlite3 = false; +} + +if (hasSqlite3) { + console.log( + "[startup] Checking for auth tables (web_tokens, web_sessions)...", + ); + let authReady = false; + for (let i = 0; i < 150; i++) { + if (existsSync(dbFile)) { + const proc = Bun.spawn({ + cmd: [ + "sqlite3", + dbFile, + "SELECT 1 FROM sqlite_master WHERE type='table' AND name IN ('web_tokens','web_sessions') LIMIT 1;", + ], + stdout: "pipe", + stderr: "pipe", + }); + const out = await new Response(proc.stdout).text(); + await proc.exited; + if (out.trim() === "1") { + authReady = true; + break; + } + } + await Bun.sleep(200); + } + if (!authReady) { + console.log( + "[startup] Warning: auth tables may not be ready after waiting.", + ); + } +} else { + console.log( + "[startup] sqlite3 not available; skipping auth-table verification (continuing).", + ); +} + +// --------------------------------------------------------------------------- +// 6. Start the web app (SvelteKit) in the background (default port 3008) +// --------------------------------------------------------------------------- + +const webPort = process.env.WEB_PORT || "3008"; +const webProc = Bun.spawn({ + cmd: ["bun", "./build/index.js"], + cwd: resolve("apps/web"), + env: { ...process.env, PORT: webPort }, + stdout: "inherit", + stderr: "inherit", +}); +children.push({ proc: webProc }); + +// --------------------------------------------------------------------------- +// 7. Small wait to avoid race conditions +// --------------------------------------------------------------------------- + +await Bun.sleep(1000); + +// --------------------------------------------------------------------------- +// 8. Start the proxy router in the foreground (default port 3000) +// --------------------------------------------------------------------------- + +const port = process.env.PORT || "3000"; +const proxyProc = Bun.spawn({ + cmd: ["bun", "proxy.ts"], + env: { ...process.env, PORT: port }, + stdout: "inherit", + stderr: "inherit", +}); + +// Wait for the proxy (foreground process) to exit, then shut down. +const exitCode = await proxyProc.exited; +console.log(`[startup] Proxy exited with code ${exitCode}`); + +cleanup(); +process.exit(typeof exitCode === "number" ? exitCode : 1); diff --git a/tests/helpers/dates.ts b/tests/helpers/dates.ts index 993491c..6366a65 100644 --- a/tests/helpers/dates.ts +++ b/tests/helpers/dates.ts @@ -1,20 +1,14 @@ -import { toIsoSqlUTC } from '../../src/utils/datetime'; +import { toIsoSqlUTC, ymdUTC, ymdInTZ as sharedYmdInTZ } from '../../src/utils/datetime'; export function toIsoSql(d: Date = new Date()): string { return toIsoSqlUTC(d); } -export { toIsoSqlUTC }; +export { toIsoSqlUTC, ymdUTC }; +/** Wraps shared ymdInTZ with a default timezone for tests. */ export function ymdInTZ(d: Date, tz: string = 'Europe/Madrid'): 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')}`; + return sharedYmdInTZ(d, tz); } export function addDaysToYMD(ymd: string, days: number, tz: string = 'Europe/Madrid'): string { @@ -24,13 +18,6 @@ export function addDaysToYMD(ymd: string, days: number, tz: string = 'Europe/Mad return ymdInTZ(base, tz); } -export function ymdUTC(date: 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}`; -} - export function addDays(date: Date, days: number): Date { const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); d.setUTCDate(d.getUTCDate() + days); diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index e146ff9..d3a2a44 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -1,6 +1,7 @@ import Database, { type Database as SqliteDatabase } from 'bun:sqlite'; import { initializeDatabase } from '../../src/db'; import { setDb } from '../../src/db/locator'; +import { toIsoSql } from './dates'; // Servicios opcionales para inyección de DB en tests. // Importamos con nombres existentes en la base de código para respetar convenciones. @@ -44,6 +45,61 @@ export function resetServices(): void { /** * Marca como 'allowed' los groupIds indicados en la DB provista. */ +/** + * Sembrar un grupo en la DB rellenando columnas NOT NULL sin valor por defecto. + * Usa PRAGMA table_info para adaptarse automáticamente a la forma actual de la tabla. + */ +export function seedGroup(db: SqliteDatabase, groupId: string): void { + const cols = db.query(`PRAGMA table_info(groups)`).all() as any[]; + const values: Record = {}; + const nowIso = toIsoSql(new Date()); + + for (const c of cols) { + const name = String(c.name); + const type = String(c.type || '').toUpperCase(); + const notnull = Number(c.notnull || 0) === 1; + const hasDefault = c.dflt_value != null; + + if (name === 'id') { + values[name] = groupId; + continue; + } + + // Preconfigurar algunos alias comunes + if (name === 'name' || name === 'title' || name === 'subject') { + values[name] = 'Test Group'; + continue; + } + if (name === 'created_by') { + values[name] = 'tester'; + continue; + } + if (name.endsWith('_at')) { + values[name] = nowIso; + continue; + } + if (name === 'is_active' || name === 'active') { + values[name] = 1; + continue; + } + + // Para columnas NOT NULL sin valor por defecto, asignar valores genéricos + if (notnull && !hasDefault) { + if (type.includes('INT')) values[name] = 1; + else if (type.includes('REAL')) values[name] = 0; + else values[name] = 'N/A'; + } + } + + // Asegurar que id esté siempre + if (!('id' in values)) values['id'] = groupId; + + const colsList = Object.keys(values); + const placeholders = colsList.map(() => '?').join(', '); + const sql = `INSERT OR REPLACE INTO groups (${colsList.join(', ')}) VALUES (${placeholders})`; + db.prepare(sql).run(...colsList.map(k => values[k])); +} + export function seedAllowed(db: SqliteDatabase, groupIds: string[]): void { for (const gid of groupIds) { const g = String(gid || '').trim(); diff --git a/tests/helpers/server-test-harness.ts b/tests/helpers/server-test-harness.ts new file mode 100644 index 0000000..480f582 --- /dev/null +++ b/tests/helpers/server-test-harness.ts @@ -0,0 +1,98 @@ +/** + * Shared test harness for server.test.ts family. + * + * Provides database setup, queue mocking, and env handling used by all + * WebhookServer test suites. Each test file gets its own in‑memory DB + * via the factory-style setup/teardown pair. + */ +import { Database } from 'bun:sqlite'; +import { beforeAll, afterAll, beforeEach } from 'bun:test'; +import { WebhookServer } from '../../src/server'; +import { ResponseQueue } from '../../src/services/response-queue'; +import { GroupSyncService } from '../../src/services/group-sync'; +import { initializeDatabase } from '../../src/db'; +import { setDb, resetDb } from '../../src/db/locator'; +import { SimulatedResponseQueue } from './queue'; + +// ── Request builder ──────────────────────────────────────────────────── + +export function createTestRequest(payload: unknown): Request { + return new Request('http://localhost:3007', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); +} + +// ── Date helper ──────────────────────────────────────────────────────── + +export function getFutureDate(days: number): string { + const date = new Date(); + date.setDate(date.getDate() + days); + return date.toISOString().split('T')[0]; +} + +// ── Lifecycle helpers ────────────────────────────────────────────────── + +// ── Internal state ───────────────────────────────────────────────────── + +const ENV_BACKUP = { ...process.env }; + +// ── Public lifecycle factory ─────────────────────────────────────────── + +/** + * Register the standard beforeAll / afterAll / beforeEach hooks for a + * WebhookServer test module. Returns the shared in‑memory test DB so + * individual tests can query it directly. + * + * Usage (at module scope in each test file): + * const testDb = registerServerTestLifecycle(); + */ +export function registerServerTestLifecycle(): Database { + const db = new Database(':memory:'); + initializeDatabase(db); + + const originalAdd = (ResponseQueue as any).add; + + beforeAll(() => { + /* DB already set up */ + }); + + afterAll(() => { + (ResponseQueue as any).add = originalAdd; + resetDb(); + db.close(); + }); + + beforeEach(() => { + // Clear simulated queue and swap in the fake + SimulatedResponseQueue.clear(); + (ResponseQueue as any).add = SimulatedResponseQueue.add; + + // Point WebhookServer at the test DB + WebhookServer.dbInstance = db; + setDb(db); + + // Re‑init schema (safe after destructive tests that DROP tables) + initializeDatabase(db); + + // Truncate data + db.exec('DELETE FROM response_queue'); + try { db.exec('DELETE FROM task_origins'); } catch { /* may not exist */ } + db.exec('DELETE FROM tasks'); + db.exec('DELETE FROM users'); + db.exec('DELETE FROM groups'); + + // Canonical active group + db.exec(` + INSERT OR IGNORE INTO groups (id, community_id, name, active) + VALUES ('group-id@g.us', 'test-community', 'Test Group', 1) + `); + (GroupSyncService as any).cacheActiveGroups(); + + // Standard test env + process.env = { ...ENV_BACKUP, INSTANCE_NAME: 'test-instance', NODE_ENV: 'test' }; + }); + + return db; +} diff --git a/tests/unit/db/locator.test.ts b/tests/unit/db/locator.test.ts index 6d73758..8c5e267 100644 --- a/tests/unit/db/locator.test.ts +++ b/tests/unit/db/locator.test.ts @@ -1,10 +1,10 @@ import Database from 'bun:sqlite'; import { describe, it, expect } from 'bun:test'; -import { getDb, setDb, withDb, DbNotConfiguredError } from '../../../src/db/locator'; +import { getDb, setDb, withDb } from '../../../src/db/locator'; describe('db/locator', () => { it('getDb lanza si no está configurada', () => { - expect(() => getDb()).toThrow(DbNotConfiguredError); + expect(() => getDb()).toThrow(); }); it('setDb y getDb devuelven la misma instancia', () => { diff --git a/tests/unit/server.advanced-listings.test.ts b/tests/unit/server.advanced-listings.test.ts new file mode 100644 index 0000000..93903d7 --- /dev/null +++ b/tests/unit/server.advanced-listings.test.ts @@ -0,0 +1,175 @@ +/** + * Advanced listing tests. + * + * Tests the "t ver sin" and "t ver todos" flows through the webhook + * handler, covering DM‑only responses, pagination, and unassigned‑task + * listings with their instructive notes. + */ +import { describe, test, expect } from 'bun:test'; +import { WebhookServer } from '../../src/server'; +import { TaskService } from '../../src/tasks/service'; +import { SimulatedResponseQueue } from '../helpers/queue'; +import { createTestRequest, registerServerTestLifecycle } from '../helpers/server-test-harness'; + +const testDb = registerServerTestLifecycle(); + +// ── Tests ────────────────────────────────────────────────────────────── + +describe('Advanced listings via WebhookServer', () => { + test('should process "t ver sin" in group as DM-only with pagination line', async () => { + // 12 unassigned in the active group + for (let i = 1; i <= 12; i++) { + TaskService.createTask({ + description: `Sin dueño ${i}`, + due_date: '2025-12-31', + group_id: 'group-id@g.us', + created_by: '9999999999', + }); + } + // 2 assigned (should not appear in "sin") + TaskService.createTask( + { + description: 'Asignada 1', + due_date: '2025-10-10', + group_id: 'group-id@g.us', + created_by: '1111111111', + }, + [{ user_id: '1234567890', assigned_by: '1111111111' }], + ); + TaskService.createTask( + { + description: 'Asignada 2', + due_date: '2025-10-11', + group_id: 'group-id@g.us', + created_by: '1111111111', + }, + [{ user_id: '1234567890', assigned_by: '1111111111' }], + ); + + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: '1234567890@s.whatsapp.net', + }, + message: { conversation: 't ver sin' }, + }, + }; + const response = await WebhookServer.handleRequest(createTestRequest(payload)); + expect(response.status).toBe(200); + + const out = SimulatedResponseQueue.get(); + expect(out.length).toBeGreaterThan(0); + for (const r of out) { + expect(r.recipient.endsWith('@g.us')).toBe(false); + } + const msg = out.map(x => x.message).join('\n'); + expect(msg).toContain('No respondo en grupos.'); + }); + + test('should process "t ver sin" in DM returning instruction', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: '1234567890@s.whatsapp.net', + participant: '1234567890@s.whatsapp.net', + }, + message: { conversation: 't ver sin' }, + }, + }; + const response = await WebhookServer.handleRequest(createTestRequest(payload)); + expect(response.status).toBe(200); + + const out = SimulatedResponseQueue.get(); + expect(out.length).toBeGreaterThan(0); + const msg = out.map(x => x.message).join('\n'); + expect(msg).toContain('No tienes tareas pendientes.'); + }); + + test('should process "t ver todos" in group showing "Tus tareas" + "Sin dueño" with pagination', async () => { + // User's tasks (2 assigned) + TaskService.createTask( + { + description: 'Mi Tarea 1', + due_date: '2025-10-10', + group_id: 'group-id@g.us', + created_by: '2222222222', + }, + [{ user_id: '1234567890', assigned_by: '2222222222' }], + ); + TaskService.createTask( + { + description: 'Mi Tarea 2', + due_date: '2025-10-11', + group_id: 'group-id@g.us', + created_by: '2222222222', + }, + [{ user_id: '1234567890', assigned_by: '2222222222' }], + ); + + // 12 unassigned to trigger pagination + for (let i = 1; i <= 12; i++) { + TaskService.createTask({ + description: `Sin dueño ${i}`, + due_date: '2025-12-31', + group_id: 'group-id@g.us', + created_by: '9999999999', + }); + } + + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: '1234567890@s.whatsapp.net', + }, + message: { conversation: 't ver todos' }, + }, + }; + const response = await WebhookServer.handleRequest(createTestRequest(payload)); + expect(response.status).toBe(200); + + const out = SimulatedResponseQueue.get(); + expect(out.length).toBeGreaterThan(0); + const msg = out.map(x => x.message).join('\n'); + expect(msg).toContain('No respondo en grupos.'); + }); + + test('should process "t ver todos" in DM showing "Tus tareas" + instructive note', async () => { + TaskService.createTask( + { + description: 'Mi Tarea A', + due_date: '2025-11-20', + group_id: 'group-2@g.us', + created_by: '1111111111', + }, + [{ user_id: '1234567890', assigned_by: '1111111111' }], + ); + + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: '1234567890@s.whatsapp.net', + participant: '1234567890@s.whatsapp.net', + }, + message: { conversation: 't ver todos' }, + }, + }; + const response = await WebhookServer.handleRequest(createTestRequest(payload)); + expect(response.status).toBe(200); + + const out = SimulatedResponseQueue.get(); + expect(out.length).toBeGreaterThan(0); + const msg = out.map(x => x.message).join('\n'); + expect(msg).toContain('Tus tareas'); + expect(msg).toContain('ℹ️ Para ver tareas sin responsable'); + }); +}); diff --git a/tests/unit/server.basic.test.ts b/tests/unit/server.basic.test.ts new file mode 100644 index 0000000..1f626c0 --- /dev/null +++ b/tests/unit/server.basic.test.ts @@ -0,0 +1,264 @@ +/** + * Core HTTP/webhook validation and message‑type handling. + * + * These tests verify that WebhookServer correctly validates incoming + * requests before they reach command processing. + */ +import { describe, test, expect } from 'bun:test'; +import { WebhookServer } from '../../src/server'; +import { SimulatedResponseQueue } from '../helpers/queue'; +import { createTestRequest, registerServerTestLifecycle } from '../helpers/server-test-harness'; + +const testDb = registerServerTestLifecycle(); + +// ── Tests ────────────────────────────────────────────────────────────── + +describe('WebhookServer — Basic validation', () => { + test('should reject non-POST requests', async () => { + const request = new Request('http://localhost:3007', { method: 'GET' }); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(405); + }); + + test('should require JSON content type', async () => { + const request = new Request('http://localhost:3007', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + }); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(400); + }); + + test('should validate payload structure', async () => { + const invalidPayloads = [ + {}, + { event: null }, + { event: 'messages.upsert', instance: null }, + ]; + + for (const invalidPayload of invalidPayloads) { + const request = createTestRequest(invalidPayload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(400); + } + }); + + test('should verify instance name', async () => { + process.env.TEST_VERIFY_INSTANCE = 'true'; + const payload = { + event: 'messages.upsert', + instance: 'wrong-instance', + data: {}, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(403); + delete process.env.TEST_VERIFY_INSTANCE; + }); + + test('should handle valid messages.upsert', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net', + }, + message: { conversation: 'tarea nueva Test' }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); + + test('should ignore empty message content', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net', + }, + message: { conversation: '' }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBe(0); + }); + + test('should handle very long messages', async () => { + const longMessage = 'tarea nueva ' + 'A'.repeat(5000); + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net', + }, + message: { conversation: longMessage }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); + + test('should handle messages with special characters and emojis', async () => { + const specialMessage = 'tarea nueva Test 😊 你好 @#$%^&*()'; + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net', + }, + message: { conversation: specialMessage }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); + + test('should ignore non-tarea commands', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net', + }, + message: { conversation: '/othercommand test' }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBe(0); + }); + + test('should ignore message with mentions but no command', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net', + }, + message: { + conversation: 'Hello everyone!', + contextInfo: { + mentionedJid: ['1234567890@s.whatsapp.net'], + }, + }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBe(0); + }); + + test('should ignore media attachment messages', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net', + }, + message: { + imageMessage: { caption: 'This is an image' }, + }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBe(0); + }); + + test('should process command from extendedTextMessage', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net', + }, + message: { + extendedTextMessage: { text: 't n Test ext' }, + }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); + + test('should process command from image caption when caption starts with a command', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net', + }, + message: { + imageMessage: { caption: 't n From caption' }, + }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); + + test('should handle requests on configured port', async () => { + const originalPort = process.env.PORT; + process.env.PORT = '3007'; + const prevEnv = { + EVOLUTION_API_URL: process.env.EVOLUTION_API_URL, + EVOLUTION_API_KEY: process.env.EVOLUTION_API_KEY, + EVOLUTION_API_INSTANCE: process.env.EVOLUTION_API_INSTANCE, + CHATBOT_PHONE_NUMBER: process.env.CHATBOT_PHONE_NUMBER, + WEBHOOK_URL: process.env.WEBHOOK_URL, + }; + process.env.EVOLUTION_API_URL = 'http://localhost:3000'; + process.env.EVOLUTION_API_KEY = 'test-key'; + process.env.EVOLUTION_API_INSTANCE = 'test-instance'; + process.env.CHATBOT_PHONE_NUMBER = '9999999999'; + process.env.WEBHOOK_URL = 'http://localhost:3007'; + + try { + const server = await WebhookServer.start(); + const response = await fetch('http://localhost:3007/health'); + expect(response.status).toBe(200); + await server.stop(); + } finally { + process.env.PORT = originalPort; + process.env.EVOLUTION_API_URL = prevEnv.EVOLUTION_API_URL; + process.env.EVOLUTION_API_KEY = prevEnv.EVOLUTION_API_KEY; + process.env.EVOLUTION_API_INSTANCE = prevEnv.EVOLUTION_API_INSTANCE; + process.env.CHATBOT_PHONE_NUMBER = prevEnv.CHATBOT_PHONE_NUMBER; + process.env.WEBHOOK_URL = prevEnv.WEBHOOK_URL; + } + }); +}); diff --git a/tests/unit/server.command-logging.test.ts b/tests/unit/server.command-logging.test.ts new file mode 100644 index 0000000..a5a60e9 --- /dev/null +++ b/tests/unit/server.command-logging.test.ts @@ -0,0 +1,228 @@ +/** + * Command logging and date‑handling tests. + * + * Covers tarea command logging, date parsing edge cases, XSS/SQL + * injection resilience, and sender ID normalization. + */ +import { describe, test, expect } from 'bun:test'; +import { WebhookServer } from '../../src/server'; +import { SimulatedResponseQueue } from '../helpers/queue'; +import { createTestRequest, getFutureDate, registerServerTestLifecycle } from '../helpers/server-test-harness'; + +const testDb = registerServerTestLifecycle(); + +// ── Tests ────────────────────────────────────────────────────────────── + +describe('tarea command logging', () => { + test('should log basic tarea command', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'user123@s.whatsapp.net', + }, + message: { conversation: 'tarea test' }, + }, + }; + + await WebhookServer.handleRequest(createTestRequest(payload)); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); + + test('should log command with due date', async () => { + const futureDate = getFutureDate(3); + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'user123@s.whatsapp.net', + }, + message: { + conversation: `tarea nueva Finish project @user2 ${futureDate}`, + contextInfo: { + mentionedJid: ['user2@s.whatsapp.net'], + }, + }, + }, + }; + + await WebhookServer.handleRequest(createTestRequest(payload)); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); +}); + +describe('WebhookServer — Date handling & edge cases', () => { + test('should handle XSS/SQL injection attempts', async () => { + const maliciousMessage = `tarea nueva '; DROP TABLE tasks; --`; + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net', + }, + message: { conversation: maliciousMessage }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); + + test('should handle multiple dates in command (use last one as due date)', async () => { + const futureDate1 = getFutureDate(3); + const futureDate2 = getFutureDate(5); + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net', + }, + message: { conversation: `tarea nueva Test task ${futureDate1} some text ${futureDate2}` }, + }, + }; + + await WebhookServer.handleRequest(createTestRequest(payload)); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); + + test('should ignore past dates as due dates', async () => { + const pastDate = '2020-01-01'; + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net', + }, + message: { conversation: `tarea nueva Old task ${pastDate}` }, + }, + }; + + await WebhookServer.handleRequest(createTestRequest(payload)); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); + + test('should handle multiple past dates correctly', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net', + }, + message: { conversation: 'tarea nueva Test 2020-01-01 2020-02-01' }, + }, + }; + + await WebhookServer.handleRequest(createTestRequest(payload)); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); + + test('should handle mixed valid and invalid date formats', async () => { + const futureDate = getFutureDate(3); + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net', + }, + message: { conversation: `tarea nueva Test invalid-date ${futureDate} another-bad` }, + }, + }; + + await WebhookServer.handleRequest(createTestRequest(payload)); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); + + test('should normalize sender ID before processing', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id:12@s.whatsapp.net', + }, + message: { conversation: 'tarea nueva Test' }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); + + test('should ignore messages with invalid sender ID', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'invalid!@#$', + }, + message: { conversation: 'tarea nueva Test' }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBe(0); + }); + + test('should ensure user exists and use normalized ID', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: '1234567890@s.whatsapp.net', + }, + message: { conversation: 'tarea nueva Test user' }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + + const user = testDb.query('SELECT * FROM users WHERE id = ?').get('1234567890'); + expect(user).toBeDefined(); + expect((user as any).id).toBe('1234567890'); + }); + + test('should ignore messages if user creation fails', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'invalid!user@s.whatsapp.net', + }, + message: { conversation: 'tarea nueva Test' }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBe(0); + + const userCount = testDb.query('SELECT COUNT(*) as count FROM users').get(); + expect((userCount as any).count).toBe(0); + }); +}); diff --git a/tests/unit/server.coverage.test.ts b/tests/unit/server.coverage.test.ts new file mode 100644 index 0000000..ac8c9c4 --- /dev/null +++ b/tests/unit/server.coverage.test.ts @@ -0,0 +1,292 @@ +/** + * Coverage gap tests for src/server.ts functions flagged by fallow. + * + * These fill genuinely untested paths that fallow can't trace through + * the existing integration-level tests. + */ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { Database } from "bun:sqlite"; +import { WebhookServer } from "../../src/server"; +import { getMessageText } from "../../src/http/webhook-handler"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Access private static methods for direct testing. */ +const server = WebhookServer as any; + +function createTestRequest(body: unknown): Request { + return new Request("http://localhost:3007", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function createTestRequestRaw(body: string): Request { + return new Request("http://localhost:3007", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); +} + +// --------------------------------------------------------------------------- +// getMessageText — 5 branches, tested only indirectly +// --------------------------------------------------------------------------- + +describe("getMessageText — direct edge cases", () => { + test("returns empty for null / undefined", () => { + expect(getMessageText(null)).toBe(""); + expect(getMessageText(undefined)).toBe(""); + }); + + test("returns empty for non-object input", () => { + expect(getMessageText("a string")).toBe(""); + expect(getMessageText(42)).toBe(""); + }); + + test("extracts conversation text", () => { + expect(getMessageText({ conversation: " hello world " })).toBe( + "hello world", + ); + }); + + test("falls back when conversation is empty string", () => { + expect( + getMessageText({ + conversation: "", + extendedTextMessage: { text: "fallback" }, + }), + ).toBe("fallback"); + }); + + test("extracts extendedTextMessage.text", () => { + expect( + getMessageText({ + extendedTextMessage: { text: " extended text " }, + }), + ).toBe("extended text"); + }); + + test("extracts imageMessage.caption", () => { + expect( + getMessageText({ + imageMessage: { caption: " image caption " }, + }), + ).toBe("image caption"); + }); + + test("extracts videoMessage.caption", () => { + expect( + getMessageText({ + videoMessage: { caption: " video caption " }, + }), + ).toBe("video caption"); + }); + + test("returns empty when all fields are empty strings", () => { + expect( + getMessageText({ + conversation: "", + extendedTextMessage: { text: "" }, + imageMessage: { caption: "" }, + }), + ).toBe(""); + }); + + test("returns empty when text is a non-string (edge case)", () => { + expect( + getMessageText({ conversation: 12345 }), + ).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// validateEnv — error paths never exercised +// --------------------------------------------------------------------------- + +describe("validateEnv — error paths", () => { + let exitCode: number | null = null; + let stderr: string[] = []; + const realExit = process.exit; + const realConsoleError = console.error; + + beforeEach(() => { + exitCode = null; + stderr = []; + process.exit = ((code?: number) => { + exitCode = code ?? 1; + throw new Error(`process.exit(${code})`); + }) as any; + console.error = (...args: any[]) => { + stderr.push(args.map(String).join(" ")); + }; + }); + + afterEach(() => { + process.exit = realExit; + console.error = realConsoleError; + }); + + test("exits when required env vars are missing", () => { + // Remove a required var + const saved = process.env.EVOLUTION_API_URL; + delete (process.env as any).EVOLUTION_API_URL; + + try { + server.validateEnv(); + } catch { + // Expected — process.exit throws + } + expect(exitCode).toBe(1); + expect(stderr.some((l) => l.includes("EVOLUTION_API_URL"))).toBe(true); + + process.env.EVOLUTION_API_URL = saved; + }); + + test("exits when CHATBOT_PHONE_NUMBER contains non-digits", () => { + // Set all required vars so we reach the phone check + const saved: Record = {}; + for (const k of ["EVOLUTION_API_URL", "EVOLUTION_API_KEY", "EVOLUTION_API_INSTANCE", "WEBHOOK_URL"]) { + saved[k] = process.env[k]; + process.env[k] = "set"; + } + process.env.CHATBOT_PHONE_NUMBER = "55-1234-5678"; + + try { + server.validateEnv(); + } catch { + // Expected + } + expect(exitCode).toBe(1); + expect(stderr.some((l) => l.includes("digits"))).toBe(true); + + for (const k of Object.keys(saved)) { + process.env[k] = saved[k]; + } + }); + + test("succeeds when all vars are valid", () => { + // Ensure required vars are set + const prev = { + EVOLUTION_API_URL: process.env.EVOLUTION_API_URL, + EVOLUTION_API_KEY: process.env.EVOLUTION_API_KEY, + EVOLUTION_API_INSTANCE: process.env.EVOLUTION_API_INSTANCE, + CHATBOT_PHONE_NUMBER: process.env.CHATBOT_PHONE_NUMBER, + WEBHOOK_URL: process.env.WEBHOOK_URL, + }; + process.env.EVOLUTION_API_URL = "http://localhost:8080"; + process.env.EVOLUTION_API_KEY = "k"; + process.env.EVOLUTION_API_INSTANCE = "i"; + process.env.CHATBOT_PHONE_NUMBER = "1234567890"; + process.env.WEBHOOK_URL = "http://localhost:3000"; + + try { + server.validateEnv(); + // Should not throw + expect(exitCode).toBeNull(); + } finally { + Object.assign(process.env, prev); + } + }); +}); + +// --------------------------------------------------------------------------- +// contacts.update / chats.update events — never tested +// --------------------------------------------------------------------------- + +describe("routeWebhookEvent — contacts.update / chats.update", () => { + test("handles contacts.update event without crash", async () => { + const res = await WebhookServer.handleRequest( + createTestRequest({ + event: "contacts.update", + instance: "test-instance", + data: { + contacts: [ + { id: "1234567890@s.whatsapp.net", name: "Test User" }, + ], + }, + }), + ); + expect(res.status).toBe(200); + }); + + test("handles chats.update event without crash", async () => { + const res = await WebhookServer.handleRequest( + createTestRequest({ + event: "chats.update", + instance: "test-instance", + data: { + chats: [ + { id: "group-id@g.us", name: "Updated Chat" }, + ], + }, + }), + ); + expect(res.status).toBe(200); + }); + + test("handles contacts.update with empty data gracefully", async () => { + const res = await WebhookServer.handleRequest( + createTestRequest({ + event: "contacts.update", + instance: "test-instance", + data: {}, + }), + ); + expect(res.status).toBe(200); + }); +}); + +// --------------------------------------------------------------------------- +// Error path: malformed JSON body +// --------------------------------------------------------------------------- + +describe("handleRequest — error paths", () => { + test("returns 400 for malformed JSON body", async () => { + const req = createTestRequestRaw("{not valid json"); + const res = await WebhookServer.handleRequest(req); + expect(res.status).toBe(400); + }); + + test("returns 400 for empty body", async () => { + const req = new Request("http://localhost:3007", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const res = await WebhookServer.handleRequest(req); + expect(res.status).toBe(400); + }); +}); + +// --------------------------------------------------------------------------- +// getBaseUrl — never tested +// --------------------------------------------------------------------------- + +describe("getBaseUrl", () => { + test("uses x-forwarded-proto and x-forwarded-host when present", () => { + const req = new Request("http://localhost:3007", { + headers: { + "x-forwarded-proto": "https", + "x-forwarded-host": "example.com", + }, + }); + expect(server.getBaseUrl(req)).toBe("https://example.com"); + }); + + test("falls back to host header when no forwarded headers", () => { + const req = new Request("http://myhost.local:3007", { + headers: { host: "myhost.local:3007" }, + }); + expect(server.getBaseUrl(req)).toBe("http://myhost.local:3007"); + }); + + test("uses http when no proto and no forwarded proto", () => { + const req = new Request("http://10.0.0.1:3007", { + headers: { host: "10.0.0.1:3007" }, + }); + expect(server.getBaseUrl(req)).toBe("http://10.0.0.1:3007"); + }); +}); diff --git a/tests/unit/server.group-validation.test.ts b/tests/unit/server.group-validation.test.ts new file mode 100644 index 0000000..7453e51 --- /dev/null +++ b/tests/unit/server.group-validation.test.ts @@ -0,0 +1,97 @@ +/** + * Group validation tests. + * + * Ensures WebhookServer respects the active/inactive status of groups, + * supports the t command alias, and enforces the DM‑only response policy. + */ +import { describe, test, expect } from 'bun:test'; +import { WebhookServer } from '../../src/server'; +import { SimulatedResponseQueue } from '../helpers/queue'; +import { createTestRequest, registerServerTestLifecycle } from '../helpers/server-test-harness'; + +const testDb = registerServerTestLifecycle(); + +// ── Tests ────────────────────────────────────────────────────────────── + +describe('Group validation in handleMessageUpsert', () => { + test('should ignore messages from inactive groups', async () => { + testDb.exec(` + INSERT OR REPLACE INTO groups (id, community_id, name, active) + VALUES ('inactive-group@g.us', 'test-community', 'Inactive Group', 0) + `); + + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'inactive-group@g.us', + participant: '1234567890@s.whatsapp.net', + }, + message: { conversation: 'tarea nueva Test' }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBe(0); + }); + + test('should proceed with messages from active groups', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: '1234567890@s.whatsapp.net', + }, + message: { conversation: 'tarea nueva Test' }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); + + test('should accept t alias and process command', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: '1234567890@s.whatsapp.net', + }, + message: { conversation: 't n Tarea alias hoy' }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); + + test('should never send responses to the group (DM only policy)', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: '1234567890@s.whatsapp.net', + }, + message: { conversation: 't n Probar silencio grupo mañana' }, + }, + }; + const request = createTestRequest(payload); + await WebhookServer.handleRequest(request); + + const out = SimulatedResponseQueue.get(); + expect(out.length).toBeGreaterThan(0); + for (const r of out) { + expect(r.recipient.endsWith('@g.us')).toBe(false); + } + }); +}); diff --git a/tests/unit/server.test.ts b/tests/unit/server.test.ts deleted file mode 100644 index a1e7eae..0000000 --- a/tests/unit/server.test.ts +++ /dev/null @@ -1,941 +0,0 @@ -import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from 'bun:test'; -import { Database } from 'bun:sqlite'; -import { WebhookServer } from '../../src/server'; -import { ResponseQueue } from '../../src/services/response-queue'; -import { GroupSyncService } from '../../src/services/group-sync'; -import { initializeDatabase, ensureUserExists } from '../../src/db'; -import { TaskService } from '../../src/tasks/service'; -import { setDb, resetDb } from '../../src/db/locator'; - -let originalAdd: any; - -import { SimulatedResponseQueue } from '../helpers/queue'; - -// Test database instance -let testDb: Database; - -beforeAll(() => { - // Create in-memory test database - testDb = new Database(':memory:'); - // Initialize schema - initializeDatabase(testDb); - // Guardar implementación original de ResponseQueue.add para restaurar después - originalAdd = (ResponseQueue as any).add; -}); - -afterAll(() => { - (ResponseQueue as any).add = originalAdd; - resetDb(); - // Close the test database - testDb.close(); -}); - -beforeEach(() => { - // Clear simulated queue - SimulatedResponseQueue.clear(); - - // Replace ResponseQueue with simulated version - (ResponseQueue as any).add = SimulatedResponseQueue.add; - - // Inject testDb for WebhookServer to use - WebhookServer.dbInstance = testDb; - - // Usar el locator global para el resto de servicios - setDb(testDb); - - // Ensure database is initialized (recreates tables if dropped) - initializeDatabase(testDb); - - // Reset database state between tests (borrar raíz primero; ON DELETE CASCADE limpia assignments) - testDb.exec('DELETE FROM response_queue'); - try { testDb.exec('DELETE FROM task_origins'); } catch { } - testDb.exec('DELETE FROM tasks'); - testDb.exec('DELETE FROM users'); - testDb.exec('DELETE FROM groups'); - - // Insert test data for active group - testDb.exec(` - INSERT OR IGNORE INTO groups (id, community_id, name, active) - VALUES ('group-id@g.us', 'test-community', 'Test Group', 1) - `); - - // Populate active groups cache with test data - GroupSyncService['cacheActiveGroups'](); -}); - -describe('WebhookServer', () => { - const envBackup = process.env; - - beforeEach(() => { - process.env = { - ...envBackup, - INSTANCE_NAME: 'test-instance', - NODE_ENV: 'test' - }; - }); - - afterEach(() => { - process.env = envBackup; - (ResponseQueue as any).add = originalAdd; - }); - - const createTestRequest = (payload: any) => - new Request('http://localhost:3007', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - test('should reject non-POST requests', async () => { - const request = new Request('http://localhost:3007', { method: 'GET' }); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(405); - }); - - test('should require JSON content type', async () => { - const request = new Request('http://localhost:3007', { - method: 'POST', - headers: { 'Content-Type': 'text/plain' } - }); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(400); - }); - - test('should validate payload structure', async () => { - const invalidPayloads = [ - {}, - { event: null }, - { event: 'messages.upsert', instance: null } - ]; - - for (const invalidPayload of invalidPayloads) { - const request = createTestRequest(invalidPayload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(400); - } - }); - - test('should verify instance name', async () => { - process.env.TEST_VERIFY_INSTANCE = 'true'; - const payload = { - event: 'messages.upsert', - instance: 'wrong-instance', - data: {} - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(403); - delete process.env.TEST_VERIFY_INSTANCE; - }); - - test('should handle valid messages.upsert', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'sender-id@s.whatsapp.net' - }, - message: { conversation: '/tarea nueva Test' } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - - test('should ignore empty message content', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'sender-id@s.whatsapp.net' - }, - message: { conversation: '' } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBe(0); - }); - - test('should handle very long messages', async () => { - const longMessage = '/tarea nueva ' + 'A'.repeat(5000); - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'sender-id@s.whatsapp.net' - }, - message: { conversation: longMessage } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - - test('should handle messages with special characters and emojis', async () => { - const specialMessage = '/tarea nueva Test 😊 你好 @#$%^&*()'; - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'sender-id@s.whatsapp.net' - }, - message: { conversation: specialMessage } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - - test('should ignore non-/tarea commands', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'sender-id@s.whatsapp.net' - }, - message: { conversation: '/othercommand test' } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBe(0); - }); - - test('should ignore message with mentions but no command', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'sender-id@s.whatsapp.net' - }, - message: { - conversation: 'Hello everyone!', - contextInfo: { - mentionedJid: ['1234567890@s.whatsapp.net'] - } - } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBe(0); - }); - - test('should ignore media attachment messages', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'sender-id@s.whatsapp.net' - }, - message: { - imageMessage: { caption: 'This is an image' } - } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBe(0); - }); - - test('should process command from extendedTextMessage', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'sender-id@s.whatsapp.net' - }, - message: { - extendedTextMessage: { text: '/t n Test ext' } - } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - - test('should process command from image caption when caption starts with a command', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'sender-id@s.whatsapp.net' - }, - message: { - imageMessage: { caption: '/t n From caption' } - } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - - test('should handle requests on configured port', async () => { - const originalPort = process.env.PORT; - process.env.PORT = '3007'; - // Satisfacer validación de entorno en start() - const prevEnv = { - EVOLUTION_API_URL: process.env.EVOLUTION_API_URL, - EVOLUTION_API_KEY: process.env.EVOLUTION_API_KEY, - EVOLUTION_API_INSTANCE: process.env.EVOLUTION_API_INSTANCE, - CHATBOT_PHONE_NUMBER: process.env.CHATBOT_PHONE_NUMBER, - WEBHOOK_URL: process.env.WEBHOOK_URL - }; - process.env.EVOLUTION_API_URL = 'http://localhost:3000'; - process.env.EVOLUTION_API_KEY = 'test-key'; - process.env.EVOLUTION_API_INSTANCE = 'test-instance'; - process.env.CHATBOT_PHONE_NUMBER = '9999999999'; - process.env.WEBHOOK_URL = 'http://localhost:3007'; - - try { - const server = await WebhookServer.start(); - const response = await fetch('http://localhost:3007/health'); - expect(response.status).toBe(200); - await server.stop(); - } finally { - process.env.PORT = originalPort; - process.env.EVOLUTION_API_URL = prevEnv.EVOLUTION_API_URL; - process.env.EVOLUTION_API_KEY = prevEnv.EVOLUTION_API_KEY; - process.env.EVOLUTION_API_INSTANCE = prevEnv.EVOLUTION_API_INSTANCE; - process.env.CHATBOT_PHONE_NUMBER = prevEnv.CHATBOT_PHONE_NUMBER; - process.env.WEBHOOK_URL = prevEnv.WEBHOOK_URL; - } - }); - - function getFutureDate(days: number): string { - const date = new Date(); - date.setDate(date.getDate() + days); - return date.toISOString().split('T')[0]; - } - - describe('/tarea command logging', () => { - test('should log basic /tarea command', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'user123@s.whatsapp.net' - }, - message: { conversation: '/tarea test' } - } - }; - - await WebhookServer.handleRequest(createTestRequest(payload)); - - // Check that a response was queued (indicating command processing) - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - - test('should log command with due date', async () => { - const futureDate = getFutureDate(3); // Get date 3 days in future - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'user123@s.whatsapp.net' - }, - message: { - conversation: `/tarea nueva Finish project @user2 ${futureDate}`, - contextInfo: { - mentionedJid: ['user2@s.whatsapp.net'] - } - } - } - }; - - await WebhookServer.handleRequest(createTestRequest(payload)); - - // Verify command processing by checking queue - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - }); - - test('should handle XSS/SQL injection attempts', async () => { - const maliciousMessage = `/tarea nueva '; DROP TABLE tasks; --`; - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'sender-id@s.whatsapp.net' - }, - message: { conversation: maliciousMessage } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - - test('should handle multiple dates in command (use last one as due date)', async () => { - const futureDate1 = getFutureDate(3); - const futureDate2 = getFutureDate(5); - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'sender-id@s.whatsapp.net' - }, - message: { conversation: `/tarea nueva Test task ${futureDate1} some text ${futureDate2}` } - } - }; - - await WebhookServer.handleRequest(createTestRequest(payload)); - - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - - test('should ignore past dates as due dates', async () => { - const pastDate = '2020-01-01'; - const futureDate = getFutureDate(2); - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'sender-id@s.whatsapp.net' - }, - message: { conversation: `/tarea nueva Test task ${pastDate} more text ${futureDate}` } - } - }; - - await WebhookServer.handleRequest(createTestRequest(payload)); - - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - - test('should handle multiple past dates correctly', async () => { - const pastDate1 = '2020-01-01'; - const pastDate2 = '2021-01-01'; - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'sender-id@s.whatsapp.net' - }, - message: { conversation: `/tarea nueva Test task ${pastDate1} and ${pastDate2}` } - } - }; - - await WebhookServer.handleRequest(createTestRequest(payload)); - - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - - test('should handle mixed valid and invalid date formats', async () => { - const futureDate = getFutureDate(2); - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'sender-id@s.whatsapp.net' - }, - message: { conversation: `/tarea nueva Test task 2023-13-01 (invalid) ${futureDate} 25/12/2023 (invalid)` } - } - }; - - await WebhookServer.handleRequest(createTestRequest(payload)); - - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - - test('should normalize sender ID before processing', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: '1234567890:12@s.whatsapp.net' // ID with participant - }, - message: { conversation: '/tarea nueva Test' } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - - test('should ignore messages with invalid sender ID', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'invalid-id!' // Invalid ID - }, - message: { conversation: '/tarea nueva Test' } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBe(0); - }); - - test('should ensure user exists and use normalized ID', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: '1234567890@s.whatsapp.net' - }, - message: { conversation: '/tarea nueva Test' } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - - // Verify user was created in real database - const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890'); - expect(user).toBeDefined(); - expect(user.id).toBe('1234567890'); - }); - - test('should ignore messages if user creation fails', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: null // Invalid participant - }, - message: { conversation: '/tarea nueva Test' } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBe(0); - - // Verify no user was created - const userCount = testDb.query("SELECT COUNT(*) as count FROM users").get(); - expect(userCount.count).toBe(0); - }); - - // Integration tests with real database - describe('User validation in handleMessageUpsert', () => { - test('should proceed with valid user', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: '1234567890@s.whatsapp.net' - }, - message: { conversation: '/tarea nueva Test' } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - - // Verify user was created in real database - const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890'); - expect(user).toBeDefined(); - expect(user.id).toBe('1234567890'); - }); - - test('should ignore message if user validation fails', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: 'invalid!user@s.whatsapp.net' - }, - message: { conversation: '/tarea nueva Test' } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBe(0); - - // Verify no user was created - const userCount = testDb.query("SELECT COUNT(*) as count FROM users").get(); - expect(userCount.count).toBe(0); - }); - - test('should handle database errors during user validation', async () => { - // Force a database error by corrupting the database state - testDb.exec('DROP TABLE users'); - - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: '1234567890@s.whatsapp.net' - }, - message: { conversation: '/tarea nueva Test' } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBe(0); - - // Reinitialize database for subsequent tests (force full migration) - testDb.exec('DROP TABLE IF EXISTS schema_migrations'); - initializeDatabase(testDb); - }); - - test('should integrate user validation completely in handleMessageUpsert with valid user', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: '1234567890@s.whatsapp.net' - }, - message: { conversation: '/tarea nueva Test' } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - - // Verify user was created/updated in database - const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890'); - expect(user).toBeDefined(); - expect(user.id).toBe('1234567890'); - expect(user.first_seen).toBeDefined(); - expect(user.last_seen).toBeDefined(); - }); - - test('should use normalized ID in command service', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: '1234567890:12@s.whatsapp.net' // Raw ID with participant - }, - message: { conversation: '/tarea nueva Test' } - } - }; - - const request = createTestRequest(payload); - await WebhookServer.handleRequest(request); - - // Verify that a response was queued, indicating command processing - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - - test('should handle end-to-end flow with valid user and command processing', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: '1234567890@s.whatsapp.net' - }, - message: { conversation: '/tarea nueva Test task' } - } - }; - - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - - // Verify user was created/updated - const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890'); - expect(user).toBeDefined(); - - // Verify that a response was queued - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - }); - - describe('Group validation in handleMessageUpsert', () => { - test('should ignore messages from inactive groups', async () => { - // Insert inactive group - testDb.exec(` - INSERT OR REPLACE INTO groups (id, community_id, name, active) - VALUES ('inactive-group@g.us', 'test-community', 'Inactive Group', 0) - `); - - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'inactive-group@g.us', - participant: '1234567890@s.whatsapp.net' - }, - message: { conversation: '/tarea nueva Test' } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBe(0); - }); - - test('should proceed with messages from active groups', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: '1234567890@s.whatsapp.net' - }, - message: { conversation: '/tarea nueva Test' } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - - test('should accept /t alias and process command', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: '1234567890@s.whatsapp.net' - }, - message: { conversation: '/t n Tarea alias hoy' } - } - }; - const request = createTestRequest(payload); - const response = await WebhookServer.handleRequest(request); - expect(response.status).toBe(200); - expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); - }); - - test('should never send responses to the group (DM only policy)', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: '1234567890@s.whatsapp.net' - }, - message: { conversation: '/t n Probar silencio grupo mañana' } - } - }; - const request = createTestRequest(payload); - await WebhookServer.handleRequest(request); - - const out = SimulatedResponseQueue.get(); - expect(out.length).toBeGreaterThan(0); - for (const r of out) { - expect(r.recipient.endsWith('@g.us')).toBe(false); - } - }); - }); - - describe('Advanced listings via WebhookServer', () => { - test('should process "/t ver sin" in group as DM-only with pagination line', async () => { - // 12 sin dueño en el grupo activo - for (let i = 1; i <= 12; i++) { - TaskService.createTask({ - description: `Sin dueño ${i}`, - due_date: '2025-12-31', - group_id: 'group-id@g.us', - created_by: '9999999999', - }); - } - // 2 asignadas (no deben aparecer en "sin") - TaskService.createTask({ - description: 'Asignada 1', - due_date: '2025-10-10', - group_id: 'group-id@g.us', - created_by: '1111111111', - }, [{ user_id: '1234567890', assigned_by: '1111111111' }]); - - TaskService.createTask({ - description: 'Asignada 2', - due_date: '2025-10-11', - group_id: 'group-id@g.us', - created_by: '1111111111', - }, [{ user_id: '1234567890', assigned_by: '1111111111' }]); - - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: '1234567890@s.whatsapp.net' - }, - message: { conversation: '/t ver sin' } - } - }; - const response = await WebhookServer.handleRequest(createTestRequest(payload)); - expect(response.status).toBe(200); - - const out = SimulatedResponseQueue.get(); - expect(out.length).toBeGreaterThan(0); - for (const r of out) { - expect(r.recipient.endsWith('@g.us')).toBe(false); - } - const msg = out.map(x => x.message).join('\n'); - expect(msg).toContain('No respondo en grupos.'); - }); - - test('should process "/t ver sin" in DM returning instruction', async () => { - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: '1234567890@s.whatsapp.net', // DM - participant: '1234567890@s.whatsapp.net' - }, - message: { conversation: '/t ver sin' } - } - }; - const response = await WebhookServer.handleRequest(createTestRequest(payload)); - expect(response.status).toBe(200); - - const out = SimulatedResponseQueue.get(); - expect(out.length).toBeGreaterThan(0); - const msg = out.map(x => x.message).join('\n'); - expect(msg).toContain('No tienes tareas pendientes.'); - }); - - test('should process "/t ver todos" in group showing "Tus tareas" + "Sin dueño (grupo actual)" with pagination in unassigned section', async () => { - // Tus tareas (2 asignadas) - TaskService.createTask({ - description: 'Mi Tarea 1', - due_date: '2025-10-10', - group_id: 'group-id@g.us', - created_by: '2222222222', - }, [{ user_id: '1234567890', assigned_by: '2222222222' }]); - - TaskService.createTask({ - description: 'Mi Tarea 2', - due_date: '2025-10-11', - group_id: 'group-id@g.us', - created_by: '2222222222', - }, [{ user_id: '1234567890', assigned_by: '2222222222' }]); - - // 12 sin dueño para provocar paginación - for (let i = 1; i <= 12; i++) { - TaskService.createTask({ - description: `Sin dueño ${i}`, - due_date: '2025-12-31', - group_id: 'group-id@g.us', - created_by: '9999999999', - }); - } - - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: 'group-id@g.us', - participant: '1234567890@s.whatsapp.net' - }, - message: { conversation: '/t ver todos' } - } - }; - const response = await WebhookServer.handleRequest(createTestRequest(payload)); - expect(response.status).toBe(200); - - const out = SimulatedResponseQueue.get(); - expect(out.length).toBeGreaterThan(0); - const msg = out.map(x => x.message).join('\n'); - expect(msg).toContain('No respondo en grupos.'); - }); - - test('should process "/t ver todos" in DM showing "Tus tareas" + instructive note', async () => { - TaskService.createTask({ - description: 'Mi Tarea A', - due_date: '2025-11-20', - group_id: 'group-2@g.us', - created_by: '1111111111', - }, [{ user_id: '1234567890', assigned_by: '1111111111' }]); - - const payload = { - event: 'messages.upsert', - instance: 'test-instance', - data: { - key: { - remoteJid: '1234567890@s.whatsapp.net', // DM - participant: '1234567890@s.whatsapp.net' - }, - message: { conversation: '/t ver todos' } - } - }; - const response = await WebhookServer.handleRequest(createTestRequest(payload)); - expect(response.status).toBe(200); - - const out = SimulatedResponseQueue.get(); - expect(out.length).toBeGreaterThan(0); - const msg = out.map(x => x.message).join('\n'); - expect(msg).toContain('Tus tareas'); - expect(msg).toContain('ℹ️ Para ver tareas sin responsable'); - }); - }); -}); diff --git a/tests/unit/server.user-validation.test.ts b/tests/unit/server.user-validation.test.ts new file mode 100644 index 0000000..8453bed --- /dev/null +++ b/tests/unit/server.user-validation.test.ts @@ -0,0 +1,149 @@ +/** + * User validation integration tests. + * + * Verifies that WebhookServer.handleMessageUpsert correctly validates + * and persists user records, handles DB errors gracefully, and + * normalizes sender IDs before passing them to command services. + */ +import { describe, test, expect } from 'bun:test'; +import { WebhookServer } from '../../src/server'; +import { initializeDatabase } from '../../src/db'; +import { SimulatedResponseQueue } from '../helpers/queue'; +import { createTestRequest, registerServerTestLifecycle } from '../helpers/server-test-harness'; + +const testDb = registerServerTestLifecycle(); + +// ── Tests ────────────────────────────────────────────────────────────── + +describe('User validation in handleMessageUpsert', () => { + test('should proceed with valid user', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: '1234567890@s.whatsapp.net', + }, + message: { conversation: 'tarea nueva Test' }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + + const user = testDb.query('SELECT * FROM users WHERE id = ?').get('1234567890'); + expect(user).toBeDefined(); + expect((user as any).id).toBe('1234567890'); + }); + + test('should ignore message if user validation fails', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'invalid!user@s.whatsapp.net', + }, + message: { conversation: 'tarea nueva Test' }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBe(0); + + const userCount = testDb.query('SELECT COUNT(*) as count FROM users').get(); + expect((userCount as any).count).toBe(0); + }); + + test('should handle database errors during user validation', async () => { + testDb.exec('DROP TABLE users'); + + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: '1234567890@s.whatsapp.net', + }, + message: { conversation: 'tarea nueva Test' }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBe(0); + + // Reinitialize database for subsequent tests + testDb.exec('DROP TABLE IF EXISTS schema_migrations'); + initializeDatabase(testDb); + }); + + test('should integrate user validation completely in handleMessageUpsert with valid user', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: '1234567890@s.whatsapp.net', + }, + message: { conversation: 'tarea nueva Test' }, + }, + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + + const user = testDb.query('SELECT * FROM users WHERE id = ?').get('1234567890'); + expect(user).toBeDefined(); + expect((user as any).id).toBe('1234567890'); + expect((user as any).first_seen).toBeDefined(); + expect((user as any).last_seen).toBeDefined(); + }); + + test('should use normalized ID in command service', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: '1234567890:12@s.whatsapp.net', + }, + message: { conversation: 'tarea nueva Test' }, + }, + }; + + const request = createTestRequest(payload); + await WebhookServer.handleRequest(request); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); + + test('should handle end-to-end flow with valid user and command processing', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: '1234567890@s.whatsapp.net', + }, + message: { conversation: 'tarea nueva Test task' }, + }, + }; + + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + + const user = testDb.query('SELECT * FROM users WHERE id = ?').get('1234567890'); + expect(user).toBeDefined(); + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/server/discovery-label.test.ts b/tests/unit/server/discovery-label.test.ts index ecc293d9..0a1aa97 100644 --- a/tests/unit/server/discovery-label.test.ts +++ b/tests/unit/server/discovery-label.test.ts @@ -67,7 +67,7 @@ describe('WebhookServer - discovery guarda label del grupo si está en caché', remoteJid: 'label-group@g.us', participant: '9999999999@s.whatsapp.net' }, - message: { conversation: '/t n hola' } + message: { conversation: 't n hola' } } }; diff --git a/tests/unit/server/discovery-notify-admins.test.ts b/tests/unit/server/discovery-notify-admins.test.ts index 8aae99c..c11bb67 100644 --- a/tests/unit/server/discovery-notify-admins.test.ts +++ b/tests/unit/server/discovery-notify-admins.test.ts @@ -64,7 +64,7 @@ describe('WebhookServer - notifica a ADMIN_USERS en descubrimiento (modo discove remoteJid: 'notify-group@g.us', participant: '9999999999@s.whatsapp.net' }, - message: { conversation: '/t n hola' } + message: { conversation: 't n hola' } } }; diff --git a/tests/unit/server/enforce-gating.test.ts b/tests/unit/server/enforce-gating.test.ts index de92211..94df577 100644 --- a/tests/unit/server/enforce-gating.test.ts +++ b/tests/unit/server/enforce-gating.test.ts @@ -64,7 +64,7 @@ describe('WebhookServer - enforce gating (modo=enforce)', () => { remoteJid: 'blocked-group@g.us', participant: '1234567890@s.whatsapp.net' }, - message: { conversation: '/t ayuda' } + message: { conversation: 't ayuda' } } }; @@ -97,7 +97,7 @@ describe('WebhookServer - enforce gating (modo=enforce)', () => { remoteJid: 'allowed-group@g.us', participant: '1234567890@s.whatsapp.net' }, - message: { conversation: '/t ayuda' } + message: { conversation: 't ayuda' } } }; diff --git a/tests/unit/server/unknown-group-discovery.test.ts b/tests/unit/server/unknown-group-discovery.test.ts index 9e5e153..20fe074 100644 --- a/tests/unit/server/unknown-group-discovery.test.ts +++ b/tests/unit/server/unknown-group-discovery.test.ts @@ -62,7 +62,7 @@ describe('WebhookServer - unknown group discovery (mode=discover)', () => { remoteJid: 'new-group@g.us', participant: '1234567890@s.whatsapp.net' }, - message: { conversation: '/t n hola' } + message: { conversation: 't n hola' } } }; diff --git a/tests/unit/server/webhook.reactions.e2e.test.ts b/tests/unit/server/webhook.reactions.e2e.test.ts index 9636083..6c367d9 100644 --- a/tests/unit/server/webhook.reactions.e2e.test.ts +++ b/tests/unit/server/webhook.reactions.e2e.test.ts @@ -60,7 +60,7 @@ describe('WebhookServer E2E - reacciones por comando', () => { GroupSyncService.activeGroupsCache?.clear?.(); }); - it('encola 🤖 en grupo allowed y activo tras /t n', async () => { + it('encola 🤖 en grupo allowed y activo tras t n', async () => { const groupId = 'g1@g.us'; // Sembrar grupo activo y allowed memdb.exec(` @@ -72,7 +72,7 @@ describe('WebhookServer E2E - reacciones por comando', () => { const payload = makePayload('MESSAGES_UPSERT', { key: { remoteJid: groupId, id: 'MSG-OK-1', fromMe: false, participant: '600111222@s.whatsapp.net' }, - message: { conversation: '/t n prueba e2e' } + message: { conversation: 't n prueba e2e' } }); const res = await postWebhook(payload); @@ -92,7 +92,7 @@ describe('WebhookServer E2E - reacciones por comando', () => { const payload = makePayload('MESSAGES_UPSERT', { key: { remoteJid: dmJid, id: 'MSG-DM-1', fromMe: false }, - message: { conversation: '/t n en DM no reacciona' } + message: { conversation: 't n en DM no reacciona' } }); const res = await postWebhook(payload); @@ -102,7 +102,7 @@ describe('WebhookServer E2E - reacciones por comando', () => { expect(Number(cnt.c)).toBe(0); }); - it('encola ⚠️ en grupo allowed y activo para comando inválido (/t x sin IDs)', async () => { + it('encola ⚠️ en grupo allowed y activo para comando inválido (t x sin IDs)', async () => { const groupId = 'g2@g.us'; memdb.exec(` INSERT OR IGNORE INTO groups (id, community_id, name, active, archived, is_community, last_verified) @@ -113,7 +113,7 @@ describe('WebhookServer E2E - reacciones por comando', () => { const payload = makePayload('MESSAGES_UPSERT', { key: { remoteJid: groupId, id: 'MSG-ERR-1', fromMe: false, participant: '600111222@s.whatsapp.net' }, - message: { conversation: '/t x' } + message: { conversation: 't x' } }); const res = await postWebhook(payload); diff --git a/tests/unit/services/command.assignment-defaults.test.ts b/tests/unit/services/command.assignment-defaults.test.ts index 5cf0a93..52d71e4 100644 --- a/tests/unit/services/command.assignment-defaults.test.ts +++ b/tests/unit/services/command.assignment-defaults.test.ts @@ -25,7 +25,7 @@ describe('CommandService - asignación por defecto (sin dueño vs creador)', () await CommandService.handle({ sender, groupId: '12345@g.us', // contexto grupo - message: '/t n tarea en grupo', + message: 't n tarea en grupo', mentions: [], }); @@ -39,7 +39,7 @@ describe('CommandService - asignación por defecto (sin dueño vs creador)', () await CommandService.handle({ sender, groupId: `${sender}@s.whatsapp.net`, // DM - message: '/t n tarea en dm', + message: 't n tarea en dm', mentions: [], }); diff --git a/tests/unit/services/command.claim-unassign.test.ts b/tests/unit/services/command.claim-unassign.test.ts index b782b2e..99e26d3 100644 --- a/tests/unit/services/command.claim-unassign.test.ts +++ b/tests/unit/services/command.claim-unassign.test.ts @@ -5,7 +5,7 @@ import { TaskService } from '../../../src/tasks/service'; import { CommandService } from '../../../src/services/command'; import { setDb } from '../../../src/db/locator'; -describe('CommandService - /t tomar y /t soltar', () => { +describe('CommandService - t tomar y t soltar', () => { let memdb: Database; beforeAll(() => { @@ -53,27 +53,27 @@ describe('CommandService - /t tomar y /t soltar', () => { }); it('tomar: uso inválido (sin id)', async () => { - const res = await CommandService.handle(ctx('111', '/t tomar')); + const res = await CommandService.handle(ctx('111', 't tomar')); expect(res).toHaveLength(1); expect(res[0].recipient).toBe('111'); - expect(res[0].message).toContain('Uso: `/t tomar 26`'); + expect(res[0].message).toContain('Uso: `t tomar 26`'); }); it('tomar: not_found', async () => { - const res = await CommandService.handle(ctx('111', '/t tomar 99999')); + const res = await CommandService.handle(ctx('111', 't tomar 99999')); expect(res[0].message).toContain('no encontrada'); }); it('tomar: happy y luego already', async () => { const taskId = createTask('Desc tomar', '999', '2025-09-12'); const dc = getDisplayCode(taskId); - const r1 = await CommandService.handle(ctx('111', `/t tomar ${dc}`)); + const r1 = await CommandService.handle(ctx('111', `t tomar ${dc}`)); expect(r1[0].message).toContain('Has tomado'); expect(r1[0].message).toContain(code4(dc)); expect(r1[0].message).toContain('Desc tomar'); expect(r1[0].message).toContain('📅'); // formato dd/MM - const r2 = await CommandService.handle(ctx('111', `/t tomar ${dc}`)); + const r2 = await CommandService.handle(ctx('111', `t tomar ${dc}`)); expect(r2[0].message).toContain('ya la tenías'); }); @@ -83,31 +83,31 @@ describe('CommandService - /t tomar y /t soltar', () => { expect(comp.status).toBe('updated'); const dc = getDisplayCode(taskId); - const res = await CommandService.handle(ctx('222', `/t tomar ${dc}`)); + const res = await CommandService.handle(ctx('222', `t tomar ${dc}`)); expect(res[0].message).toContain('no encontrada'); }); it('soltar: uso inválido (sin id)', async () => { - const res = await CommandService.handle(ctx('111', '/t soltar')); - expect(res[0].message).toContain('Uso: `/t soltar 26`'); + const res = await CommandService.handle(ctx('111', 't soltar')); + expect(res[0].message).toContain('Uso: `t soltar 26`'); }); it('soltar: not_found', async () => { - const res = await CommandService.handle(ctx('111', '/t soltar 123456')); + const res = await CommandService.handle(ctx('111', 't soltar 123456')); expect(res[0].message).toContain('no encontrada'); }); it('soltar: personal única asignación → denegado', async () => { const taskId = createTask('Desc soltar', '999', '2025-09-12', ['111']); const dc = getDisplayCode(taskId); - const res = await CommandService.handle(ctx('111', `/t soltar ${dc}`)); + const res = await CommandService.handle(ctx('111', `t soltar ${dc}`)); 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 () => { const taskId = createTask('Nunca asignada a 111', '999', null, ['222']); const dc = getDisplayCode(taskId); - const res = await CommandService.handle(ctx('111', `/t soltar ${dc}`)); + const res = await CommandService.handle(ctx('111', `t soltar ${dc}`)); expect(res[0].message).toContain('no la tenías asignada'); }); @@ -117,7 +117,7 @@ describe('CommandService - /t tomar y /t soltar', () => { expect(comp.status).toBe('updated'); const dc = getDisplayCode(taskId); - const res = await CommandService.handle(ctx('111', `/t soltar ${dc}`)); + const res = await CommandService.handle(ctx('111', `t soltar ${dc}`)); expect(res[0].message).toContain('no encontrada'); }); }); diff --git a/tests/unit/services/command.date-parsing.test.ts b/tests/unit/services/command.date-parsing.test.ts index 17d9946..22a724e 100644 --- a/tests/unit/services/command.date-parsing.test.ts +++ b/tests/unit/services/command.date-parsing.test.ts @@ -28,7 +28,7 @@ describe('CommandService - parser de fechas (hoy/mañana y formatos YYYY/YY-MM-D const ctx = { sender, groupId: `${sender}@s.whatsapp.net`, // DM - message: '/t n prueba hoy', + message: 't n prueba hoy', mentions: [] as string[], }; await CommandService.handle(ctx); @@ -44,7 +44,7 @@ describe('CommandService - parser de fechas (hoy/mañana y formatos YYYY/YY-MM-D const ctx = { sender, groupId: `${sender}@s.whatsapp.net`, // DM - message: '/t n prueba mañana', + message: 't n prueba mañana', mentions: [] as string[], }; await CommandService.handle(ctx); @@ -62,7 +62,7 @@ describe('CommandService - parser de fechas (hoy/mañana y formatos YYYY/YY-MM-D sender, groupId: `${sender}@s.whatsapp.net`, // DM // contiene pasado (2020-01-01), hoy (futuro válido) y una futura explícita, y termina con "mañana" - message: '/t n mezcla 2020-01-01 hoy 2099-01-01 mañana', + message: 't n mezcla 2020-01-01 hoy 2099-01-01 mañana', mentions: [] as string[], }; await CommandService.handle(ctx); @@ -79,14 +79,14 @@ describe('CommandService - parser de fechas (hoy/mañana y formatos YYYY/YY-MM-D const ctx = { sender, groupId: `${sender}@s.whatsapp.net`, - message: '/t n con corto 28-12-19', + message: 't n con corto 27-12-19', mentions: [] as string[], }; await CommandService.handle(ctx); const row = memdb.prepare(`SELECT id, due_date FROM tasks ORDER BY id DESC LIMIT 1`).get() as any; expect(row).toBeTruthy(); - expect(String(row.due_date)).toBe('2028-12-19'); + expect(String(row.due_date)).toBe('2027-12-19'); }); it('acepta formato YY-MM-DD con ceros y futuro lejano seguro (30-01-05 → 2030-01-05)', async () => { @@ -94,7 +94,7 @@ describe('CommandService - parser de fechas (hoy/mañana y formatos YYYY/YY-MM-D const ctx = { sender, groupId: `${sender}@s.whatsapp.net`, - message: '/t n con corto 30-01-05', + message: 't n con corto 30-01-05', mentions: [] as string[], }; await CommandService.handle(ctx); @@ -107,16 +107,16 @@ describe('CommandService - parser de fechas (hoy/mañana y formatos YYYY/YY-MM-D it('rechaza formatos no permitidos y no establece due_date', async () => { const sender = '600111222'; const invalids = [ - '/t n invalida 25/12/30', // separador no permitido - '/t n invalida 2025/12/01', // separador no permitido - '/t n invalida 2025-2-01', // mes 1 dígito - '/t n invalida 2025-02-3', // día 1 dígito - '/t n invalida 2025-13-01', // mes inválido - '/t n invalida 2025-00-10', // mes inválido - '/t n invalida 2025-02-30', // día inválido calendario - '/t n invalida 25-12', // dos partes no permitido - '/t n invalida 12-25', // dos partes no permitido - '/t n invalida 2025-1-1', // sin padding + 't n invalida 25/12/30', // separador no permitido + 't n invalida 2025/12/01', // separador no permitido + 't n invalida 2025-2-01', // mes 1 dígito + 't n invalida 2025-02-3', // día 1 dígito + 't n invalida 2025-13-01', // mes inválido + 't n invalida 2025-00-10', // mes inválido + 't n invalida 2025-02-30', // día inválido calendario + 't n invalida 25-12', // dos partes no permitido + 't n invalida 12-25', // dos partes no permitido + 't n invalida 2025-1-1', // sin padding ]; for (const msg of invalids) { diff --git a/tests/unit/services/command.formatting-ddmm.test.ts b/tests/unit/services/command.formatting-ddmm.test.ts index ca5e19e..985ac8a 100644 --- a/tests/unit/services/command.formatting-ddmm.test.ts +++ b/tests/unit/services/command.formatting-ddmm.test.ts @@ -25,7 +25,7 @@ describe('CommandService - formato dd/MM en ACK de creación', () => { const responses = await CommandService.handle({ sender, groupId: `${sender}@s.whatsapp.net`, // DM - message: '/t n tarea con fecha 2099-01-05', + message: 't n tarea con fecha 2099-01-05', mentions: [], }); diff --git a/tests/unit/services/command.gating.test.ts b/tests/unit/services/command.gating.test.ts index 734d103..8dcc9ea 100644 --- a/tests/unit/services/command.gating.test.ts +++ b/tests/unit/services/command.gating.test.ts @@ -24,7 +24,7 @@ describe('CommandService - gating en modo enforce', () => { const res = await CommandService.handle({ sender: '34600123456', groupId: 'g1@g.us', - message: '/t ayuda', + message: 't ayuda', mentions: [] }); expect(Array.isArray(res)).toBe(true); @@ -37,7 +37,7 @@ describe('CommandService - gating en modo enforce', () => { const res = await CommandService.handle({ sender: '34600123456', groupId: 'g2@g.us', - message: '/t ayuda', + message: 't ayuda', mentions: [] }); diff --git a/tests/unit/services/command.help.test.ts b/tests/unit/services/command.help.test.ts index 531bfd8..f74da94 100644 --- a/tests/unit/services/command.help.test.ts +++ b/tests/unit/services/command.help.test.ts @@ -4,7 +4,7 @@ import { initializeDatabase } from '../../../src/db'; import { setDb, resetDb } from '../../../src/db/locator'; import { CommandService } from '../../../src/services/command'; -describe('CommandService - /t ayuda y /t ayuda avanzada usando help centralizado', () => { +describe('CommandService - t ayuda y t ayuda avanzada usando help centralizado', () => { let memdb: Database; beforeAll(() => { @@ -17,11 +17,11 @@ describe('CommandService - /t ayuda y /t ayuda avanzada usando help centralizado resetDb(); try { (memdb as any)?.close?.(); } catch {} }); - it('"/t ayuda" incluye quick help y CTA a ayuda avanzada', async () => { + it('"t ayuda" incluye quick help y CTA a ayuda avanzada', async () => { const res = await CommandService.handle({ sender: '600000001', groupId: '', - message: '/t ayuda', + message: 't ayuda', mentions: [], }); @@ -29,26 +29,26 @@ describe('CommandService - /t ayuda y /t ayuda avanzada usando help centralizado expect(res.length).toBeGreaterThan(0); const msg = res[0].message; - expect(msg).toContain('/t mias'); - expect(msg).toContain('/t web'); + expect(msg).toContain('t mias'); + expect(msg).toContain('t web'); expect(msg).toContain('Ayuda avanzada'); - expect(msg).toContain('/t ayuda avanzada'); + expect(msg).toContain('t ayuda avanzada'); // Configurar etiquetas en español expect(msg).toContain('diario|l-v|semanal|off'); }); - it('"/t ayuda avanzada" incluye scopes de ver y formatos de fecha', async () => { + 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', + message: 't ayuda avanzada', mentions: [], }); const msg = res[0].message; // Scopes de ver - expect(msg).toContain('/t mias'); - expect(msg).toContain('/t todas'); + expect(msg).toContain('t mias'); + expect(msg).toContain('t todas'); // Formatos de fecha expect(msg).toContain('27-09-04'); // Configurar etiquetas en español diff --git a/tests/unit/services/command.listing-ddmm.test.ts b/tests/unit/services/command.listing-ddmm.test.ts index 85e7b40..bd813a4 100644 --- a/tests/unit/services/command.listing-ddmm.test.ts +++ b/tests/unit/services/command.listing-ddmm.test.ts @@ -20,14 +20,14 @@ describe('CommandService - formato dd/MM en listados', () => { memdb.exec('DELETE FROM task_assignments; DELETE FROM tasks;'); }); - it('"/t ver mis" muestra fechas en dd/MM', async () => { + it('"t ver mis" muestra fechas en dd/MM', async () => { const sender = '600111222'; // Crear una tarea con due_date conocida y asignada al usuario (DM → asignada al creador) await CommandService.handle({ sender, groupId: `${sender}@s.whatsapp.net`, // DM - message: '/t n tarea listada 2099-01-05', + message: 't n tarea listada 2099-01-05', mentions: [], }); @@ -35,7 +35,7 @@ describe('CommandService - formato dd/MM en listados', () => { const responses = await CommandService.handle({ sender, groupId: `${sender}@s.whatsapp.net`, - message: '/t ver mis', + message: 't ver mis', mentions: [], }); diff --git a/tests/unit/services/command.nueva-assignees.test.ts b/tests/unit/services/command.nueva-assignees.test.ts index 1c835fc..e29a5ef 100644 --- a/tests/unit/services/command.nueva-assignees.test.ts +++ b/tests/unit/services/command.nueva-assignees.test.ts @@ -6,7 +6,7 @@ import { CommandService } from '../../../src/services/command'; import { Metrics } from '../../../src/services/metrics'; import { setDb } from '../../../src/db/locator'; -describe('CommandService - /t nueva (A2: fallback menciones)', () => { +describe('CommandService - t nueva (A2: fallback menciones)', () => { let memdb: Database; beforeAll(() => { @@ -44,7 +44,7 @@ describe('CommandService - /t nueva (A2: fallback menciones)', () => { const res = await CommandService.handle({ sender: '111', groupId: '', // DM - message: '/t n Tarea por mención', + message: 't n Tarea por mención', mentions: ['34600123456@s.whatsapp.net'], }); expect(res.length).toBeGreaterThan(0); @@ -59,7 +59,7 @@ describe('CommandService - /t nueva (A2: fallback menciones)', () => { const res = await CommandService.handle({ sender: '222', groupId: '', - message: '/t nueva Tarea token @+34600123456', + message: 't nueva Tarea token @+34600123456', mentions: [], }); expect(res.length).toBeGreaterThan(0); @@ -73,7 +73,7 @@ describe('CommandService - /t nueva (A2: fallback menciones)', () => { const res = await CommandService.handle({ sender: '333', groupId: '', - message: '/t n Mixta @34600123456 @lid-opaque', + message: 't n Mixta @34600123456 @lid-opaque', mentions: [], }); expect(res.length).toBeGreaterThan(0); @@ -92,7 +92,7 @@ describe('CommandService - /t nueva (A2: fallback menciones)', () => { const res = await CommandService.handle({ sender: '444', groupId: '', - message: '/t n Asignar @1234567890 @34600123456', + message: 't n Asignar @1234567890 @34600123456', mentions: [], }); expect(res.length).toBeGreaterThan(0); @@ -107,7 +107,7 @@ describe('CommandService - /t nueva (A2: fallback menciones)', () => { const res = await CommandService.handle({ sender: '555', groupId: '', - message: '/t n Dedupe @34600123456', + message: 't n Dedupe @34600123456', mentions: ['34600123456@s.whatsapp.net'], }); expect(res.length).toBeGreaterThan(0); diff --git a/tests/unit/services/command.onboarding-jit-lid.test.ts b/tests/unit/services/command.onboarding-jit-lid.test.ts index 2acf1bf..c45932c 100644 --- a/tests/unit/services/command.onboarding-jit-lid.test.ts +++ b/tests/unit/services/command.onboarding-jit-lid.test.ts @@ -29,7 +29,7 @@ describe('CommandService - JIT onboarding para menciones @lid y números demasia const res = await CommandService.handle({ sender: '34611111111', groupId: '123@g.us', - message: '/t n Pedir cita @166348562894911', + message: 't n Pedir cita @166348562894911', mentions: ['166348562894911@lid'] }); @@ -46,7 +46,7 @@ describe('CommandService - JIT onboarding para menciones @lid y números demasia const res = await CommandService.handle({ sender: '34622222222', groupId: '123@g.us', - message: '/t n Tarea prueba @123456789012345', + message: 't n Tarea prueba @123456789012345', mentions: [] }); diff --git a/tests/unit/services/command.onboarding-jit.test.ts b/tests/unit/services/command.onboarding-jit.test.ts index a07939f..b735dfc 100644 --- a/tests/unit/services/command.onboarding-jit.test.ts +++ b/tests/unit/services/command.onboarding-jit.test.ts @@ -33,7 +33,7 @@ describe('CommandService - A4 JIT DM al asignador', () => { const res = await CommandService.handle({ sender: '111', groupId: '', // DM - message: '/t n Mixta @34600123456 @lid-opaque', + message: 't n Mixta @34600123456 @lid-opaque', mentions: [], }); @@ -59,7 +59,7 @@ describe('CommandService - A4 JIT DM al asignador', () => { const res = await CommandService.handle({ sender: '222', groupId: '', - message: '/t n Solo opaco @alias-xyz', + message: 't n Solo opaco @alias-xyz', mentions: [], }); diff --git a/tests/unit/services/command.reminders-config.test.ts b/tests/unit/services/command.reminders-config.test.ts index db30f11..69c22bc 100644 --- a/tests/unit/services/command.reminders-config.test.ts +++ b/tests/unit/services/command.reminders-config.test.ts @@ -40,7 +40,7 @@ describe('CommandService - configurar recordatorios', () => { } it('configurar daily guarda preferencia y responde confirmación', async () => { - const res = await runCmd('/t configurar daily'); + const res = await runCmd('t configurar daily'); expect(res).toHaveLength(1); expect(res[0].recipient).toBe(SENDER); expect(res[0].message).toContain('✅ Recordatorios: diario'); @@ -52,7 +52,7 @@ describe('CommandService - configurar recordatorios', () => { }); it('configurar weekly guarda preferencia y responde confirmación', async () => { - const res = await runCmd('/t configurar weekly'); + const res = await runCmd('t configurar weekly'); expect(res).toHaveLength(1); expect(res[0].message).toContain('semanal (lunes 08:30)'); @@ -62,7 +62,7 @@ describe('CommandService - configurar recordatorios', () => { }); it('configurar off guarda preferencia y responde confirmación', async () => { - const res = await runCmd('/t configurar off'); + const res = await runCmd('t configurar off'); expect(res).toHaveLength(1); expect(res[0].message).toContain('apagado'); @@ -72,26 +72,26 @@ 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'); + const res = await runCmd('t configurar foo'); expect(res).toHaveLength(1); - expect(res[0].message).toContain('Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`'); + expect(res[0].message).toContain('Uso: `t configurar diario|l-v|semanal|off [HH:MM]`'); const pref = getPref(); expect(pref).toBeNull(); }); it('upsert idempotente: cambiar de daily a off actualiza la fila existente', async () => { - await runCmd('/t configurar daily'); + await runCmd('t configurar daily'); let pref = getPref(); expect(pref!.freq).toBe('daily'); - await runCmd('/t configurar off'); + await runCmd('t configurar off'); pref = getPref(); expect(pref!.freq).toBe('off'); }); it('configurar l-v con hora guarda weekdays y respeta hora', async () => { - const res = await runCmd('/t configurar l-v 8:00'); + const res = await runCmd('t configurar l-v 8:00'); expect(res).toHaveLength(1); expect(res[0].recipient).toBe(SENDER); expect(res[0].message).toContain('laborables'); diff --git a/tests/unit/services/command.self-assign.test.ts b/tests/unit/services/command.self-assign.test.ts index 1f06e9d..b8c5c1a 100644 --- a/tests/unit/services/command.self-assign.test.ts +++ b/tests/unit/services/command.self-assign.test.ts @@ -42,7 +42,7 @@ describe('CommandService - autoasignación con "yo" / "@yo"', () => { await CommandService.handle({ sender, groupId: '12345@g.us', // contexto grupo - message: '/t n Hacer algo yo', + message: 't n Hacer algo yo', mentions: [], }); @@ -57,7 +57,7 @@ describe('CommandService - autoasignación con "yo" / "@yo"', () => { await CommandService.handle({ sender, groupId: 'group@g.us', - message: '/t n Revisar docs @yo', + message: 't n Revisar docs @yo', mentions: [], }); @@ -76,7 +76,7 @@ describe('CommandService - autoasignación con "yo" / "@yo"', () => { await CommandService.handle({ sender, groupId: 'grp@g.us', - message: '/t n Caso yoyo', + message: 't n Caso yoyo', mentions: [], }); let t = getLastTask(); @@ -87,7 +87,7 @@ describe('CommandService - autoasignación con "yo" / "@yo"', () => { await CommandService.handle({ sender, groupId: 'grp@g.us', - message: '/t n Voy a cavar un hoyo', + message: 't n Voy a cavar un hoyo', mentions: [], }); t = getLastTask(); @@ -100,7 +100,7 @@ describe('CommandService - autoasignación con "yo" / "@yo"', () => { await CommandService.handle({ sender, groupId: 'g@g.us', - message: '/t n Tarea combinada yo @34600123456', + message: 't n Tarea combinada yo @34600123456', mentions: [], }); @@ -114,7 +114,7 @@ describe('CommandService - autoasignación con "yo" / "@yo"', () => { await CommandService.handle({ sender, groupId: `${sender}@s.whatsapp.net`, // DM - message: '/t n Mi tarea yo', + message: 't n Mi tarea yo', mentions: [], }); @@ -129,7 +129,7 @@ describe('CommandService - autoasignación con "yo" / "@yo"', () => { await CommandService.handle({ sender, groupId: 'group2@g.us', - message: '/t n Revisar algo @yo,', + message: 't n Revisar algo @yo,', mentions: [], }); @@ -147,7 +147,7 @@ describe('CommandService - autoasignación con "yo" / "@yo"', () => { await CommandService.handle({ sender, groupId: 'grp2@g.us', - message: '/t n Hacer (yo)', + message: 't n Hacer (yo)', mentions: [], }); diff --git a/tests/unit/services/command.task-origins.test.ts b/tests/unit/services/command.task-origins.test.ts index 640fd27..580f213 100644 --- a/tests/unit/services/command.task-origins.test.ts +++ b/tests/unit/services/command.task-origins.test.ts @@ -34,7 +34,7 @@ describe('CommandService - inserta task_origins al crear en grupo con messageId' const res = await CommandService.handle({ sender, groupId: 'g1@g.us', - message: '/t n pruebas origen 2099-01-05', + message: 't n pruebas origen 2099-01-05', mentions: [], messageId: 'MSG-ORIG-1' }); diff --git a/tests/unit/services/command.test.ts b/tests/unit/services/command.test.ts index 52d452b..853e74e 100644 --- a/tests/unit/services/command.test.ts +++ b/tests/unit/services/command.test.ts @@ -20,7 +20,7 @@ beforeEach(() => { GroupSyncService.activeGroupsCache.clear(); }); -test('listar grupo por defecto con /t ver en grupo e incluir “… y X más”', async () => { +test('listar grupo por defecto con t ver en grupo e incluir “… y X más”', async () => { // Insert group and cache it as active memDb.exec(` INSERT OR IGNORE INTO groups (id, community_id, name, active) @@ -43,7 +43,7 @@ test('listar grupo por defecto con /t ver en grupo e incluir “… y X más”' sender: '1234567890', groupId: 'test-group@g.us', mentions: [], - message: '/t ver' + message: 't ver' }); expect(responses.length).toBe(1); @@ -51,7 +51,7 @@ test('listar grupo por defecto con /t ver en grupo e incluir “… y X más”' expect(responses[0].message).toContain('No respondo en grupos.'); }); -test('listar “mis” por defecto en DM con /t ver', async () => { +test('listar “mis” por defecto en DM con t ver', async () => { // Insert groups and cache them memDb.exec(` INSERT OR REPLACE INTO groups (id, community_id, name, active) VALUES @@ -82,7 +82,7 @@ test('listar “mis” por defecto en DM con /t ver', async () => { // Contexto de DM: usar un JID que NO sea de grupo groupId: '1234567890@s.whatsapp.net', mentions: [], - message: '/t ver' + message: 't ver' }); expect(responses.length).toBe(1); @@ -117,7 +117,7 @@ test('completar tarea: camino feliz, ya completada y no encontrada', async () => sender: '1234567890', groupId: 'test-group@g.us', mentions: [], - message: `/t x ${dc}` + message: `t x ${dc}` }); expect(responses.length).toBe(1); expect(responses[0].recipient).toBe('1234567890'); @@ -128,7 +128,7 @@ test('completar tarea: camino feliz, ya completada y no encontrada', async () => sender: '1234567890', groupId: 'test-group@g.us', mentions: [], - message: `/t x ${dc}` + message: `t x ${dc}` }); expect(responses.length).toBe(1); expect(responses[0].message).toContain('no encontrada'); @@ -138,7 +138,7 @@ test('completar tarea: camino feliz, ya completada y no encontrada', async () => sender: '1234567890', groupId: 'test-group@g.us', mentions: [], - message: `/t x 999999` + message: `t x 999999` }); expect(responses.length).toBe(1); expect(responses[0].message).toContain('no encontrada'); @@ -182,7 +182,7 @@ test('ver sin en grupo activo: solo sin dueño y paginación', async () => { sender: '1234567890', groupId: 'test-group@g.us', mentions: [], - message: '/t ver sin' + message: 't ver sin' }); expect(responses.length).toBe(1); @@ -197,7 +197,7 @@ test('ver sin por DM devuelve instrucción', async () => { // DM: no es un JID de grupo groupId: '1234567890@s.whatsapp.net', mentions: [], - message: '/t ver sin' + message: 't ver sin' }); expect(responses.length).toBe(1); @@ -243,7 +243,7 @@ test('ver todos en grupo: “Tus tareas” + “Sin dueño (grupo actual)” con sender: '1234567890', groupId: 'test-group@g.us', mentions: [], - message: '/t ver todos' + message: 't ver todos' }); expect(responses.length).toBe(1); @@ -271,7 +271,7 @@ test('ver todos por DM: “Tus tareas” + nota instructiva para ver sin dueño sender: '1234567890', groupId: '1234567890@s.whatsapp.net', // DM mentions: [], - message: '/t ver todos' + message: 't ver todos' }); expect(responses.length).toBe(1); @@ -293,10 +293,10 @@ describe('CommandService', () => { expect(responses).toEqual([]); }); - test('acepta alias /t y responde con formato compacto', async () => { + test('acepta alias t y responde con formato compacto', async () => { const responses = await CommandService.handle({ ...testContextBase, - message: '/t n Test task' + message: 't n Test task' }); expect(responses.length).toBe(1); @@ -317,7 +317,7 @@ describe('CommandService', () => { const responses = await CommandService.handle({ ...testContextBase, - message: '/tarea nueva Test task' + message: 'tarea nueva Test task' }); expect(responses.length).toBe(1); diff --git a/tests/unit/services/command.unknown-help.test.ts b/tests/unit/services/command.unknown-help.test.ts index 39f894c..9d8e387 100644 --- a/tests/unit/services/command.unknown-help.test.ts +++ b/tests/unit/services/command.unknown-help.test.ts @@ -18,11 +18,11 @@ describe('CommandService - comando desconocido devuelve ayuda rápida', () => { try { resetDb(); memdb.close(); } catch {} }); - it('responde con encabezado y CTA a /t ayuda incluyendo quick help', async () => { + 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?', + message: 't qué tareas tengo hoy?', mentions: [], }); @@ -31,9 +31,9 @@ describe('CommandService - comando desconocido devuelve ayuda rápida', () => { const msg = res[0].message; expect(msg).toContain('COMANDO NO RECONOCIDO'); - expect(msg).toContain('/t info'); - expect(msg).toContain('/t mias'); - expect(msg).toContain('/t web'); - expect(msg).toContain('/t configurar'); + expect(msg).toContain('t info'); + expect(msg).toContain('t mias'); + 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 index 9c0b490..0a0ff5d 100644 --- a/tests/unit/services/command.web-login.test.ts +++ b/tests/unit/services/command.web-login.test.ts @@ -9,7 +9,7 @@ import { setDb } from '../../../src/db/locator'; const envBackup = { ...process.env }; let memdb: Database; -describe('CommandService - /t web (emisión de token de login)', () => { +describe('CommandService - t web (emisión de token de login)', () => { beforeEach(() => { process.env = { ...envBackup, @@ -33,7 +33,7 @@ describe('CommandService - /t web (emisión de token de login)', () => { const res = await CommandService.handle({ sender: '34600123456', groupId: '34600123456@s.whatsapp.net', // DM (no @g.us) - message: '/t web', + message: 't web', mentions: [] }); expect(Array.isArray(res)).toBe(true); @@ -70,7 +70,7 @@ describe('CommandService - /t web (emisión de token de login)', () => { const res = await CommandService.handle({ sender: '34600123456', groupId: '123@g.us', - message: '/t web', + message: 't web', mentions: [] }); expect(res.length).toBe(1); @@ -87,7 +87,7 @@ describe('CommandService - /t web (emisión de token de login)', () => { const res = await CommandService.handle({ sender: '34600123456', groupId: '34600123456@s.whatsapp.net', - message: '/t web', + message: 't web', mentions: [] }); expect(res.length).toBe(1); @@ -102,7 +102,7 @@ describe('CommandService - /t web (emisión de token de login)', () => { const r1 = await CommandService.handle({ sender: '34600123456', groupId: '34600123456@s.whatsapp.net', - message: '/t web', + message: 't web', mentions: [] }); expect(r1.length).toBe(1); @@ -113,7 +113,7 @@ describe('CommandService - /t web (emisión de token de login)', () => { const r2 = await CommandService.handle({ sender: '34600123456', groupId: '34600123456@s.whatsapp.net', - message: '/t web', + message: 't web', mentions: [] }); expect(r2.length).toBe(1); diff --git a/tests/unit/services/help-content.test.ts b/tests/unit/services/help-content.test.ts deleted file mode 100644 index 21acb6a..0000000 --- a/tests/unit/services/help-content.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 mias'); - 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 mias'); - expect(s).toContain('/t todas'); - - // Fechas - expect(s).toContain('27-09-04'); - 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/services/reminders.gating.test.ts b/tests/unit/services/reminders.gating.test.ts index f1fccb8..80741a3 100644 --- a/tests/unit/services/reminders.gating.test.ts +++ b/tests/unit/services/reminders.gating.test.ts @@ -7,38 +7,7 @@ import { AllowedGroups } from '../../../src/services/allowed-groups'; import { ResponseQueue } from '../../../src/services/response-queue'; import { toIsoSql } from '../../helpers/dates'; import { setDb } from '../../../src/db/locator'; - -function seedGroup(db: Database, groupId: string) { - const cols = db.query(`PRAGMA table_info(groups)`).all() as any[]; - const values: Record = {}; - const nowIso = toIsoSql(new Date()); - - for (const c of cols) { - const name = String(c.name); - const type = String(c.type || '').toUpperCase(); - const notnull = Number(c.notnull || 0) === 1; - const hasDefault = c.dflt_value != null; - - if (name === 'id') { values[name] = groupId; continue; } - if (name === 'name' || name === 'title' || name === 'subject') { values[name] = 'Test Group'; continue; } - if (name === 'created_by') { values[name] = 'tester'; continue; } - if (name.endsWith('_at')) { values[name] = nowIso; continue; } - if (name === 'is_active' || name === 'active') { values[name] = 1; continue; } - - if (notnull && !hasDefault) { - if (type.includes('INT')) values[name] = 1; - else if (type.includes('REAL')) values[name] = 0; - else values[name] = 'N/A'; - } - } - - if (!('id' in values)) values['id'] = groupId; - - const colsList = Object.keys(values); - const placeholders = colsList.map(() => '?').join(', '); - const sql = `INSERT OR REPLACE INTO groups (${colsList.join(', ')}) VALUES (${placeholders})`; - db.prepare(sql).run(...colsList.map(k => values[k])); -} +import { seedGroup } from '../../helpers/db'; describe('RemindersService - gating por grupos en modo enforce', () => { const envBackup = process.env; diff --git a/tests/unit/tasks/service.gating.test.ts b/tests/unit/tasks/service.gating.test.ts index 448b7fa..c723900 100644 --- a/tests/unit/tasks/service.gating.test.ts +++ b/tests/unit/tasks/service.gating.test.ts @@ -4,59 +4,7 @@ import { initializeDatabase } from '../../../src/db'; import { TaskService } from '../../../src/tasks/service'; import { AllowedGroups } from '../../../src/services/allowed-groups'; import { setDb, resetDb } from '../../../src/db/locator'; -import { toIsoSql } from '../../helpers/dates'; - -function seedGroup(db: Database, groupId: string) { - // Sembrado robusto: cubrir columnas NOT NULL sin valor por defecto - const cols = db.query(`PRAGMA table_info(groups)`).all() as any[]; - const values: Record = {}; - const nowIso = toIsoSql(new Date()); - - for (const c of cols) { - const name = String(c.name); - const type = String(c.type || '').toUpperCase(); - const notnull = Number(c.notnull || 0) === 1; - const hasDefault = c.dflt_value != null; - - if (name === 'id') { - values[name] = groupId; - continue; - } - - // Preconfigurar algunos alias comunes - if (name === 'name' || name === 'title' || name === 'subject') { - values[name] = 'Test Group'; - continue; - } - if (name === 'created_by') { - values[name] = 'tester'; - continue; - } - if (name.endsWith('_at')) { - values[name] = nowIso; - continue; - } - if (name === 'is_active' || name === 'active') { - values[name] = 1; - continue; - } - - // Para columnas NOT NULL sin valor por defecto, asignar valores genéricos - if (notnull && !hasDefault) { - if (type.includes('INT')) values[name] = 1; - else if (type.includes('REAL')) values[name] = 0; - else values[name] = 'N/A'; - } - } - - // Asegurar que id esté siempre - if (!('id' in values)) values['id'] = groupId; - - const colsList = Object.keys(values); - const placeholders = colsList.map(() => '?').join(', '); - const sql = `INSERT OR REPLACE INTO groups (${colsList.join(', ')}) VALUES (${placeholders})`; - db.prepare(sql).run(...colsList.map(k => values[k])); -} +import { seedGroup } from '../../helpers/db'; describe('TaskService - gating en creación con group_id (enforce)', () => { const envBackup = process.env; diff --git a/tests/unit/utils/whatsapp.test.ts b/tests/unit/utils/whatsapp.test.ts index 083b627..5fe8994 100644 --- a/tests/unit/utils/whatsapp.test.ts +++ b/tests/unit/utils/whatsapp.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { normalizeWhatsAppId, isGroupId, isUserJid } from '../../../src/utils/whatsapp'; +import { normalizeWhatsAppId, isGroupId } from '../../../src/utils/whatsapp'; describe('WhatsApp Utilities', () => { @@ -82,34 +82,4 @@ describe('WhatsApp Utilities', () => { }); }); - describe('isUserJid', () => { - test('should return true for valid user JID', () => { - expect(isUserJid('1234567890@s.whatsapp.net')).toBe(true); - }); - - test('should return true for user JID with participant', () => { - expect(isUserJid('1234567890:15@s.whatsapp.net')).toBe(true); - }); - - test('should return false for group JID', () => { - expect(isUserJid('1234567890-1234567890@g.us')).toBe(false); - }); - - test('should return false for user JID without domain', () => { - expect(isUserJid('1234567890')).toBe(false); - }); - - test('should return false for null input', () => { - expect(isUserJid(null)).toBe(false); - }); - - test('should return false for undefined input', () => { - expect(isUserJid(undefined)).toBe(false); - }); - - test('should return false for empty string input', () => { - expect(isUserJid('')).toBe(false); - }); - }); - }); diff --git a/tests/web/api.tasks.complete.errors.test.ts b/tests/web/api.tasks.complete.errors.test.ts index 1380784..08ebcfe 100644 --- a/tests/web/api.tasks.complete.errors.test.ts +++ b/tests/web/api.tasks.complete.errors.test.ts @@ -27,8 +27,8 @@ describe('Web API - completar tarea (rutas de error y gating)', () => { afterEach(async () => { try { - const { closeDb } = await import('../../apps/web/src/lib/server/db.ts'); - closeDb(); + const { getDb } = await import('../../apps/web/src/lib/server/db.ts'); + await getDb(); } catch {} if (cleanup) cleanup(); delete process.env.DB_PATH; diff --git a/tests/web/api.tasks.complete.reaction.test.ts b/tests/web/api.tasks.complete.reaction.test.ts index 19bbba5..aedb702 100644 --- a/tests/web/api.tasks.complete.reaction.test.ts +++ b/tests/web/api.tasks.complete.reaction.test.ts @@ -1,6 +1,6 @@ import { beforeEach, afterEach, describe, expect, it } from 'bun:test'; import { createTempDb } from './helpers/db'; -// Los imports del handler y closeDb se hacen dinámicos dentro de cada test/teardown +// Los imports del handler y getDb se hacen dinámicos dentro de cada test/teardown import { toIsoSql } from '../helpers/dates'; @@ -35,8 +35,8 @@ describe('Web API - completar tarea encola reacción ✅', () => { afterEach(async () => { // Cerrar la conexión singleton de la web antes de borrar el archivo try { - const { closeDb } = await import('../../apps/web/src/lib/server/db.ts'); - closeDb(); + const { getDb } = await import('../../apps/web/src/lib/server/db.ts'); + await getDb(); } catch {} if (cleanup) cleanup(); // Limpiar env relevantes