Compare commits

..

87 Commits

Author SHA1 Message Date
brobert 4c9f4d1439 refactor: quitar AsyncLocalStorage del locator y dejar currentDb simple
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert ac680ac467 refactor: mantener única DB por suite y limpiar tablas en beforeEach
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 84a5b80cae fix: evitar contaminación entre pruebas quitando fallback a currentDb
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert fc6f6d8128 fix: permitir fallback a currentDb en tests cuando no hay scope
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert c1083b19d3 fix: evitar usar currentDb en tests al obtener DB
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert dce88e3874 fix: esperar a server.stop() y resetear DB en tests
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert b429053ee2 feat: aislar DB por contexto en tests con AsyncLocalStorage
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert a153163b5e test: inicializar DB en memoria para pruebas de CommandService
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 5cd8f77b56 test: usa setDb(memdb) para configurar DB en gating
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert c7c960619f test: configurar DB en tests con setDb y resetDb
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 500018c129 test: configurar BD de pruebas con setDb y resetDb
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 63f330a065 test: usar setDb y resetDb para configurar y limpiar BD en tests
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 39b6251b7b test: usar setDb y resetDb para gestionar DB en tests
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 2ea389cf7d fix: configurar DB global en pruebas para evitar DbNotConfiguredError
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert f743fd892b test: usar setDb y resetDb para configurar DB global en pruebas
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert e8ceec1aff fix: configurar DB global en tests con setDb y eliminar dbInstance
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert e6c7e6e61e fix: corregir import '../../../src/db' en el test
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert e315899728 test: usar setDb(memdb) en admin.test.ts
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert ecc0cc6fd8 refactor: usar locator para inyectar DB en tests
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 6cb6c31d8d fix: usar getDb() en IdentityService y quitar AllowedGroups.dbInstance
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert e415a26442 refactor: eliminar dbInstance y añadir resetDb/clearDb, usar getDb()
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 5c6ca072e4 docs: reflejar progreso hasta Lote 6.4 completado en plan técnico
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert f786ba8bfa fix: usar dbi en mantenimiento para evitar que instance sea undefined
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert cd834552cc feat: aplicar fallback de DB: parámetro → .dbInstance → getDb()
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert b0e33385b4 test: añade prueba de fallback del locator y actualiza la documentación
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 77ad9d76c5 feat: aplicar fallback getDb() en ResponseQueue y TaskService
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 1300f60f58 docs: actualizar plan refactor técnico con Lote 6.0-6.2 completados
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 2669d4287c feat: centralizar resolución de DB y reexport en web
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 6196dbadc9 feat: exponer la DB global mediante locator en startServices
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 9222242eda feat: añadir locator de DB con setDb/getDb/withDb y tests
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 02331790df docs: detallar PRs necesarios para Lote 6 DB Locator
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert f7b8b1449f docs: reflejar finalización de 5.5-d y ajuste de webhook-handler.ts
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert e430fc1d4a refactor: extrae handleMessageUpsert a webhook-handler.ts y úsalo en WebhookServer
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 7189756182 feat: integrar utilidades de WhatsApp y servicios en server.ts
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 46bec524a2 refactor: modularizar WebhookServer y endpoints /metrics /health
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert ffad59f18f refactor: quitar _membersGlobalCooldownUntil y llamar a ensureUserExists
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 2f24806a06 refactor: modularizar group-sync y añadir API, cache y repo
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 0ce3ecbcd3 fix: aplicar umbral de cobertura en publishGroupCoveragePrompt
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 1b0d2ec91c refactor: desacoplar onboarding y eliminar dependencia GroupSync
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 8e79b1fa43 docs: actualizar plan técnico con 5.5-c completado y LOC ≈621
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert a72184f82c fix: inyectar db en AllowedGroups y extraer mappers en src/tasks
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert e3ec82037b refactor: extraer display_code y complete-reaction; ajustar TaskService
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert a46b5dac68 docs: reflejar completado Lote 5.5-a y extracción de ResponseQueue
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 2032712d2b fix: agregar isReactionMeta y usarla en response-queue
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 1b7420e123 feat: integrar EvolutionClient, limpieza de cola y parseo de metadata
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert a25fd4ee3b docs: añade Lote 5.5 al plan de refactor técnico
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 7dd835a3bc docs: actualizar estado de la tarea (Lote 5) a Completado
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 0249f8a395 docs: actualizar plan técnico para indicar Lote 5 Completado
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert b02ca36383 feat: extrae TaskText y TaskMeta para TaskItem
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 415548cdce refactor: extraer TaskCompleteButton y TaskActions y usar en TaskItem
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 815f060156 refactor: extrae TaskDueBadge y TaskAssignees y actualiza TaskItem
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 7a4b21da6f refactor: extrae SVGs embebidos a iconos y actualiza TaskItem
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 7a78ae5859 docs: indicar eliminar apps/web/tmp para regenerar BD en dev
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 7af5fe682c ejecuta bun i en ambos lados 3 months ago
brobert a4dc11a124 docs: refleja cierre del Lote 4 y estado de ICS en plan técnico
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 234053c609 feat: añadir ICS con límite por token y títulos Personal/Grupo/Agregado
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 69487c7e0a docs: actualizar plan refactor con finalización de Lote 3 y estado 11-10
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert fea178cd3d fix: ampliar tipado de get(taskId) en TaskService
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 83e11a69ab refactor: eliminar as any y tipar servicios y DB en lote 3
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 1ecf8a5ff2 refactor: tipar consultas SQLite y eliminar as any en varias rutas
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert a391ef7467 fix: tipar filas de grupos y convertir id/nombre a string
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert dab7e8fa9d fix: tipar groups como EvolutionGroup[] y usar group.id en group-sync.ts
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert eb47184660 fix: usar display_code seguro en ver.ts y forzar EvolutionGroup en group-sync.ts
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 97624ad923 fix: corregir tipado para typecheck en nueva.ts, ver.ts y group-sync.ts
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 16e35c6827 feat: incluir servicios en typecheck/core y quitar exclusiones
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert db9baca508 feat: centralizar normalizeTime en core y exponerla en web para preferencias
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert e7bcdbf17e fix: ajustar tipado en proxy.ts, response-queue.ts y tasks/service.ts
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert f0038ed763 fix: activar strictNullChecks en tsconfig.core.json
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert a62706d5d6 chore: activar noImplicitAny y exactOptionalPropertyTypes en core
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 240406aace feat: centralizar helpers de tests (ymdUTC/addDays) y marcar Lote 2
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 1ad36ee898 refactor: centralizar SimulatedResponseQueue y actualizar TaskItem
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 8a7c8b7a5f actualiza algunas llamadas a getQueue para que sean get que es el helper que he creado 3 months ago
brobert 77e318e677 refactor: centralizar pruebas de crypto/fechas y alias toIsoSql
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert ca06b85c48 docs: actualiza progreso del Lote 0 a completado
Co-authored-by: aider (openrouter/z-ai/glm-4.6) <aider@aider.chat>
3 months ago
brobert f2ee3bbd11 feat: añadir health check para reiniciar instancia de Evolution API
Co-authored-by: aider (openrouter/z-ai/glm-4.6) <aider@aider.chat>
3 months ago
brobert 80c38a6590 docs: documentar avances Lote 1: util canónica, wrapper datetime, tests verdes
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert a0f35b8138 fix: evitar columnas created_at/updated_at al insertar usuarios y ajustar tests
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert df27161216 test: agregar pruebas de datetime, mantenimiento y API de completar
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert c1f12ff953 fix: usar toIsoSqlUTC y corregir import de datetime
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 882f5c92d2 refactor: usar toIsoSqlUTC en routes/login/+server.ts y dev-seed.ts
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert f4f7d95485 feat: centralizar formateo UTC con util canónica y adaptar web/ICS
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 21164194c0 fix: añadir httpVersion y ajustar tsconfig.core.json
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert c65db73ac3 chore: añadir shims de tipos y adaptar tsconfig.core
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert c3095153ca chore: dividir typecheck en core y web, con tsconfig.core.json y scripts
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert 11b599bb38 docs: marcar Fase 1 como completada y añadir progreso
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert a104b69065 docs: añadir plan de refactor técnico 2025-11-01
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
3 months ago
brobert fd64cbd67d docs: agregar plan de refactorización para mejorar mantenibilidad
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4.5) <aider@aider.chat>
3 months ago

@ -64,6 +64,8 @@ Pasos:
Recomendación: planificar copias de seguridad periódicas del directorio data/. Recomendación: planificar copias de seguridad periódicas del directorio data/.
Nota (desarrollo): si al ejecutar bun run dev tienes problemas con la base de datos de la web, elimina el contenido del directorio apps/web/tmp para que se regenere por completo (se volverá a crear en el siguiente arranque).
## Configuración esencial ## Configuración esencial
Variables clave: Variables clave:

@ -1,11 +1,9 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "web", "name": "web",
"dependencies": {
"better-sqlite3": "^12.4.1",
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^6.1.0", "@sveltejs/adapter-auto": "^6.1.0",
"@sveltejs/adapter-node": "^5.3.3", "@sveltejs/adapter-node": "^5.3.3",
@ -17,6 +15,9 @@
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vite": "^7.1.7", "vite": "^7.1.7",
}, },
"optionalDependencies": {
"better-sqlite3": "^12.4.1",
},
}, },
}, },
"packages": { "packages": {

@ -2,10 +2,7 @@ import type { Handle } from '@sveltejs/kit';
import { getDb } from '$lib/server/db'; import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto'; import { sha256Hex } from '$lib/server/crypto';
import { isProd, sessionIdleTtlMs, isDev, DEV_BYPASS_AUTH, DEV_DEFAULT_USER } from '$lib/server/env'; import { isProd, sessionIdleTtlMs, isDev, DEV_BYPASS_AUTH, DEV_DEFAULT_USER } from '$lib/server/env';
import { toIsoSqlUTC } from '$lib/server/datetime';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
// Bypass de auth en desarrollo (siempre activo en dev para entorno de pruebas) // Bypass de auth en desarrollo (siempre activo en dev para entorno de pruebas)
@ -47,7 +44,7 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.userId = row.user_id; event.locals.userId = row.user_id;
// Renovar expiración por inactividad y last_seen_at // Renovar expiración por inactividad y last_seen_at
const newExpIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs)); const newExpIso = toIsoSqlUTC(new Date(Date.now() + sessionIdleTtlMs));
try { try {
db.prepare( db.prepare(
`UPDATE web_sessions `UPDATE web_sessions

@ -1,13 +1,10 @@
import { getDb } from './db'; import { getDb } from './db';
import { randomTokenBase64Url, sha256Hex } from './crypto'; import { randomTokenBase64Url, sha256Hex } from './crypto';
import { WEB_BASE_URL } from './env'; import { WEB_BASE_URL } from './env';
import { toIsoSqlUTC } from './datetime';
export type CalendarTokenType = 'personal' | 'group' | 'aggregate'; export type CalendarTokenType = 'personal' | 'group' | 'aggregate';
function toIsoSql(d: Date = new Date()): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
function requireBaseUrl(): string { function requireBaseUrl(): string {
const base = (WEB_BASE_URL || '').trim(); const base = (WEB_BASE_URL || '').trim();
if (!base) { if (!base) {
@ -73,7 +70,7 @@ export async function createCalendarTokenUrl(
const token = randomTokenBase64Url(32); const token = randomTokenBase64Url(32);
const tokenHash = await sha256Hex(token); const tokenHash = await sha256Hex(token);
const createdAt = toIsoSql(new Date()); const createdAt = toIsoSqlUTC(new Date());
const insert = db.prepare(` const insert = db.prepare(`
INSERT INTO calendar_tokens (type, user_id, group_id, token_hash, token_plain, created_at) INSERT INTO calendar_tokens (type, user_id, group_id, token_hash, token_plain, created_at)
@ -94,7 +91,7 @@ export async function rotateCalendarTokenUrl(
groupId?: string | null groupId?: string | null
): Promise<{ url: string; token: string; id: number; revoked: number | null }> { ): Promise<{ url: string; token: string; id: number; revoked: number | null }> {
const db = await getDb(); const db = await getDb();
const now = toIsoSql(new Date()); const now = toIsoSqlUTC(new Date());
const existing = await findActiveToken(type, userId, groupId ?? null); const existing = await findActiveToken(type, userId, groupId ?? null);
let revoked: number | null = null; let revoked: number | null = null;

@ -0,0 +1,35 @@
import { toIsoSqlUTC as coreToIsoSqlUTC, normalizeTime as coreNormalizeTime } from '../../../../../src/utils/datetime';
/**
* Serializa una fecha en UTC al formato SQL ISO "YYYY-MM-DD HH:MM:SS[.SSS]".
* Mantiene exactamente la semántica previa basada en toISOString().replace().
*/
export function toIsoSqlUTC(d: Date = new Date()): string {
return coreToIsoSqlUTC(d);
}
/**
* Normaliza una hora 'HH:mm'. Devuelve null si no es válida.
*/
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}`;
}
/**
* Suma meses en UTC preservando día (ajustado por desbordes de fin de mes por el propio motor).
*/
export function addMonthsUTC(date: Date, months: number): Date {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
d.setUTCMonth(d.getUTCMonth() + months);
return d;
}

@ -96,7 +96,7 @@ async function openDb(filename: string = 'tasks.db'): Promise<any> {
} else { } else {
// En SSR Node: aplicar migraciones directamente con compat para .query // En SSR Node: aplicar migraciones directamente con compat para .query
try { try {
const mod = await import('../../../../../src/db/migrations/index.ts'); const mod = await import('../../../../../src/db/migrations/index');
const list = (mod as any).migrations as any[]; const list = (mod as any).migrations as any[];
const compat: any = instance; const compat: any = instance;
if (typeof compat.query !== 'function') { if (typeof compat.query !== 'function') {

@ -12,9 +12,7 @@ function toIsoYmd(d: Date): string {
function addDays(base: Date, days: number): Date { function addDays(base: Date, days: number): Date {
return new Date(base.getTime() + days * 24 * 3600 * 1000); return new Date(base.getTime() + days * 24 * 3600 * 1000);
} }
function isoSql(dt: Date): string { import { toIsoSqlUTC } from './datetime';
return dt.toISOString().replace('T', ' ').replace('Z', '');
}
export async function seedDev(db: any, defaultUser: string): Promise<void> { export async function seedDev(db: any, defaultUser: string): Promise<void> {
try { db.exec(`PRAGMA foreign_keys = ON;`); } catch {} try { db.exec(`PRAGMA foreign_keys = ON;`); } catch {}
@ -108,11 +106,11 @@ export async function seedDev(db: any, defaultUser: string): Promise<void> {
`); `);
// Helpers para completadas // Helpers para completadas
const completedRecent = isoSql(addDays(now, 0)); // ahora const completedRecent = toIsoSqlUTC(addDays(now, 0)); // ahora
const completed2hAgo = isoSql(new Date(Date.now() - 2 * 3600 * 1000)); const completed2hAgo = toIsoSqlUTC(new Date(Date.now() - 2 * 3600 * 1000));
const completed12hAgo = isoSql(new Date(Date.now() - 12 * 3600 * 1000)); const completed12hAgo = toIsoSqlUTC(new Date(Date.now() - 12 * 3600 * 1000));
const completed48hAgo = isoSql(new Date(Date.now() - 48 * 3600 * 1000)); const completed48hAgo = toIsoSqlUTC(new Date(Date.now() - 48 * 3600 * 1000));
const completed72hAgo = isoSql(new Date(Date.now() - 72 * 3600 * 1000)); const completed72hAgo = toIsoSqlUTC(new Date(Date.now() - 72 * 3600 * 1000));
type Spec = { type Spec = {
desc: string; desc: string;

@ -1,4 +1,3 @@
import { join, resolve } from 'path';
// Carga compatible del entorno: en SvelteKit usa $env/dynamic/private; // Carga compatible del entorno: en SvelteKit usa $env/dynamic/private;
// en tests/ejecución fuera de SvelteKit cae a process.env. // en tests/ejecución fuera de SvelteKit cae a process.env.
@ -10,21 +9,7 @@ try {
env = process.env as any; env = process.env as any;
} }
/** export { resolveDbAbsolutePath } from '../../../../../src/env/db-path';
* Resuelve la ruta absoluta al archivo de la base de datos SQLite compartida.
* Prioridad:
* 1) DB_PATH (ruta completa al archivo)
* 2) DATA_DIR + filename (en prod por defecto /app/data; en dev por defecto ./tmp)
*/
export function resolveDbAbsolutePath(filename: string = 'tasks.db'): string {
const dbPathEnv = (env.DB_PATH || '').trim();
if (dbPathEnv) {
return resolve(dbPathEnv);
}
const isProdEnv = String(env.NODE_ENV || 'development').trim().toLowerCase() === 'production';
const dataDir = env.DATA_DIR ? String(env.DATA_DIR) : (isProdEnv ? '/app/data' : 'tmp');
return resolve(join(dataDir, filename));
}
export const WEB_BASE_URL = (env.WEB_BASE_URL || '').trim(); export const WEB_BASE_URL = (env.WEB_BASE_URL || '').trim();
export const COOKIE_SECRET = (env.COOKIE_SECRET || '').trim(); export const COOKIE_SECRET = (env.COOKIE_SECRET || '').trim();

@ -1,4 +1,5 @@
import { sha256Hex } from './crypto'; import { sha256Hex } from './crypto';
import { icsRateLimitPerMin } from './env';
function escapeIcsText(s: string): string { function escapeIcsText(s: string): string {
return String(s) return String(s)
@ -55,11 +56,33 @@ export type IcsEvent = {
prefix?: string; // ej: "T" para [T0123] prefix?: string; // ej: "T" para [T0123]
}; };
// Rate limiting por token (ventana 1 minuto)
const __icsRateMap = new Map<string, { start: number; count: number }>();
export function checkIcsRateLimit(tokenKey: string): { ok: boolean; retryAfterSec?: number } {
const limit = Number(icsRateLimitPerMin || 0);
if (!Number.isFinite(limit) || limit <= 0) return { ok: true };
const now = Date.now();
const windowMs = 60 * 1000;
const winStart = Math.floor(now / windowMs) * windowMs;
const rec = __icsRateMap.get(tokenKey);
if (!rec || rec.start !== winStart) {
__icsRateMap.set(tokenKey, { start: winStart, count: 1 });
return { ok: true };
}
if (rec.count < limit) {
rec.count += 1;
return { ok: true };
}
const retryAfterSec = Math.max(1, Math.ceil((rec.start + windowMs - now) / 1000));
return { ok: false, retryAfterSec };
}
export async function buildIcsCalendar(title: string, events: IcsEvent[]): Promise<{ body: string; etag: string }> { export async function buildIcsCalendar(title: string, events: IcsEvent[]): Promise<{ body: string; etag: string }> {
const lines: string[] = []; const lines: string[] = [];
lines.push('BEGIN:VCALENDAR'); lines.push('BEGIN:VCALENDAR');
lines.push('VERSION:2.0'); lines.push('VERSION:2.0');
lines.push('PRODID:-//TaskWhatsApp//Calendar//ES'); lines.push('PRODID:-/Wtask.org//ICS 1.0//ES');
lines.push('CALSCALE:GREGORIAN'); lines.push('CALSCALE:GREGORIAN');
lines.push('METHOD:PUBLISH'); lines.push('METHOD:PUBLISH');
lines.push(`X-WR-CALNAME:${escapeIcsText(title)}`); lines.push(`X-WR-CALNAME:${escapeIcsText(title)}`);

@ -1,24 +1,15 @@
<script lang="ts"> <script lang="ts">
import {
compareYmd,
todayYmdUTC,
ymdToDmy,
isToday,
isTomorrow,
} from "$lib/utils/date";
import { success, error as toastError } from "$lib/stores/toasts"; import { success, error as toastError } from "$lib/stores/toasts";
import { tick, onDestroy, createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import Popover from "$lib/ui/feedback/Popover.svelte"; import { normalizeDigits } from "$lib/utils/phone";
import { normalizeDigits, buildWaMeUrl } from "$lib/utils/phone";
import { colorForGroup } from "$lib/utils/groupColor"; import { colorForGroup } from "$lib/utils/groupColor";
import Hourglass from "$lib/ui/icons/Hourglass.svelte"; import TaskDueBadge from "$lib/ui/data/task/TaskDueBadge.svelte";
import duedateicon from "$lib/assets/on-time-icon.svg"; import TaskAssignees from "$lib/ui/data/task/TaskAssignees.svelte";
import releaseicon from "$lib/assets/emergency-exit-icon.svg"; import TaskCompleteButton from "$lib/ui/data/task/TaskCompleteButton.svelte";
import overdueicon from "$lib/assets/time-period-icon.svg"; import TaskActions from "$lib/ui/data/task/TaskActions.svelte";
import asigneesicon from "$lib/assets/friends-icon.svg"; import TaskText from "$lib/ui/data/task/TaskText.svelte";
import claimicon from "$lib/assets/mining-icon.svg"; import TaskMeta from "$lib/ui/data/task/TaskMeta.svelte";
import changedateicon from "$lib/assets/remove-date-calendar-icon.svg";
export let id: number; export let id: number;
export let description: string; export let description: string;
@ -40,10 +31,7 @@
assignees.some( assignees.some(
(a) => normalizeDigits(a) === normalizeDigits(currentUserId), (a) => normalizeDigits(a) === normalizeDigits(currentUserId),
); );
$: today = todayYmdUTC(); // Derivados de fecha ahora los maneja TaskDueBadge
$: overdue = !!due_date && compareYmd(due_date, today) < 0;
$: imminent = !!due_date && (isToday(due_date) || isTomorrow(due_date));
$: dateDmy = due_date ? ymdToDmy(due_date) : "";
$: groupLabel = groupName != null ? groupName : "Personal"; $: groupLabel = groupName != null ? groupName : "Personal";
$: gc = groupId ? colorForGroup(groupId) : null; $: gc = groupId ? colorForGroup(groupId) : null;
@ -52,23 +40,12 @@
let busy = false; let busy = false;
// Popover de responsables // Popover de responsables
let showAssignees = false;
let assigneesButtonEl: HTMLButtonElement | null = null;
$: assigneesCount = Array.isArray(assignees) ? assignees.length : 0; $: assigneesCount = Array.isArray(assignees) ? assignees.length : 0;
$: canUnassign = !(groupId == null && assigneesCount === 1 && isAssigned); $: canUnassign = !(groupId == null && assigneesCount === 1 && isAssigned);
$: assigneesAria =
assigneesCount === 0
? "Sin responsables"
: `${assigneesCount} responsable${assigneesCount === 1 ? "" : "s"}${isAssigned ? "; tú incluido" : ""}`;
onDestroy(() => {
// Cerrar popover si se desmonta el item (por navegación o filtrado)
showAssignees = false;
});
// Edición de texto (inline) // Edición de texto (inline)
let editingText = false; let editingText = false;
let descEl: HTMLElement | null = null; let taskText: any | null = null;
async function doClaim() { async function doClaim() {
if (busy) return; if (busy) return;
@ -95,6 +72,11 @@
} }
} }
function toIsoSqlLocal(d: Date = new Date()): string {
const iso = d.toISOString();
return iso.substring(0, 23).replace('T', ' ');
}
async function doComplete() { async function doComplete() {
if (busy || completed) return; if (busy || completed) return;
busy = true; busy = true;
@ -103,7 +85,7 @@
const res = await fetch(`/api/tasks/${id}/complete`, { method: "POST" }); const res = await fetch(`/api/tasks/${id}/complete`, { method: "POST" });
if (res.ok) { if (res.ok) {
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
const newCompletedAt: string | null = data?.task?.completed_at ? String(data.task.completed_at) : new Date().toISOString().replace('T', ' ').replace('Z', ''); const newCompletedAt: string | null = data?.task?.completed_at ? String(data.task.completed_at) : toIsoSqlLocal(new Date());
// Si no tenía responsables, el backend te auto-asigna: reflejarlo localmente // Si no tenía responsables, el backend te auto-asigna: reflejarlo localmente
if (hadNoAssignees && currentUserId) { if (hadNoAssignees && currentUserId) {
const set = new Set<string>(assignees || []); const set = new Set<string>(assignees || []);
@ -170,13 +152,11 @@
} }
} }
async function saveDate() { async function saveDate(value: string | null) {
if (busy) return; if (busy) return;
busy = true; busy = true;
try { try {
const body = { const body = { due_date: value };
due_date: dateValue && dateValue.trim() !== "" ? dateValue.trim() : null,
};
const res = await fetch(`/api/tasks/${id}`, { const res = await fetch(`/api/tasks/${id}`, {
method: "PATCH", method: "PATCH",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
@ -187,6 +167,7 @@
success("Fecha actualizada"); success("Fecha actualizada");
dispatch("changed", { id, action: "update_due", patch: { due_date } }); dispatch("changed", { id, action: "update_due", patch: { due_date } });
editing = false; editing = false;
dateValue = due_date ?? "";
} else { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || "No se pudo actualizar la fecha"); toastError(txt || "No se pudo actualizar la fecha");
@ -207,39 +188,21 @@
function clearDate() { function clearDate() {
if (busy) return; if (busy) return;
if (!confirm("¿Quitar la fecha de vencimiento?")) return; if (!confirm("¿Quitar la fecha de vencimiento?")) return;
dateValue = ""; saveDate(null);
saveDate();
} }
function toggleEditText() { function toggleEditText() {
editingText = !editingText; editingText = !editingText;
if (editingText) { if (editingText) {
editing = false; editing = false;
// Asegurar que el elemento refleja el texto actual y enfocarlo
if (descEl) {
descEl.textContent = description;
}
tick().then(() => {
if (descEl) {
descEl.focus();
placeCaretAtEnd(descEl);
}
});
} }
} }
function placeCaretAtEnd(el: HTMLElement) {
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
async function saveText() { async function saveText(text?: string) {
if (busy) return; if (busy) return;
const raw = (descEl?.textContent || "").replace(/\s+/g, " ").trim(); const rawSource = typeof text === "string" ? text : (taskText?.getCurrentText?.() ?? "");
const raw = String(rawSource).replace(/\s+/g, " ").trim();
if (raw.length < 1 || raw.length > 1000) { if (raw.length < 1 || raw.length > 1000) {
toastError("La descripción debe tener entre 1 y 1000 caracteres."); toastError("La descripción debe tener entre 1 y 1000 caracteres.");
return; return;
@ -272,356 +235,57 @@
} }
function cancelText() { function cancelText() {
if (descEl) {
descEl.textContent = description;
}
editingText = false; editingText = false;
} }
</script> </script>
<li class="task" class:completed in:fade={{ duration: 180 }} out:fade={{ duration: 180 }}> <li class="task" class:completed in:fade={{ duration: 180 }} out:fade={{ duration: 180 }}>
<div class="code">{codeStr}</div> <div class="code">{codeStr}</div>
<div <TaskText
tabindex="0" description={description}
class="desc" {completed}
class:editing={editingText} editing={editingText}
class:completed {busy}
contenteditable={editingText && !completed} bind:this={taskText}
role="textbox" on:toggleEdit={toggleEditText}
aria-label="Descripción de la tarea" on:saveText={(e) => saveText((e as CustomEvent<{ text: string }>).detail.text)}
spellcheck="true" on:cancelText={cancelText}
bind:this={descEl} />
on:keydown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
cancelText();
} else if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
saveText();
} else if (e.key === "Enter") {
e.preventDefault();
}
}}
>
{description}
</div>
<div class="meta"> <div class="meta">
<span <TaskMeta {groupLabel} {gc} {due_date} />
class="group-badge"
title="Grupo"
style={gc
? `--gc-border: ${gc.border}; --gc-bg: ${gc.bg}; --gc-text: ${gc.text};`
: undefined}>{groupLabel}</span
>
{#if due_date}
<span
class="date-badge"
class:overdue
class:soon={imminent}
title={overdue ? "Vencida" : imminent ? "Próxima" : "Fecha"}
>
{#if !overdue && !imminent}
<svg
version="1.1"
id="Layer_5"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 122.88 99.56"
style="enable-background:new 0 0 122.88 99.56"
xml:space="preserve"
><style type="text/css">
.st0 {
fill: var(--color-text);
}
.st1 {
fill-rule: evenodd;
clip-rule: evenodd;
fill: #38ae48;
}
</style><g
><path
class="st0"
d="M73.1,0c6.73,0,13.16,1.34,19.03,3.78c6.09,2.52,11.57,6.22,16.16,10.81c4.59,4.58,8.28,10.06,10.81,16.17 c2.43,5.87,3.78,12.3,3.78,19.03c0,6.73-1.34,13.16-3.78,19.03c-2.52,6.09-6.22,11.58-10.81,16.16 c-4.58,4.59-10.06,8.28-16.17,10.81c-5.87,2.43-12.3,3.78-19.03,3.78c-6.73,0-13.16-1.34-19.03-3.77 c-6.09-2.52-11.57-6.22-16.16-10.81l-0.01-0.01c-4.59-4.59-8.29-10.07-10.81-16.16c-0.78-1.89-1.45-3.83-2-5.82 c1.04,0.1,2.1,0.15,3.17,0.15c2.03,0,4.01-0.18,5.94-0.53c0.32,0.96,0.67,1.91,1.05,2.84c2.07,5,5.11,9.51,8.9,13.29 c3.78,3.78,8.29,6.82,13.29,8.9c4.81,1.99,10.11,3.1,15.66,3.1c5.56,0,10.85-1.1,15.66-3.1c5-2.07,9.51-5.11,13.29-8.9 c3.78-3.78,6.82-8.29,8.9-13.29c1.99-4.81,3.1-10.11,3.1-15.66c0-5.56-1.1-10.85-3.1-15.66c-2.07-5-5.11-9.51-8.9-13.29 c-3.78-3.78-8.29-6.82-13.29-8.9c-4.81-1.99-10.11-3.1-15.66-3.1c-5.56,0-10.85,1.1-15.66,3.1c-0.43,0.18-0.86,0.37-1.28,0.56 c-1.64-2.58-3.62-4.92-5.89-6.95c1.24-0.64,2.51-1.23,3.8-1.77C59.94,1.34,66.37,0,73.1,0L73.1,0z M67.38,26.12 c0-1.22,0.5-2.33,1.3-3.13c0.8-0.8,1.9-1.3,3.12-1.3c1.22,0,2.33,0.5,3.13,1.3c0.8,0.8,1.3,1.91,1.3,3.13v23.22l17.35,10.29 c1.04,0.62,1.74,1.6,2.03,2.7c0.28,1.09,0.15,2.29-0.47,3.34c-0.62,1.04-1.6,1.74-2.7,2.03c-1.09,0.28-2.29,0.15-3.33-0.47 L69.65,55.71c-0.67-0.37-1.22-0.91-1.62-1.55c-0.41-0.67-0.65-1.46-0.65-2.3V26.12L67.38,26.12z"
/><path
class="st1"
d="M26.99,2.56c14.91,0,26.99,12.08,26.99,26.99c0,14.91-12.08,26.99-26.99,26.99C12.08,56.54,0,44.45,0,29.55 C0,14.64,12.08,2.56,26.99,2.56L26.99,2.56z M15.05,30.27c0.36-2.1,2.76-3.27,4.65-2.13c0.17,0.1,0.34,0.22,0.49,0.36l0.02,0.01 c0.85,0.81,1.8,1.66,2.74,2.5l0.81,0.73l9.59-10.06c0.57-0.6,0.99-0.99,1.85-1.18c2.94-0.65,5.01,2.95,2.93,5.15L26.17,38.19 c-1.13,1.2-3.14,1.31-4.35,0.16c-0.69-0.64-1.45-1.3-2.21-1.96c-1.32-1.15-2.67-2.32-3.77-3.48 C15.18,32.25,14.89,31.17,15.05,30.27L15.05,30.27z"
/></g
></svg
>
{:else if imminent}
<svg
version="1.1"
id="Layer_5"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 122.88 99.56"
style="enable-background:new 0 0 122.88 99.56"
xml:space="preserve"
><style type="text/css">
.st0 {
fill: var(--color-text);
}
.st3 {
fill-rule: evenodd;
clip-rule: evenodd;
fill: var(--color-warning);
}
</style><g
><path
class="st0"
d="M73.1,0c6.73,0,13.16,1.34,19.03,3.78c6.09,2.52,11.57,6.22,16.16,10.81c4.59,4.58,8.28,10.06,10.81,16.17 c2.43,5.87,3.78,12.3,3.78,19.03c0,6.73-1.34,13.16-3.78,19.03c-2.52,6.09-6.22,11.58-10.81,16.16 c-4.58,4.59-10.06,8.28-16.17,10.81c-5.87,2.43-12.3,3.78-19.03,3.78c-6.73,0-13.16-1.34-19.03-3.77 c-6.09-2.52-11.57-6.22-16.16-10.81l-0.01-0.01c-4.59-4.59-8.29-10.07-10.81-16.16c-0.78-1.89-1.45-3.83-2-5.82 c1.04,0.1,2.1,0.15,3.17,0.15c2.03,0,4.01-0.18,5.94-0.53c0.32,0.96,0.67,1.91,1.05,2.84c2.07,5,5.11,9.51,8.9,13.29 c3.78,3.78,8.29,6.82,13.29,8.9c4.81,1.99,10.11,3.1,15.66,3.1c5.56,0,10.85-1.1,15.66-3.1c5-2.07,9.51-5.11,13.29-8.9 c3.78-3.78,6.82-8.29,8.9-13.29c1.99-4.81,3.1-10.11,3.1-15.66c0-5.56-1.1-10.85-3.1-15.66c-2.07-5-5.11-9.51-8.9-13.29 c-3.78-3.78-8.29-6.82-13.29-8.9c-4.81-1.99-10.11-3.1-15.66-3.1c-5.56,0-10.85,1.1-15.66,3.1c-0.43,0.18-0.86,0.37-1.28,0.56 c-1.64-2.58-3.62-4.92-5.89-6.95c1.24-0.64,2.51-1.23,3.8-1.77C59.94,1.34,66.37,0,73.1,0L73.1,0z M67.38,26.12 c0-1.22,0.5-2.33,1.3-3.13c0.8-0.8,1.9-1.3,3.12-1.3c1.22,0,2.33,0.5,3.13,1.3c0.8,0.8,1.3,1.91,1.3,3.13v23.22l17.35,10.29 c1.04,0.62,1.74,1.6,2.03,2.7c0.28,1.09,0.15,2.29-0.47,3.34c-0.62,1.04-1.6,1.74-2.7,2.03c-1.09,0.28-2.29,0.15-3.33-0.47 L69.65,55.71c-0.67-0.37-1.22-0.91-1.62-1.55c-0.41-0.67-0.65-1.46-0.65-2.3V26.12L67.38,26.12z"
/><path
class="st3"
d="M26.99,2.56c14.91,0,26.99,12.08,26.99,26.99c0,14.91-12.08,26.99-26.99,26.99C12.08,56.54,0,44.45,0,29.55 C0,14.64,12.08,2.56,26.99,2.56L26.99,2.56z M15.05,30.27c0.36-2.1,2.76-3.27,4.65-2.13c0.17,0.1,0.34,0.22,0.49,0.36l0.02,0.01 c0.85,0.81,1.8,1.66,2.74,2.5l0.81,0.73l9.59-10.06c0.57-0.6,0.99-0.99,1.85-1.18c2.94-0.65,5.01,2.95,2.93,5.15L26.17,38.19 c-1.13,1.2-3.14,1.31-4.35,0.16c-0.69-0.64-1.45-1.3-2.21-1.96c-1.32-1.15-2.67-2.32-3.77-3.48 C15.18,32.25,14.89,31.17,15.05,30.27L15.05,30.27z"
/></g
></svg
>
{:else}
<svg
version="1.1"
id="Layer_2"
x="0px"
y="0px"
viewBox="0 0 122.88 100.6"
style="enable-background:new 0 0 122.88 100.6"
xml:space="preserve"
><style type="text/css">
.st0 {
fill: var(--color-text);
}
.st2 {
fill-rule: evenodd;
clip-rule: evenodd;
fill: #d8453e;
}
</style><g
><path
class="st0"
d="M72.58,0c6.8,0,13.3,1.36,19.23,3.81c6.16,2.55,11.7,6.29,16.33,10.92l0,0c4.63,4.63,8.37,10.17,10.92,16.34 c2.46,5.93,3.81,12.43,3.81,19.23c0,6.8-1.36,13.3-3.81,19.23c-2.55,6.16-6.29,11.7-10.92,16.33l0,0 c-4.63,4.63-10.17,8.37-16.34,10.92c-5.93,2.46-12.43,3.81-19.23,3.81c-6.8,0-13.3-1.36-19.23-3.81 c-6.15-2.55-11.69-6.28-16.33-10.92l-0.01-0.01c-4.64-4.64-8.37-10.17-10.92-16.33c-0.79-1.91-1.47-3.87-2.02-5.89 c1.05,0.1,2.12,0.15,3.2,0.15c2.05,0,4.05-0.19,6-0.54c0.32,0.97,0.67,1.93,1.06,2.87c2.09,5.05,5.17,9.6,8.99,13.43 c3.82,3.82,8.38,6.9,13.43,8.99c4.87,2.02,10.21,3.13,15.83,3.13c5.62,0,10.96-1.11,15.83-3.13c5.05-2.09,9.6-5.17,13.43-8.99 c3.82-3.82,6.9-8.38,8.99-13.43c2.02-4.87,3.13-10.21,3.13-15.83c0-5.62-1.11-10.96-3.13-15.83c-2.09-5.05-5.17-9.6-8.99-13.43 c-3.82-3.82-8.38-6.9-13.43-8.99c-4.87-2.02-10.21-3.13-15.83-3.13c-5.62,0-10.96,1.11-15.83,3.13c-0.44,0.18-0.87,0.37-1.3,0.56 c-1.65-2.61-3.66-4.97-5.95-7.02c1.25-0.65,2.53-1.24,3.84-1.79C59.28,1.36,65.78,0,72.58,0L72.58,0z M66.8,26.39 c0-1.23,0.5-2.35,1.31-3.16c0.81-0.81,1.93-1.31,3.16-1.31c1.23,0,2.35,0.5,3.16,1.31c0.81,0.81,1.31,1.93,1.31,3.16v23.47 l17.54,10.4c1.05,0.62,1.76,1.62,2.05,2.73c0.28,1.1,0.15,2.31-0.47,3.37l0,0.01l0,0c-0.62,1.05-1.62,1.76-2.73,2.05 c-1.1,0.28-2.31,0.15-3.37-0.47l-0.01,0l0,0L69.1,56.29c-0.67-0.38-1.24-0.92-1.64-1.57c-0.42-0.68-0.66-1.48-0.66-2.32V26.39 L66.8,26.39z"
/><path
class="st2"
d="M27.27,3.18c15.06,0,27.27,12.21,27.27,27.27c0,15.06-12.21,27.27-27.27,27.27C12.21,57.73,0,45.52,0,30.45 C0,15.39,12.21,3.18,27.27,3.18L27.27,3.18z M24.35,41.34h5.82v5.16h-5.82V41.34L24.35,41.34L24.35,41.34z M30.17,37.77h-5.82 c-0.58-7.07-1.8-11.56-1.8-18.63c0-2.61,2.12-4.72,4.72-4.72c2.61,0,4.72,2.12,4.72,4.72C32,26.2,30.76,30.7,30.17,37.77 L30.17,37.77L30.17,37.77L30.17,37.77z"
/></g
></svg
>
{/if}
{dateDmy}
</span>
{/if}
</div> </div>
<div class="complete"> <div class="complete">
{#if completed} <TaskCompleteButton
<button {completed}
class="btn primary primary-action" {busy}
aria-label="Deshacer completar" on:complete={doComplete}
title="Deshacer completar" on:uncomplete={doUncomplete}
on:click|preventDefault={doUncomplete} />
disabled={busy}
>
↩️ Deshacer
</button>
{:else}
<button
class="btn primary primary-action"
aria-label="Completar"
title="Completar"
on:click|preventDefault={doComplete}
disabled={busy}
><svg viewBox="0 0 96 96" xml:space="preserve"
><g
><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#6BBE66"
class=""
d="M48,0c26.51,0,48,21.49,48,48S74.51,96,48,96S0,74.51,0,48 S21.49,0,48,0L48,0z M26.764,49.277c0.644-3.734,4.906-5.813,8.269-3.79c0.305,0.182,0.596,0.398,0.867,0.646l0.026,0.025 c1.509,1.446,3.2,2.951,4.876,4.443l1.438,1.291l17.063-17.898c1.019-1.067,1.764-1.757,3.293-2.101 c5.235-1.155,8.916,5.244,5.206,9.155L46.536,63.366c-2.003,2.137-5.583,2.332-7.736,0.291c-1.234-1.146-2.576-2.312-3.933-3.489 c-2.35-2.042-4.747-4.125-6.701-6.187C26.993,52.809,26.487,50.89,26.764,49.277L26.764,49.277z"
/></g
></svg
>
Completar
</button>
{/if}
</div> </div>
<div class="assignees-container"> <div class="assignees-container">
{#if assigneesCount === 0} <TaskAssignees {id} {assignees} {currentUserId} />
<button
class="assignees-badge empty"
type="button"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls={"assignees-popover-" + id}
title="Sin responsables"
disabled
bind:this={assigneesButtonEl}
>
🙅
</button>
{:else}
<button
class="assignees-badge"
class:mine={isAssigned}
type="button"
aria-haspopup="dialog"
aria-expanded={showAssignees}
aria-controls={"assignees-popover-" + id}
title={assigneesAria}
aria-label={assigneesAria}
on:click|preventDefault={() => (showAssignees = true)}
bind:this={assigneesButtonEl}
>
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 122.88 91.99" xml:space="preserve"
><g
><path
class="icon-btn-svg"
d="M45.13,35.29h-0.04c-7.01-0.79-16.42,0.01-20.78,0C17.04,35.6,9.47,41.91,5.02,51.3 c-2.61,5.51-3.3,9.66-3.73,15.55C0.42,72.79-0.03,78.67,0,84.47c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2 c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.39,2.78h31.49l-0.42-3.1l0.61-36.67 c3.2-1.29,5.96-1.89,8.39-1.99c-0.12,0.25-0.25,0.5-0.37,0.75c-2.61,5.51-3.3,9.66-3.73,15.55c-0.86,5.93-1.32,11.81-1.29,17.61 c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.46,3.24h31.62 l-0.48-3.55l0.49-28.62v0.56l0.1-4.87c0.74,0.87,1.36,1.92,1.89,3.15c1.12,2.62,1.3,4.48,1.51,7.23l1.39,18.2 c1.34,8.68,13.85,8.85,13.85,0c0.03-5.81-0.42-11.68-1.29-17.61c-0.43-5.89-1.12-10.04-3.73-15.55 c-4.57-9.65-10.48-14.76-19.45-15.81c-5.53-0.45-14.82,0.06-20.36-0.1c-1.38,0.19-2.74,0.47-4.06,0.87 c-3.45-0.48-8.01-1.07-12.56-1.09C54.76,34.77,48.15,35.91,45.13,35.29L45.13,35.29z M88.3,0c9.01,0,16.32,7.31,16.32,16.32 c0,9.01-7.31,16.32-16.32,16.32c-9.01,0-16.32-7.31-16.32-16.32C71.98,7.31,79.29,0,88.3,0L88.3,0z M34.56,0 c9.01,0,16.32,7.31,16.32,16.32c0,9.01-7.31,16.32-16.32,16.32s-16.32-7.31-16.32-16.32C18.24,7.31,25.55,0,34.56,0L34.56,0z"
/></g
></svg
></span
>
<span class="count">{assigneesCount}</span>
</button>
{/if}
</div> </div>
<div class="actions"> <div class="actions">
{#if !completed} <TaskActions
{#if !isAssigned} {isAssigned}
<button {canUnassign}
class="icon-btn secondary-action" {busy}
aria-label="Reclamar" {completed}
on:click|preventDefault={doClaim} editingText={editingText}
disabled={busy} editingDate={editing}
><svg viewBox="0 0 121.2 122.88" dateValue={dateValue}
><g on:claim={doClaim}
><path on:unassign={doUnassign}
class="icon-btn-svg" on:toggleEditText={toggleEditText}
d="M66.17,24.52c9.78,12.13,14.55,26.46,13.93,39.58c-4.52-11.08-11.54-22.31-20.85-32.65l-3.12,3.12 c-0.35,0.35-0.93,0.35-1.28,0l-9.87-9.87c-0.35-0.35-0.35-0.93,0-1.28l3.03-3.03C37.4,11.2,25.96,4.37,14.75,0.13 c13.22-0.99,27.81,3.57,40.2,13.31l1.36-1.36c0.35-0.35,0.93-0.35,1.28,0l9.87,9.87c0.35,0.35,0.35,0.93,0,1.28L66.17,24.52 L66.17,24.52z M49.32,58.69v-4.05l19.11,2.04L49.32,58.69L49.32,58.69z M57.83,74.36l-2.87-2.87l14.96-12.07L57.83,74.36 L57.83,74.36z M111.77,35.18l2.32,5.02l-24.85,8.4L111.77,35.18L111.77,35.18z M92.26,20.63l5.19,1.91L85.82,46.05L92.26,20.63 L92.26,20.63z M102.7,57.6l18.5,11.47v53.81H25.73c19.44-19.44,46.04-25.61,61.42-52.24C92.99,60.52,91.01,49.7,102.7,57.6 L102.7,57.6z M44.6,27.81l7.99,7.99L9.64,78.76c-2.2,2.2-5.8,2.2-7.99,0l0,0c-2.2-2.2-2.2-5.8,0-7.99L44.6,27.81L44.6,27.81z" on:saveText={saveText}
/></g on:cancelText={cancelText}
></svg on:toggleEditDate={toggleEdit}
> on:saveDate={(e) => saveDate((e as CustomEvent<{ value: string | null }>).detail.value)}
Reclamar</button on:clearDate={clearDate}
> on:cancelDate={() => (editing = false)}
{:else} />
<button
class="icon-btn secondary-action"
aria-label="Soltar"
title={canUnassign ? "Soltar" : "No puedes soltar una tarea personal. Márcala como completada para eliminarla"}
on:click|preventDefault={doUnassign}
disabled={busy || !canUnassign}
><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108.01 122.88">
<path
class="icon-btn-svg"
d="M.5,0H15a.51.51,0,0,1,.5.5V83.38L35.16,82h.22l.24,0c2.07-.14,3.65-.26,4.73-1.23l1.86-2.17a1.12,1.12,0,0,1,1.49-.18l9.35,6.28a1.15,1.15,0,0,1,.49,1c0,.55-.19.7-.61,1.08A11.28,11.28,0,0,0,51.78,88a27.27,27.27,0,0,1-3,3.1,15.84,15.84,0,0,1-3.68,2.45c-2.8,1.36-5.45,1.54-8.59,1.76l-.24,0-.21,0L15.5,96.77v25.61a.52.52,0,0,1-.5.5H.5a.51.51,0,0,1-.5-.5V.5A.5.5,0,0,1,.5,0ZM46,59.91l9-19.12-.89-.25a12.43,12.43,0,0,0-4.77-.82c-1.9.28-3.68,1.42-5.67,2.7-.83.53-1.69,1.09-2.62,1.63-.7.33-1.51.86-2.19,1.25l-8.7,5a1.11,1.11,0,0,1-1.51-.42l-5.48-9.64a1.1,1.1,0,0,1,.42-1.51c3.43-2,7.42-4,10.75-6.14,4-2.49,7.27-4.48,11.06-5.42s8-.8,13.89,1c2.12.59,4.55,1.48,6.55,2.2,1,.35,1.8.66,2.44.87,9.86,3.29,13.19,9.66,15.78,14.6,1.12,2.13,2.09,4,3.34,5,.51.42,1.67.27,3,.09a21.62,21.62,0,0,1,2.64-.23c4.32-.41,8.66-.66,13-1a1.1,1.1,0,0,1,1.18,1L108,61.86A1.11,1.11,0,0,1,107,63L95,63.9c-5.33.38-9.19.66-15-2.47l-.12-.07a23.23,23.23,0,0,1-7.21-8.5l0,0L65.73,68.4a63.9,63.9,0,0,0,5.85,5.32c6,5,11,9.21,9.38,20.43a23.89,23.89,0,0,1-.65,2.93c-.27,1-.56,1.9-.87,2.84-2.29,6.54-4.22,13.5-6.29,20.13a1.1,1.1,0,0,1-1,.81l-11.66.78a1,1,0,0,1-.39,0,1.12,1.12,0,0,1-.75-1.38c2.45-8.12,5-16.25,7.39-24.38a29,29,0,0,0,.87-3,7,7,0,0,0,.08-2.65l0-.24a4.16,4.16,0,0,0-.73-2.22,53.23,53.23,0,0,0-8.76-5.57c-3.75-2.07-7.41-4.08-10.25-7a12.15,12.15,0,0,1-3.59-7.36A14.76,14.76,0,0,1,46,59.91ZM80.07,6.13a12.29,12.29,0,0,1,13.1,11.39v0a12.29,12.29,0,0,1-24.52,1.72v0A12.3,12.3,0,0,1,80,6.13ZM3.34,35H6.69V51.09H3.34V35Z"
/></svg
>
Soltar</button
>
{/if}
{#if !editingText}
<button
class="icon-btn secondary-action"
aria-label="Editar texto"
title="Editar texto"
on:click|preventDefault={toggleEditText}
disabled={busy}
><svg viewBox="0 0 121.48 122.88"
><g
><path
class="icon-btn-svg"
d="M96.84,2.22l22.42,22.42c2.96,2.96,2.96,7.8,0,10.76l-12.4,12.4L73.68,14.62l12.4-12.4 C89.04-0.74,93.88-0.74,96.84,2.22L96.84,2.22z M70.18,52.19L70.18,52.19l0,0.01c0.92,0.92,1.38,2.14,1.38,3.34 c0,1.2-0.46,2.41-1.38,3.34v0.01l-0.01,0.01L40.09,88.99l0,0h-0.01c-0.26,0.26-0.55,0.48-0.84,0.67h-0.01 c-0.3,0.19-0.61,0.34-0.93,0.45c-1.66,0.58-3.59,0.2-4.91-1.12h-0.01l0,0v-0.01c-0.26-0.26-0.48-0.55-0.67-0.84v-0.01 c-0.19-0.3-0.34-0.61-0.45-0.93c-0.58-1.66-0.2-3.59,1.11-4.91v-0.01l30.09-30.09l0,0h0.01c0.92-0.92,2.14-1.38,3.34-1.38 c1.2,0,2.41,0.46,3.34,1.38L70.18,52.19L70.18,52.19L70.18,52.19z M45.48,109.11c-8.98,2.78-17.95,5.55-26.93,8.33 C-2.55,123.97-2.46,128.32,3.3,108l9.07-32v0l-0.03-0.03L67.4,20.9l33.18,33.18l-55.07,55.07L45.48,109.11L45.48,109.11z M18.03,81.66l21.79,21.79c-5.9,1.82-11.8,3.64-17.69,5.45c-13.86,4.27-13.8,7.13-10.03-6.22L18.03,81.66L18.03,81.66z"
/></g
></svg
>
Editar</button
>
{:else}
<button
class="btn primary secondary-action"
on:click|preventDefault={saveText}
disabled={busy}>Guardar</button
>
<button
class="btn ghost secondary-action"
on:click|preventDefault={cancelText}
disabled={busy}>Cancelar</button
>
{/if}
{#if !editing}
<button
class="icon-btn secondary-action"
aria-label="Editar fecha"
title="Editar fecha"
on:click|preventDefault={toggleEdit}
disabled={busy}
><svg viewBox="0 0 110.01 122.88" xml:space="preserve"
><g
><path
class="icon-btn-svg"
d="M1.87,14.69h22.66L24.5,14.3V4.13C24.5,1.86,26.86,0,29.76,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39 h38.59l-0.03-0.39V4.13C73.55,1.86,75.91,0,78.8,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39h24.11c1.03,0,1.87,0.84,1.87,1.87 v19.46c0,1.03-0.84,1.87-1.87,1.87H1.87C0.84,37.88,0,37.04,0,36.01V16.55C0,15.52,0.84,14.69,1.87,14.69L1.87,14.69z M71.6,74.59 c2.68-0.02,4.85,2.14,4.85,4.82c-0.01,2.68-2.19,4.87-4.87,4.89l-11.76,0.08l-0.08,11.77c-0.02,2.66-2.21,4.81-4.89,4.81 c-2.68-0.01-4.84-2.17-4.81-4.83l0.08-11.69L38.4,84.54c-2.68,0.02-4.85-2.14-4.85-4.82c0.01-2.68,2.19-4.88,4.87-4.9l11.76-0.08 l0.08-11.77c0.02-2.66,2.21-4.82,4.89-4.81c2.68,0,4.83,2.16,4.81,4.82l-0.08,11.69L71.6,74.59L71.6,74.59L71.6,74.59z M0.47,42.19 h109.08c0.26,0,0.46,0.21,0.46,0.46l0,0v79.76c0,0.25-0.21,0.46-0.46,0.46l-109.08,0c-0.25,0-0.46-0.21-0.46-0.46V42.66 C0,42.4,0.21,42.19,0.47,42.19L0.47,42.19L0.47,42.19z M8.84,50.58h93.84c0.52,0,0.94,0.45,0.94,0.94v62.85 c0,0.49-0.45,0.94-0.94,0.94H8.39c-0.49,0-0.94-0.42-0.94-0.94v-62.4c0-1.03,0.84-1.86,1.86-1.86L8.84,50.58L8.84,50.58z M78.34,29.87c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H73.11l-0.03,0.41v10.16c0,2.27,2.36,4.13,5.25,4.13L78.34,29.87 L78.34,29.87z M29.29,29.87c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H24.06l-0.03,0.41v10.16c0,2.27,2.36,4.13,5.25,4.13V29.87 L29.29,29.87z"
/></g
></svg
>
Fecha</button
>
{:else}
<input class="date" type="date" bind:value={dateValue} />
<button
class="btn primary secondary-action"
on:click|preventDefault={saveDate}
disabled={busy}>Guardar</button
>
<button
class="btn danger secondary-action"
on:click|preventDefault={clearDate}
disabled={busy}>Quitar</button
>
<button
class="btn ghost secondary-action"
on:click|preventDefault={toggleEdit}
disabled={busy}>Cancelar</button
>
{/if}
{/if}
</div> </div>
<Popover
bind:open={showAssignees}
ariaLabel="Responsables"
id={"assignees-popover-" + id}
>
<h3 class="popover-title">Responsables</h3>
{#if assigneesCount === 0}
<p class="muted">No hay responsables asignados.</p>
{:else}
<ul class="assignees-list">
{#each assignees as a}
<li>
<a
href={buildWaMeUrl(normalizeDigits(a))}
target="_blank"
rel="noopener noreferrer nofollow"
>
{normalizeDigits(a)}
</a>
{#if currentUserId && normalizeDigits(a) === normalizeDigits(currentUserId)}
<span class="you-pill"></span>
{/if}
</li>
{/each}
</ul>
{/if}
<div class="popover-actions">
<button class="btn ghost" on:click={() => (showAssignees = false)}
>Cerrar</button
>
</div>
</Popover>
</li> </li>
<style> <style>
@ -651,27 +315,6 @@
.completed { .completed {
opacity: 0.5; opacity: 0.5;
} }
.desc {
padding: 8px 4px;
grid-column: 1/3;
grid-row: 2/3;
}
.desc.editing {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
background: var(--color-surface);
padding: 2px 4px;
border-radius: 4px;
white-space: normal;
text-overflow: clip;
grid-column: 1/3;
grid-row: 2/3;
margin: 16px 0;
}
.desc.completed {
text-decoration: line-through;
}
.meta { .meta {
justify-self: end; justify-self: end;
align-items: start; align-items: start;
@ -684,33 +327,6 @@
.muted { .muted {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.group-badge {
padding: 2px 6px;
border-radius: 6px;
border: 1px solid var(--gc-border, var(--color-border));
background: var(--gc-bg, transparent);
color: var(--gc-text, inherit);
font-size: 12px;
}
.date-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
border-radius: 6px;
border: 1px solid transparent;
font-size: 12px;
}
.date-badge img {
max-height: 1rem;
min-width: 1.2rem;
}
.date-badge.overdue {
border-color: var(--color-danger);
}
.date-badge.soon {
border-color: var(--color-warning);
}
.assignees-container { .assignees-container {
grid-row: 4/5; grid-row: 4/5;
grid-column: 1/2; grid-column: 1/2;
@ -755,7 +371,7 @@
background: var(--color-primary-muted); background: var(--color-primary-muted);
color: var(--color-text); color: var(--color-text);
} }
.btn.primary svg { :global(.task .btn.primary svg) {
margin-right: 8px; margin-right: 8px;
} }
.btn.ghost { .btn.ghost {
@ -775,7 +391,7 @@
font-family: monospace; font-family: monospace;
box-shadow: 0 0 8px 4px var(--color-border); box-shadow: 0 0 8px 4px var(--color-border);
} }
.icon-btn svg { :global(.task .icon-btn svg) {
margin-right: 8px; margin-right: 8px;
} }
.date { .date {
@ -817,92 +433,7 @@
} }
/* Badge de responsables */ /* Badge de responsables */
.assignees-badge {
display: inline-flex;
align-items: center;
justify-self: start;
gap: 8px;
padding: 2px 8px;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-surface);
font-size: 12px;
cursor: pointer;
box-shadow: 0 0 4px 4px var(--color-border);
}
.assignees-badge .icon {
font-size: 16px;
line-height: 1;
}
.assignees-badge .count {
font-size: 12px;
line-height: 1;
}
.assignees-badge.mine {
border-color: var(--color-surface);
}
.assignees-badge.mine .icon {
position: relative;
}
.assignees-badge.mine .icon::after {
content: "";
position: absolute;
right: -6px;
top: -6px;
width: 8px;
height: 8px;
background: var(--color-primary);
border: 1px solid var(--color-surface);
border-radius: 50%;
}
.assignees-badge[aria-expanded="true"] {
border-color: var(--color-primary);
}
.assignees-badge:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.assignees-badge.empty {
padding: 2px 6px;
gap: 0;
}
.assignees-list {
list-style: none;
margin: 8px 0;
padding: 0;
}
.assignees-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.assignees-list a {
color: var(--color-primary);
text-decoration: none;
}
.assignees-list a:hover,
.assignees-list a:focus-visible {
text-decoration: underline;
}
.popover-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.popover-title {
margin: 0 0 4px 0;
font-size: 0.95rem;
}
.you-pill {
margin-left: 6px;
padding: 1px 6px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.12);
color: var(--color-primary);
font-size: 11px;
}
.icon-btn-svg { .icon-btn-svg {
fill-rule: evenodd; fill-rule: evenodd;
clip-rule: evenodd; clip-rule: evenodd;

@ -0,0 +1,103 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import ClaimIcon from "$lib/ui/icons/ClaimIcon.svelte";
import UnassignIcon from "$lib/ui/icons/UnassignIcon.svelte";
import EditIcon from "$lib/ui/icons/EditIcon.svelte";
import CalendarEditIcon from "$lib/ui/icons/CalendarEditIcon.svelte";
export let isAssigned: boolean;
export let canUnassign: boolean;
export let busy: boolean;
export let completed: boolean;
export let editingText: boolean;
export let editingDate: boolean;
export let dateValue: string = "";
const dispatch = createEventDispatcher<{
claim: void;
unassign: void;
toggleEditText: void;
saveText: void;
cancelText: void;
toggleEditDate: void;
saveDate: { value: string | null };
clearDate: void;
cancelDate: void;
}>();
let localDate = dateValue;
$: if (editingDate) {
localDate = dateValue;
}
</script>
{#if !completed}
{#if !isAssigned}
<button
class="icon-btn secondary-action"
aria-label="Reclamar"
on:click|preventDefault={() => dispatch("claim")}
disabled={busy}
>
<ClaimIcon /> Reclamar
</button>
{:else}
<button
class="icon-btn secondary-action"
aria-label="Soltar"
title={canUnassign ? "Soltar" : "No puedes soltar una tarea personal. Márcala como completada para eliminarla"}
on:click|preventDefault={() => dispatch("unassign")}
disabled={busy || !canUnassign}
>
<UnassignIcon /> Soltar
</button>
{/if}
{#if !editingText}
<button
class="icon-btn secondary-action"
aria-label="Editar texto"
title="Editar texto"
on:click|preventDefault={() => dispatch("toggleEditText")}
disabled={busy}
>
<EditIcon /> Editar
</button>
{:else}
<button class="btn primary secondary-action" on:click|preventDefault={() => dispatch("saveText")} disabled={busy}>
Guardar
</button>
<button class="btn ghost secondary-action" on:click|preventDefault={() => dispatch("cancelText")} disabled={busy}>
Cancelar
</button>
{/if}
{#if !editingDate}
<button
class="icon-btn secondary-action"
aria-label="Editar fecha"
title="Editar fecha"
on:click|preventDefault={() => dispatch("toggleEditDate")}
disabled={busy}
>
<CalendarEditIcon /> Fecha
</button>
{:else}
<input class="date" type="date" bind:value={localDate} />
<button
class="btn primary secondary-action"
on:click|preventDefault={() => dispatch("saveDate", { value: (localDate || "").trim() || null })}
disabled={busy}
>
Guardar
</button>
<button class="btn danger secondary-action" on:click|preventDefault={() => dispatch("clearDate")} disabled={busy}>
Quitar
</button>
<button class="btn ghost secondary-action" on:click|preventDefault={() => dispatch("cancelDate")} disabled={busy}>
Cancelar
</button>
{/if}
{/if}

@ -0,0 +1,144 @@
<script lang="ts">
import { normalizeDigits, buildWaMeUrl } from "$lib/utils/phone";
import Popover from "$lib/ui/feedback/Popover.svelte";
import AssigneesIcon from "$lib/ui/icons/AssigneesIcon.svelte";
import { onDestroy } from "svelte";
export let id: number;
export let assignees: string[] = [];
export let currentUserId: string | null | undefined = null;
let open = false;
$: assigneesCount = Array.isArray(assignees) ? assignees.length : 0;
$: isAssigned =
!!currentUserId &&
(assignees || []).some((a) => normalizeDigits(a) === normalizeDigits(currentUserId!));
$: assigneesAria =
assigneesCount === 0
? "Sin responsables"
: `${assigneesCount} responsable${assigneesCount === 1 ? "" : "s"}${isAssigned ? "; tú incluido" : ""}`;
onDestroy(() => { open = false; });
</script>
{#if assigneesCount === 0}
<button
class="assignees-badge empty"
type="button"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls={"assignees-popover-" + id}
title="Sin responsables"
disabled
>
🙅
</button>
{:else}
<button
class="assignees-badge"
class:mine={isAssigned}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={"assignees-popover-" + id}
title={assigneesAria}
aria-label={assigneesAria}
on:click={() => (open = true)}
>
<span class="icon" aria-hidden="true">
<AssigneesIcon />
</span>
<span class="count">{assigneesCount}</span>
</button>
{/if}
<Popover bind:open={open} ariaLabel="Responsables" id={"assignees-popover-" + id}>
<h3 class="popover-title">Responsables</h3>
{#if assigneesCount === 0}
<p class="muted">No hay responsables asignados.</p>
{:else}
<ul class="assignees-list">
{#each assignees as a}
<li>
<a href={buildWaMeUrl(normalizeDigits(a))} target="_blank" rel="noopener noreferrer nofollow">
{normalizeDigits(a)}
</a>
{#if currentUserId && normalizeDigits(a) === normalizeDigits(currentUserId)}
<span class="you-pill"></span>
{/if}
</li>
{/each}
</ul>
{/if}
<div class="popover-actions">
<button class="btn ghost" on:click={() => (open = false)}>Cerrar</button>
</div>
</Popover>
<style>
.muted { color: var(--color-text-muted); }
.assignees-badge {
display: inline-flex;
align-items: center;
justify-self: start;
gap: 8px;
padding: 2px 8px;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-surface);
font-size: 12px;
cursor: pointer;
box-shadow: 0 0 4px 4px var(--color-border);
}
.assignees-badge .icon {
font-size: 16px;
line-height: 1;
}
.assignees-badge .count {
font-size: 12px;
line-height: 1;
}
.assignees-badge.mine { border-color: var(--color-surface); }
.assignees-badge.mine .icon { position: relative; }
.assignees-badge.mine .icon::after {
content: "";
position: absolute;
right: -6px;
top: -6px;
width: 8px;
height: 8px;
background: var(--color-primary);
border: 1px solid var(--color-surface);
border-radius: 50%;
}
.assignees-badge[aria-expanded="true"] { border-color: var(--color-primary); }
.assignees-badge:disabled { cursor: not-allowed; opacity: 0.6; }
.assignees-badge.empty { padding: 2px 6px; gap: 0; }
.assignees-list { list-style: none; margin: 8px 0; padding: 0; }
.assignees-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.assignees-list a { color: var(--color-primary); text-decoration: none; }
.assignees-list a:hover, .assignees-list a:focus-visible { text-decoration: underline; }
.popover-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.popover-title { margin: 0 0 4px 0; font-size: 0.95rem; }
.you-pill {
margin-left: 6px;
padding: 1px 6px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.12);
color: var(--color-primary);
font-size: 11px;
}
</style>

@ -0,0 +1,35 @@
<script lang="ts">
import CheckCircleSuccessIcon from "$lib/ui/icons/CheckCircleSuccessIcon.svelte";
import { createEventDispatcher } from "svelte";
export let completed: boolean;
export let busy: boolean;
const dispatch = createEventDispatcher<{
complete: void;
uncomplete: void;
}>();
</script>
{#if completed}
<button
class="btn primary primary-action"
aria-label="Deshacer completar"
title="Deshacer completar"
on:click|preventDefault={() => dispatch("uncomplete")}
disabled={busy}
>
↩️ Deshacer
</button>
{:else}
<button
class="btn primary primary-action"
aria-label="Completar"
title="Completar"
on:click|preventDefault={() => dispatch("complete")}
disabled={busy}
>
<CheckCircleSuccessIcon />
Completar
</button>
{/if}

@ -0,0 +1,45 @@
<script lang="ts">
import { compareYmd, todayYmdUTC, ymdToDmy, isToday, isTomorrow } from "$lib/utils/date";
import DueOkIcon from "$lib/ui/icons/DueOkIcon.svelte";
import DueSoonIcon from "$lib/ui/icons/DueSoonIcon.svelte";
import DueOverdueIcon from "$lib/ui/icons/DueOverdueIcon.svelte";
export let due_date: string | null;
$: today = todayYmdUTC();
$: overdue = !!due_date && compareYmd(due_date, today) < 0;
$: imminent = !!due_date && (isToday(due_date!) || isTomorrow(due_date!));
$: dateDmy = due_date ? ymdToDmy(due_date) : "";
$: title = overdue ? "Vencida" : imminent ? "Próxima" : "Fecha";
</script>
{#if due_date}
<span class="date-badge" class:overdue class:soon={imminent} {title}>
{#if !overdue && !imminent}
<DueOkIcon />
{:else if imminent}
<DueSoonIcon />
{:else}
<DueOverdueIcon />
{/if}
{dateDmy}
</span>
{/if}
<style>
.date-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
border-radius: 6px;
border: 1px solid transparent;
font-size: 12px;
}
.date-badge.overdue {
border-color: var(--color-danger);
}
.date-badge.soon {
border-color: var(--color-warning);
}
</style>

@ -0,0 +1,31 @@
<script lang="ts">
import TaskDueBadge from "$lib/ui/data/task/TaskDueBadge.svelte";
export let groupLabel: string;
export let gc: { border?: string; bg?: string; text?: string } | null = null;
export let due_date: string | null = null;
</script>
<span
class="group-badge"
title="Grupo"
style={gc
? `--gc-border: ${gc.border}; --gc-bg: ${gc.bg}; --gc-text: ${gc.text};`
: undefined}
>
{groupLabel}
</span>
{#if due_date}
<TaskDueBadge {due_date} />
{/if}
<style>
.group-badge {
padding: 2px 6px;
border-radius: 6px;
border: 1px solid var(--gc-border, var(--color-border));
background: var(--gc-bg, transparent);
color: var(--gc-text, inherit);
font-size: 12px;
}
</style>

@ -0,0 +1,99 @@
<script lang="ts">
import { tick, createEventDispatcher } from "svelte";
export let description: string;
export let completed: boolean;
export let editing: boolean;
export let busy: boolean;
const dispatch = createEventDispatcher<{
toggleEdit: void;
saveText: { text: string };
cancelText: void;
}>();
let el: HTMLElement | null = null;
// Mantener el DOM sincronizado cuando se cierra la edición o cambia la descripción
$: if (el && !editing) {
el.textContent = description;
}
// Enfocar al entrar en modo edición
$: if (editing) {
tick().then(() => {
if (el) {
el.focus();
placeCaretAtEnd(el);
}
});
}
function placeCaretAtEnd(node: HTMLElement) {
const range = document.createRange();
range.selectNodeContents(node);
range.collapse(false);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
function normalizeText(s: string): string {
return s.replace(/\s+/g, " ").trim();
}
export function getCurrentText(): string {
return normalizeText(el?.textContent || "");
}
</script>
<div
tabindex="0"
class="desc"
class:editing={editing}
class:completed
contenteditable={editing && !completed}
role="textbox"
aria-label="Descripción de la tarea"
spellcheck="true"
bind:this={el}
on:dblclick={() => !busy && !completed && dispatch('toggleEdit')}
on:keydown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
dispatch('cancelText');
} else if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
dispatch('saveText', { text: getCurrentText() });
} else if (e.key === "Enter") {
e.preventDefault();
}
}}
>
{description}
</div>
<style>
.desc {
padding: 8px 4px;
grid-column: 1/3;
grid-row: 2/3;
}
.desc.editing {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
background: var(--color-surface);
padding: 2px 4px;
border-radius: 4px;
white-space: normal;
text-overflow: clip;
grid-column: 1/3;
grid-row: 2/3;
margin: 16px 0;
}
.desc.completed {
text-decoration: line-through;
}
</style>

@ -0,0 +1,21 @@
<script lang="ts">
export let className: string = '';
</script>
<svg
viewBox="0 0 122.88 91.99"
aria-hidden="true"
focusable="false"
class={className}
xmlns="http://www.w3.org/2000/svg"
>
<style>
.icon-btn-svg { fill-rule: evenodd; clip-rule: evenodd; fill: var(--color-text); }
</style>
<g>
<path
class="icon-btn-svg"
d="M45.13,35.29h-0.04c-7.01-0.79-16.42,0.01-20.78,0C17.04,35.6,9.47,41.91,5.02,51.3 c-2.61,5.51-3.3,9.66-3.73,15.55C0.42,72.79-0.03,78.67,0,84.47c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2 c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.39,2.78h31.49l-0.42-3.1l0.61-36.67 c3.2-1.29,5.96-1.89,8.39-1.99c-0.12,0.25-0.25,0.5-0.37,0.75c-2.61,5.51-3.3,9.66-3.73,15.55c-0.86,5.93-1.32,11.81-1.29,17.61 c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.46,3.24h31.62 l-0.48-3.55l0.49-28.62v0.56l0.1-4.87c0.74,0.87,1.36,1.92,1.89,3.15c1.12,2.62,1.3,4.48,1.51,7.23l1.39,18.2 c1.34,8.68,13.85,8.85,13.85,0c0.03-5.81-0.42-11.68-1.29-17.61c-0.43-5.89-1.12-10.04-3.73-15.55 c-4.57-9.65-10.48-14.76-19.45-15.81c-5.53-0.45-14.82,0.06-20.36-0.1c-1.38,0.19-2.74,0.47-4.06,0.87 c-3.45-0.48-8.01-1.07-12.56-1.09C54.76,34.77,48.15,35.91,45.13,35.29L45.13,35.29z M88.3,0c9.01,0,16.32,7.31,16.32,16.32 c0,9.01-7.31,16.32-16.32,16.32c-9.01,0-16.32-7.31-16.32-16.32C71.98,7.31,79.29,0,88.3,0L88.3,0z M34.56,0 c9.01,0,16.32,7.31,16.32,16.32c0,9.01-7.31,16.32-16.32,16.32s-16.32-7.31-16.32-16.32C18.24,7.31,25.55,0,34.56,0L34.56,0z"
/>
</g>
</svg>

@ -0,0 +1,21 @@
<script lang="ts">
export let className: string = '';
</script>
<svg
viewBox="0 0 110.01 122.88"
aria-hidden="true"
focusable="false"
class={className}
xmlns="http://www.w3.org/2000/svg"
>
<style>
.icon-btn-svg { fill-rule: evenodd; clip-rule: evenodd; fill: var(--color-text); }
</style>
<g>
<path
class="icon-btn-svg"
d="M1.87,14.69h22.66L24.5,14.3V4.13C24.5,1.86,26.86,0,29.76,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39 h38.59l-0.03-0.39V4.13C73.55,1.86,75.91,0,78.8,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39h24.11c1.03,0,1.87,0.84,1.87,1.87 v19.46c0,1.03-0.84,1.87-1.87,1.87H1.87C0.84,37.88,0,37.04,0,36.01V16.55C0,15.52,0.84,14.69,1.87,14.69L1.87,14.69z M71.6,74.59 c2.68-0.02,4.85,2.14,4.85,4.82c-0.01,2.68-2.19,4.87-4.87,4.89l-11.76,0.08l-0.08,11.77c-0.02,2.66-2.21,4.81-4.89,4.81 c-2.68-0.01-4.84-2.17-4.81-4.83l0.08-11.69L38.4,84.54c-2.68,0.02-4.85-2.14-4.85-4.82c0.01-2.68,2.19-4.88,4.87-4.9l11.76-0.08 l0.08-11.77c0.02-2.66,2.21-4.82,4.89-4.81c2.68,0,4.83,2.16,4.81,4.82l-0.08,11.69L71.6,74.59L71.6,74.59L71.6,74.59z M0.47,42.19 h109.08c0.26,0,0.46,0.21,0.46,0.46l0,0v79.76c0,0.25-0.21,0.46-0.46,0.46l-109.08,0c-0.25,0-0.46-0.21-0.46-0.46V42.66 C0,42.4,0.21,42.19,0.47,42.19L0.47,42.19L0.47,42.19z M8.84,50.58h93.84c0.52,0,0.94,0.45,0.94,0.94v62.85 c0,0.49-0.45,0.94-0.94,0.94H8.39c-0.49,0-0.94-0.42-0.94-0.94v-62.4c0-1.03,0.84-1.86,1.86-1.86L8.84,50.58L8.84,50.58z M78.34,29.87c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H73.11l-0.03,0.41v10.16c0,2.27,2.36,4.13,5.25,4.13L78.34,29.87 L78.34,29.87z M29.29,29.87c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H24.06l-0.03,0.41v10.16c0,2.27,2.36,4.13,5.25,4.13V29.87 L29.29,29.87z"
/>
</g>
</svg>

@ -0,0 +1,20 @@
<script lang="ts">
export let className: string = '';
</script>
<svg
viewBox="0 0 96 96"
aria-hidden="true"
focusable="false"
class={className}
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#6BBE66"
d="M48,0c26.51,0,48,21.49,48,48S74.51,96,48,96S0,74.51,0,48 S21.49,0,48,0L48,0z M26.764,49.277c0.644-3.734,4.906-5.813,8.269-3.79c0.305,0.182,0.596,0.398,0.867,0.646l0.026,0.025 c1.509,1.446,3.2,2.951,4.876,4.443l1.438,1.291l17.063-17.898c1.019-1.067,1.764-1.757,3.293-2.101 c5.235-1.155,8.916,5.244,5.206,9.155L46.536,63.366c-2.003,2.137-5.583,2.332-7.736,0.291c-1.234-1.146-2.576-2.312-3.933-3.489 c-2.35-2.042-4.747-4.125-6.701-6.187C26.993,52.809,26.487,50.89,26.764,49.277L26.764,49.277z"
/>
</g>
</svg>

@ -0,0 +1,21 @@
<script lang="ts">
export let className: string = '';
</script>
<svg
viewBox="0 0 121.2 122.88"
aria-hidden="true"
focusable="false"
class={className}
xmlns="http://www.w3.org/2000/svg"
>
<style>
.icon-btn-svg { fill-rule: evenodd; clip-rule: evenodd; fill: var(--color-text); }
</style>
<g>
<path
class="icon-btn-svg"
d="M66.17,24.52c9.78,12.13,14.55,26.46,13.93,39.58c-4.52-11.08-11.54-22.31-20.85-32.65l-3.12,3.12 c-0.35,0.35-0.93,0.35-1.28,0l-9.87-9.87c-0.35-0.35-0.35-0.93,0-1.28l3.03-3.03C37.4,11.2,25.96,4.37,14.75,0.13 c13.22-0.99,27.81,3.57,40.2,13.31l1.36-1.36c0.35-0.35,0.93-0.35,1.28,0l9.87,9.87c0.35,0.35,0.35,0.93,0,1.28L66.17,24.52 L66.17,24.52z M49.32,58.69v-4.05l19.11,2.04L49.32,58.69L49.32,58.69z M57.83,74.36l-2.87-2.87l14.96-12.07L57.83,74.36 L57.83,74.36z M111.77,35.18l2.32,5.02l-24.85,8.4L111.77,35.18L111.77,35.18z M92.26,20.63l5.19,1.91L85.82,46.05L92.26,20.63 L92.26,20.63z M102.7,57.6l18.5,11.47v53.81H25.73c19.44-19.44,46.04-25.61,61.42-52.24C92.99,60.52,91.01,49.7,102.7,57.6 L102.7,57.6z M44.6,27.81l7.99,7.99L9.64,78.76c-2.2,2.2-5.8,2.2-7.99,0l0,0c-2.2-2.2-2.2-5.8,0-7.99L44.6,27.81L44.6,27.81z"
/>
</g>
</svg>

@ -0,0 +1,26 @@
<script lang="ts">
export let className: string = '';
</script>
<svg
viewBox="0 0 122.88 99.56"
aria-hidden="true"
focusable="false"
class={className}
xmlns="http://www.w3.org/2000/svg"
>
<style>
.st0 { fill: var(--color-text); }
.st1 { fill-rule: evenodd; clip-rule: evenodd; fill: #38ae48; }
</style>
<g>
<path
class="st0"
d="M73.1,0c6.73,0,13.16,1.34,19.03,3.78c6.09,2.52,11.57,6.22,16.16,10.81c4.59,4.58,8.28,10.06,10.81,16.17 c2.43,5.87,3.78,12.3,3.78,19.03c0,6.73-1.34,13.16-3.78,19.03c-2.52,6.09-6.22,11.58-10.81,16.16 c-4.58,4.59-10.06,8.28-16.17,10.81c-5.87,2.43-12.3,3.78-19.03,3.78c-6.73,0-13.16-1.34-19.03-3.77 c-6.09-2.52-11.57-6.22-16.16-10.81l-0.01-0.01c-4.59-4.59-8.29-10.07-10.81-16.16c-0.78-1.89-1.45-3.83-2-5.82 c1.04,0.1,2.1,0.15,3.17,0.15c2.03,0,4.01-0.18,5.94-0.53c0.32,0.96,0.67,1.91,1.05,2.84c2.07,5,5.11,9.51,8.9,13.29 c3.78,3.78,8.29,6.82,13.29,8.9c4.81,1.99,10.11,3.1,15.66,3.1c5.56,0,10.85-1.1,15.66-3.1c5-2.07,9.51-5.11,13.29-8.9 c3.78-3.78,6.82-8.29,8.9-13.29c1.99-4.81,3.1-10.11,3.1-15.66c0-5.56-1.1-10.85-3.1-15.66c-2.07-5-5.11-9.51-8.9-13.29 c-3.78-3.78-8.29-6.82-13.29-8.9c-4.81-1.99-10.11-3.1-15.66-3.1c-5.56,0-10.85,1.1-15.66,3.1c-0.43,0.18-0.86,0.37-1.28,0.56 c-1.64-2.58-3.62-4.92-5.89-6.95c1.24-0.64,2.51-1.23,3.8-1.77C59.94,1.34,66.37,0,73.1,0L73.1,0z M67.38,26.12 c0-1.22,0.5-2.33,1.3-3.13c0.8-0.8,1.9-1.3,3.12-1.3c1.22,0,2.33,0.5,3.13,1.3c0.8,0.8,1.3,1.91,1.3,3.13v23.22l17.35,10.29 c1.04,0.62,1.74,1.6,2.03,2.7c0.28,1.09,0.15,2.29-0.47,3.34c-0.62,1.04-1.6,1.74-2.7,2.03c-1.09,0.28-2.29,0.15-3.33-0.47 L69.65,55.71c-0.67-0.37-1.22-0.91-1.62-1.55c-0.41-0.67-0.65-1.46-0.65-2.3V26.12L67.38,26.12z"
/>
<path
class="st1"
d="M26.99,2.56c14.91,0,26.99,12.08,26.99,26.99c0,14.91-12.08,26.99-26.99,26.99C12.08,56.54,0,44.45,0,29.55 C0,14.64,12.08,2.56,26.99,2.56L26.99,2.56z M15.05,30.27c0.36-2.1,2.76-3.27,4.65-2.13c0.17,0.1,0.34,0.22,0.49,0.36l0.02,0.01 c0.85,0.81,1.8,1.66,2.74,2.5l0.81,0.73l9.59-10.06c0.57-0.6,0.99-0.99,1.85-1.18c2.94-0.65,5.01,2.95,2.93,5.15L26.17,38.19 c-1.13,1.2-3.14,1.31-4.35,0.16c-0.69-0.64-1.45-1.3-2.21-1.96c-1.32-1.15-2.67-2.32-3.77-3.48 C15.18,32.25,14.89,31.17,15.05,30.27L15.05,30.27z"
/>
</g>
</svg>

@ -0,0 +1,26 @@
<script lang="ts">
export let className: string = '';
</script>
<svg
viewBox="0 0 122.88 100.6"
aria-hidden="true"
focusable="false"
class={className}
xmlns="http://www.w3.org/2000/svg"
>
<style>
.st0 { fill: var(--color-text); }
.st2 { fill-rule: evenodd; clip-rule: evenodd; fill: #d8453e; }
</style>
<g>
<path
class="st0"
d="M72.58,0c6.8,0,13.3,1.36,19.23,3.81c6.16,2.55,11.7,6.29,16.33,10.92l0,0c4.63,4.63,8.37,10.17,10.92,16.34 c2.46,5.93,3.81,12.43,3.81,19.23c0,6.8-1.36,13.3-3.81,19.23c-2.55,6.16-6.29,11.7-10.92,16.33l0,0 c-4.63,4.63-10.17,8.37-16.34,10.92c-5.93,2.46-12.43,3.81-19.23,3.81c-6.8,0-13.3-1.36-19.23-3.81 c-6.15-2.55-11.69-6.28-16.33-10.92l-0.01-0.01c-4.64-4.64-8.37-10.17-10.92-16.33c-0.79-1.91-1.47-3.87-2.02-5.89 c1.05,0.1,2.12,0.15,3.2,0.15c2.05,0,4.05-0.19,6-0.54c0.32,0.97,0.67,1.93,1.06,2.87c2.09,5.05,5.17,9.6,8.99,13.43 c3.82,3.82,8.38,6.9,13.43,8.99c4.87,2.02,10.21,3.13,15.83,3.13c5.62,0,10.96-1.11,15.83-3.13c5.05-2.09,9.6-5.17,13.43-8.99 c3.82-3.82,6.9-8.38,8.99-13.43c2.02-4.87,3.13-10.21,3.13-15.83c0-5.62-1.11-10.96-3.13-15.83c-2.09-5.05-5.17-9.6-8.99-13.43 c-3.82-3.82-8.38-6.9-13.43-8.99c-4.87-2.02-10.21-3.13-15.83-3.13c-5.62,0-10.96,1.11-15.83,3.13c-0.44,0.18-0.87,0.37-1.3,0.56 c-1.65-2.61-3.66-4.97-5.95-7.02c1.25-0.65,2.53-1.24,3.84-1.79C59.28,1.36,65.78,0,72.58,0L72.58,0z M66.8,26.39 c0-1.23,0.5-2.35,1.31-3.16c0.81-0.81,1.93-1.31,3.16-1.31c1.23,0,2.35,0.5,3.16,1.31c0.81,0.81,1.31,1.93,1.31,3.16v23.47 l17.54,10.4c1.05,0.62,1.76,1.62,2.05,2.73c0.28,1.1,0.15,2.31-0.47,3.37l0,0.01l0,0c-0.62,1.05-1.62,1.76-2.73,2.05 c-1.1,0.28-2.31,0.15-3.37-0.47l-0.01,0l0,0L69.1,56.29c-0.67-0.38-1.24-0.92-1.64-1.57c-0.42-0.68-0.66-1.48-0.66-2.32V26.39 L66.8,26.39z"
/>
<path
class="st2"
d="M27.27,3.18c15.06,0,27.27,12.21,27.27,27.27c0,15.06-12.21,27.27-27.27,27.27C12.21,57.73,0,45.52,0,30.45 C0,15.39,12.21,3.18,27.27,3.18L27.27,3.18z M24.35,41.34h5.82v5.16h-5.82V41.34L24.35,41.34L24.35,41.34z M30.17,37.77h-5.82 c-0.58-7.07-1.8-11.56-1.8-18.63c0-2.61,2.12-4.72,4.72-4.72c2.61,0,4.72,2.12,4.72,4.72C32,26.2,30.76,30.7,30.17,37.77 L30.17,37.77L30.17,37.77L30.17,37.77z"
/>
</g>
</svg>

@ -0,0 +1,26 @@
<script lang="ts">
export let className: string = '';
</script>
<svg
viewBox="0 0 122.88 99.56"
aria-hidden="true"
focusable="false"
class={className}
xmlns="http://www.w3.org/2000/svg"
>
<style>
.st0 { fill: var(--color-text); }
.st3 { fill-rule: evenodd; clip-rule: evenodd; fill: var(--color-warning); }
</style>
<g>
<path
class="st0"
d="M73.1,0c6.73,0,13.16,1.34,19.03,3.78c6.09,2.52,11.57,6.22,16.16,10.81c4.59,4.58,8.28,10.06,10.81,16.17 c2.43,5.87,3.78,12.3,3.78,19.03c0,6.73-1.34,13.16-3.78,19.03c-2.52,6.09-6.22,11.58-10.81,16.16 c-4.58,4.59-10.06,8.28-16.17,10.81c-5.87,2.43-12.3,3.78-19.03,3.78c-6.73,0-13.16-1.34-19.03-3.77 c-6.09-2.52-11.57-6.22-16.16-10.81l-0.01-0.01c-4.59-4.59-8.29-10.07-10.81-16.16c-0.78-1.89-1.45-3.83-2-5.82 c1.04,0.1,2.1,0.15,3.17,0.15c2.03,0,4.01-0.18,5.94-0.53c0.32,0.96,0.67,1.91,1.05,2.84c2.07,5,5.11,9.51,8.9,13.29 c3.78,3.78,8.29,6.82,13.29,8.9c4.81,1.99,10.11,3.1,15.66,3.1c5.56,0,10.85-1.1,15.66-3.1c5-2.07,9.51-5.11,13.29-8.9 c3.78-3.78,6.82-8.29,8.9-13.29c1.99-4.81,3.1-10.11,3.1-15.66c0-5.56-1.1-10.85-3.1-15.66c-2.07-5-5.11-9.51-8.9-13.29 c-3.78-3.78-8.29-6.82-13.29-8.9c-4.81-1.99-10.11-3.1-15.66-3.1c-5.56,0-10.85,1.1-15.66,3.1c-0.43,0.18-0.86,0.37-1.28,0.56 c-1.64-2.58-3.62-4.92-5.89-6.95c1.24-0.64,2.51-1.23,3.8-1.77C59.94,1.34,66.37,0,73.1,0L73.1,0z M67.38,26.12 c0-1.22,0.5-2.33,1.3-3.13c0.8-0.8,1.9-1.3,3.12-1.3c1.22,0,2.33,0.5,3.13,1.3c0.8,0.8,1.3,1.91,1.3,3.13v23.22l17.35,10.29 c1.04,0.62,1.74,1.6,2.03,2.7c0.28,1.09,0.15,2.29-0.47,3.34c-0.62,1.04-1.6,1.74-2.7,2.03c-1.09,0.28-2.29,0.15-3.33-0.47 L69.65,55.71c-0.67-0.37-1.22-0.91-1.62-1.55c-0.41-0.67-0.65-1.46-0.65-2.3V26.12L67.38,26.12z"
/>
<path
class="st3"
d="M26.99,2.56c14.91,0,26.99,12.08,26.99,26.99c0,14.91-12.08,26.99-26.99,26.99C12.08,56.54,0,44.45,0,29.55 C0,14.64,12.08,2.56,26.99,2.56L26.99,2.56z M15.05,30.27c0.36-2.1,2.76-3.27,4.65-2.13c0.17,0.1,0.34,0.22,0.49,0.36l0.02,0.01 c0.85,0.81,1.8,1.66,2.74,2.5l0.81,0.73l9.59-10.06c0.57-0.6,0.99-0.99,1.85-1.18c2.94-0.65,5.01,2.95,2.93,5.15L26.17,38.19 c-1.13,1.2-3.14,1.31-4.35,0.16c-0.69-0.64-1.45-1.3-2.21-1.96c-1.32-1.15-2.67-2.32-3.77-3.48 C15.18,32.25,14.89,31.17,15.05,30.27L15.05,30.27z"
/>
</g>
</svg>

@ -0,0 +1,21 @@
<script lang="ts">
export let className: string = '';
</script>
<svg
viewBox="0 0 121.48 122.88"
aria-hidden="true"
focusable="false"
class={className}
xmlns="http://www.w3.org/2000/svg"
>
<style>
.icon-btn-svg { fill-rule: evenodd; clip-rule: evenodd; fill: var(--color-text); }
</style>
<g>
<path
class="icon-btn-svg"
d="M96.84,2.22l22.42,22.42c2.96,2.96,2.96,7.8,0,10.76l-12.4,12.4L73.68,14.62l12.4-12.4 C89.04-0.74,93.88-0.74,96.84,2.22L96.84,2.22z M70.18,52.19L70.18,52.19l0,0.01c0.92,0.92,1.38,2.14,1.38,3.34 c0,1.2-0.46,2.41-1.38,3.34v0.01l-0.01,0.01L40.09,88.99l0,0h-0.01c-0.26,0.26-0.55,0.48-0.84,0.67h-0.01 c-0.3,0.19-0.61,0.34-0.93,0.45c-1.66,0.58-3.59,0.2-4.91-1.12h-0.01l0,0v-0.01c-0.26-0.26-0.48-0.55-0.67-0.84v-0.01 c-0.19-0.3-0.34-0.61-0.45-0.93c-0.58-1.66-0.2-3.59,1.11-4.91v-0.01l30.09-30.09l0,0h0.01c0.92-0.92,2.14-1.38,3.34-1.38 c1.2,0,2.41,0.46,3.34,1.38L70.18,52.19L70.18,52.19L70.18,52.19z M45.48,109.11c-8.98,2.78-17.95,5.55-26.93,8.33 C-2.55,123.97-2.46,128.32,3.3,108l9.07-32v0l-0.03-0.03L67.4,20.9l33.18,33.18l-55.07,55.07L45.48,109.11L45.48,109.11z M18.03,81.66l21.79,21.79c-5.9,1.82-11.8,3.64-17.69,5.45c-13.86,4.27-13.8,7.13-10.03-6.22L18.03,81.66L18.03,81.66z"
/>
</g>
</svg>

@ -0,0 +1,19 @@
<script lang="ts">
export let className: string = '';
</script>
<svg
viewBox="0 0 108.01 122.88"
aria-hidden="true"
focusable="false"
class={className}
xmlns="http://www.w3.org/2000/svg"
>
<style>
.icon-btn-svg { fill-rule: evenodd; clip-rule: evenodd; fill: var(--color-text); }
</style>
<path
class="icon-btn-svg"
d="M.5,0H15a.51.51,0,0,1,.5.5V83.38L35.16,82h.22l.24,0c2.07-.14,3.65-.26,4.73-1.23l1.86-2.17a1.12,1.12,0,0,1,1.49-.18l9.35,6.28a1.15,1.15,0,0,1,.49,1c0,.55-.19.7-.61,1.08A11.28,11.28,0,0,0,51.78,88a27.27,27.27,0,0,1-3,3.1,15.84,15.84,0,0,1-3.68,2.45c-2.8,1.36-5.45,1.54-8.59,1.76l-.24,0-.21,0L15.5,96.77v25.61a.52.52,0,0,1-.5.5H.5a.51.51,0,0,1-.5-.5V.5A.5.5,0,0,1,.5,0ZM46,59.91l9-19.12-.89-.25a12.43,12.43,0,0,0-4.77-.82c-1.9.28-3.68,1.42-5.67,2.7-.83.53-1.69,1.09-2.62,1.63-.7.33-1.51.86-2.19,1.25l-8.7,5a1.11,1.11,0,0,1-1.51-.42l-5.48-9.64a1.1,1.1,0,0,1,.42-1.51c3.43-2,7.42-4,10.75-6.14,4-2.49,7.27-4.48,11.06-5.42s8-.8,13.89,1c2.12.59,4.55,1.48,6.55,2.2,1,.35,1.8.66,2.44.87,9.86,3.29,13.19,9.66,15.78,14.6,1.12,2.13,2.09,4,3.34,5,.51.42,1.67.27,3,.09a21.62,21.62,0,0,1,2.64-.23c4.32-.41,8.66-.66,13-1a1.1,1.1,0,0,1,1.18,1L108,61.86A1.11,1.11,0,0,1,107,63L95,63.9c-5.33.38-9.19.66-15-2.47l-.12-.07a23.23,23.23,0,0,1-7.21-8.5l0,0L65.73,68.4a63.9,63.9,0,0,0,5.85,5.32c6,5,11,9.21,9.38,20.43a23.89,23.89,0,0,1-.65,2.93c-.27,1-.56,1.9-.87,2.84-2.29,6.54-4.22,13.5-6.29,20.13a1.1,1.1,0,0,1-1,.81l-11.66.78a1,1,0,0,1-.39,0,1.12,1.12,0,0,1-.75-1.38c2.45-8.12,5-16.25,7.39-24.38a29,29,0,0,0,.87-3,7,7,0,0,0,.08-2.65l0-.24a4.16,4.16,0,0,0-.73-2.22,53.23,53.23,0,0,0-8.76-5.57c-3.75-2.07-7.41-4.08-10.25-7a12.15,12.15,0,0,1-3.59-7.36A14.76,14.76,0,0,1,46,59.91ZM80.07,6.13a12.29,12.29,0,0,1,13.1,11.39v0a12.29,12.29,0,0,1-24.52,1.72v0A12.3,12.3,0,0,1,80,6.13ZM3.34,35H6.69V51.09H3.34V35Z"
/>
</svg>

@ -1,5 +1,6 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db'; import { getDb } from '$lib/server/db';
import { normalizeTime } from '$lib/server/datetime';
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
// Requiere sesión // Requiere sesión
@ -36,15 +37,16 @@ export const POST: RequestHandler = async (event) => {
return new Response('Unauthorized', { status: 401 }); return new Response('Unauthorized', { status: 401 });
} }
let payload: any = null; let payload: unknown = null;
try { try {
payload = await event.request.json(); payload = await event.request.json();
} catch { } catch {
return new Response('Bad Request', { status: 400 }); return new Response('Bad Request', { status: 400 });
} }
const body = payload && typeof payload === 'object' ? (payload as { freq?: unknown; time?: unknown }) : null;
const freqRaw = String(payload?.freq || '').trim().toLowerCase(); const freqRaw = String(body?.freq ?? '').trim().toLowerCase();
const timeRaw = payload?.time == null ? null : String(payload.time).trim(); const timeRaw = body?.time == null ? null : String(body.time).trim();
const allowed = new Set(['off', 'daily', 'weekly', 'weekdays']); const allowed = new Set(['off', 'daily', 'weekly', 'weekdays']);
if (!allowed.has(freqRaw)) { if (!allowed.has(freqRaw)) {
@ -54,17 +56,6 @@ export const POST: RequestHandler = async (event) => {
}); });
} }
function normalizeTime(input: string): string | null {
const m = /^\s*(\d{1,2}):(\d{1,2})\s*$/.exec(input || '');
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
const hh = String(h).padStart(2, '0');
const mm = String(min).padStart(2, '0');
return `${hh}:${mm}`;
}
const db = await getDb(); const db = await getDb();

@ -1,10 +1,6 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db'; import { getDb } from '$lib/server/db';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null; const userId = event.locals.userId ?? null;
if (!userId) { if (!userId) {

@ -1,6 +1,7 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db'; import { getDb } from '$lib/server/db';
import { REACTIONS_ENABLED, REACTIONS_TTL_DAYS, REACTIONS_SCOPE, GROUP_GATING_MODE } from '$lib/server/env'; 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) => { export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null; const userId = event.locals.userId ?? null;
@ -142,8 +143,8 @@ export const POST: RequestHandler = async (event) => {
if (withinTtl && allowed) { if (withinTtl && allowed) {
// Idempotencia 24h por metadata canónica exacta // Idempotencia 24h por metadata canónica exacta
const nowIso = new Date().toISOString().replace('T', ' ').replace('Z', ''); const nowIso = toIsoSqlUTC(new Date());
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').replace('Z', ''); const cutoff = toIsoSqlUTC(new Date(Date.now() - 24 * 60 * 60 * 1000));
const meta: any = { kind: 'reaction', emoji: '✅', chatId, messageId: String(origin.message_id) }; 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.from_me === 1 || origin.from_me === true)) meta.fromMe = true;

@ -1,10 +1,7 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db'; import { getDb } from '$lib/server/db';
import { UNCOMPLETE_WINDOW_MIN } from '$lib/server/env'; import { UNCOMPLETE_WINDOW_MIN } from '$lib/server/env';
import { toIsoSqlUTC } from '$lib/server/datetime';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null; const userId = event.locals.userId ?? null;
@ -79,7 +76,7 @@ export const POST: RequestHandler = async (event) => {
if (!task.completed_at) { if (!task.completed_at) {
return new Response('Forbidden', { status: 403 }); return new Response('Forbidden', { status: 403 });
} }
const cutoff = toIsoSql(new Date(Date.now() - UNCOMPLETE_WINDOW_MIN * 60 * 1000)); const cutoff = toIsoSqlUTC(new Date(Date.now() - UNCOMPLETE_WINDOW_MIN * 60 * 1000));
if (String(task.completed_at) < String(cutoff)) { if (String(task.completed_at) < String(cutoff)) {
return new Response('Forbidden', { status: 403 }); return new Response('Forbidden', { status: 403 });
} }

@ -1,6 +1,7 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { getDb } from '$lib/server/db'; import { getDb } from '$lib/server/db';
import { redirect, fail } from '@sveltejs/kit'; import { redirect, fail } from '@sveltejs/kit';
import { normalizeTime } from '$lib/server/datetime';
function ymdInTZ(d: Date, tz: string): string { function ymdInTZ(d: Date, tz: string): string {
const parts = new Intl.DateTimeFormat('en-GB', { const parts = new Intl.DateTimeFormat('en-GB', {
@ -28,17 +29,6 @@ function weekdayShortInTZ(d: Date, tz: string): string {
return new Intl.DateTimeFormat('en-GB', { timeZone: tz, weekday: 'short' }).format(d); return new Intl.DateTimeFormat('en-GB', { timeZone: tz, weekday: 'short' }).format(d);
} }
function normalizeTime(input: string): string | null {
const m = /^\s*(\d{1,2}):(\d{1,2})\s*$/.exec(input || '');
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
const hh = String(h).padStart(2, '0');
const mm = String(min).padStart(2, '0');
return `${hh}:${mm}`;
}
function computeNextReminder( function computeNextReminder(
freq: 'off' | 'daily' | 'weekly' | 'weekdays', freq: 'off' | 'daily' | 'weekly' | 'weekdays',
@ -123,17 +113,6 @@ export const actions: Actions = {
return fail(400, { error: 'freq inválida' }); return fail(400, { error: 'freq inválida' });
} }
function normalizeTime(input: string): string | null {
const m = /^\s*(\d{1,2}):(\d{1,2})\s*$/.exec(input || '');
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
const hh = String(h).padStart(2, '0');
const mm = String(min).padStart(2, '0');
return `${hh}:${mm}`;
}
const db = await getDb(); const db = await getDb();
let timeToSave: string | null = null; let timeToSave: string | null = null;

@ -2,24 +2,8 @@ import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db'; import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto'; import { sha256Hex } from '$lib/server/crypto';
import { icsHorizonMonths } from '$lib/server/env'; import { icsHorizonMonths } from '$lib/server/env';
import { buildIcsCalendar } from '$lib/server/ics'; import { buildIcsCalendar, checkIcsRateLimit } from '$lib/server/ics';
import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime';
function toIsoSql(d = new Date()): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
function ymdUTC(date: Date): string {
const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function addMonthsUTC(date: Date, months: number): Date {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
d.setUTCMonth(d.getUTCMonth() + months);
return d;
}
export const GET: RequestHandler = async ({ params, request }) => { export const GET: RequestHandler = async ({ params, request }) => {
const db = await getDb(); const db = await getDb();
@ -40,6 +24,11 @@ export const GET: RequestHandler = async ({ params, request }) => {
if (row.revoked_at) return new Response('Gone', { status: 410 }); if (row.revoked_at) return new Response('Gone', { status: 410 });
if (String(row.type) !== 'aggregate') return new Response('Not Found', { status: 404 }); 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 today = new Date(); const today = new Date();
const startYmd = ymdUTC(today); const startYmd = ymdUTC(today);
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
@ -68,16 +57,15 @@ export const GET: RequestHandler = async ({ params, request }) => {
prefix: 'T' prefix: 'T'
})); }));
const { body, etag } = await buildIcsCalendar('Tareas sin responsable (mis grupos)', events); const { body, etag } = await buildIcsCalendar('Wtask.org Tareas Agregado', events);
// 304 si ETag coincide // 304 si ETag coincide
const inm = request.headers.get('if-none-match'); const inm = request.headers.get('if-none-match');
if (inm && inm === etag) { if (inm && inm === etag) {
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } }); return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
} }
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id); db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id);
return new Response(body, { return new Response(body, {
status: 200, status: 200,

@ -2,24 +2,8 @@ import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db'; import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto'; import { sha256Hex } from '$lib/server/crypto';
import { icsHorizonMonths } from '$lib/server/env'; import { icsHorizonMonths } from '$lib/server/env';
import { buildIcsCalendar } from '$lib/server/ics'; import { buildIcsCalendar, checkIcsRateLimit } from '$lib/server/ics';
import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime';
function toIsoSql(d = new Date()): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
function ymdUTC(date: Date): string {
const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function addMonthsUTC(date: Date, months: number): Date {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
d.setUTCMonth(d.getUTCMonth() + months);
return d;
}
export const GET: RequestHandler = async ({ params, request }) => { export const GET: RequestHandler = async ({ params, request }) => {
const db = await getDb(); const db = await getDb();
@ -45,10 +29,14 @@ export const GET: RequestHandler = async ({ params, request }) => {
.prepare(`SELECT COALESCE(active,1) as active, COALESCE(archived,0) as archived, COALESCE(is_community,0) as is_community FROM groups WHERE id = ?`) .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; .get(row.group_id) as any;
if (!gRow || Number(gRow.active || 0) !== 1 || Number(gRow.archived || 0) === 1 || Number(gRow.is_community || 0) === 1) { if (!gRow || Number(gRow.active || 0) !== 1 || Number(gRow.archived || 0) === 1 || Number(gRow.is_community || 0) === 1) {
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
return new Response('Gone', { status: 410 }); 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 today = new Date();
const startYmd = ymdUTC(today); const startYmd = ymdUTC(today);
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
@ -75,17 +63,15 @@ export const GET: RequestHandler = async ({ params, request }) => {
prefix: 'T' prefix: 'T'
})); }));
const { body, etag } = await buildIcsCalendar('Tareas sin responsable (grupo)', events); const { body, etag } = await buildIcsCalendar('Wtask.org Tareas Grupo', events);
// 304 si ETag coincide // 304 si ETag coincide
const inm = request.headers.get('if-none-match'); const inm = request.headers.get('if-none-match');
if (inm && inm === etag) { if (inm && inm === etag) {
// Actualizar last_used_at aunque sea 304
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } }); return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
} }
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id); db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id);
return new Response(body, { return new Response(body, {
status: 200, status: 200,

@ -2,24 +2,8 @@ import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db'; import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto'; import { sha256Hex } from '$lib/server/crypto';
import { icsHorizonMonths } from '$lib/server/env'; import { icsHorizonMonths } from '$lib/server/env';
import { buildIcsCalendar } from '$lib/server/ics'; import { buildIcsCalendar, checkIcsRateLimit } from '$lib/server/ics';
import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime';
function toIsoSql(d = new Date()): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
function ymdUTC(date: Date): string {
const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function addMonthsUTC(date: Date, months: number): Date {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
d.setUTCMonth(d.getUTCMonth() + months);
return d;
}
export const GET: RequestHandler = async ({ params, request }) => { export const GET: RequestHandler = async ({ params, request }) => {
const db = await getDb(); const db = await getDb();
@ -40,6 +24,11 @@ export const GET: RequestHandler = async ({ params, request }) => {
if (row.revoked_at) return new Response('Gone', { status: 410 }); if (row.revoked_at) return new Response('Gone', { status: 410 });
if (String(row.type) !== 'personal') return new Response('Not Found', { status: 404 }); 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 today = new Date(); const today = new Date();
const startYmd = ymdUTC(today); const startYmd = ymdUTC(today);
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
@ -69,16 +58,15 @@ export const GET: RequestHandler = async ({ params, request }) => {
prefix: 'T' prefix: 'T'
})); }));
const { body, etag } = await buildIcsCalendar('Mis tareas', events); const { body, etag } = await buildIcsCalendar('Wtask.org Tareas Personal', events);
// 304 si ETag coincide // 304 si ETag coincide
const inm = request.headers.get('if-none-match'); const inm = request.headers.get('if-none-match');
if (inm && inm === etag) { if (inm && inm === etag) {
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } }); return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
} }
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id); db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id);
return new Response(body, { return new Response(body, {
status: 200, status: 200,

@ -3,10 +3,8 @@ import { redirect } from '@sveltejs/kit';
import { getDb } from '$lib/server/db'; import { getDb } from '$lib/server/db';
import { sha256Hex, randomTokenBase64Url } from '$lib/server/crypto'; import { sha256Hex, randomTokenBase64Url } from '$lib/server/crypto';
import { sessionIdleTtlMs, isProd, isDev, DEV_BYPASS_AUTH } from '$lib/server/env'; import { sessionIdleTtlMs, isProd, isDev, DEV_BYPASS_AUTH } from '$lib/server/env';
import { toIsoSqlUTC } from '$lib/server/datetime';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
function escapeHtml(s: string): string { function escapeHtml(s: string): string {
return s return s
@ -154,7 +152,7 @@ export const POST: RequestHandler = async (event) => {
const sessionToken = randomTokenBase64Url(32); const sessionToken = randomTokenBase64Url(32);
const sessionHash = await sha256Hex(sessionToken); const sessionHash = await sha256Hex(sessionToken);
const sessionId = randomTokenBase64Url(16); const sessionId = randomTokenBase64Url(16);
const expiresAtIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs)); const expiresAtIso = toIsoSqlUTC(new Date(Date.now() + sessionIdleTtlMs));
// Datos de agente e IP (best-effort) // Datos de agente e IP (best-effort)
const userAgent = event.request.headers.get('user-agent') || null; const userAgent = event.request.headers.get('user-agent') || null;

@ -1,5 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { fileURLToPath } from 'node:url';
import { dirname, resolve as resolvePath } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const repoRoot = resolvePath(__dirname, '..', '..');
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const isDev = mode === 'development'; const isDev = mode === 'development';
@ -8,7 +14,7 @@ export default defineConfig(({ mode }) => {
plugins: [sveltekit()], plugins: [sveltekit()],
resolve: { resolve: {
// En desarrollo, alias para usar better-sqlite3 (Vite/HMR no entiende 'bun:sqlite') // En desarrollo, alias para usar better-sqlite3 (Vite/HMR no entiende 'bun:sqlite')
alias: isDev ? { 'bun:sqlite': 'better-sqlite3' } : {} alias: isDev ? [{ find: 'bun:sqlite', replacement: 'better-sqlite3' }] : []
}, },
ssr: { ssr: {
// En dev, externalizar better-sqlite3 (CJS nativo) para que se cargue vía require; // En dev, externalizar better-sqlite3 (CJS nativo) para que se cargue vía require;
@ -19,7 +25,7 @@ export default defineConfig(({ mode }) => {
// Evitar prebundling de drivers nativos // Evitar prebundling de drivers nativos
exclude: ['bun:sqlite', 'better-sqlite3'] exclude: ['bun:sqlite', 'better-sqlite3']
}, },
// Permitir host remoto en desarrollo // Permitir host remoto en desarrollo y permitir importar fuera del root (reexport desde core)
server: isDev ? { allowedHosts: ['server.brobert.net'] } : undefined server: isDev ? { allowedHosts: ['server.brobert.net'], fs: { allow: [repoRoot] } } : undefined
}; };
}); });

@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "task-whatsapp", "name": "task-whatsapp",

@ -0,0 +1,433 @@
# Plan de Refactor Técnico — 2025-11-01
Este documento describe un plan de refactor seguro, incremental y medible para estabilizar tipado, reducir duplicación, mejorar estructura y preparar la base para evolución a largo plazo. Se prioriza no añadir dependencias externas y mantener el comportamiento actual cubierto por tests.
## Objetivos
- Reducir duplicación (fechas/horas, validaciones, helpers de tests, cripto).
- Endurecer tipado y contratos en TypeScript (mientras mantenemos compatibilidad).
- Clarificar capas (utils compartidos, servicios, API web, UI SvelteKit, tests).
- Estabilizar convenciones (UTC, formato SQLite, alias/imports, nombres).
- Mantener comportamiento: cambios sin funcionalidad nueva, validados por tests.
## Hallazgos clave (del diagnóstico inicial)
- Archivos grandes:
- apps/web/src/lib/ui/data/TaskItem.svelte ~911 LOC (punto caliente claro).
- Varias páginas Svelte de 250335 LOC (AppShell, /app, /app/groups, etc.).
- Duplicación evidente:
- Formateo SQL-UTC con `toISOString().replace('T',' ').replace('Z','')` aparece 35+ veces en app y tests.
- `normalizeTime` duplicado en API y page.server.
- `ymdUTC` y `addMonthsUTC` definidas 3 veces en rutas ICS y también en tests.
- `sha256Hex` reimplementado en múltiples tests pese a existir en `src/utils/crypto.ts`.
- SimulatedResponseQueue replicado en varios tests.
- Tipado/infra:
- tsc falla en apps/web por `$lib` y `./$types` (faltan tipos generados SvelteKit) y por DOM (navigator/document) y Bun types (HeadersInit/fetch).
- apps/web/vite.config.ts incompatibilidad de tipos (UserConfig).
- Errores “unknown” por resultados de SQLite (faltan tipos/casts).
- Inconsistencias null vs undefined (p. ej., groupColor, whatsapp utils).
- Cobertura mejorable:
- webhook-manager (~32%), maintenance (~17%), contacts (~26%), onboarding (~30%), response-queue (~50%).
- Organización:
- Inyección de DB mediante propiedades estáticas dispersas; funcional pero frágil. Falta un “locator” central.
## Fase 1 — Diagnóstico asistido (Completada)
Comandos ejecutados con éxito el 2025-11-01; a continuación se conservan para referencia y repetibilidad:
```bash
git ls-files '*.ts' '*.tsx' '*.svelte' | xargs wc -l | sort -nr | head -n 30
```
```bash
git grep -n "toISOString().replace('T', ' ').replace('Z', '')"
```
```bash
git grep -n "normalizeTime(" && git grep -n "ymdUTC(" && git grep -n "addMonthsUTC(" && git grep -n "isValidYmd("
```
```bash
git grep -n "sha256Hex(" && git grep -n "SimulatedResponseQueue"
```
```bash
bunx tsc --noEmit --pretty false
```
```bash
bun test --coverage
```
Resultados esperados después del refactor: disminución drástica de duplicados, tsc limpio por paquete, y cobertura estable o al alza.
## Progreso
- 2025-11-01:
- Documento creado y versionado (commit a104b69).
- Diagnóstico ejecutado (wc -l, git grep, tsc, coverage) y hallazgos consolidados en este documento.
- Pendiente iniciar Lote 0 (Infra de typecheck): a la espera de revisar tsconfig raíz, apps/web/tsconfig y apps/web/vite.config.
- 2025-11-02:
- Lote 1 completado: util canónica de fechas/UTC creada (src/utils/datetime.ts) y wrapper web (apps/web/src/lib/server/datetime.ts); migración de rutas ICS y reemplazos en producción (hooks, login, complete/uncomplete, calendar tokens, dev-seed, response-queue, maintenance, group-sync, migrator).
- Commits relevantes: f4f7d95, 882f5c9, c1f12ff, df27161, a0f35b8.
- Tests verdes. Cobertura actual: 85.91% funciones / 82.12% líneas.
- 2025-11-07:
- Lote 0 completado: scripts `typecheck:core` y `typecheck:web` configurados y verificados. Se utiliza `tsconfig.core.json` para aislar el typecheck del core con reglas laxas, mientras que la web usa su propia configuración de SvelteKit. Los shims en `src/types/shims.d.ts` resuelven conflictos de tipos entre Bun y el DOM.
- Verificación exitosa: `bun run typecheck:core` y `bun run typecheck:web` se ejecutan sin errores.
- 2025-11-09:
- Lote 2 completado: centralización de helpers de tests (sha256Hex, toIsoSql), unificación de SimulatedResponseQueue y helpers ICS (ymdUTC, addDays) en tests.
- Commits relevantes: 77e318e, 1ad36ee, y este commit.
- 2025-11-10:
- Lote 3 completado: endurecimiento de tipos (noImplicitAny, exactOptionalPropertyTypes, strictNullChecks), centralización de normalizeTime y tipado ligero de SQLite en servicios; inclusión de servicios en typecheck; limpieza de as any en puntos clave.
- Verificación: typecheck core/web limpios y tests verdes.
- Lote 4 completado: ICS central y rutas homogéneas; rate limiting por token; títulos “Wtask.org Tareas Personal/Grupo/Agregado” y PRODID “-/Wtask.org//ICS 1.0//ES”; eventos de día completo; ETag/304 y Cache-Control; actualización de last_used_at solo en 200.
- Commit relevante: 234053c.
- Lote 5 completado: división de TaskItem en subcomponentes (SVGs a iconos, TaskDueBadge, TaskAssignees, TaskCompleteButton, TaskActions, TaskText, TaskMeta); sin cambios funcionales; tests verdes y typecheck web limpio.
- Lote 5.5-a completado: ResponseQueue extraído (EvolutionClient, limpieza modular, parseo de metadata); sin cambios funcionales; commits: 1b7420e, 2032712.
- Lote 5.5-c completado: TaskService extraído (display_code, reacción al completar, mapeadores); sin cambios funcionales; LOC actual en src/tasks/service.ts ≈ 621; commits: e3ec820, a72184f.
- Lote 5.5-b completado: GroupSyncService modularizado (api.ts, repo.ts, cache.ts, reconcile.ts) y desacople de Onboarding A3 (publishGroupCoveragePrompt); umbral aplicado; tests y typecheck limpios; commits: 1b0d2ec, 0ce3ecb, 2f24806.
- Lote 5.5-d completado: WebhookServer modularizado (/metrics, /health y bootstrap a src/http; handleMessageUpsert extraído a src/http/webhook-handler.ts); sin cambios funcionales; tests verdes; commits: 46bec52, 7189756, e430fc1.
- Lote 6.0-6.2 completados: DB Locator mínimo, conexión en bootstrap con setDb y ruta única de DB (centralización y reexport en web); sin cambios funcionales; tests y typecheck limpios; commits: 9222242, 6196dba, 2669d42.
- Lote 6.3 completado: adopción piloto con fallback en ResponseQueue y TaskService; añadido smoke test de fallback (tests/unit/locator.fallback.test.ts); tests y typecheck limpios; commit relevante: 77ad9d7.
- Lote 6.4 completado: adopción progresiva de servicios al locator (fallback parámetro → .dbInstance → getDb()) en CommandService, RemindersService, MaintenanceService, AdminService y fachada de GroupSync; tests y typecheck limpios; commits: cd83455, f786ba8.
## Estado actual (2025-11-10)
- Fase 1 — Diagnóstico asistido: Completada.
- Fase 2 — Plan de refactor por lotes: En curso.
- Lote 0 — Infra de typecheck: Completado.
- Lote 1 — Utilidades de fecha/hora y validaciones: Completado.
- Lote 2 — Helpers de test y cripto: Completado.
- Lote 3 — Tipos y endurecimiento suave: Completado.
- Lote 4 — ICS central y rutas homogéneas: Completado.
- Lote 5 — Svelte: dividir componentes grandes: Completado.
- Lote 5.5 — Refactor de servicios grandes (god classes): Completado.
- Lote 6 — DB Locator / DI ligera: En curso (PRs 6.0, 6.1, 6.2, 6.3 y 6.4 completados).
- Lote 7 — Cobertura en módulos flojos: Pendiente.
## Fase 2 — Plan de refactor por lotes (PRs pequeñas y seguras)
Cada lote incluye objetivo, cambios, métricas y comprobaciones. Mantener tests verdes en cada paso.
### Lote 0 — Infra de typecheck (preparación) - Completado
- Objetivo:
- Separar el chequeo de tipos por paquete (core vs web) para aislar errores de SvelteKit/Bun/DOM.
- Cambios:
- Scripts: `typecheck:core` (tsc -p tsconfig.core.json), `typecheck:web` (apps/web: svelte-kit sync + tsc).
- En raíz, excluir apps/web del typecheck general si hace falta hasta que esté sincronizado.
- Ajustar lib/types: en core, definir tipos mínimos para HeadersInit/fetch si no se quiere añadir bun-types; en web, asegurar DOM y tipos SvelteKit (via `svelte-kit sync`).
- Revisar apps/web/vite.config.ts para que encaje con tipos `UserConfig`.
- Métricas:
- `bunx tsc --noEmit` limpio por paquete.
- Desaparecen errores `$lib` y `./$types` tras `svelte-kit sync`.
- Comprobaciones:
- `cd apps/web && bunx svelte-kit sync && bunx tsc --noEmit`.
### Lote 1 — Utilidades de fecha/hora y validaciones - Completado
- Objetivo:
- Centralizar y unificar: SQL-UTC, validadores YMD y HH:mm, utilidades ICS (ymdUTC, addMonthsUTC).
- Cambios:
- Crear helpers canónicos (por ejemplo, `toIsoSqlUTC`, `isValidYmd`, `normalizeTime`, `ymdUTC`, `addMonthsUTC`) en un módulo compartido (core o shared) y exportarlos también para web si aplica.
- Reemplazar todas las ocurrencias del patrón `toISOString().replace(...)`.
- Unificar las 3 rutas ICS para usar las mismas utilidades.
- Métricas:
- `git grep "toISOString().replace('T', ' ').replace('Z', '')"` → 1 referencia (en el helper) o 0 si se encapsula internamente.
- `git grep "ymdUTC("` y `"addMonthsUTC("` solo en helpers + imports.
- Comprobaciones:
- Tests ICS y API siguen verdes.
### Lote 2 — Helpers de test y cripto - Completado
- Objetivo:
- Eliminar duplicación en tests.
- Cambios:
- Reemplazar implementaciones locales de `sha256Hex` por import desde `src/utils/crypto`.
- Centralizar SimulatedResponseQueue en `tests/helpers/queue.ts` y reusarlo.
- Unificar helpers de fechas en tests (`toIsoSql`, `ymdUTC`, `addDays`) en `tests/helpers/dates.ts`.
- Métricas:
- `git grep -n "async function sha256Hex"` en tests → 0.
- `git grep -n "SimulatedResponseQueue"` → 1 implementación única.
- Comprobaciones:
- `bun test --coverage` estable o mejor.
### Lote 3 — Tipos y endurecimiento suave - Completado
- Objetivo:
- Bajar ruido de TS con cambios mínimos, sin funcionalidad nueva.
- Cambios:
- Resolver `undefined` vs `null` en utils como groupColor/whatsapp.
- Corregir `HeadersInit`/`fetch` en servicios (group-sync, webhook-manager, contacts) para encajar con tipos.
- Añadir refinamientos y cheques donde TS marca “posiblemente undefined”.
- Tipar resultados de SQLite `.get()`/`.all()` en tests y servicios.
- Métricas:
- `bunx tsc --noEmit` limpio en core.
- Comprobaciones:
- Tests verdes.
### Lote 4 — ICS central y rutas homogéneas - Completado
- Objetivo:
- Consolidar construcción de ICS (escape, folding, etag) en un módulo central.
- Cambios:
- Mover `ymdUTC`/`addMonthsUTC` al módulo `apps/web/src/lib/server/ics.ts` y usarlos desde aggregate/group/personal.
- Mantener helpers de escape/folding/etag en el mismo módulo.
- Métricas:
- `git grep -n "function ymdUTC"` y `"function addMonthsUTC"` solo en ics.ts.
- Comprobaciones:
- Tests ICS (`tests/web/ics.*`) verdes.
### Lote 5 — Svelte: dividir componentes grandes
- Objetivo:
- Reducir complejidad de UI; mejorar mantenibilidad.
- Cambios:
- Dividir `TaskItem.svelte` (~911 LOC) en subcomponentes: Header/Status, Assignees, DueBadge, Actions, Metadata, etc.
- Revisar `AppShell` y `/app/+page` para separar lógica de datos y presentación (stores y load).
- Métricas:
- `TaskItem.svelte` < 300 LOC.
- Comprobaciones:
- Pruebas E2E web verdes.
### Lote 5.5 — Refactor de servicios grandes (god classes)
- Objetivo general:
- Reducir acoplamiento y responsabilidades múltiples en servicios grandes para mejorar testabilidad, DX y preparar Lote 6 (locator/DI) sin cambios funcionales.
- Mantener APIs públicas y rutas de import estables, delegando a nuevos módulos internos.
- Alcance y orden de PRs:
1) PR 5.5-a — ResponseQueue (673 LOC)
- Motivos: mezcla de repositorio (SQLite), backoff, envío HTTP (texto/reacciones), idempotencia, limpieza/retención, métricas y lógica de onboarding.
- Cambios clave:
- Extraer cliente Evolution (HTTP):
- Nuevo módulo: `src/clients/evolution.ts` con `buildHeaders()`, `sendText(payload)`, `sendReaction(payload)` y manejo de errores/logging.
- Tipar metadata y parsers:
- Tipo `QueueMetadata = { kind: 'onboarding' | 'reaction'; ... }`, helpers `parseQueueMetadata()`, `isReactionJob()`.
- Aislar limpieza/retención:
- Nuevo módulo: `src/services/queue/cleanup.ts` con `runCleanupOnce()`, `startCleanupScheduler()`, `stopCleanupScheduler()`.
- ResponseQueue queda como orquestador (claim → enviar → marcar), delegando a EvolutionClient y Cleanup.
- Archivos a consultar:
- `src/services/response-queue.ts` (origen), `src/utils/datetime.ts`, `src/services/metrics.ts`, `src/services/identity.ts`, `src/utils/whatsapp.ts`, `src/db/migrations/index.ts` (esquema `response_queue`), tests `tests/unit/services/response-queue.test.ts`.
- Métricas de aceptación:
- `src/services/response-queue.ts` < 350 LOC.
- No variación en defaults (`MAX_ATTEMPTS`, `REACTIONS_MAX_ATTEMPTS`, backoff).
- Idempotencia de reacciones (24h) intacta.
- Comprobaciones y comandos:
```bash
bunx tsc -p tsconfig.core.json --noEmit --pretty false
bun test --coverage
git grep -n "sendReaction\\|sendText" src
```
2) PR 5.5-c — TaskService (Completado; ~621 LOC)
- Motivos: mezcla de creación (códigos display), queries de listado/contadores, reglas de negocio (claim/unassign), y reacción al completar.
- Cambios clave:
- Extraer generación de display_code:
- Nuevo: `src/tasks/display-code.ts` con `pickNextDisplayCode(db)`.
- Extraer reacción al completar:
- Nuevo: `src/tasks/complete-reaction.ts` con `enqueueCompletionReactionIfEligible(db, taskId)`.
- Extraer mapeos a DTO:
- Nuevo: `src/tasks/mappers.ts` con `mapRowToTask()`, `mapRowsToListItem()`.
- `TaskService.createTask/completeTask/list*` delegan en estos helpers.
- Archivos a consultar:
- `src/tasks/service.ts` (origen), `src/services/allowed-groups.ts`, `src/services/response-queue.ts` (reacción), `src/utils/whatsapp.ts`, `src/db/migrations/index.ts`, tests `tests/unit/tasks/*.test.ts`, `tests/unit/services/command.*.test.ts`.
- Métricas de aceptación:
- `src/tasks/service.ts` < 500 LOC.
- TTL y gating de reacciones sin cambios de comportamiento.
- Comprobaciones y comandos:
```bash
bunx tsc -p tsconfig.core.json --noEmit --pretty false
bun test --coverage
git grep -n "display_code" src
```
3) PR 5.5-b — GroupSyncService (1310 LOC) — Completado
- Motivos: agrupa API (Evolution), parseos, upsert/caché, scheduling, reconciliación de miembros, allowed-groups y publicación de onboarding.
- Cambios clave:
- Extraer acceso Evolution API:
- Nuevo: `src/services/group-sync/api.ts` con `fetchGroups()`, `fetchMembers(groupId)` y parseos robustos.
- Extraer repositorio/caché:
- Nuevo: `src/services/group-sync/repo.ts` con `upsertGroups()`, `ensureGroupExists()`, `getActiveCount()`, `cacheActiveGroups()`, `listActiveMemberIds()`, getters/setters `onboarding_prompted_at`.
- Nuevo: `src/services/group-sync/cache.ts` para la `Map<string,string>` y helpers.
- Extraer reconciliación:
- Nuevo: `src/services/group-sync/reconcile.ts` con `reconcileGroupMembers(db, groupId, snapshot, nowIso?)`.
- Mover publicación de mensajes de cobertura de alias a OnboardingService:
- Añadir método en `src/services/onboarding.ts`: `publishGroupCoveragePrompt(db, groupId, ratio, options)`.
- `GroupSyncService` queda como fachada (APIs públicas y schedulers), delegando internamente.
- Archivos a consultar:
- `src/services/group-sync.ts` (origen), `src/services/allowed-groups.ts`, `src/services/identity.ts`, `src/services/metrics.ts`, `src/utils/datetime.ts`, `src/db/migrations/index.ts`, tests `tests/unit/services/group-sync.*.test.ts`.
- Métricas de aceptación:
- `src/services/group-sync.ts` ≈ 600700 LOC (fase 1), sin cambios funcionales.
- Caché y métricas sin variaciones de semántica.
- Comprobaciones y comandos:
```bash
bunx tsc -p tsconfig.core.json --noEmit --pretty false
bun test --coverage
git grep -n "fetchAllGroups\\|participants" src/services/group-sync*
```
4) PR 5.5-d — WebhookServer (665 LOC)
- Motivos: `src/server.ts` mezcla handlers HTTP (/metrics, /health), bootstrap y manejo del webhook (message upsert).
- Cambios clave:
- Controladores HTTP dedicados:
- Nuevo: `src/http/metrics.ts` (render y headers).
- Nuevo: `src/http/health.ts` (cálculo payload; usa `GroupSyncService.getSecondsUntilNextGroupSync()`).
- Nuevo: `src/http/webhook-handler.ts` con `handleMessageUpsert(data)`.
- Nuevo: `src/http/bootstrap.ts` con `startServices()` (webhook-manager, schedulers, queues, maintenance).
- `src/server.ts` queda como wire-up (validateEnv → migrate → Bun.serve) y reexporta fachada `WebhookServer` para no romper tests.
- Archivos a consultar:
- `src/server.ts` (origen), `src/services/webhook-manager.ts`, `src/services/response-queue.ts`, `src/services/maintenance.ts`, `src/services/reminders.ts`, `src/services/group-sync.ts`, `src/services/admin.ts`, `src/services/allowed-groups.ts`, `src/services/contacts.ts`, `src/services/command.ts`.
- Métricas de aceptación:
- `src/server.ts` < 350 LOC.
- Rutas/contratos HTTP y logs sin cambios de comportamiento.
- Comprobaciones y comandos:
```bash
bunx tsc -p tsconfig.core.json --noEmit --pretty false
bun test --coverage
git grep -n "handleRequest(\\|handleMessageUpsert" src
```
- Métricas de aceptación (global Lote 5.5):
- Tests verdes y sin cambios funcionales observables.
- Reducción de LOC por fichero objetivo:
- response-queue.ts < 350, tasks/service.ts < 500, group-sync.ts 600700, server.ts < 350.
- `bunx tsc --noEmit` limpio en core.
- Cobertura igual o superior en piezas extraídas (api/reconcile/cleanup/complete-reaction).
- Riesgos y mitigación:
- Dividir módulos puede introducir imports cíclicos:
- Mitigación: módulos nuevos sin importar servicios de alto nivel; inyectar `db` como parámetro; reexportar desde fachada.
- Cambios en logging o tiempos:
- Mitigación: mantener mensajes/claves existentes; conservar defaults; validar en tests de integración.
### Lote 6 — DB Locator / DI ligera
- Objetivo:
- Simplificar inyección de DB en runtime y tests.
- Cambios:
- Crear un “dbLocator” (getDb/setDb/withDb) central y migrar servicios a usarlo progresivamente, manteniendo compatibilidad con propiedades estáticas mientras dura la transición.
- Unificar `resolveDbAbsolutePath` entre core y web (una sola fuente, con reexport en web para no romper imports).
- Métricas:
- Sin cambios de comportamiento; tests de servicios verdes.
- PRs propuestos y archivos a tocar:
PR 6.0 — Locator mínimo (infraestructura, sin usos en servicios) — Completado
- Nuevos archivos:
- src/db/locator.ts — export { setDb, getDb, withDb }.
- tests/unit/db/locator.test.ts — pruebas básicas del locator.
- Cambios en docs:
- Este documento (apartado de Lote 6).
PR 6.1 — Conexión en bootstrap (setDb al arrancar) — Completado
- Archivos a modificar:
- src/http/bootstrap.ts — importar setDb desde src/db/locator y llamarlo en el arranque (cuando se abra la DB).
- src/server.ts — si es quien abre la DB, llamar setDb(db) justo después de crearla (o delegar en bootstrap).
- Servicios: sin cambios (compatibilidad total con dbInstance estático).
PR 6.2 — Ruta única de DB (centralizar resolveDbAbsolutePath) — Completado
- Nuevos archivos:
- src/env/db-path.ts — export function resolveDbAbsolutePath(filename = 'tasks.db'): string.
- Archivos a modificar:
- apps/web/src/lib/server/env.ts — reexportar desde src/env/db-path.ts para mantener compatibilidad (sin cambios de API pública).
- src/server.ts o src/http/bootstrap.ts — actualizar imports a src/env/db-path.
- Comprobaciones esperadas:
- bunx tsc -p tsconfig.core.json --noEmit
- bun test --coverage
PR 6.3 — Adopción piloto con fallback (2 servicios) — Completado
- Archivos a modificar:
- src/services/response-queue.ts — usar (ResponseQueue as any).dbInstance ?? getDb().
- src/tasks/service.ts — usar (TaskService as any).dbInstance ?? getDb().
- Tests a ajustar (si aplica):
- tests/unit/services/response-queue.test.ts — asegurar setDb(memdb) en beforeAll cuando no se inyecte dbInstance.
- tests/unit/tasks/*.test.ts — idem.
- Comprobaciones esperadas:
- bunx tsc -p tsconfig.core.json --noEmit
- bun test --coverage
PR 6.4 — Adopción progresiva (resto de servicios principales) — Completado
- Archivos a modificar (por tandas pequeñas):
- src/services/group-sync/*.ts (api.ts, repo.ts, cache.ts, reconcile.ts) — fallback a getDb().
- src/services/command.ts, src/services/reminders.ts, src/services/maintenance.ts, src/services/admin.ts, src/services/contacts.ts, src/services/webhook-manager.ts — fallback a getDb().
- Tests a ajustar según servicio:
- tests/unit/services/**/*.test.ts — añadir setDb(memdb) donde no se use dbInstance explícita.
- Comprobaciones esperadas:
- bunx tsc -p tsconfig.core.json --noEmit
- bun test --coverage
PR 6.5 — Limpieza opcional (cuando no queden usos de .dbInstance)
- Archivos a modificar:
- Eliminar propiedades estáticas dbInstance de servicios ya migrados.
- Actualizar tests que aún dependan de ellas.
- Nota: no es obligatorio para completar el lote; puede posponerse a una fase de limpieza.
### Lote 7 — Cobertura en módulos flojos
- Objetivo:
- Aumentar cobertura en módulos con lógica y baja cobertura (webhook-manager, maintenance, contacts, onboarding, response-queue).
- Cambios:
- Añadir pruebas de ramas de error y caminos menos transitados.
- Métricas:
- Cobertura de líneas > 80% en esos módulos.
## Convenciones acordadas
- Fechas/horas:
- Persistir en UTC en SQLite con formato ISO-SQL “YYYY-MM-DD HH:MM:SS[.SSS]”.
- Un solo helper para serializar/deserializar timestamps.
- Validadores canónicos: YMD (YYYY-MM-DD), HH:mm (0023:0059).
- Tipado:
- TS estricto por módulos de forma incremental: `strict`, `noImplicitAny`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes` (activación progresiva).
- Resultados SQLite tipados (interfaces pequeñas y casts controlados).
- Organización:
- Helpers compartidos (fecha/validaciones/cripto) en módulo único (o shared).
- Tests reusan helpers de `tests/helpers`.
- SvelteKit:
- `svelte-kit sync` antes de `tsc` para generar tipos.
- Rutas ICS homogéneas con helpers de `lib/server/ics`.
## Riesgos y mitigación
- Fechas/UTC: posibles regresiones en límites y recordatorios.
- Mitigación: snapshots/fixtures antes/después de serializaciones, comparar salidas ICS.
- DI de DB: transición puede generar inconsistencias si se mezcla estático/locator.
- Mitigación: migración gradual y adaptador de compatibilidad temporal.
- Tipado estricto: ruido inicial.
- Mitigación: endurecer flags por paquete/módulo en varias PRs pequeñas.
## Métricas de aceptación por lote
- Tests verdes y sin cambios funcionales.
- Reducción de duplicados medible (git grep antes/después).
- `bunx tsc --noEmit` limpio en el ámbito del lote.
- Cobertura estable o en subida en módulos afectados.
## Siguientes pasos prácticos
1) Estabilizar typecheck (Lote 0): **Completado**.
2) Unificar fechas/validaciones (Lote 1): **Completado**.
3) Centralizar helpers de tests y cripto (Lote 2): **Completado**.
4) Endurecer tipos en core (Lote 3): **Completado**.
5) Consolidar ICS (Lote 4): **Completado**.
6) Dividir TaskItem y revisar AppShell (Lote 5): **Completado**.
7) DI ligera para DB (Lote 6).
8) Aumentar cobertura en módulos flojos (Lote 7).
## Información adicional a recopilar (para Lote 0)
- tsconfig.json (raíz)
- apps/web/tsconfig.json
- apps/web/vite.config.ts
Con estos 3 archivos podremos definir cambios mínimos para tener un typecheck limpio por paquetes sin introducir dependencias externas.

@ -90,9 +90,9 @@ Datos y backups
Semilla de desarrollo (apps/web) Semilla de desarrollo (apps/web)
- Activación: establecer DEV_AUTOSEED_DB='true'. La semilla solo se ejecuta en desarrollo cuando la tabla tasks está vacía. - Activación: establecer DEV_AUTOSEED_DB='true'. La semilla solo se ejecuta en desarrollo cuando la tabla tasks está vacía.
- Usuario por defecto: definir DEV_DEFAULT_USER con un ID numérico (p. ej., 34600123456). Se crea como usuario, se hace miembro activo de varios grupos y se usa para asignaciones. - Usuario por defecto: definir DEV_DEFAULT_USER con un ID numérico (p. ej., 34600123456). Se crea como usuario, se hace miembro activo de varios grupos y se usa para asignaciones.
- Ruta del archivo en dev (apps/web): por defecto tmp/tasks.db (véase apps/web/src/lib/server/env.ts). En producción, la web usa /app/data por defecto. - Ruta del archivo en dev (apps/web): por defecto apps/web/tmp/tasks.db (véase apps/web/src/lib/server/env.ts). En producción, la web usa /app/data por defecto.
- Regenerar la BD de dev: detener el servidor web, borrar el archivo de BD y reiniciar con DEV_AUTOSEED_DB='true'. - Regenerar la BD de dev: detener el servidor web, borrar el archivo de BD o eliminar por completo el directorio apps/web/tmp y reiniciar con DEV_AUTOSEED_DB='true'.
- Ejemplo: rm -f tmp/tasks.db - Ejemplos: rm -f apps/web/tmp/tasks.db o rm -rf apps/web/tmp
- Datos que se crean: - Datos que se crean:
- Usuarios: 35 (incluido el usuario por defecto). - Usuarios: 35 (incluido el usuario por defecto).
- Grupos: “Familia”, “Trabajo”, “Voluntariado”, “Compras” (allowed) y “Varios” (pending). - Grupos: “Familia”, “Trabajo”, “Voluntariado”, “Compras” (allowed) y “Varios” (pending).

@ -0,0 +1,118 @@
# Plan de Refactorización - Mejora de Mantenibilidad
**Fecha:** 2024
**Estado:** Propuesto
**Objetivo:** Mejorar la mantenibilidad del código eliminando duplicación y reduciendo la complejidad de archivos grandes.
## Resumen Ejecutivo
El proyecto ha crecido orgánicamente y presenta varios problemas de mantenibilidad:
- **19 duplicaciones** de la función `toIsoSql`
- **Múltiples duplicaciones** de `sha256Hex` en tests
- **Archivos excesivamente grandes**: `group-sync.ts` (1307 líneas), `server.ts` (665 líneas)
- **Código compartido duplicado** entre `src/` (bot) y `apps/web/`
- **Setup repetitivo** en tests (60+ archivos)
Este plan aborda estos problemas en 4 fases priorizadas.
---
## 🔴 Fase 1: Eliminación de Duplicación de Utilidades (1-2 horas)
**Prioridad:** CRÍTICA
**Impacto:** Alto (reduce ~150 líneas duplicadas)
**Riesgo:** Bajo (cambio mecánico)
### Objetivos
1. Centralizar función `toIsoSql` en un único lugar
2. Centralizar función `sha256Hex` para tests
3. Eliminar todas las duplicaciones mediante imports
### Archivos a Crear
#### `src/utils/date.ts`
- Exportar función `toIsoSql(d?: Date): string`
- Documentar formato de salida (ISO SQL: `YYYY-MM-DD HH:MM:SS.mmm`)
#### `tests/helpers/crypto.ts`
- Exportar función `sha256Hex(input: string): Promise<string>`
- Reutilizar en todos los tests web y unit
### Archivos a Modificar
**Archivos con `toIsoSql` duplicada (19 archivos):**
- `apps/web/src/hooks.server.ts`
- `apps/web/src/routes/api/tasks/[id]/claim/+server.ts`
- `apps/web/src/routes/api/tasks/[id]/uncomplete/+server.ts`
- `src/services/group-sync.ts`
- `tests/unit/services/cleanup-inactive.test.ts`
- `tests/unit/tasks/complete-reaction.test.ts`
- `tests/unit/services/metrics-health.test.ts`
- `tests/unit/services/response-queue.cleanup.test.ts`
- `tests/web/api.integrations.feeds.test.ts`
- `tests/web/api.me.preferences.test.ts`
- `tests/web/api.me.tasks.test.ts`
- `tests/web/api.tasks.complete.reaction.test.ts`
- `tests/web/app.integrations.page.test.ts`
- `tests/web/app.preferences.page.test.ts`
- Y otros según `grep -rn "function toIsoSql"`
**Archivos con `sha256Hex` duplicada:**
- `tests/web/api.integrations.feeds.test.ts`
- `tests/web/api.me.preferences.test.ts`
- `tests/web/api.me.tasks.test.ts`
- `tests/web/app.integrations.page.test.ts`
- `tests/web/app.preferences.page.test.ts`
- Verificar que `src/utils/crypto.ts` y `apps/web/src/lib/server/crypto.ts` no estén duplicados
### Pasos de Ejecución
1. Crear `src/utils/date.ts` con implementación de referencia
2. Crear `tests/helpers/crypto.ts` con implementación de referencia
3. Buscar y reemplazar imports en todos los archivos afectados
4. Eliminar definiciones locales de las funciones
5. Ejecutar suite completa de tests: `bun test`
6. Verificar que no hay regresiones
### Criterios de Éxito
- ✅ Todos los tests pasan
- ✅ Solo existe 1 definición de `toIsoSql` en el código fuente
- ✅ Solo existe 1 definición de `sha256Hex` en tests
- ✅ Reducción de ~150 líneas de código
---
## 🔴 Fase 2: División de `group-sync.ts` (3-4 horas)
**Prioridad:** CRÍTICA
**Impacto:** Alto (mejora legibilidad y testabilidad)
**Riesgo:** Medio (requiere análisis cuidadoso)
### Objetivos
1. Dividir archivo monolítico de 1307 líneas en módulos cohesivos
2. Separar responsabilidades claramente
3. Facilitar testing unitario de cada componente
4. Mantener API pública compatible
### Análisis Previo Necesario
**Archivos a consultar para entender el alcance:**
- `src/services/group-sync.ts` (archivo principal a dividir)
- `tests/unit/services/group-sync.test.ts`
- `tests/unit/services/group-sync.onboarding.test.ts`
- `tests/unit/services/group-sync.coverage.test.ts`
- `tests/unit/services/group-sync.gating.test.ts`
- `tests/unit/services/group-sync.label-update.test.ts`
- `tests/unit/services/group-sync.sync-members.test.ts`
- `tests/unit/services/group-sync.members.test.ts`
- `tests/unit/services/group-sync.fetch-members.test.ts`
- `tests/unit/services/group-sync.scheduler.test.ts`
- `tests/unit/services/group-sync.scheduler.gating.test.ts`
**Identificar en el código:**
- Funciones públicas vs privadas
- Responsabilidades principales (sincronización, scheduling, gating, eventos)
- Dependencias entre funciones
- Estado compartido (si existe)
### Estructura Propuesta

@ -3,6 +3,10 @@
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"typecheck:web": "cd apps/web && bunx svelte-kit sync && bunx tsc --noEmit --pretty false",
"typecheck:core": "bunx tsc -p tsconfig.core.json --noEmit --pretty false"
},
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"bun-types": "^1.2.6" "bun-types": "^1.2.6"

@ -41,9 +41,11 @@ Bun.serve({
const init: RequestInit = { const init: RequestInit = {
method: req.method, method: req.method,
headers, headers,
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req.body,
redirect: 'manual', redirect: 'manual',
}; };
if (req.method !== 'GET' && req.method !== 'HEAD' && req.body !== null) {
(init as any).body = req.body as any;
}
const started = Date.now(); const started = Date.now();
try { try {

@ -0,0 +1,63 @@
export type EvolutionResult = { ok: boolean; status?: number; error?: string };
export 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<EvolutionResult> {
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}`;
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 };
}
}
export async function sendReaction(payload: {
key: { remoteJid: string; id: string; fromMe: boolean; participant?: string };
reaction: string;
}): Promise<EvolutionResult> {
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 };
}
}

@ -1,9 +1,10 @@
import { Database } from 'bun:sqlite'; import { Database } from 'bun:sqlite';
import { normalizeWhatsAppId } from './utils/whatsapp'; import { normalizeWhatsAppId } from './utils/whatsapp';
import { mkdirSync } from 'fs'; import { mkdirSync } from 'fs';
import { join, resolve, dirname } from 'path'; import { dirname } from 'path';
import { Migrator } from './db/migrator'; import { Migrator } from './db/migrator';
import { migrations } from './db/migrations'; import { migrations } from './db/migrations';
import { resolveDbAbsolutePath } from './env/db-path';
function applyDefaultPragmas(instance: Database): void { function applyDefaultPragmas(instance: Database): void {
try { try {
@ -19,36 +20,19 @@ function applyDefaultPragmas(instance: Database): void {
} }
} }
// Function to get a database instance. Defaults to 'data/tasks.db' // 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 { export function getDb(filename: string = 'tasks.db'): Database {
// Prioridad 1: DB_PATH (ruta completa al archivo). Si está definida, se usa tal cual. const absolutePath = resolveDbAbsolutePath(filename);
const dbPathEnv = process?.env?.DB_PATH ? String(process.env.DB_PATH).trim() : '';
if (dbPathEnv) {
const absolutePath = resolve(dbPathEnv);
// Crear directorio padre si no existe
try {
mkdirSync(dirname(absolutePath), { recursive: true });
} catch (err) {
if ((err as any)?.code !== 'EEXIST') throw err; // Solo ignorar "ya existe"
}
const instance = new Database(absolutePath);
applyDefaultPragmas(instance);
return instance;
}
// Prioridad 2: DATA_DIR + filename (comportamiento actual)
// Determine base directory for the database (env DATA_DIR or default './data'), resolve to absolute
const dataDir = process?.env?.DATA_DIR ? String(process.env.DATA_DIR) : 'data';
const dirPath = resolve(dataDir);
// Try to create data directory if it doesn't exist (ignore if already exists) // Asegurar directorio padre
try { try {
mkdirSync(dirPath, { recursive: true }); mkdirSync(dirname(absolutePath), { recursive: true });
} catch (err) { } catch (err) {
if ((err as any)?.code !== 'EEXIST') throw err; // Only ignore "already exists" errors const code = (err && typeof err === 'object') ? (err as { code?: string }).code : undefined;
if (code !== 'EEXIST') throw err;
} }
const instance = new Database(join(dirPath, filename)); const instance = new Database(absolutePath);
applyDefaultPragmas(instance); applyDefaultPragmas(instance);
return instance; return instance;
} }
@ -75,7 +59,7 @@ export function initializeDatabase(instance: Database) {
try { try {
const row = instance const row = instance
.query(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`) .query(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`)
.get(name) as any; .get(name) as { name?: string } | undefined;
return Boolean(row && row.name === name); return Boolean(row && row.name === name);
} catch { } catch {
return false; return false;

@ -0,0 +1,51 @@
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;
/**
* Establece la instancia global de DB.
* Se permite sobrescribir (útil en tests).
*/
export function setDb(db: Database): void {
currentDb = db;
}
/**
* Obtiene la instancia global de DB o lanza si no está configurada.
*/
export function getDb(): Database {
if (currentDb) return currentDb;
throw new DbNotConfiguredError('Database has not been configured. Call setDb(db) before using getDb().');
}
/**
* Resetea la instancia global de DB. Útil en tests para detectar fugas entre suites.
*/
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.
*/
export function withDb<T>(fn: (db: Database) => T | Promise<T>): T | Promise<T> {
const db = getDb();
return fn(db);
}

@ -8,7 +8,7 @@ export type Migration = {
}; };
function tableHasColumn(db: Database, table: string, column: string): boolean { function tableHasColumn(db: Database, table: string, column: string): boolean {
const cols = db.query(`PRAGMA table_info(${table})`).all() as any[]; const cols = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name?: string }>;
return Array.isArray(cols) && cols.some((c: any) => c.name === column); return Array.isArray(cols) && cols.some((c: any) => c.name === column);
} }
@ -250,7 +250,7 @@ export const migrations: Migration[] = [
up: (db: Database) => { up: (db: Database) => {
// Añadir columna display_code si no existe // Añadir columna display_code si no existe
try { try {
const cols = db.query(`PRAGMA table_info(tasks)`).all() as any[]; const cols = db.query(`PRAGMA table_info(tasks)`).all() as Array<{ name?: string }>;
const hasDisplay = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'display_code'); const hasDisplay = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'display_code');
if (!hasDisplay) { if (!hasDisplay) {
db.exec(`ALTER TABLE tasks ADD COLUMN display_code INTEGER NULL;`); db.exec(`ALTER TABLE tasks ADD COLUMN display_code INTEGER NULL;`);
@ -368,7 +368,7 @@ export const migrations: Migration[] = [
// Añadir columna para poder mostrar siempre la URL (guardando el token en claro). // Añadir columna para poder mostrar siempre la URL (guardando el token en claro).
// Nota: mantenemos token_hash para validación; token_plain se usa solo para construir la URL en UI. // Nota: mantenemos token_hash para validación; token_plain se usa solo para construir la URL en UI.
try { try {
const cols = db.query(`PRAGMA table_info(calendar_tokens)`).all() as any[]; const cols = db.query(`PRAGMA table_info(calendar_tokens)`).all() as Array<{ name?: string }>;
const hasPlain = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'token_plain'); const hasPlain = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'token_plain');
if (!hasPlain) { if (!hasPlain) {
db.exec(`ALTER TABLE calendar_tokens ADD COLUMN token_plain TEXT NULL;`); db.exec(`ALTER TABLE calendar_tokens ADD COLUMN token_plain TEXT NULL;`);
@ -382,7 +382,7 @@ export const migrations: Migration[] = [
checksum: 'v13-groups-onboarding-2025-10-17', checksum: 'v13-groups-onboarding-2025-10-17',
up: (db: Database) => { up: (db: Database) => {
try { try {
const cols = db.query(`PRAGMA table_info(groups)`).all() as any[]; const cols = db.query(`PRAGMA table_info(groups)`).all() as Array<{ name?: string }>;
const hasCol = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'onboarding_prompted_at'); const hasCol = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'onboarding_prompted_at');
if (!hasCol) { if (!hasCol) {
db.exec(`ALTER TABLE groups ADD COLUMN onboarding_prompted_at TEXT NULL;`); db.exec(`ALTER TABLE groups ADD COLUMN onboarding_prompted_at TEXT NULL;`);
@ -396,7 +396,7 @@ export const migrations: Migration[] = [
checksum: 'v14-groups-archived-2025-10-19', checksum: 'v14-groups-archived-2025-10-19',
up: (db: Database) => { up: (db: Database) => {
try { try {
const cols = db.query(`PRAGMA table_info(groups)`).all() as any[]; const cols = db.query(`PRAGMA table_info(groups)`).all() as Array<{ name?: string }>;
const hasArchived = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'archived'); const hasArchived = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'archived');
if (!hasArchived) { if (!hasArchived) {
db.exec(`ALTER TABLE groups ADD COLUMN archived BOOLEAN NOT NULL DEFAULT 0;`); db.exec(`ALTER TABLE groups ADD COLUMN archived BOOLEAN NOT NULL DEFAULT 0;`);
@ -443,7 +443,7 @@ export const migrations: Migration[] = [
checksum: 'v16-groups-is-community-2025-10-19', checksum: 'v16-groups-is-community-2025-10-19',
up: (db: Database) => { up: (db: Database) => {
try { try {
const cols = db.query(`PRAGMA table_info(groups)`).all() as any[]; const cols = db.query(`PRAGMA table_info(groups)`).all() as Array<{ name?: string }>;
const hasCol = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'is_community'); const hasCol = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'is_community');
if (!hasCol) { if (!hasCol) {
db.exec(`ALTER TABLE groups ADD COLUMN is_community BOOLEAN NOT NULL DEFAULT 0;`); db.exec(`ALTER TABLE groups ADD COLUMN is_community BOOLEAN NOT NULL DEFAULT 0;`);
@ -479,7 +479,7 @@ export const migrations: Migration[] = [
checksum: 'v18-task-origins-participant-fromme-2025-10-21', checksum: 'v18-task-origins-participant-fromme-2025-10-21',
up: (db: Database) => { up: (db: Database) => {
try { try {
const cols = db.query(`PRAGMA table_info(task_origins)`).all() as any[]; const cols = db.query(`PRAGMA table_info(task_origins)`).all() as Array<{ name?: string }>;
const hasParticipant = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'participant'); const hasParticipant = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'participant');
if (!hasParticipant) { if (!hasParticipant) {
db.exec(`ALTER TABLE task_origins ADD COLUMN participant TEXT NULL;`); db.exec(`ALTER TABLE task_origins ADD COLUMN participant TEXT NULL;`);
@ -497,7 +497,7 @@ export const migrations: Migration[] = [
checksum: 'v19-users-last-command-at-2025-10-25', checksum: 'v19-users-last-command-at-2025-10-25',
up: (db: Database) => { up: (db: Database) => {
try { try {
const cols = db.query(`PRAGMA table_info(users)`).all() as any[]; const cols = db.query(`PRAGMA table_info(users)`).all() as Array<{ name?: string }>;
const hasCol = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'last_command_at'); const hasCol = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'last_command_at');
if (!hasCol) { if (!hasCol) {
db.exec(`ALTER TABLE users ADD COLUMN last_command_at TEXT NULL;`); db.exec(`ALTER TABLE users ADD COLUMN last_command_at TEXT NULL;`);

@ -2,12 +2,13 @@ import type { Database } from 'bun:sqlite';
import { mkdirSync, appendFileSync } from 'fs'; import { mkdirSync, appendFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { migrations, type Migration } from './migrations'; import { migrations, type Migration } from './migrations';
import { toIsoSqlUTC } from '../utils/datetime';
const MIGRATIONS_LOG_LEVEL = (process.env.MIGRATIONS_LOG_LEVEL || '').toLowerCase(); const MIGRATIONS_LOG_LEVEL = (process.env.MIGRATIONS_LOG_LEVEL || '').toLowerCase();
const MIGRATIONS_QUIET = process.env.NODE_ENV === 'test' || MIGRATIONS_LOG_LEVEL === 'silent'; const MIGRATIONS_QUIET = process.env.NODE_ENV === 'test' || MIGRATIONS_LOG_LEVEL === 'silent';
function nowIso(): string { function nowIso(): string {
return new Date().toISOString().replace('T', ' ').replace('Z', ''); return toIsoSqlUTC(new Date());
} }
function logEvent(level: 'info' | 'error', event: string, data: any = {}) { function logEvent(level: 'info' | 'error', event: string, data: any = {}) {
@ -34,7 +35,7 @@ function ensureMigrationsTable(db: Database) {
} }
function getAppliedVersions(db: Database): Map<number, { name: string; checksum: string; applied_at: string }> { function getAppliedVersions(db: Database): Map<number, { name: string; checksum: string; applied_at: string }> {
const rows = db.query(`SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`).all() as any[]; const rows = db.query(`SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`).all() as Array<{ version: number; name: string; checksum: string; applied_at: string }>;
const map = new Map<number, { name: string; checksum: string; applied_at: string }>(); const map = new Map<number, { name: string; checksum: string; applied_at: string }>();
for (const r of rows) { for (const r of rows) {
map.set(Number(r.version), { name: String(r.name), checksum: String(r.checksum), applied_at: String(r.applied_at) }); map.set(Number(r.version), { name: String(r.name), checksum: String(r.checksum), applied_at: String(r.applied_at) });
@ -43,7 +44,7 @@ function getAppliedVersions(db: Database): Map<number, { name: string; checksum:
} }
function tableExists(db: Database, table: string): boolean { function tableExists(db: Database, table: string): boolean {
const row = db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(table) as any; const row = db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(table) as { name?: string } | undefined;
return !!row; return !!row;
} }
@ -102,8 +103,8 @@ export const Migrator = {
} }
// Resumen inicial // Resumen inicial
const jmRow = db.query(`PRAGMA journal_mode`).get() as any; const jmRow = db.query(`PRAGMA journal_mode`).get() as Record<string, unknown> | undefined;
const journalMode = jmRow ? (jmRow.journal_mode || jmRow.value || jmRow.mode || 'unknown') : 'unknown'; const journalMode = jmRow ? String((jmRow['journal_mode'] ?? jmRow['value'] ?? jmRow['mode'] ?? 'unknown')) : 'unknown';
const currentVersion = applied.size ? Math.max(...Array.from(applied.keys())) : 0; 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}`); 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 {} try { logEvent('info', 'startup_summary', { journal_mode: journalMode, current_version: currentVersion, pending: pending.length }); } catch {}

18
src/env/db-path.ts vendored

@ -0,0 +1,18 @@
import { join, resolve } from 'path';
/**
* Resuelve la ruta absoluta al archivo de la base de datos SQLite compartida.
* Prioridad:
* 1) DB_PATH (ruta completa al archivo)
* 2) DATA_DIR + filename
* - En producción (NODE_ENV=production) por defecto '/app/data'
* - En no-producción por defecto './tmp'
*/
export function resolveDbAbsolutePath(filename: string = 'tasks.db'): string {
const dbPathEnv = String(process.env.DB_PATH || '').trim();
if (dbPathEnv) return resolve(dbPathEnv);
const isProdEnv = String(process.env.NODE_ENV || 'development').trim().toLowerCase() === 'production';
const dataDir = process.env.DATA_DIR ? String(process.env.DATA_DIR) : (isProdEnv ? '/app/data' : 'tmp');
return resolve(join(dataDir, filename));
}

@ -0,0 +1,93 @@
import type { Database } from 'bun:sqlite';
import { setDb } from '../db/locator';
import { WebhookManager } from '../services/webhook-manager';
import { GroupSyncService } from '../services/group-sync';
import { ResponseQueue } from '../services/response-queue';
import { RemindersService } from '../services/reminders';
import { MaintenanceService } from '../services/maintenance';
export async function startServices(_db: Database): Promise<void> {
// Exponer la DB globalmente vía locator para servicios que lo usen.
try { setDb(_db); } catch {}
await WebhookManager.registerWebhook();
// Add small delay to allow webhook to propagate
await new Promise(resolve => setTimeout(resolve, 1000));
const isActive = await WebhookManager.verifyWebhook();
if (!isActive) {
console.error('❌ Webhook verification failed - retrying in 2 seconds...');
await new Promise(resolve => setTimeout(resolve, 2000));
const isActiveRetry = await WebhookManager.verifyWebhook();
if (!isActiveRetry) {
console.error('❌ Webhook verification failed after retry');
process.exit(1);
}
}
// Initialize groups - critical for operation
await GroupSyncService.checkInitialGroups();
// Start groups scheduler (periodic sync of groups)
try {
GroupSyncService.startGroupsScheduler();
console.log('✅ Group scheduler started');
} catch (e) {
console.error('⚠️ Failed to start Group scheduler:', e);
}
// Initial members sync (non-blocking if fails)
try {
await GroupSyncService.syncMembersForActiveGroups();
GroupSyncService.startMembersScheduler();
console.log('✅ Group members scheduler started');
} catch (e) {
console.error('⚠️ Failed to run initial members sync or start scheduler:', e);
}
// Start response queue worker (background)
try {
await ResponseQueue.process();
console.log('✅ ResponseQueue worker started');
// Start cleanup scheduler (daily retention)
ResponseQueue.startCleanupScheduler();
console.log('✅ ResponseQueue cleanup scheduler started');
RemindersService.start();
console.log('✅ RemindersService started');
} catch (e) {
console.error('❌ Failed to start ResponseQueue worker or cleanup scheduler:', e);
}
// Mantenimiento (cleanup de miembros inactivos)
try {
MaintenanceService.start();
console.log('✅ MaintenanceService started');
// Ejecutar reconciliación de alias una vez al arranque (one-shot)
try {
await MaintenanceService.reconcileAliasUsersOnce();
console.log('✅ MaintenanceService: reconciliación de alias ejecutada (one-shot)');
} catch (e2) {
console.error('⚠️ Failed to run alias reconciliation one-shot:', e2);
}
} catch (e) {
console.error('⚠️ Failed to start MaintenanceService:', e);
}
}
export function stopServices(): void {
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 {}
}

@ -0,0 +1,46 @@
import type { Database } from 'bun:sqlite';
import { Metrics } from '../services/metrics';
export async function handleHealthRequest(url: URL, db: Database): Promise<Response> {
// /health?full=1 devuelve JSON con detalles
if (url.searchParams.get('full') === '1') {
try {
const rowG = db.prepare(`SELECT COUNT(*) AS c, MAX(last_verified) AS lv FROM groups WHERE active = 1`).get() as { c?: number; lv?: string | null } | undefined;
const rowM = db.prepare(`SELECT COUNT(*) AS c FROM group_members WHERE is_active = 1`).get() as { c?: number } | undefined;
const active_groups = Number(rowG?.c || 0);
const active_members = Number(rowM?.c || 0);
const lv = rowG?.lv ? String(rowG.lv) : null;
let last_sync_at: string | null = lv;
let snapshot_age_ms: number | null = null;
if (lv) {
const iso = lv.includes('T') ? lv : (lv.replace(' ', 'T') + 'Z');
const ms = Date.parse(iso);
if (Number.isFinite(ms)) {
snapshot_age_ms = Date.now() - ms;
}
}
const lastSyncMetric = Metrics.get('last_sync_ok');
const maxAgeRaw = Number(process.env.MAX_MEMBERS_SNAPSHOT_AGE_MS);
const maxAgeMs = Number.isFinite(maxAgeRaw) && maxAgeRaw > 0 ? maxAgeRaw : 24 * 60 * 60 * 1000;
const snapshot_fresh = typeof snapshot_age_ms === 'number' ? (snapshot_age_ms <= maxAgeMs) : false;
let last_sync_ok: number;
if (typeof lastSyncMetric === 'number') {
last_sync_ok = (lastSyncMetric === 1 && snapshot_fresh) ? 1 : 0;
} else {
// Si no hay métrica explícita, nos basamos exclusivamente en la frescura de la snapshot
last_sync_ok = snapshot_fresh ? 1 : 0;
}
const payload = { status: 'ok', active_groups, active_members, last_sync_at, snapshot_age_ms, snapshot_fresh, last_sync_ok };
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (e) {
return new Response(JSON.stringify({ status: 'error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
return new Response('OK', { status: 200 });
}

@ -0,0 +1,44 @@
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<Response> {
if (request.method !== 'GET') {
return new Response('🚫 Method not allowed', { status: 405 });
}
if (!Metrics.enabled()) {
return new Response('Metrics disabled', { status: 404 });
}
// Gauges de allowed_groups por estado (best-effort)
try {
const rows = db
.prepare(`SELECT status, COUNT(*) AS c FROM allowed_groups GROUP BY status`)
.all() as Array<{ status: string; c: number }>;
let pending = 0, allowed = 0, blocked = 0;
for (const r of rows) {
const s = String(r?.status || '');
const c = Number(r?.c || 0);
if (s === 'pending') pending = c;
else if (s === 'allowed') allowed = c;
else if (s === 'blocked') blocked = c;
}
Metrics.set('allowed_groups_total_pending', pending);
Metrics.set('allowed_groups_total_allowed', allowed);
Metrics.set('allowed_groups_total_blocked', blocked);
} catch {}
// Exponer métrica con el tiempo restante hasta el próximo group sync (o -1 si scheduler inactivo)
try {
const secs = GroupSyncService.getSecondsUntilNextGroupSync();
const val = (secs == null || !Number.isFinite(secs)) ? -1 : secs;
Metrics.set('group_sync_seconds_until_next', val);
} catch {}
const format = (process.env.METRICS_FORMAT || 'prom').toLowerCase() === 'json' ? 'json' : 'prom';
const body = Metrics.render(format as any);
return new Response(body, {
status: 200,
headers: { 'Content-Type': format === 'json' ? 'application/json' : 'text/plain; version=0.0.4' }
});
}

@ -0,0 +1,315 @@
import type { Database } from 'bun:sqlite';
import { ensureUserExists } from '../db';
import { normalizeWhatsAppId, isGroupId } from '../utils/whatsapp';
import { IdentityService } from '../services/identity';
import { AllowedGroups } from '../services/allowed-groups';
import { GroupSyncService } from '../services/group-sync';
import { ResponseQueue } from '../services/response-queue';
import { AdminService } from '../services/admin';
import { CommandService } from '../services/command';
import { TaskService } from '../tasks/service';
import { RateLimiter } from '../services/rate-limit';
import { Metrics } from '../services/metrics';
function 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() : '';
}
export async function handleMessageUpsert(data: any, db: Database): Promise<void> {
if (!data?.key?.remoteJid || !data.message) {
if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Invalid message format - missing required fields');
console.log(data);
}
return;
}
const messageText = getMessageText(data.message);
if (!messageText) {
if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Empty or unsupported message content');
}
return;
}
// 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;
// Ignore broadcasts/status
if (remoteJid === 'status@broadcast' || (typeof remoteJid === 'string' && remoteJid.endsWith('@broadcast'))) {
if (process.env.NODE_ENV !== 'test') {
console.log(' Ignoring broadcast/status message');
}
return;
}
// Ignore our own messages
if (fromMe) {
if (process.env.NODE_ENV !== 'test') {
console.log(' Ignoring message sent by the bot (fromMe=true)');
}
return;
}
// 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)
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 {}
}
}
// 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 });
}
return;
}
// 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;
}
// Ensure user exists in database (swallow DB errors to keep webhook 200)
let userId: string | null = null;
try {
userId = ensureUserExists(senderRaw, db);
} catch (e) {
if (process.env.NODE_ENV !== 'test') {
console.error('⚠️ Error ensuring user exists, ignoring message:', e);
}
return;
}
if (!userId) {
if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Failed to ensure user exists, ignoring message');
}
return;
}
const messageTextTrimmed = messageText.trim();
const isAdminCmd = messageTextTrimmed.startsWith('/admin');
// A4: Primer DM "activar" — alta/confirmación idempotente (solo en DM)
if (!isGroupId(remoteJid) && messageTextTrimmed === 'activar') {
const base = (process.env.WEB_BASE_URL || '').trim();
const msg = base
? "Listo, ya puedes reclamar/ser responsable y acceder a la web. Para acceder a la web, envía '/t web' y abre el enlace."
: "Listo, ya puedes reclamar/ser responsable.";
try {
await ResponseQueue.add([{ recipient: normalizedSenderId, message: msg }]);
} catch {}
return;
}
// Etapa 2: Descubrimiento seguro de grupos (modo 'discover')
if (isGroupId(remoteJid)) {
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;
}
}
}
// 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
}
}
}
// 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;
}
// 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)');
}
try {
GroupSyncService.ensureGroupExists(data.key.remoteJid);
try { GroupSyncService.upsertMemberSeen(data.key.remoteJid, normalizedSenderId); } catch {}
} catch (e) {
if (process.env.NODE_ENV !== 'test') {
console.error('⚠️ Failed to ensure group on-the-fly:', 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);
}
// 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);
}
}
}
}

@ -1,21 +1,15 @@
//// <reference types="bun-types" /> //// <reference types="bun-types" />
import type { Database } from 'bun:sqlite'; import type { Database } from 'bun:sqlite';
import { CommandService } from './services/command';
import { GroupSyncService } from './services/group-sync'; import { GroupSyncService } from './services/group-sync';
import { ResponseQueue } from './services/response-queue';
import { TaskService } from './tasks/service';
import { WebhookManager } from './services/webhook-manager';
import { normalizeWhatsAppId, isGroupId } from './utils/whatsapp';
import { ensureUserExists, db } from './db';
import { ContactsService } from './services/contacts'; import { ContactsService } from './services/contacts';
import { Migrator } from './db/migrator'; import { Migrator } from './db/migrator';
import { RateLimiter } from './services/rate-limit';
import { RemindersService } from './services/reminders';
import { Metrics } from './services/metrics'; import { Metrics } from './services/metrics';
import { MaintenanceService } from './services/maintenance';
import { IdentityService } from './services/identity';
import { AllowedGroups } from './services/allowed-groups'; import { AllowedGroups } from './services/allowed-groups';
import { AdminService } from './services/admin'; import { db } from './db';
import { handleMetricsRequest } from './http/metrics';
import { handleHealthRequest } from './http/health';
import { startServices } from './http/bootstrap';
import { handleMessageUpsert as handleMessageUpsertFn } from './http/webhook-handler';
// Bun is available globally when running under Bun runtime // Bun is available globally when running under Bun runtime
declare global { declare global {
@ -61,84 +55,10 @@ export class WebhookServer {
// Health check endpoint y métricas // Health check endpoint y métricas
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname.endsWith('/metrics')) { if (url.pathname.endsWith('/metrics')) {
if (request.method !== 'GET') { return await handleMetricsRequest(request, WebhookServer.dbInstance);
return new Response('🚫 Method not allowed', { status: 405 });
}
if (!Metrics.enabled()) {
return new Response('Metrics disabled', { status: 404 });
}
// Gauges de allowed_groups por estado (best-effort)
try {
const rows = WebhookServer.dbInstance
.prepare(`SELECT status, COUNT(*) AS c FROM allowed_groups GROUP BY status`)
.all() as any[];
let pending = 0, allowed = 0, blocked = 0;
for (const r of rows) {
const s = String(r?.status || '');
const c = Number(r?.c || 0);
if (s === 'pending') pending = c;
else if (s === 'allowed') allowed = c;
else if (s === 'blocked') blocked = c;
}
Metrics.set('allowed_groups_total_pending', pending);
Metrics.set('allowed_groups_total_allowed', allowed);
Metrics.set('allowed_groups_total_blocked', blocked);
} catch {}
// Exponer métrica con el tiempo restante hasta el próximo group sync (o -1 si scheduler inactivo)
try {
const secs = GroupSyncService.getSecondsUntilNextGroupSync();
const val = (secs == null || !Number.isFinite(secs)) ? -1 : secs;
Metrics.set('group_sync_seconds_until_next', val);
} catch {}
const format = (process.env.METRICS_FORMAT || 'prom').toLowerCase() === 'json' ? 'json' : 'prom';
const body = Metrics.render(format as any);
return new Response(body, {
status: 200,
headers: { 'Content-Type': format === 'json' ? 'application/json' : 'text/plain; version=0.0.4' }
});
} }
if (url.pathname.endsWith('/health')) { if (url.pathname.endsWith('/health')) {
// /health?full=1 devuelve JSON con detalles return await handleHealthRequest(url, WebhookServer.dbInstance);
if (url.searchParams.get('full') === '1') {
try {
const rowG = WebhookServer.dbInstance.prepare(`SELECT COUNT(*) AS c, MAX(last_verified) AS lv FROM groups WHERE active = 1`).get() as any;
const rowM = WebhookServer.dbInstance.prepare(`SELECT COUNT(*) AS c FROM group_members WHERE is_active = 1`).get() as any;
const active_groups = Number(rowG?.c || 0);
const active_members = Number(rowM?.c || 0);
const lv = rowG?.lv ? String(rowG.lv) : null;
let last_sync_at: string | null = lv;
let snapshot_age_ms: number | null = null;
if (lv) {
const iso = lv.includes('T') ? lv : (lv.replace(' ', 'T') + 'Z');
const ms = Date.parse(iso);
if (Number.isFinite(ms)) {
snapshot_age_ms = Date.now() - ms;
}
}
const lastSyncMetric = Metrics.get('last_sync_ok');
const maxAgeRaw = Number(process.env.MAX_MEMBERS_SNAPSHOT_AGE_MS);
const maxAgeMs = Number.isFinite(maxAgeRaw) && maxAgeRaw > 0 ? maxAgeRaw : 24 * 60 * 60 * 1000;
const snapshot_fresh = typeof snapshot_age_ms === 'number' ? (snapshot_age_ms <= maxAgeMs) : false;
let last_sync_ok: number;
if (typeof lastSyncMetric === 'number') {
last_sync_ok = (lastSyncMetric === 1 && snapshot_fresh) ? 1 : 0;
} else {
// Si no hay métrica explícita, nos basamos exclusivamente en la frescura de la snapshot
last_sync_ok = snapshot_fresh ? 1 : 0;
}
const payload = { status: 'ok', active_groups, active_members, last_sync_at, snapshot_age_ms, snapshot_fresh, last_sync_ok };
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (e) {
return new Response(JSON.stringify({ status: 'error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
return new Response('OK', { status: 200 });
} }
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
@ -237,305 +157,7 @@ export class WebhookServer {
} }
static async handleMessageUpsert(data: any) { static async handleMessageUpsert(data: any) {
if (!data?.key?.remoteJid || !data.message) { return await handleMessageUpsertFn(data, WebhookServer.dbInstance);
if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Invalid message format - missing required fields');
console.log(data);
}
return;
}
const messageText = WebhookServer.getMessageText(data.message);
if (!messageText) {
if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Empty or unsupported message content');
}
return;
}
// 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;
// Ignore broadcasts/status
if (remoteJid === 'status@broadcast' || (typeof remoteJid === 'string' && remoteJid.endsWith('@broadcast'))) {
if (process.env.NODE_ENV !== 'test') {
console.log(' Ignoring broadcast/status message');
}
return;
}
// Ignore our own messages
if (fromMe) {
if (process.env.NODE_ENV !== 'test') {
console.log(' Ignoring message sent by the bot (fromMe=true)');
}
return;
}
// 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)
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 {}
}
}
// 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 });
}
return;
}
// 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;
}
// Ensure user exists in database (swallow DB errors to keep webhook 200)
let userId: string | null = null;
try {
userId = ensureUserExists(senderRaw, WebhookServer.dbInstance);
} catch (e) {
if (process.env.NODE_ENV !== 'test') {
console.error('⚠️ Error ensuring user exists, ignoring message:', e);
}
return;
}
if (!userId) {
if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Failed to ensure user exists, ignoring message');
}
return;
}
const messageTextTrimmed = messageText.trim();
const isAdminCmd = messageTextTrimmed.startsWith('/admin');
// A4: Primer DM "activar" — alta/confirmación idempotente (solo en DM)
if (!isGroupId(remoteJid) && messageTextTrimmed === 'activar') {
const base = (process.env.WEB_BASE_URL || '').trim();
const msg = base
? "Listo, ya puedes reclamar/ser responsable y acceder a la web. Para acceder a la web, envía '/t web' y abre el enlace."
: "Listo, ya puedes reclamar/ser responsable.";
try {
await ResponseQueue.add([{ recipient: normalizedSenderId, message: msg }]);
} catch {}
return;
}
// Etapa 2: Descubrimiento seguro de grupos (modo 'discover')
if (isGroupId(remoteJid)) {
try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {}
const gatingMode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (gatingMode === 'discover') {
try {
const exists = WebhookServer.dbInstance
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? LIMIT 1`)
.get(remoteJid) as any;
if (!exists) {
try { await GroupSyncService.ensureGroupLabelAndName(remoteJid); } catch {}
try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } 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 as any).dbInstance = WebhookServer.dbInstance; } 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;
}
}
}
// Etapa 3: Gating en modo 'enforce' — ignorar mensajes de grupos no permitidos
if (isGroupId(remoteJid)) {
try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {}
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
}
}
}
// Manejo de comandos de administración (/admin) antes de cualquier otra lógica de grupo
if (messageTextTrimmed.startsWith('/admin')) {
try { (AdminService as any).dbInstance = WebhookServer.dbInstance; } catch {}
try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {}
const adminResponses = await AdminService.handle({
sender: normalizedSenderId,
groupId: remoteJid,
message: messageText
});
if (adminResponses.length > 0) {
await ResponseQueue.add(adminResponses);
}
return;
}
// 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)');
}
try {
GroupSyncService.ensureGroupExists(data.key.remoteJid);
try { GroupSyncService.upsertMemberSeen(data.key.remoteJid, normalizedSenderId); } catch {}
} catch (e) {
if (process.env.NODE_ENV !== 'test') {
console.error('⚠️ Failed to ensure group on-the-fly:', e);
}
}
}
// Forward to command service only if it's a text-ish message and starts with /t or /tarea
// messageTextTrimmed computed earlier
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)
(CommandService as any).dbInstance = WebhookServer.dbInstance;
(TaskService as any).dbInstance = WebhookServer.dbInstance;
// 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);
}
// 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'
try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {}
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);
}
}
}
} }
static validateEnv() { static validateEnv() {
@ -567,7 +189,6 @@ export class WebhookServer {
await Migrator.migrateToLatest(this.dbInstance); await Migrator.migrateToLatest(this.dbInstance);
// Etapa 7: seed inicial de grupos permitidos desde ALLOWED_GROUPS (best-effort) // Etapa 7: seed inicial de grupos permitidos desde ALLOWED_GROUPS (best-effort)
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
try { AllowedGroups.seedFromEnv(); } catch {} try { AllowedGroups.seedFromEnv(); } catch {}
const PORT = process.env.PORT || '3007'; const PORT = process.env.PORT || '3007';
@ -588,67 +209,7 @@ export class WebhookServer {
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
try { try {
await WebhookManager.registerWebhook(); await startServices(this.dbInstance);
// Add small delay to allow webhook to propagate
await new Promise(resolve => setTimeout(resolve, 1000));
const isActive = await WebhookManager.verifyWebhook();
if (!isActive) {
console.error('❌ Webhook verification failed - retrying in 2 seconds...');
await new Promise(resolve => setTimeout(resolve, 2000));
const isActiveRetry = await WebhookManager.verifyWebhook();
if (!isActiveRetry) {
console.error('❌ Webhook verification failed after retry');
process.exit(1);
}
}
// Initialize groups - critical for operation
await GroupSyncService.checkInitialGroups();
// Start groups scheduler (periodic sync of groups)
try {
GroupSyncService.startGroupsScheduler();
console.log('✅ Group scheduler started');
} catch (e) {
console.error('⚠️ Failed to start Group scheduler:', e);
}
// Initial members sync (non-blocking if fails)
try {
await GroupSyncService.syncMembersForActiveGroups();
GroupSyncService.startMembersScheduler();
console.log('✅ Group members scheduler started');
} catch (e) {
console.error('⚠️ Failed to run initial members sync or start scheduler:', e);
}
// Start response queue worker (background)
try {
await ResponseQueue.process();
console.log('✅ ResponseQueue worker started');
// Start cleanup scheduler (daily retention)
ResponseQueue.startCleanupScheduler();
console.log('✅ ResponseQueue cleanup scheduler started');
RemindersService.start();
console.log('✅ RemindersService started');
} catch (e) {
console.error('❌ Failed to start ResponseQueue worker or cleanup scheduler:', e);
}
// Mantenimiento (cleanup de miembros inactivos)
try {
MaintenanceService.start();
console.log('✅ MaintenanceService started');
// Ejecutar reconciliación de alias una vez al arranque (one-shot)
try {
await MaintenanceService.reconcileAliasUsersOnce();
console.log('✅ MaintenanceService: reconciliación de alias ejecutada (one-shot)');
} catch (e2) {
console.error('⚠️ Failed to run alias reconciliation one-shot:', e2);
}
} catch (e) {
console.error('⚠️ Failed to start MaintenanceService:', e);
}
} catch (error) { } catch (error) {
console.error('❌ Failed to setup webhook:', error instanceof Error ? error.message : error); console.error('❌ Failed to setup webhook:', error instanceof Error ? error.message : error);
process.exit(1); process.exit(1);

@ -1,11 +1,11 @@
import type { Database } from 'bun:sqlite'; import type { Database } from 'bun:sqlite';
import { db } from '../db';
import { AllowedGroups } from './allowed-groups'; import { AllowedGroups } from './allowed-groups';
import { GroupSyncService } from './group-sync'; import { GroupSyncService } from './group-sync';
import { normalizeWhatsAppId, isGroupId } from '../utils/whatsapp'; import { normalizeWhatsAppId, isGroupId } from '../utils/whatsapp';
import { Metrics } from './metrics'; import { Metrics } from './metrics';
import { TaskService } from '../tasks/service'; import { TaskService } from '../tasks/service';
import { codeId, formatDDMM } from '../utils/formatting'; import { codeId, formatDDMM } from '../utils/formatting';
import { getDb } from '../db/locator';
type AdminContext = { type AdminContext = {
sender: string; // normalized user id (digits only) sender: string; // normalized user id (digits only)
@ -16,7 +16,7 @@ type AdminContext = {
type AdminResponse = { recipient: string; message: string }; type AdminResponse = { recipient: string; message: string };
export class AdminService { export class AdminService {
static dbInstance: Database = db;
private static admins(): Set<string> { private static admins(): Set<string> {
const raw = String(process.env.ADMIN_USERS || ''); const raw = String(process.env.ADMIN_USERS || '');
@ -60,8 +60,8 @@ export class AdminService {
return [{ recipient: sender, message: '🚫 No estás autorizado para usar /admin.' }]; return [{ recipient: sender, message: '🚫 No estás autorizado para usar /admin.' }];
} }
const instanceDb = getDb() as Database;
// Asegurar acceso a la misma DB para AllowedGroups // Asegurar acceso a la misma DB para AllowedGroups
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
const raw = String(ctx.message || '').trim(); const raw = String(ctx.message || '').trim();
const lower = raw.toLowerCase(); const lower = raw.toLowerCase();
@ -109,18 +109,18 @@ export class AdminService {
if (!isGroupId(ctx.groupId)) { if (!isGroupId(ctx.groupId)) {
return [{ recipient: sender, message: ' Este comando se debe usar dentro de un grupo.' }]; return [{ recipient: sender, message: ' Este comando se debe usar dentro de un grupo.' }];
} }
this.dbInstance.transaction(() => { instanceDb.transaction(() => {
this.dbInstance.prepare(` instanceDb.prepare(`
UPDATE groups UPDATE groups
SET active = 0, archived = 1, last_verified = strftime('%Y-%m-%d %H:%M:%f','now') SET active = 0, archived = 1, last_verified = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE id = ? WHERE id = ?
`).run(ctx.groupId); `).run(ctx.groupId);
this.dbInstance.prepare(` instanceDb.prepare(`
UPDATE calendar_tokens UPDATE calendar_tokens
SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now') SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE group_id = ? AND revoked_at IS NULL WHERE group_id = ? AND revoked_at IS NULL
`).run(ctx.groupId); `).run(ctx.groupId);
this.dbInstance.prepare(` instanceDb.prepare(`
UPDATE group_members UPDATE group_members
SET is_active = 0 SET is_active = 0
WHERE group_id = ? AND is_active = 1 WHERE group_id = ? AND is_active = 1
@ -136,18 +136,18 @@ export class AdminService {
if (!isGroupId(arg)) { if (!isGroupId(arg)) {
return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }]; return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
} }
this.dbInstance.transaction(() => { instanceDb.transaction(() => {
this.dbInstance.prepare(` instanceDb.prepare(`
UPDATE groups UPDATE groups
SET active = 0, archived = 1, last_verified = strftime('%Y-%m-%d %H:%M:%f','now') SET active = 0, archived = 1, last_verified = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE id = ? WHERE id = ?
`).run(arg); `).run(arg);
this.dbInstance.prepare(` instanceDb.prepare(`
UPDATE calendar_tokens UPDATE calendar_tokens
SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now') SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE group_id = ? AND revoked_at IS NULL WHERE group_id = ? AND revoked_at IS NULL
`).run(arg); `).run(arg);
this.dbInstance.prepare(` instanceDb.prepare(`
UPDATE group_members UPDATE group_members
SET is_active = 0 SET is_active = 0
WHERE group_id = ? AND is_active = 1 WHERE group_id = ? AND is_active = 1
@ -162,10 +162,10 @@ export class AdminService {
if (!isGroupId(ctx.groupId)) { if (!isGroupId(ctx.groupId)) {
return [{ recipient: sender, message: ' Este comando se debe usar dentro de un grupo.' }]; return [{ recipient: sender, message: ' Este comando se debe usar dentro de un grupo.' }];
} }
this.dbInstance.transaction(() => { instanceDb.transaction(() => {
this.dbInstance.prepare(`DELETE FROM tasks WHERE group_id = ?`).run(ctx.groupId); instanceDb.prepare(`DELETE FROM tasks WHERE group_id = ?`).run(ctx.groupId);
this.dbInstance.prepare(`DELETE FROM groups WHERE id = ?`).run(ctx.groupId); instanceDb.prepare(`DELETE FROM groups WHERE id = ?`).run(ctx.groupId);
try { this.dbInstance.prepare(`DELETE FROM allowed_groups WHERE group_id = ?`).run(ctx.groupId); } catch {} 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 [{ recipient: sender, message: `🗑️ Grupo borrado y datos asociados eliminados: ${ctx.groupId}` }];
} }
@ -176,10 +176,10 @@ export class AdminService {
if (!isGroupId(arg)) { if (!isGroupId(arg)) {
return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }]; return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
} }
this.dbInstance.transaction(() => { instanceDb.transaction(() => {
this.dbInstance.prepare(`DELETE FROM tasks WHERE group_id = ?`).run(arg); instanceDb.prepare(`DELETE FROM tasks WHERE group_id = ?`).run(arg);
this.dbInstance.prepare(`DELETE FROM groups WHERE id = ?`).run(arg); instanceDb.prepare(`DELETE FROM groups WHERE id = ?`).run(arg);
try { this.dbInstance.prepare(`DELETE FROM allowed_groups WHERE group_id = ?`).run(arg); } catch {} try { instanceDb.prepare(`DELETE FROM allowed_groups WHERE group_id = ?`).run(arg); } catch {}
})(); })();
return [{ recipient: sender, message: `🗑️ Grupo borrado y datos asociados eliminados: ${arg}` }]; return [{ recipient: sender, message: `🗑️ Grupo borrado y datos asociados eliminados: ${arg}` }];
} }
@ -229,7 +229,6 @@ export class AdminService {
// /admin sync-grupos // /admin sync-grupos
if (rest === 'sync-grupos' || rest === 'group-sync' || rest === 'syncgroups') { if (rest === 'sync-grupos' || rest === 'group-sync' || rest === 'syncgroups') {
try { (GroupSyncService as any).dbInstance = this.dbInstance; } catch {}
try { try {
const r = await GroupSyncService.syncGroups(true); const r = await GroupSyncService.syncGroups(true);
return [{ recipient: sender, message: `✅ Sync de grupos ejecutado: ${r.added} añadidos, ${r.updated} actualizados.` }]; return [{ recipient: sender, message: `✅ Sync de grupos ejecutado: ${r.added} añadidos, ${r.updated} actualizados.` }];
@ -251,7 +250,6 @@ export class AdminService {
rest.startsWith('list-all ') rest.startsWith('list-all ')
) { ) {
// Asegurar acceso a la misma DB para TaskService // Asegurar acceso a la misma DB para TaskService
try { (TaskService as any).dbInstance = this.dbInstance; } catch {}
const DEFAULT_LIMIT = 50; const DEFAULT_LIMIT = 50;
let limit = DEFAULT_LIMIT; let limit = DEFAULT_LIMIT;

@ -1,5 +1,4 @@
import type { Database } from 'bun:sqlite'; import { getDb } from '../db/locator';
import { db } from '../db';
type GroupStatus = 'pending' | 'allowed' | 'blocked'; type GroupStatus = 'pending' | 'allowed' | 'blocked';
@ -9,7 +8,6 @@ type CacheEntry = {
}; };
export class AllowedGroups { export class AllowedGroups {
static dbInstance: Database = db;
// Caché en memoria: group_id (JID completo) -> { status, label } // Caché en memoria: group_id (JID completo) -> { status, label }
private static cache = new Map<string, CacheEntry>(); private static cache = new Map<string, CacheEntry>();
@ -26,9 +24,9 @@ export class AllowedGroups {
private static getRow(groupId: string): { group_id: string; label: string | null; status: GroupStatus } | null { private static getRow(groupId: string): { group_id: string; label: string | null; status: GroupStatus } | null {
try { try {
const row = this.dbInstance const row = getDb()
.prepare(`SELECT group_id, label, status FROM allowed_groups WHERE group_id = ?`) .prepare(`SELECT group_id, label, status FROM allowed_groups WHERE group_id = ?`)
.get(groupId) as any; .get(groupId) as { group_id?: string; label?: string | null; status?: string } | undefined;
if (!row) return null; if (!row) return null;
return { return {
group_id: String(row.group_id), group_id: String(row.group_id),
@ -66,7 +64,7 @@ export class AllowedGroups {
const row = this.getRow(gid); const row = this.getRow(gid);
if (!row) { if (!row) {
// Insertar como pending // Insertar como pending
this.dbInstance getDb()
.prepare(` .prepare(`
INSERT INTO allowed_groups (group_id, label, status, discovered_at, updated_at, discovered_by) INSERT INTO allowed_groups (group_id, label, status, discovered_at, updated_at, discovered_by)
VALUES (?, ?, 'pending', ${this.nowExpr}, ${this.nowExpr}, ?) VALUES (?, ?, 'pending', ${this.nowExpr}, ${this.nowExpr}, ?)
@ -79,7 +77,7 @@ export class AllowedGroups {
// No cambiar status existente. Solo actualizar label si se aporta y cambió. // No cambiar status existente. Solo actualizar label si se aporta y cambió.
const newLabel = label ?? row.label; const newLabel = label ?? row.label;
if (label != null && String(row.label ?? '') !== String(label)) { if (label != null && String(row.label ?? '') !== String(label)) {
this.dbInstance getDb()
.prepare(` .prepare(`
UPDATE allowed_groups UPDATE allowed_groups
SET label = ?, updated_at = ${this.nowExpr} SET label = ?, updated_at = ${this.nowExpr}
@ -98,7 +96,7 @@ export class AllowedGroups {
if (!gid) return false; if (!gid) return false;
const before = this.getRow(gid); const before = this.getRow(gid);
this.dbInstance getDb()
.prepare(` .prepare(`
INSERT INTO allowed_groups (group_id, label, status, discovered_at, updated_at) INSERT INTO allowed_groups (group_id, label, status, discovered_at, updated_at)
VALUES (?, ?, ?, ${this.nowExpr}, ${this.nowExpr}) VALUES (?, ?, ?, ${this.nowExpr}, ${this.nowExpr})
@ -120,14 +118,14 @@ export class AllowedGroups {
} }
static listByStatus(status: GroupStatus): Array<{ group_id: string; label: string | null }> { static listByStatus(status: GroupStatus): Array<{ group_id: string; label: string | null }> {
const rows = this.dbInstance const rows = getDb()
.prepare( .prepare(
`SELECT group_id, label FROM allowed_groups WHERE status = ? ORDER BY group_id` `SELECT group_id, label FROM allowed_groups WHERE status = ? ORDER BY group_id`
) )
.all(status) as Array<{ group_id: string; label: string | null }>; .all(status) as Array<{ group_id: string; label: string | null }>;
return rows.map(r => ({ return rows.map(r => ({
group_id: String((r as any).group_id), group_id: String(r.group_id),
label: (r as any).label != null ? String((r as any).label) : null, label: r.label != null ? String(r.label) : null,
})); }));
} }

@ -1,10 +1,11 @@
import type { Database } from 'bun:sqlite'; import type { Database } from 'bun:sqlite';
import { db, ensureUserExists } from '../db'; import { ensureUserExists } from '../db';
import { isGroupId } from '../utils/whatsapp'; import { isGroupId } from '../utils/whatsapp';
import { AllowedGroups } from './allowed-groups'; import { AllowedGroups } from './allowed-groups';
import { Metrics } from './metrics'; import { Metrics } from './metrics';
import { route as routeCommand } from './commands'; import { route as routeCommand } from './commands';
import { ACTION_ALIASES } from './commands/shared'; import { ACTION_ALIASES } from './commands/shared';
import { getDb } from '../db/locator';
type CommandContext = { type CommandContext = {
sender: string; // normalized user id (digits only), but accept raw too sender: string; // normalized user id (digits only), but accept raw too
@ -29,10 +30,6 @@ export type CommandOutcome = {
}; };
export class CommandService { export class CommandService {
static dbInstance: Database = db;
static async handle(context: CommandContext): Promise<CommandResponse[]> { static async handle(context: CommandContext): Promise<CommandResponse[]> {
const outcome = await this.handleWithOutcome(context); const outcome = await this.handleWithOutcome(context);
@ -41,6 +38,7 @@ export class CommandService {
static async handleWithOutcome(context: CommandContext): Promise<CommandOutcome> { static async handleWithOutcome(context: CommandContext): Promise<CommandOutcome> {
const msg = (context.message || '').trim(); const msg = (context.message || '').trim();
const instanceDb = getDb() as Database;
if (!/^\/(tarea|t)\b/i.test(msg)) { if (!/^\/(tarea|t)\b/i.test(msg)) {
return { responses: [], ok: true }; return { responses: [], ok: true };
} }
@ -49,14 +47,14 @@ export class CommandService {
try { try {
let usersTableExists = false; let usersTableExists = false;
try { try {
const row = this.dbInstance.query(`SELECT name FROM sqlite_master WHERE type='table' AND name='users'`).get() as any; const row = instanceDb.query(`SELECT name FROM sqlite_master WHERE type='table' AND name='users'`).get() as { name?: string } | undefined;
usersTableExists = !!row; usersTableExists = !!row;
} catch {} } catch {}
if (usersTableExists) { if (usersTableExists) {
const ensured = ensureUserExists(context.sender, this.dbInstance); const ensured = ensureUserExists(context.sender, instanceDb);
if (ensured) { if (ensured) {
try { try {
this.dbInstance.prepare(`UPDATE users SET last_command_at = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`).run(ensured); instanceDb.prepare(`UPDATE users SET last_command_at = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`).run(ensured);
} catch {} } catch {}
} }
} }
@ -64,7 +62,6 @@ export class CommandService {
// Gating de grupos en modo 'enforce' (cuando CommandService se invoca directamente) // Gating de grupos en modo 'enforce' (cuando CommandService se invoca directamente)
if (isGroupId(context.groupId)) { if (isGroupId(context.groupId)) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch { }
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') { if (mode === 'enforce') {
try { try {
@ -79,7 +76,7 @@ export class CommandService {
} }
try { try {
const routed = await routeCommand(context, { db: this.dbInstance }); const routed = await routeCommand(context, { db: instanceDb });
const responses = routed ?? []; const responses = routed ?? [];
// Clasificación explícita del outcome (evita lógica en server) // Clasificación explícita del outcome (evita lógica en server)

@ -222,7 +222,7 @@ export async function handleNueva(context: Ctx, deps: { db: Database }): Promise
responses.push({ responses.push({
recipient: createdBy, recipient: createdBy,
message: [ackLines.join('\n'), '', CTA_HELP].join('\n'), message: [ackLines.join('\n'), '', CTA_HELP].join('\n'),
mentions: mentionsForSending.length > 0 ? mentionsForSending : undefined ...(mentionsForSending.length > 0 ? { mentions: mentionsForSending } : {})
}); });
// 2) DM a cada asignado (excluyendo al creador para evitar duplicados) // 2) DM a cada asignado (excluyendo al creador para evitar duplicados)

@ -64,7 +64,8 @@ export async function handleVer(context: Ctx): Promise<Msg[]> {
: `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`;
const isOverdue = t.due_date ? t.due_date < today : false; 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 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}`; 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(...rendered);
sections.push(''); sections.push('');
@ -99,7 +100,8 @@ export async function handleVer(context: Ctx): Promise<Msg[]> {
const renderedUnassigned = unassigned.map((t) => { const renderedUnassigned = unassigned.map((t) => {
const isOverdue = t.due_date ? t.due_date < today : false; 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 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}`; 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); sections.push(...renderedUnassigned);
@ -157,7 +159,8 @@ export async function handleVer(context: Ctx): Promise<Msg[]> {
: `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`;
const isOverdue = t.due_date ? t.due_date < today : false; 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 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}`; 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(...rendered);
sections.push(''); sections.push('');

@ -3,6 +3,7 @@ import { ensureUserExists } from '../../../db';
import { isGroupId } from '../../../utils/whatsapp'; import { isGroupId } from '../../../utils/whatsapp';
import { randomTokenBase64Url, sha256Hex } from '../../../utils/crypto'; import { randomTokenBase64Url, sha256Hex } from '../../../utils/crypto';
import { Metrics } from '../../metrics'; import { Metrics } from '../../metrics';
import { toIsoSqlUTC } from '../../../utils/datetime';
type Ctx = { type Ctx = {
sender: string; sender: string;
@ -38,10 +39,9 @@ export async function handleWeb(context: Ctx, deps: { db: Database }): Promise<M
throw new Error('No se pudo asegurar el usuario'); throw new Error('No se pudo asegurar el usuario');
} }
const toIso = (d: Date) => d.toISOString().replace('T', ' ').replace('Z', '');
const now = new Date(); const now = new Date();
const nowIso = toIso(now); const nowIso = toIsoSqlUTC(now);
const expiresIso = toIso(new Date(now.getTime() + 10 * 60 * 1000)); // 10 minutos const expiresIso = toIsoSqlUTC(new Date(now.getTime() + 10 * 60 * 1000)); // 10 minutos
// Invalidar tokens vigentes (uso único) // Invalidar tokens vigentes (uso único)
deps.db.prepare(` deps.db.prepare(`

@ -12,6 +12,13 @@ import { handleCompletar } from './handlers/completar';
import { handleTomar } from './handlers/tomar'; import { handleTomar } from './handlers/tomar';
import { handleSoltar } from './handlers/soltar'; import { handleSoltar } from './handlers/soltar';
import { handleNueva } from './handlers/nueva'; import { handleNueva } from './handlers/nueva';
type NuevaCtx = Parameters<typeof handleNueva>[0];
type VerCtx = Parameters<typeof handleVer>[0];
type CompletarCtx = Parameters<typeof handleCompletar>[0];
type TomarCtx = Parameters<typeof handleTomar>[0];
type SoltarCtx = Parameters<typeof handleSoltar>[0];
type ConfigurarCtx = Parameters<typeof handleConfigurar>[0];
type WebCtx = Parameters<typeof handleWeb>[0];
import { ResponseQueue } from '../response-queue'; import { ResponseQueue } from '../response-queue';
import { isGroupId } from '../../utils/whatsapp'; import { isGroupId } from '../../utils/whatsapp';
import { Metrics } from '../metrics'; import { Metrics } from '../metrics';
@ -105,7 +112,7 @@ export async function route(context: RouteContext, deps?: { db: Database }): Pro
if (action === 'nueva') { if (action === 'nueva') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return await handleNueva(context as any, { db: database }); return await handleNueva(context as unknown as NuevaCtx, { db: database });
} }
if (action === 'ver') { if (action === 'ver') {
@ -129,32 +136,32 @@ export async function route(context: RouteContext, deps?: { db: Database }): Pro
}]; }];
} }
return await handleVer(context as any); return await handleVer(context as unknown as VerCtx);
} }
if (action === 'completar') { if (action === 'completar') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return await handleCompletar(context as any); return await handleCompletar(context as unknown as CompletarCtx);
} }
if (action === 'tomar') { if (action === 'tomar') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return await handleTomar(context as any); return await handleTomar(context as unknown as TomarCtx);
} }
if (action === 'soltar') { if (action === 'soltar') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return await handleSoltar(context as any); return await handleSoltar(context as unknown as SoltarCtx);
} }
if (action === 'configurar') { if (action === 'configurar') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return handleConfigurar(context as any, { db: database }); return handleConfigurar(context as unknown as ConfigurarCtx, { db: database });
} }
if (action === 'web') { if (action === 'web') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return await handleWeb(context as any, { db: database }); return await handleWeb(context as unknown as WebCtx, { db: database });
} }
// Desconocido → ayuda rápida // Desconocido → ayuda rápida

@ -104,7 +104,7 @@ export class ContactsService {
return null; return null;
} }
const data = await res.json().catch(() => null as any); const data = await res.json().catch(() => null);
const arrayCandidates: any[] = Array.isArray(data) const arrayCandidates: any[] = Array.isArray(data)
? data ? data
: Array.isArray((data as any)?.contacts) : Array.isArray((data as any)?.contacts)

@ -1,10 +1,17 @@
import type { Database } from 'bun:sqlite'; import type { Database } from 'bun:sqlite';
import { db, ensureUserExists } from '../db'; import { ensureUserExists } from '../db';
import { getDb as getGlobalDb } from '../db/locator';
import { normalizeWhatsAppId } from '../utils/whatsapp'; import { normalizeWhatsAppId } from '../utils/whatsapp';
import { Metrics } from './metrics'; import { Metrics } from './metrics';
import { IdentityService } from './identity'; import { IdentityService } from './identity';
import { AllowedGroups } from './allowed-groups'; import { AllowedGroups } from './allowed-groups';
import { ResponseQueue } from './response-queue'; 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';
// In-memory cache for active groups // In-memory cache for active groups
// const activeGroupsCache = new Map<string, string>(); // groupId -> groupName // const activeGroupsCache = new Map<string, string>(); // groupId -> groupName
@ -37,8 +44,14 @@ type EvolutionGroup = {
}; };
export class GroupSyncService { export class GroupSyncService {
// Static property for DB instance injection (defaults to global db) // Static property for DB instance injection (with fallback to global locator)
static dbInstance: Database = db; 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) // In-memory cache for active groups (made public for tests)
public static readonly activeGroupsCache = new Map<string, string>(); // groupId -> groupName public static readonly activeGroupsCache = new Map<string, string>(); // groupId -> groupName
@ -74,7 +87,6 @@ export class GroupSyncService {
private static _membersSchedulerRunning = false; private static _membersSchedulerRunning = false;
private static _groupsIntervalMs: number | null = null; private static _groupsIntervalMs: number | null = null;
private static _groupsNextTickAt: number | null = null; private static _groupsNextTickAt: number | null = null;
private static _membersGlobalCooldownUntil: number = 0;
private static _lastChangedActive: string[] = []; private static _lastChangedActive: string[] = [];
static async syncGroups(force: boolean = false): Promise<{ added: number; updated: number }> { static async syncGroups(force: boolean = false): Promise<{ added: number; updated: number }> {
@ -90,30 +102,30 @@ export class GroupSyncService {
console.log(' Grupos crudos de la API:', JSON.stringify(groups, null, 2)); console.log(' Grupos crudos de la API:', JSON.stringify(groups, null, 2));
console.log(' Sin filtrar por comunidad (modo multicomunidad). Total grupos:', groups.length); 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(); 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); console.log(' Grupos en DB antes de upsert:', dbGroupsBefore);
const result = await this.upsertGroups(groups); 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(); 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); 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 // Detectar grupos que pasaron de activos a inactivos (y no están archivados) en este sync
try { try {
const beforeMap = new Map<string, { active: number; archived: number; is_community: number; name?: string | null }>(); const beforeMap = new Map<string, { active: number; archived: number; is_community: number; name?: string | null }>();
for (const r of dbGroupsBefore as any[]) { for (const r of dbGroupsBefore) {
beforeMap.set(String(r.id), { active: Number(r.active || 0), archived: Number(r.archived || 0), is_community: Number((r as any).is_community || 0), name: r.name ? String(r.name) : null }); 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<string, { active: number; archived: number; is_community: number; name?: string | null }>(); const afterMap = new Map<string, { active: number; archived: number; is_community: number; name?: string | null }>();
for (const r of dbGroupsAfter as any[]) { for (const r of dbGroupsAfter) {
afterMap.set(String(r.id), { active: Number(r.active || 0), archived: Number(r.archived || 0), is_community: Number((r as any).is_community || 0), name: r.name ? String(r.name) : null }); 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) // Determinar grupos que pasaron a estar activos (nuevos o reactivados)
const newlyActivatedLocal: string[] = []; const newlyActivatedLocal: string[] = [];
for (const [id, a] of afterMap.entries()) { for (const [id, a] of afterMap.entries()) {
const b = beforeMap.get(id); const b = beforeMap.get(id);
const becameActive = Number(a.active) === 1 && Number(a.archived) === 0 && Number((a as any).is_community || 0) === 0; const becameActive = Number(a.active) === 1 && Number(a.archived) === 0 && Number(a.is_community || 0) === 0;
if (becameActive && (!b || Number(b.active) !== 1)) { if (becameActive && (!b || Number(b.active) !== 1)) {
newlyActivatedLocal.push(id); newlyActivatedLocal.push(id);
} }
@ -178,12 +190,12 @@ export class GroupSyncService {
} }
// Completar labels faltantes en allowed_groups usando todos los grupos devueltos por la API // Completar labels faltantes en allowed_groups usando todos los grupos devueltos por la API
try { (AllowedGroups as any).dbInstance = this.dbInstance; this.fillMissingAllowedGroupLabels(groups); } catch {} try { this.fillMissingAllowedGroupLabels(groups); } catch {}
// Actualizar métricas // Actualizar métricas
this.cacheActiveGroups(); this.cacheActiveGroups();
Metrics.set('active_groups', this.activeGroupsCache.size); 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 any; 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('active_members', Number(rowM?.c || 0));
Metrics.set('last_sync_timestamp_seconds', Math.floor(Date.now() / 1000)); Metrics.set('last_sync_timestamp_seconds', Math.floor(Date.now() / 1000));
Metrics.set('last_sync_ok', 1); Metrics.set('last_sync_ok', 1);
@ -217,83 +229,14 @@ export class GroupSyncService {
} }
private static async fetchGroupsFromAPI(): Promise<EvolutionGroup[]> { private static async fetchGroupsFromAPI(): Promise<EvolutionGroup[]> {
const url = `${process.env.EVOLUTION_API_URL}/group/fetchAllGroups/${process.env.EVOLUTION_API_INSTANCE}?getParticipants=false`; return await apiFetchGroups() as unknown as EvolutionGroup[];
console.log(' Fetching groups from API:', {
url: `${url}...`, // Log partial URL for security
communityId: process.env.WHATSAPP_COMMUNITY_ID,
time: new Date().toISOString()
});
try {
const response = await fetch(url, {
method: 'GET',
headers: {
apikey: process.env.EVOLUTION_API_KEY,
},
httpVersion: '2',
timeout: 320000 // 120 second timeout
});
if (!response.ok) {
const errorBody = await response.text().catch(() => 'Unable to read error body');
console.error('❌ API request failed:', {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: errorBody
});
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const rawResponse = await response.text();
console.log(' Raw API response length:', rawResponse.length);
// Parse response which could be either:
// 1. Direct array of groups: [{group1}, {group2}]
// 2. Or wrapped response: {status, message, response}
let groups;
try {
const parsed = JSON.parse(rawResponse);
if (Array.isArray(parsed)) {
// Case 1: Direct array response
groups = parsed;
console.log(' Received direct array of', groups.length, 'groups');
} else if (parsed.response && Array.isArray(parsed.response)) {
// Case 2: Wrapped response
if (parsed.status !== 'success') {
throw new Error(`API error: ${parsed.message || 'Unknown error'}`);
}
groups = parsed.response;
console.log(' Received wrapped response with', groups.length, 'groups');
} else {
throw new Error('Invalid API response format - expected array or wrapped response');
}
} catch (e) {
console.error('❌ Failed to parse API response:', {
error: e instanceof Error ? e.message : String(e),
responseSample: rawResponse.substring(0, 100) + '...'
});
throw e;
}
if (!groups.length) {
console.warn('⚠️ API returned empty group list');
}
return groups;
} catch (error) {
console.error('❌ Failed to fetch groups:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
throw error;
}
} }
private static cacheActiveGroups(): void { private static cacheActiveGroups(): void {
const groups = this.dbInstance.prepare('SELECT id, name FROM groups WHERE active = TRUE AND COALESCE(is_community,0) = 0 AND COALESCE(archived,0) = 0').all(); const map = computeActiveCache(this.dbInstance);
this.activeGroupsCache.clear(); this.activeGroupsCache.clear();
for (const group of groups) { for (const [id, name] of map.entries()) {
this.activeGroupsCache.set(group.id, group.name); this.activeGroupsCache.set(id, name);
} }
console.log(`Cached ${this.activeGroupsCache.size} active groups`); console.log(`Cached ${this.activeGroupsCache.size} active groups`);
} }
@ -318,7 +261,7 @@ export class GroupSyncService {
SELECT group_id AS id SELECT group_id AS id
FROM allowed_groups FROM allowed_groups
WHERE label IS NULL OR TRIM(label) = '' WHERE label IS NULL OR TRIM(label) = ''
`).all() as any[]; `).all() as Array<{ id: string }>;
if (!rows || rows.length === 0) return 0; if (!rows || rows.length === 0) return 0;
let filled = 0; let filled = 0;
@ -327,7 +270,7 @@ export class GroupSyncService {
if (!id) continue; if (!id) continue;
const label = nameById.get(id); const label = nameById.get(id);
if (label) { if (label) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; AllowedGroups.upsertPending(id, label, null); } catch {} try { AllowedGroups.upsertPending(id, label, null); } catch {}
filled++; filled++;
} }
} }
@ -343,8 +286,8 @@ export class GroupSyncService {
} }
private static getActiveGroupsCount(): number { 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(); 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 result?.count || 0; return Number(result?.count || 0);
} }
static async checkInitialGroups(): Promise<void> { static async checkInitialGroups(): Promise<void> {
@ -411,78 +354,8 @@ export class GroupSyncService {
} }
private static async upsertGroups(groups: EvolutionGroup[]): Promise<{ added: number; updated: number }> { private static async upsertGroups(groups: EvolutionGroup[]): Promise<{ added: number; updated: number }> {
let added = 0;
let updated = 0;
const transactionResult = this.dbInstance.transaction(() => {
// First mark all groups as inactive and update verification timestamp
const inactiveResult = this.dbInstance.prepare(`
UPDATE groups
SET active = FALSE,
last_verified = CURRENT_TIMESTAMP
WHERE active = TRUE
`).run();
console.log(' Grupos marcados como inactivos:', {
count: inactiveResult.changes,
lastId: inactiveResult.lastInsertRowid
});
for (const group of groups) {
const existing = this.dbInstance.prepare('SELECT 1 FROM groups WHERE id = ?').get(group.id);
console.log('Checking group:', group.id, 'exists:', !!existing);
const isCommunityFlag = !!(((group as any)?.isCommunity) || ((group as any)?.is_community) || ((group as any)?.isCommunityAnnounce) || ((group as any)?.is_community_announce));
if (existing) {
const updateResult = this.dbInstance.prepare(
'UPDATE groups SET name = ?, community_id = COALESCE(?, community_id), is_community = ?, active = TRUE, last_verified = CURRENT_TIMESTAMP WHERE id = ?'
).run(group.subject, group.linkedParent || null, isCommunityFlag ? 1 : 0, group.id);
console.log('Updated group:', group.id, 'result:', updateResult);
updated++;
} else {
const insertResult = this.dbInstance.prepare(
'INSERT INTO groups (id, community_id, name, active, is_community) VALUES (?, ?, ?, TRUE, ?)'
).run(group.id, (group.linkedParent ?? ''), group.subject, isCommunityFlag ? 1 : 0);
console.log('Added group:', group.id, 'result:', insertResult);
added++;
}
// Propagar subject a allowed_groups:
// - Si es grupo "comunidad/announce", bloquearlo.
// - En caso contrario, upsert pending y label.
try {
(AllowedGroups as any).dbInstance = this.dbInstance;
if (isCommunityFlag) {
AllowedGroups.setStatus(group.id, 'blocked', group.subject);
} else {
AllowedGroups.upsertPending(group.id, group.subject, null);
}
} catch {}
// Si es grupo de comunidad, limpiar residuos: revocar tokens y desactivar membresías
if (isCommunityFlag) {
try {
this.dbInstance.prepare(`
UPDATE calendar_tokens
SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE group_id = ? AND revoked_at IS NULL
`).run(group.id);
} catch {}
try {
this.dbInstance.prepare(`
UPDATE group_members
SET is_active = 0
WHERE group_id = ? AND is_active = 1
`).run(group.id);
} catch {}
}
}
return { added, updated };
});
try { try {
const result = transactionResult(); return await repoUpsertGroups(this.dbInstance, groups as any);
console.log(`Group sync completed: ${result.added} added, ${result.updated} updated`);
return result;
} catch (error) { } catch (error) {
console.error('Error in upsertGroups:', error); console.error('Error in upsertGroups:', error);
throw error; throw error;
@ -501,186 +374,7 @@ export class GroupSyncService {
// Fetch members for a single group from Evolution API. Uses a robust parser to accept multiple payload shapes. // 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<Array<{ userId: string; isAdmin: boolean }>> { private static async fetchGroupMembersFromAPI(groupId: string): Promise<Array<{ userId: string; isAdmin: boolean }>> {
// Cooldown global por rate limit 429 (evitar ráfagas) return await apiFetchMembers(groupId);
try {
if (this._membersGlobalCooldownUntil && Date.now() < this._membersGlobalCooldownUntil) {
console.warn('⚠️ Skipping members fetch due to global cooldown');
return [];
}
} catch {}
// En tests se recomienda simular fetch; no retornamos temprano para permitir validar el parser
// 1) Intento preferente: endpoint de Evolution "Find Group Members"
// Documentación provista: GET /group/participants/{instance}
// Suponemos soporte de query param groupJid
try {
const url1 = `${process.env.EVOLUTION_API_URL}/group/participants/${process.env.EVOLUTION_API_INSTANCE}?groupJid=${encodeURIComponent(groupId)}`;
console.log(' Fetching members via /group/participants:', { groupId });
const r1 = await fetch(url1, {
method: 'GET',
headers: { apikey: process.env.EVOLUTION_API_KEY },
httpVersion: '2',
timeout: 320000
});
if (r1.ok) {
const raw1 = await r1.text();
let parsed1: any;
try {
parsed1 = JSON.parse(raw1);
} catch (e) {
console.error('❌ Failed to parse /group/participants JSON:', String(e));
throw e;
}
const participantsArr = Array.isArray(parsed1?.participants) ? parsed1.participants : null;
if (participantsArr) {
const result: Array<{ userId: string; isAdmin: boolean }> = [];
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; // preferir .jid cuando exista
jid = rawJid || rawId || null;
// Aprender mapping alias→número si vienen ambos
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;
}
// Si no viene en el formato esperado, caemos al plan B
console.warn('⚠️ /group/participants responded without participants array, falling back to fetchAllGroups');
} else {
const body = await r1.text().catch(() => '');
if (r1.status === 429) {
console.warn(`⚠️ /group/participants rate-limited (429): ${body.slice(0, 200)}`);
this._membersGlobalCooldownUntil = Date.now() + 2 * 60 * 1000;
return [];
}
console.warn(`⚠️ /group/participants failed: ${r1.status} ${r1.statusText} - ${body.slice(0, 200)}. Falling back to fetchAllGroups`);
}
} catch (e) {
console.warn('⚠️ Error calling /group/participants, falling back to fetchAllGroups:', e instanceof Error ? e.message : String(e));
}
// 2) Fallback robusto: fetchAllGroups(getParticipants=true) y filtrar por groupId
const url = `${process.env.EVOLUTION_API_URL}/group/fetchAllGroups/${process.env.EVOLUTION_API_INSTANCE}?getParticipants=true`;
console.log(' Fetching members via fetchAllGroups (participants=true):', { groupId });
const response = await fetch(url, {
method: 'GET',
headers: { apikey: process.env.EVOLUTION_API_KEY },
httpVersion: '2',
timeout: 320000
});
if (!response.ok) {
const body = await response.text().catch(() => '');
if (response.status === 429) {
console.warn(`⚠️ fetchAllGroups(getParticipants=true) rate-limited (429): ${body.slice(0, 200)}`);
this._membersGlobalCooldownUntil = Date.now() + 2 * 60 * 1000;
return [];
}
throw new Error(`Failed to fetch groups with participants: ${response.status} ${response.statusText} - ${body.slice(0,200)}`);
}
const raw = await response.text();
let parsed: any;
try {
parsed = JSON.parse(raw);
} catch (e) {
console.error('❌ Failed to parse members response JSON:', String(e));
throw e;
}
let groups: any[] = [];
if (Array.isArray(parsed)) {
groups = parsed;
} else if (parsed && Array.isArray(parsed.response)) {
groups = parsed.response;
} else {
throw new Error('Invalid response format for groups with participants');
}
const g = groups.find((g: any) => g?.id === groupId);
if (!g) {
console.warn(`⚠️ Group ${groupId} not present in fetchAllGroups(getParticipants=true) response`);
return [];
}
const participants = Array.isArray(g.participants) ? g.participants : [];
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; // preferir .jid cuando exista
jid = rawJid || rawId || null;
// Aprender mapping alias→número si vienen ambos
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') {
// common shapes: 'admin', 'superadmin' or null
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;
} }
/** /**
@ -689,7 +383,8 @@ export class GroupSyncService {
*/ */
static upsertMemberSeen(groupId: string, userId: string, nowIso?: string): void { static upsertMemberSeen(groupId: string, userId: string, nowIso?: string): void {
if (!groupId || !userId) return; if (!groupId || !userId) return;
const now = nowIso || new Date().toISOString().replace('T', ' ').replace('Z', ''); const now = nowIso || toIsoSqlUTC(new Date());
try { ensureUserExists(userId, this.dbInstance); } catch {}
this.dbInstance.prepare(` this.dbInstance.prepare(`
INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
VALUES (?, ?, 0, 1, ?, ?) VALUES (?, ?, 0, 1, ?, ?)
@ -704,82 +399,9 @@ export class GroupSyncService {
* Idempotente y atómico por grupo. * Idempotente y atómico por grupo.
*/ */
static reconcileGroupMembers(groupId: string, snapshot: Array<{ userId: string; isAdmin: boolean }>, nowIso?: string): { added: number; updated: number; deactivated: number } { static reconcileGroupMembers(groupId: string, snapshot: Array<{ userId: string; isAdmin: boolean }>, nowIso?: string): { added: number; updated: number; deactivated: number } {
if (!groupId || !Array.isArray(snapshot)) { const res = reconcileMembers(this.dbInstance, groupId, snapshot, nowIso || toIsoSqlUTC(new Date()));
throw new Error('Invalid arguments for reconcileGroupMembers');
}
const now = nowIso || new Date().toISOString().replace('T', ' ').replace('Z', '');
let added = 0, updated = 0, deactivated = 0;
// Build quick lookup from snapshot
const incoming = new Map<string, { isAdmin: boolean }>();
for (const m of snapshot) {
if (!m?.userId) continue;
incoming.set(m.userId, { isAdmin: !!m.isAdmin });
}
this.dbInstance.transaction(() => {
// Load existing membership for group
const existingRows = this.dbInstance.prepare(`
SELECT user_id, is_admin, is_active
FROM group_members
WHERE group_id = ?
`).all(groupId) as Array<{ user_id: string; is_admin: number; is_active: number }>;
const existing = new Map(existingRows.map(r => [r.user_id, { isAdmin: !!r.is_admin, isActive: !!r.is_active }]));
// Upsert present members
for (const [userId, { isAdmin }] of incoming.entries()) {
// Ensure user exists (FK)
ensureUserExists(userId, this.dbInstance);
const row = existing.get(userId);
if (!row) {
// insert
this.dbInstance.prepare(`
INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
VALUES (?, ?, ?, 1, ?, ?)
`).run(groupId, userId, isAdmin ? 1 : 0, now, now);
added++;
} else {
// update if needed
let roleChanged = row.isAdmin !== isAdmin;
if (!row.isActive || roleChanged) {
this.dbInstance.prepare(`
UPDATE group_members
SET is_active = 1,
is_admin = ?,
last_seen_at = ?,
last_role_change_at = CASE WHEN ? THEN ? ELSE last_role_change_at END
WHERE group_id = ? AND user_id = ?
`).run(isAdmin ? 1 : 0, now, roleChanged ? 1 : 0, roleChanged ? now : null, groupId, userId);
updated++;
} else {
// still update last_seen_at to reflect presence
this.dbInstance.prepare(`
UPDATE group_members
SET last_seen_at = ?
WHERE group_id = ? AND user_id = ?
`).run(now, groupId, userId);
}
}
}
// Deactivate absent members
for (const [userId, state] of existing.entries()) {
if (!incoming.has(userId) && state.isActive) {
this.dbInstance.prepare(`
UPDATE group_members
SET is_active = 0,
last_seen_at = ?
WHERE group_id = ? AND user_id = ?
`).run(now, groupId, userId);
deactivated++;
}
}
})();
try { this.computeAndPublishAliasCoverage(groupId); } catch {} try { this.computeAndPublishAliasCoverage(groupId); } catch {}
return { added, updated, deactivated }; return res;
} }
private static computeAndPublishAliasCoverage(groupId: string): void { private static computeAndPublishAliasCoverage(groupId: string): void {
@ -813,109 +435,8 @@ export class GroupSyncService {
const ratio = Math.max(0, Math.min(1, total > 0 ? resolvable / total : 1)); const ratio = Math.max(0, Math.min(1, total > 0 ? resolvable / total : 1));
try { Metrics.set('alias_coverage_ratio', ratio, { group_id: groupId }); } catch {} try { Metrics.set('alias_coverage_ratio', ratio, { group_id: groupId }); } catch {}
// A3: publicación condicional del mensaje de onboarding (sin spam) // Delegar publicación del prompt de onboarding a OnboardingService (consulta DB directamente)
try { try { publishGroupCoveragePrompt(this.dbInstance, groupId, ratio); } catch {}
// Flags y parámetros
const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test';
const enabled =
isTest
? String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true'
: (() => {
const v = process.env.ONBOARDING_PROMPTS_ENABLED;
return v == null ? true : ['true', '1', 'yes'].includes(String(v).toLowerCase());
})();
if (!enabled) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'disabled' }); } catch {}
return;
}
const thrRaw = Number(process.env.ONBOARDING_COVERAGE_THRESHOLD);
const threshold = Number.isFinite(thrRaw) ? Math.min(1, Math.max(0, thrRaw)) : 1;
if (ratio >= threshold) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'coverage_100' }); } catch {}
return;
}
// Gating en modo enforce: no publicar en grupos no allowed
try {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
if (!AllowedGroups.isAllowed(groupId)) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'not_allowed' }); } catch {}
return;
}
}
} catch {}
// Grace y cooldown desde tabla groups
const rowG = this.dbInstance.prepare(`
SELECT last_verified, onboarding_prompted_at
FROM groups
WHERE id = ?
`).get(groupId) as any;
const nowMs = Date.now();
const graceRaw = Number(process.env.ONBOARDING_GRACE_SECONDS);
const graceSec = Number.isFinite(graceRaw) && graceRaw >= 0 ? Math.floor(graceRaw) : 90;
const lv = rowG?.last_verified ? String(rowG.last_verified) : null;
if (lv) {
const iso = lv.includes('T') ? lv : (lv.replace(' ', 'T') + 'Z');
const ms = Date.parse(iso);
if (Number.isFinite(ms)) {
const ageSec = Math.floor((nowMs - ms) / 1000);
if (ageSec < graceSec) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'grace_period' }); } catch {}
return;
}
}
}
const cdRaw = Number(process.env.ONBOARDING_COOLDOWN_DAYS);
const cdDays = Number.isFinite(cdRaw) && cdRaw >= 0 ? Math.floor(cdRaw) : 7;
const promptedAt = rowG?.onboarding_prompted_at ? String(rowG.onboarding_prompted_at) : null;
if (promptedAt) {
const iso = promptedAt.includes('T') ? promptedAt : (promptedAt.replace(' ', 'T') + 'Z');
const ms = Date.parse(iso);
if (Number.isFinite(ms)) {
const diffMs = nowMs - ms;
if (diffMs < cdDays * 24 * 60 * 60 * 1000) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'cooldown_active' }); } catch {}
return;
}
}
}
// Número del bot para construir wa.me
const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
if (!bot || !/^\d+$/.test(bot)) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'missing_bot_number' }); } catch {}
return;
}
// Encolar mensaje en la cola persistente y marcar timestamp en groups
const msg = `Para poder asignarte tareas y acceder a la web, envía 'activar' al bot por privado. Solo hace falta una vez. Enlace: https://wa.me/${bot}`;
this.dbInstance.transaction(() => {
this.dbInstance.prepare(`
INSERT INTO response_queue (recipient, message, status, attempts, metadata, created_at, updated_at, next_attempt_at)
VALUES (?, ?, 'queued', 0, NULL, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
`).run(groupId, msg);
this.dbInstance.prepare(`
UPDATE groups
SET onboarding_prompted_at = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE id = ?
`).run(groupId);
})();
try { Metrics.inc('onboarding_prompts_sent_total', 1, { group_id: groupId, reason: 'coverage_below_threshold' }); } catch {}
} catch (e) {
// Evitar romper el flujo si falla el encolado
if (process.env.NODE_ENV !== 'test') {
console.warn('⚠️ Onboarding prompt skipped due to internal error for', groupId, e);
}
}
} catch (e) { } catch (e) {
console.warn('⚠️ No se pudo calcular alias_coverage_ratio para', groupId, e); console.warn('⚠️ No se pudo calcular alias_coverage_ratio para', groupId, e);
} }
@ -938,7 +459,7 @@ export class GroupSyncService {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
const enforce = mode === 'enforce'; const enforce = mode === 'enforce';
if (enforce) { if (enforce) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {} // no-op
} }
let groups = 0, added = 0, updated = 0, deactivated = 0; let groups = 0, added = 0, updated = 0, deactivated = 0;
@ -981,7 +502,7 @@ export class GroupSyncService {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
const enforce = mode === 'enforce'; const enforce = mode === 'enforce';
if (enforce) { if (enforce) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {} // no-op
} }
let groups = 0, added = 0, updated = 0, deactivated = 0; let groups = 0, added = 0, updated = 0, deactivated = 0;
@ -1103,7 +624,7 @@ export class GroupSyncService {
*/ */
public static isSnapshotFresh(groupId: string, nowMs: number = Date.now()): boolean { public static isSnapshotFresh(groupId: string, nowMs: number = Date.now()): boolean {
try { try {
const row = this.dbInstance.prepare(`SELECT last_verified FROM groups WHERE id = ?`).get(groupId) as any; 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; const lv = row?.last_verified ? String(row.last_verified) : null;
if (!lv) return false; if (!lv) return false;
// Persistimos 'YYYY-MM-DD HH:MM:SS[.mmm]'. Convertimos a ISO-like para Date.parse // Persistimos 'YYYY-MM-DD HH:MM:SS[.mmm]'. Convertimos a ISO-like para Date.parse
@ -1143,7 +664,7 @@ export class GroupSyncService {
WHERE gm.user_id = ? AND gm.is_active = 1 AND g.active = 1 WHERE gm.user_id = ? AND gm.is_active = 1 AND g.active = 1
AND COALESCE(g.is_community,0) = 0 AND COALESCE(g.is_community,0) = 0
AND COALESCE(g.archived,0) = 0 AND COALESCE(g.archived,0) = 0
`).all(userId) as any[]; `).all(userId) as Array<{ id: string }>;
const set = new Set<string>(); const set = new Set<string>();
for (const r of rows) { for (const r of rows) {
if (r?.id) set.add(String(r.id)); if (r?.id) set.add(String(r.id));
@ -1213,18 +734,18 @@ export class GroupSyncService {
const cached = this.activeGroupsCache.get(groupId); const cached = this.activeGroupsCache.get(groupId);
if (cached && cached.trim()) { if (cached && cached.trim()) {
try { this.ensureGroupExists(groupId, cached); } catch {} try { this.ensureGroupExists(groupId, cached); } catch {}
try { (AllowedGroups as any).dbInstance = this.dbInstance; AllowedGroups.upsertPending(groupId, cached, null); } catch {} try { AllowedGroups.upsertPending(groupId, cached, null); } catch {}
this.cacheActiveGroups(); this.cacheActiveGroups();
return cached; return cached;
} }
// 2) DB (tabla groups) // 2) DB (tabla groups)
try { try {
const row = this.dbInstance.prepare('SELECT name FROM groups WHERE id = ?').get(groupId) as any; 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() : ''; const name = row?.name ? String(row.name).trim() : '';
if (name) { if (name) {
try { this.ensureGroupExists(groupId, name); } catch {} try { this.ensureGroupExists(groupId, name); } catch {}
try { (AllowedGroups as any).dbInstance = this.dbInstance; AllowedGroups.upsertPending(groupId, name, null); } catch {} try { AllowedGroups.upsertPending(groupId, name, null); } catch {}
this.cacheActiveGroups(); this.cacheActiveGroups();
return name; return name;
} }
@ -1237,7 +758,7 @@ export class GroupSyncService {
const subject = g?.subject ? String(g.subject).trim() : ''; const subject = g?.subject ? String(g.subject).trim() : '';
if (subject) { if (subject) {
try { this.ensureGroupExists(groupId, subject); } catch {} try { this.ensureGroupExists(groupId, subject); } catch {}
try { (AllowedGroups as any).dbInstance = this.dbInstance; AllowedGroups.upsertPending(groupId, subject, null); } catch {} try { AllowedGroups.upsertPending(groupId, subject, null); } catch {}
this.cacheActiveGroups(); this.cacheActiveGroups();
return subject; return subject;
} }
@ -1258,7 +779,7 @@ export class GroupSyncService {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') { if (mode === 'enforce') {
try { try {
(AllowedGroups as any).dbInstance = this.dbInstance; // no-op
if (!AllowedGroups.isAllowed(groupId)) { if (!AllowedGroups.isAllowed(groupId)) {
try { Metrics.inc('sync_skipped_group_total'); } catch {} try { Metrics.inc('sync_skipped_group_total'); } catch {}
return { added: 0, updated: 0, deactivated: 0 }; return { added: 0, updated: 0, deactivated: 0 };
@ -1272,7 +793,7 @@ export class GroupSyncService {
try { try {
// Asegurar existencia del grupo en DB (FKs) antes de reconciliar // Asegurar existencia del grupo en DB (FKs) antes de reconciliar
this.ensureGroupExists(groupId); this.ensureGroupExists(groupId);
const snapshot = await (this as any).fetchGroupMembersFromAPI(groupId); const snapshot = await this.fetchGroupMembersFromAPI(groupId);
return this.reconcileGroupMembers(groupId, snapshot); return this.reconcileGroupMembers(groupId, snapshot);
} catch (e) { } catch (e) {
console.error(`❌ Failed to sync members for group ${groupId}:`, e instanceof Error ? e.message : String(e)); console.error(`❌ Failed to sync members for group ${groupId}:`, e instanceof Error ? e.message : String(e));

@ -0,0 +1,264 @@
import { normalizeWhatsAppId } from '../../utils/whatsapp';
import { IdentityService } from '../identity';
export type ApiEvolutionGroup = {
id: string;
subject: string;
linkedParent?: string;
// flags que pueden venir en la API y usamos para filtrar comunidades/announce
isCommunity?: any;
is_community?: any;
isCommunityAnnounce?: any;
is_community_announce?: any;
};
/**
* Obtiene todos los grupos desde Evolution API.
* Acepta respuesta como array directo o envuelta { status, response }.
*/
export async function fetchGroupsFromAPI(): Promise<ApiEvolutionGroup[]> {
const url = `${process.env.EVOLUTION_API_URL}/group/fetchAllGroups/${process.env.EVOLUTION_API_INSTANCE}?getParticipants=false`;
console.log(' Fetching groups from API:', {
url: `${url}...`,
communityId: process.env.WHATSAPP_COMMUNITY_ID,
time: new Date().toISOString()
});
try {
const response = await fetch(url, {
method: 'GET',
headers: { apikey: String(process.env.EVOLUTION_API_KEY || '') },
httpVersion: '2',
timeout: 320000
});
if (!response.ok) {
const errorBody = await response.text().catch(() => 'Unable to read error body');
console.error('❌ API request failed:', {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: errorBody
});
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const rawResponse = await response.text();
console.log(' Raw API response length:', rawResponse.length);
let groups: ApiEvolutionGroup[] = [];
try {
const parsed = JSON.parse(rawResponse);
if (Array.isArray(parsed)) {
groups = parsed as ApiEvolutionGroup[];
console.log(' Received direct array of', groups.length, 'groups');
} else if (parsed?.response && Array.isArray(parsed.response)) {
if (parsed.status !== 'success') {
throw new Error(`API error: ${parsed.message || 'Unknown error'}`);
}
groups = parsed.response as ApiEvolutionGroup[];
console.log(' Received wrapped response with', groups.length, 'groups');
} else {
throw new Error('Invalid API response format - expected array or wrapped response');
}
} catch (e) {
console.error('❌ Failed to parse API response:', {
error: e instanceof Error ? e.message : String(e),
responseSample: rawResponse.substring(0, 100) + '...'
});
throw e;
}
if (!groups.length) {
console.warn('⚠️ API returned empty group list');
}
return groups;
} catch (error) {
console.error('❌ Failed to fetch groups:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
throw error;
}
}
// Cooldown global simple para 429 de miembros
let membersGlobalCooldownUntil = 0;
/**
* Obtiene miembros de un grupo desde Evolution API, con parser robusto y fallback a fetchAllGroups.
*/
export async function fetchGroupMembersFromAPI(groupId: string): Promise<Array<{ userId: string; isAdmin: boolean }>> {
try {
if (membersGlobalCooldownUntil && Date.now() < membersGlobalCooldownUntil) {
console.warn('⚠️ Skipping members fetch due to global cooldown');
return [];
}
} catch {}
// 1) Endpoint preferente: /group/participants
try {
const url1 = `${process.env.EVOLUTION_API_URL}/group/participants/${process.env.EVOLUTION_API_INSTANCE}?groupJid=${encodeURIComponent(groupId)}`;
console.log(' Fetching members via /group/participants:', { groupId });
const r1 = await fetch(url1, {
method: 'GET',
headers: { apikey: String(process.env.EVOLUTION_API_KEY || '') },
httpVersion: '2',
timeout: 320000
});
if (r1.ok) {
const raw1 = await r1.text();
let parsed1: any;
try {
parsed1 = JSON.parse(raw1);
} catch (e) {
console.error('❌ Failed to parse /group/participants JSON:', String(e));
throw e;
}
const participantsArr = Array.isArray(parsed1?.participants) ? parsed1.participants : null;
if (participantsArr) {
const result: Array<{ userId: string; isAdmin: boolean }> = [];
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;
}
console.warn('⚠️ /group/participants responded without participants array, falling back to fetchAllGroups');
} else {
const body = await r1.text().catch(() => '');
if (r1.status === 429) {
console.warn(`⚠️ /group/participants rate-limited (429): ${body.slice(0, 200)}`);
membersGlobalCooldownUntil = Date.now() + 2 * 60 * 1000;
return [];
}
console.warn(`⚠️ /group/participants failed: ${r1.status} ${r1.statusText} - ${body.slice(0, 200)}. Falling back to fetchAllGroups`);
}
} catch (e) {
console.warn('⚠️ Error calling /group/participants, falling back to fetchAllGroups:', e instanceof Error ? e.message : String(e));
}
// 2) Fallback: fetchAllGroups(getParticipants=true)
const url = `${process.env.EVOLUTION_API_URL}/group/fetchAllGroups/${process.env.EVOLUTION_API_INSTANCE}?getParticipants=true`;
console.log(' Fetching members via fetchAllGroups (participants=true):', { groupId });
const response = await fetch(url, {
method: 'GET',
headers: { apikey: String(process.env.EVOLUTION_API_KEY || '') },
httpVersion: '2',
timeout: 320000
});
if (!response.ok) {
const body = await response.text().catch(() => '');
if (response.status === 429) {
console.warn(`⚠️ fetchAllGroups(getParticipants=true) rate-limited (429): ${body.slice(0, 200)}`);
membersGlobalCooldownUntil = Date.now() + 2 * 60 * 1000;
return [];
}
throw new Error(`Failed to fetch groups with participants: ${response.status} ${response.statusText} - ${body.slice(0,200)}`);
}
const raw = await response.text();
let parsed: any;
try {
parsed = JSON.parse(raw);
} catch (e) {
console.error('❌ Failed to parse members response JSON:', String(e));
throw e;
}
let groups: any[] = [];
if (Array.isArray(parsed)) {
groups = parsed;
} else if (parsed && Array.isArray(parsed.response)) {
groups = parsed.response;
} else {
throw new Error('Invalid response format for groups with participants');
}
const g = groups.find((g: any) => g?.id === groupId);
if (!g) {
console.warn(`⚠️ Group ${groupId} not present in fetchAllGroups(getParticipants=true) response`);
return [];
}
const participants = Array.isArray(g.participants) ? g.participants : [];
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 });
}
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;
}

@ -0,0 +1,15 @@
import type { Database } from 'bun:sqlite';
/**
* Construye un mapa de grupos activos (no comunidad, no archivados) id -> nombre.
*/
export function cacheActiveGroups(db: Database): Map<string, string> {
const map = new Map<string, string>();
const rows = db
.prepare('SELECT id, name FROM groups WHERE active = TRUE AND COALESCE(is_community,0) = 0 AND COALESCE(archived,0) = 0')
.all() as Array<{ id: string; name: string | null }>;
for (const g of rows) {
map.set(String(g.id), String(g.name ?? ''));
}
return map;
}

@ -0,0 +1,79 @@
import type { Database } from 'bun:sqlite';
import { ensureUserExists } from '../../db';
import { toIsoSqlUTC } from '../../utils/datetime';
/**
* Reconciliación idempotente de membresías de un grupo.
*/
export function reconcileGroupMembers(
db: Database,
groupId: string,
snapshot: Array<{ userId: string; isAdmin: boolean }>,
nowIso?: string
): { added: number; updated: number; deactivated: number } {
if (!groupId || !Array.isArray(snapshot)) {
throw new Error('Invalid arguments for reconcileGroupMembers');
}
const now = nowIso || toIsoSqlUTC(new Date());
let added = 0, updated = 0, deactivated = 0;
const incoming = new Map<string, { isAdmin: boolean }>();
for (const m of snapshot) {
if (!m?.userId) continue;
incoming.set(m.userId, { isAdmin: !!m.isAdmin });
}
db.transaction(() => {
const existingRows = db.prepare(`
SELECT user_id, is_admin, is_active
FROM group_members
WHERE group_id = ?
`).all(groupId) as Array<{ user_id: string; is_admin: number; is_active: number }>;
const existing = new Map(existingRows.map(r => [r.user_id, { isAdmin: !!r.is_admin, isActive: !!r.is_active }]));
for (const [userId, { isAdmin }] of incoming.entries()) {
ensureUserExists(userId, db);
const row = existing.get(userId);
if (!row) {
db.prepare(`
INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
VALUES (?, ?, ?, 1, ?, ?)
`).run(groupId, userId, isAdmin ? 1 : 0, now, now);
added++;
} else {
const roleChanged = row.isAdmin !== isAdmin;
if (!row.isActive || roleChanged) {
db.prepare(`
UPDATE group_members
SET is_active = 1,
is_admin = ?,
last_seen_at = ?,
last_role_change_at = CASE WHEN ? THEN ? ELSE last_role_change_at END
WHERE group_id = ? AND user_id = ?
`).run(isAdmin ? 1 : 0, now, roleChanged ? 1 : 0, roleChanged ? now : null, groupId, userId);
updated++;
} else {
db.prepare(`
UPDATE group_members
SET last_seen_at = ?
WHERE group_id = ? AND user_id = ?
`).run(now, groupId, userId);
}
}
}
for (const [userId, state] of existing.entries()) {
if (!incoming.has(userId) && state.isActive) {
db.prepare(`
UPDATE group_members
SET is_active = 0,
last_seen_at = ?
WHERE group_id = ? AND user_id = ?
`).run(now, groupId, userId);
deactivated++;
}
}
})();
return { added, updated, deactivated };
}

@ -0,0 +1,82 @@
import type { Database } from 'bun:sqlite';
import { AllowedGroups } from '../allowed-groups';
/**
* Upsert de grupos desde snapshot de Evolution API.
* - Marca todos como inactivos al inicio (manteniendo last_verified).
* - Inserta/actualiza cada grupo, detectando flags de comunidad.
* - Propaga subject a allowed_groups: comunidades -> blocked, resto -> pending con label.
* - Limpia residuos de comunidades (revocar tokens, desactivar membresías).
*/
export async function upsertGroups(
db: Database,
groups: Array<{ id: string; subject: string; linkedParent?: string } & Record<string, any>>
): Promise<{ added: number; updated: number }> {
let added = 0;
let updated = 0;
const tx = db.transaction(() => {
const inactiveResult = db.prepare(`
UPDATE groups
SET active = FALSE,
last_verified = CURRENT_TIMESTAMP
WHERE active = TRUE
`).run();
console.log(' Grupos marcados como inactivos:', {
count: inactiveResult.changes,
lastId: inactiveResult.lastInsertRowid
});
for (const group of groups) {
const existing = db.prepare('SELECT 1 FROM groups WHERE id = ?').get(group.id);
const isCommunityFlag = !!(group?.isCommunity || group?.is_community || group?.isCommunityAnnounce || group?.is_community_announce);
if (existing) {
const updateResult = db.prepare(
'UPDATE groups SET name = ?, community_id = COALESCE(?, community_id), is_community = ?, active = TRUE, last_verified = CURRENT_TIMESTAMP WHERE id = ?'
).run(group.subject, group.linkedParent || null, isCommunityFlag ? 1 : 0, group.id);
console.log('Updated group:', group.id, 'result:', updateResult);
updated++;
} else {
const insertResult = db.prepare(
'INSERT INTO groups (id, community_id, name, active, is_community) VALUES (?, ?, ?, TRUE, ?)'
).run(group.id, (group.linkedParent ?? ''), group.subject, isCommunityFlag ? 1 : 0);
console.log('Added group:', group.id, 'result:', insertResult);
added++;
}
// Propagar sujeto a allowed_groups
try {
if (isCommunityFlag) {
AllowedGroups.setStatus(group.id, 'blocked', group.subject);
} else {
AllowedGroups.upsertPending(group.id, group.subject, null);
}
} catch {}
if (isCommunityFlag) {
try {
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(group.id);
} catch {}
try {
db.prepare(`
UPDATE group_members
SET is_active = 0
WHERE group_id = ? AND is_active = 1
`).run(group.id);
} catch {}
}
}
return { added, updated };
});
const result = tx();
console.log(`Group sync completed: ${result.added} added, ${result.updated} updated`);
return result;
}

@ -1,10 +1,9 @@
import type { Database } from 'bun:sqlite'; import { ensureUserExists } from '../db';
import { db, ensureUserExists } from '../db'; import { getDb } from '../db/locator';
import { normalizeWhatsAppId } from '../utils/whatsapp'; import { normalizeWhatsAppId } from '../utils/whatsapp';
import { Metrics } from './metrics'; import { Metrics } from './metrics';
export class IdentityService { export class IdentityService {
static dbInstance: Database = db;
// Caché en memoria como respaldo si la tabla user_aliases no está disponible (tests o migraciones incompletas) // Caché en memoria como respaldo si la tabla user_aliases no está disponible (tests o migraciones incompletas)
private static readonly inMemoryAliases = new Map<string, string>(); private static readonly inMemoryAliases = new Map<string, string>();
@ -17,9 +16,9 @@ export class IdentityService {
const u = normalizeWhatsAppId(userId || ''); const u = normalizeWhatsAppId(userId || '');
if (!a || !u || a === u) return false; if (!a || !u || a === u) return false;
// Asegurar que el user_id numérico exista para no violar la FK (user_aliases.user_id -> users.id) // Asegurar que el user_id numérico exista para no violar la FK (user_aliases.user_id -> users.id)
try { ensureUserExists(u, this.dbInstance); } catch {} try { ensureUserExists(u, getDb()); } catch {}
try { try {
this.dbInstance.prepare(` getDb().prepare(`
INSERT INTO user_aliases (alias, user_id, source, created_at, updated_at) INSERT INTO user_aliases (alias, user_id, source, created_at, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', 'now'), strftime('%Y-%m-%d %H:%M:%f', 'now')) VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', 'now'), strftime('%Y-%m-%d %H:%M:%f', 'now'))
ON CONFLICT(alias) DO UPDATE SET ON CONFLICT(alias) DO UPDATE SET
@ -58,7 +57,7 @@ export class IdentityService {
// Después, intentar en la base de datos // Después, intentar en la base de datos
try { try {
const row = this.dbInstance.prepare(`SELECT user_id FROM user_aliases WHERE alias = ?`).get(n) as any; const row = getDb().prepare(`SELECT user_id FROM user_aliases WHERE alias = ?`).get(n) as { user_id?: string } | undefined;
if (row?.user_id) { if (row?.user_id) {
const v = String(row.user_id); const v = String(row.user_id);
// Mantener caché en memoria para futuras resoluciones rápidas // Mantener caché en memoria para futuras resoluciones rápidas

@ -1,12 +1,11 @@
import type { Database } from 'bun:sqlite'; import type { Database } from 'bun:sqlite';
import { db } from '../db'; import { getDb } from '../db/locator';
import { toIsoSqlUTC } from '../utils/datetime';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
export class MaintenanceService { export class MaintenanceService {
private static _timer: any = null; private static _timer: any = null;
private static _healthCheckTimer: any = null;
private static _lastRestartAttempt: number = 0;
private static get retentionDays(): number { private static get retentionDays(): number {
const v = Number(process.env.GROUP_MEMBERS_INACTIVE_RETENTION_DAYS); const v = Number(process.env.GROUP_MEMBERS_INACTIVE_RETENTION_DAYS);
@ -14,19 +13,44 @@ export class MaintenanceService {
return 180; // por defecto 180 días return 180; // por defecto 180 días
} }
private static get evolutionApiConfig() {
return {
url: process.env.EVOLUTION_API_URL,
instance: process.env.EVOLUTION_API_INSTANCE,
apiKey: process.env.EVOLUTION_API_KEY,
intervalMs: Number(process.env.HEALTH_CHECK_INTERVAL_MS || '120000'), // 2 min por defecto
restartCooldownMs: Number(process.env.HEALTH_CHECK_RESTART_COOLDOWN_MS || '900000'), // 15 min por defecto
};
}
static start(): void { static start(): void {
if (process.env.NODE_ENV === 'test' && process.env.FORCE_SCHEDULERS !== 'true') return; if (process.env.NODE_ENV === 'test' && process.env.FORCE_SCHEDULERS !== 'true') return;
if (this.retentionDays <= 0) return;
// --- Tareas diarias existentes ---
const intervalMs = 24 * 60 * 60 * 1000; // diario if (this.retentionDays > 0) {
this._timer = setInterval(() => { const intervalMs = 24 * 60 * 60 * 1000; // diario
this.cleanupInactiveMembersOnce().catch(err => { this._timer = setInterval(() => {
console.error('❌ Error en cleanup de miembros inactivos:', err); this.cleanupInactiveMembersOnce().catch(err => {
}); console.error('❌ Error en cleanup de miembros inactivos:', err);
this.reconcileAliasUsersOnce().catch(err => { });
console.error('❌ Error en reconcile de alias de usuarios:', err); this.reconcileAliasUsersOnce().catch(err => {
}); console.error('❌ Error en reconcile de alias de usuarios:', err);
}, intervalMs); });
}, intervalMs);
}
// --- Nuevo Health Check de Evolution API ---
const { url, instance, apiKey, intervalMs } = this.evolutionApiConfig;
if (url && instance && apiKey) {
console.log('[MaintenanceService] Iniciando health check de Evolution API...');
this._healthCheckTimer = setInterval(() => {
this.performEvolutionHealthCheck().catch(err => {
console.error('❌ Error en el health check de Evolution API:', err);
});
}, intervalMs);
} else {
console.warn('[MaintenanceService] Variables de entorno para el health check de Evolution API (URL, INSTANCE, API_KEY) no encontradas. Health check desactivado.');
}
} }
static stop(): void { static stop(): void {
@ -34,12 +58,17 @@ export class MaintenanceService {
clearInterval(this._timer); clearInterval(this._timer);
this._timer = null; this._timer = null;
} }
if (this._healthCheckTimer) {
clearInterval(this._healthCheckTimer);
this._healthCheckTimer = null;
}
} }
static async cleanupInactiveMembersOnce(instance: Database = db, retentionDays: number = this.retentionDays): Promise<number> { static async cleanupInactiveMembersOnce(instance?: Database, retentionDays: number = this.retentionDays): Promise<number> {
if (retentionDays <= 0) return 0; if (retentionDays <= 0) return 0;
const threshold = toIsoSql(new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)); const threshold = toIsoSqlUTC(new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000));
const res = instance.prepare(` const dbi = ((instance ?? getDb()) as Database);
const res = dbi.prepare(`
DELETE FROM group_members DELETE FROM group_members
WHERE is_active = 0 WHERE is_active = 0
AND last_seen_at < ? AND last_seen_at < ?
@ -53,21 +82,21 @@ export class MaintenanceService {
* en todas las tablas relevantes, basándose en user_aliases. * en todas las tablas relevantes, basándose en user_aliases.
* Devuelve el número de alias procesados. * Devuelve el número de alias procesados.
*/ */
static async reconcileAliasUsersOnce(instance: Database = db): Promise<number> { static async reconcileAliasUsersOnce(instance?: Database): Promise<number> {
try { try {
const rows = instance.prepare(`SELECT alias, user_id FROM user_aliases WHERE alias != user_id`).all() as any[]; const dbi = ((instance ?? getDb()) as Database);
const rows = dbi.prepare(`SELECT alias, user_id FROM user_aliases WHERE alias != user_id`).all() as any[];
let merged = 0; let merged = 0;
for (const r of rows) { for (const r of rows) {
const alias = String(r.alias); const alias = String(r.alias);
const real = String(r.user_id); const real = String(r.user_id);
instance.transaction(() => { dbi.transaction(() => {
const nowIso = toIsoSql(new Date());
// Asegurar existencia del usuario real // Asegurar existencia del usuario real
try { try {
instance.prepare(`INSERT OR IGNORE INTO users (id, created_at, updated_at) VALUES (?, ?, ?)`) dbi.prepare(`INSERT OR IGNORE INTO users (id) VALUES (?)`)
.run(real, nowIso, nowIso); .run(real);
} catch {} } catch {}
const updates = [ const updates = [
@ -81,7 +110,7 @@ export class MaintenanceService {
for (const sql of updates) { for (const sql of updates) {
try { try {
instance.prepare(sql).run(real, alias); dbi.prepare(sql).run(real, alias);
} catch { } catch {
// Ignorar si la tabla no existe en este despliegue // Ignorar si la tabla no existe en este despliegue
} }
@ -89,7 +118,7 @@ export class MaintenanceService {
// Intentar eliminar el usuario alias si ya no tiene referencias // Intentar eliminar el usuario alias si ya no tiene referencias
try { try {
instance.prepare(`DELETE FROM users WHERE id = ?`).run(alias); dbi.prepare(`DELETE FROM users WHERE id = ?`).run(alias);
} catch {} } catch {}
})(); })();
@ -102,4 +131,51 @@ export class MaintenanceService {
return 0; return 0;
} }
} }
/**
* Verifica el estado de la instancia de Evolution API y la reinicia si es necesario.
*/
private static async performEvolutionHealthCheck(): Promise<void> {
const { url, instance, apiKey, restartCooldownMs } = this.evolutionApiConfig;
const stateUrl = `${url}/instance/connectionState/${instance}`;
const restartUrl = `${url}/instance/restart/${instance}`;
const headers: HeadersInit = { apikey: String(apiKey || '') };
try {
const response = await fetch(stateUrl, { method: 'GET', headers });
if (!response.ok) {
console.error(`[HealthCheck] Error al consultar estado de Evolution API: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
const currentState = data?.instance?.state;
console.log(`[HealthCheck] Estado de la instancia '${instance}': ${currentState}`);
if (currentState !== 'open') {
const now = Date.now();
if (now - this._lastRestartAttempt > restartCooldownMs) {
console.warn(`[HealthCheck] La instancia no está 'open'. Estado actual: ${currentState}. Intentando reiniciar...`);
try {
const restartResponse = await fetch(restartUrl, { method: 'PUT', headers });
if (restartResponse.ok) {
console.log(`[HealthCheck] Petición de reinicio para '${instance}' enviada exitosamente.`);
this._lastRestartAttempt = now;
} else {
console.error(`[HealthCheck] Fallo al reiniciar la instancia. Status: ${restartResponse.status} ${restartResponse.statusText}`);
}
} catch (restartError) {
console.error('[HealthCheck] Error de red al intentar reiniciar la instancia:', restartError);
}
} else {
console.log(`[HealthCheck] La instancia no está 'open', pero esperando cooldown de ${Math.round(restartCooldownMs / 60000)} minutos para no sobrecargar la API.`);
}
}
} catch (error) {
console.error('[HealthCheck] Error de red o inesperado al verificar el estado de la Evolution API:', error);
}
}
} }

@ -1,6 +1,5 @@
import type { Database } from 'bun:sqlite'; import type { Database } from 'bun:sqlite';
import { ResponseQueue } from './response-queue'; import { ResponseQueue } from './response-queue';
import { GroupSyncService } from './group-sync';
import { AllowedGroups } from './allowed-groups'; import { AllowedGroups } from './allowed-groups';
import { Metrics } from './metrics'; import { Metrics } from './metrics';
import { randomTokenBase64Url } from '../utils/crypto'; import { randomTokenBase64Url } from '../utils/crypto';
@ -45,7 +44,7 @@ export function buildJitAssigneePrompt(createdBy: string, groupId: string, unres
const list = unresolvedList.join(', '); const list = unresolvedList.join(', ');
let groupCtx = ''; let groupCtx = '';
if (groupId && groupId.includes('@g.us')) { if (groupId && groupId.includes('@g.us')) {
const name = GroupSyncService.activeGroupsCache.get(groupId) || groupId; const name = groupId;
groupCtx = ` (en el grupo ${name})`; groupCtx = ` (en el grupo ${name})`;
} }
const msg = `No puedo asignar a ${list} aún${groupCtx}. Pídele que toque este enlace y diga 'activar': https://wa.me/${bot}`; const msg = `No puedo asignar a ${list} aún${groupCtx}. Pídele que toque este enlace y diga 'activar': https://wa.me/${bot}`;
@ -86,7 +85,6 @@ export function maybeEnqueueOnboardingBundle(db: Database, params: {
try { try {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') { if (mode === 'enforce') {
try { (AllowedGroups as any).dbInstance = db; } catch {}
allowed = AllowedGroups.isAllowed(gid); allowed = AllowedGroups.isAllowed(gid);
} }
} catch {} } catch {}
@ -102,7 +100,14 @@ export function maybeEnqueueOnboardingBundle(db: Database, params: {
} }
// Candidatos // Candidatos
let members = GroupSyncService.listActiveMemberIds(gid); 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);
}
} catch {}
const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim(); const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
const exclude = new Set<string>([params.createdBy, ...params.assignmentUserIds]); const exclude = new Set<string>([params.createdBy, ...params.assignmentUserIds]);
members = members members = members
@ -128,7 +133,12 @@ export function maybeEnqueueOnboardingBundle(db: Database, params: {
const delayEnv = Number(process.env.ONBOARDING_BUNDLE_DELAY_MS); 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); // 510s const delay2 = Number.isFinite(delayEnv) && delayEnv >= 0 ? Math.floor(delayEnv) : 5000 + Math.floor(Math.random() * 5001); // 510s
const groupLabel = GroupSyncService.activeGroupsCache.get(gid) || gid; let groupLabel = gid;
try {
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;
} catch {}
const codeStr = String(displayCode); const codeStr = String(displayCode);
const desc = (params.description || '(sin descripción)').trim(); const desc = (params.description || '(sin descripción)').trim();
const shortDesc = desc.length > 100 ? (desc.slice(0, 100) + '…') : desc; const shortDesc = desc.length > 100 ? (desc.slice(0, 100) + '…') : desc;
@ -196,3 +206,98 @@ Puedes interactuar con el bot escribiéndome por privado:
} catch {} } catch {}
} }
} }
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;
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 {}
} catch (e) {
if (process.env.NODE_ENV !== 'test') {
console.warn('⚠️ Onboarding prompt skipped due to internal error for', groupId, e);
}
}
}

@ -0,0 +1,74 @@
import type { Database } from 'bun:sqlite';
import { toIsoSqlUTC } from '../../utils/datetime';
export type CleanupOptions = {
retentionDaysSent: number;
retentionDaysFailed: number;
batchSize: number;
optimize: boolean;
vacuum: boolean;
vacuumEveryNRuns: number;
cleanupRunCount: number;
};
export async function runCleanupOnce(
db: Database,
opts: CleanupOptions,
now: Date = new Date()
): Promise<{ deletedSent: number; deletedFailed: number; totalDeleted: number; nextCleanupRunCount: number }> {
const msPerDay = 24 * 60 * 60 * 1000;
const sentThresholdIso = toIsoSqlUTC(new Date(now.getTime() - opts.retentionDaysSent * msPerDay));
const failedThresholdIso = toIsoSqlUTC(new Date(now.getTime() - opts.retentionDaysFailed * msPerDay));
const cleanStatus = (status: 'sent' | 'failed', thresholdIso: string, batch: number): number => {
let deleted = 0;
const selectStmt = db.prepare(`
SELECT id
FROM response_queue
WHERE status = ? AND updated_at < ?
ORDER BY updated_at
LIMIT ?
`);
while (true) {
const rows = selectStmt.all(status, thresholdIso, batch) as Array<{ id: number }>;
if (!rows || rows.length === 0) break;
const ids = rows.map((r) => r.id);
const placeholders = ids.map(() => '?').join(',');
db.prepare(`DELETE FROM response_queue WHERE id IN (${placeholders})`).run(...ids);
deleted += ids.length;
if (rows.length < batch) break;
}
return deleted;
};
const deletedSent = cleanStatus('sent', sentThresholdIso, opts.batchSize);
const deletedFailed = cleanStatus('failed', failedThresholdIso, opts.batchSize);
const totalDeleted = deletedSent + deletedFailed;
// Mantenimiento ligero tras limpieza
if (opts.optimize && totalDeleted > 0) {
try {
db.exec('PRAGMA optimize;');
} catch (e) {
console.warn('PRAGMA optimize failed:', e);
}
}
// VACUUM opcional
let nextCleanupRunCount = opts.cleanupRunCount;
if (opts.vacuum && totalDeleted > 0) {
nextCleanupRunCount++;
if (nextCleanupRunCount % Math.max(1, opts.vacuumEveryNRuns) === 0) {
try {
db.exec('VACUUM;');
} catch (e) {
console.warn('VACUUM failed:', e);
}
}
}
return { deletedSent, deletedFailed, totalDeleted, nextCleanupRunCount };
}

@ -0,0 +1,55 @@
export type OnboardingMeta = {
kind: 'onboarding';
variant: 'initial' | 'reminder';
part: 1 | 2;
bundle_id: string;
group_id?: string | null;
task_id?: number | null;
display_code?: number | null;
};
export type ReactionMeta = {
kind: 'reaction';
emoji: string;
chatId: string;
messageId: string;
participant?: string;
fromMe?: boolean;
};
export type QueueMetadata = OnboardingMeta | ReactionMeta | Record<string, any>;
export function parseQueueMetadata(raw: string | null | undefined): QueueMetadata | null {
if (!raw) return null;
try {
const obj = JSON.parse(String(raw));
if (!obj || typeof obj !== 'object') return null;
const kind = String((obj as any).kind || '');
if (kind === 'reaction') {
// Validación mínima
return {
kind: 'reaction',
emoji: String((obj as any).emoji || ''),
chatId: String((obj as any).chatId || ''),
messageId: String((obj as any).messageId || ''),
participant: typeof (obj as any).participant === 'string' ? String((obj as any).participant) : undefined,
fromMe: typeof (obj as any).fromMe === 'boolean' ? Boolean((obj as any).fromMe) : undefined
} as ReactionMeta;
}
if (kind === 'onboarding') {
return obj as OnboardingMeta;
}
return obj as Record<string, any>;
} catch {
return null;
}
}
export function isReactionMeta(m: QueueMetadata | null | undefined): m is ReactionMeta {
if (!m || typeof m !== 'object') return false;
const any = m as any;
return any.kind === 'reaction'
&& typeof any.chatId === 'string'
&& typeof any.messageId === 'string'
&& typeof any.emoji === 'string';
}

@ -1,5 +1,4 @@
import type { Database } from 'bun:sqlite'; import type { Database } from 'bun:sqlite';
import { db } from '../db';
import { TaskService } from '../tasks/service'; import { TaskService } from '../tasks/service';
import { ResponseQueue } from './response-queue'; import { ResponseQueue } from './response-queue';
import { ContactsService } from './contacts'; import { ContactsService } from './contacts';
@ -8,6 +7,7 @@ import { ICONS } from '../utils/icons';
import { codeId, formatDDMM, bold, italic } from '../utils/formatting'; import { codeId, formatDDMM, bold, italic } from '../utils/formatting';
import { AllowedGroups } from './allowed-groups'; import { AllowedGroups } from './allowed-groups';
import { Metrics } from './metrics'; import { Metrics } from './metrics';
import { getDb } from '../db/locator';
type UserPreference = { type UserPreference = {
user_id: string; user_id: string;
@ -17,7 +17,7 @@ type UserPreference = {
}; };
export class RemindersService { export class RemindersService {
static dbInstance: Database = db;
private static _running = false; private static _running = false;
private static _timer: any = null; private static _timer: any = null;
@ -80,13 +80,14 @@ export class RemindersService {
} }
static async runOnce(now: Date = new Date()): Promise<void> { static async runOnce(now: Date = new Date()): Promise<void> {
const instanceDb = getDb() as Database;
const todayYMD = this.ymdInTZ(now); const todayYMD = this.ymdInTZ(now);
const nowHM = this.hmInTZ(now); const nowHM = this.hmInTZ(now);
const weekday = this.weekdayShortInTZ(now); // 'Mon'..'Sun' const weekday = this.weekdayShortInTZ(now); // 'Mon'..'Sun'
const graceRaw = Number(process.env.REMINDERS_GRACE_MINUTES); const graceRaw = Number(process.env.REMINDERS_GRACE_MINUTES);
const GRACE_MIN = Number.isFinite(graceRaw) && graceRaw >= 0 ? Math.min(Math.floor(graceRaw), 180) : 60; const GRACE_MIN = Number.isFinite(graceRaw) && graceRaw >= 0 ? Math.min(Math.floor(graceRaw), 180) : 60;
const rows = this.dbInstance.prepare(` const rows = instanceDb.prepare(`
SELECT user_id, reminder_freq, reminder_time, last_reminded_on SELECT user_id, reminder_freq, reminder_time, last_reminded_on
FROM user_preferences FROM user_preferences
WHERE reminder_freq IN ('daily', 'weekly', 'weekdays') WHERE reminder_freq IN ('daily', 'weekly', 'weekdays')
@ -96,7 +97,6 @@ export class RemindersService {
const enforce = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase() === 'enforce'; const enforce = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase() === 'enforce';
if (enforce) { if (enforce) {
try { try {
(AllowedGroups as any).dbInstance = this.dbInstance;
// Evitar falsos positivos por caché obsoleta entre operaciones previas del test // Evitar falsos positivos por caché obsoleta entre operaciones previas del test
AllowedGroups.clearCache?.(); AllowedGroups.clearCache?.();
} catch {} } catch {}
@ -228,7 +228,7 @@ export class RemindersService {
}]); }]);
// Marcar como enviado hoy // Marcar como enviado hoy
this.dbInstance.prepare(` instanceDb.prepare(`
INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at) 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'), VALUES (?, COALESCE((SELECT reminder_freq FROM user_preferences WHERE user_id = ?), 'daily'),
COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30'), COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30'),

@ -1,8 +1,12 @@
import type { Database } from 'bun:sqlite'; import type { Database } from 'bun:sqlite';
import { db } from '../db'; import { getDb } from '../db/locator';
import { IdentityService } from './identity'; import { IdentityService } from './identity';
import { normalizeWhatsAppId } from '../utils/whatsapp'; import { normalizeWhatsAppId } from '../utils/whatsapp';
import { Metrics } from './metrics'; import { Metrics } from './metrics';
import { toIsoSqlUTC } from '../utils/datetime';
import * as EvolutionClient from '../clients/evolution';
import { runCleanupOnce as cleanupRunOnce } from './queue/cleanup';
import { parseQueueMetadata, isReactionMeta } from './queue/metadata';
const MAX_FALLBACK_DIGITS = (() => { const MAX_FALLBACK_DIGITS = (() => {
const raw = (process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '').trim(); const raw = (process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '').trim();
@ -27,8 +31,7 @@ type ClaimedItem = {
}; };
export const ResponseQueue = { export const ResponseQueue = {
// Permite inyectar una DB distinta en tests si se necesita
dbInstance: db as Database,
// Conservamos la cola en memoria por compatibilidad, aunque no se usa para persistencia // Conservamos la cola en memoria por compatibilidad, aunque no se usa para persistencia
queue: [] as QueuedResponse[], queue: [] as QueuedResponse[],
@ -59,12 +62,16 @@ export const ResponseQueue = {
_cleanupRunning: false, _cleanupRunning: false,
_cleanupRunCount: 0, _cleanupRunCount: 0,
getDbInstance(): Database {
return getDb();
},
nowIso(): string { nowIso(): string {
return new Date().toISOString().replace('T', ' ').replace('Z', ''); return toIsoSqlUTC(new Date());
}, },
futureIso(ms: number): string { futureIso(ms: number): string {
return new Date(Date.now() + ms).toISOString().replace('T', ' ').replace('Z', ''); return toIsoSqlUTC(new Date(Date.now() + ms));
}, },
computeDelayMs(attempt: number): number { computeDelayMs(attempt: number): number {
@ -85,12 +92,12 @@ export const ResponseQueue = {
return; return;
} }
const insert = this.dbInstance.prepare(` const insert = this.getDbInstance().prepare(`
INSERT INTO response_queue (recipient, message, metadata, next_attempt_at) INSERT INTO response_queue (recipient, message, metadata, next_attempt_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`); `);
this.dbInstance.transaction((rows: QueuedResponse[]) => { this.getDbInstance().transaction((rows: QueuedResponse[]) => {
for (const r of rows) { for (const r of rows) {
const metadata = const metadata =
r.mentions && r.mentions.length > 0 r.mentions && r.mentions.length > 0
@ -137,7 +144,7 @@ export const ResponseQueue = {
display_code: metadata.display_code ?? null display_code: metadata.display_code ?? null
}; };
const nextAt = delayMs && delayMs > 0 ? this.futureIso(delayMs) : this.nowIso(); const nextAt = delayMs && delayMs > 0 ? this.futureIso(delayMs) : this.nowIso();
this.dbInstance.prepare(` this.getDbInstance().prepare(`
INSERT INTO response_queue (recipient, message, metadata, next_attempt_at) INSERT INTO response_queue (recipient, message, metadata, next_attempt_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`).run(recipient, message, JSON.stringify(metaObj), nextAt); `).run(recipient, message, JSON.stringify(metaObj), nextAt);
@ -146,8 +153,8 @@ export const ResponseQueue = {
// Estadísticas de onboarding por destinatario (consulta simple sobre response_queue) // Estadísticas de onboarding por destinatario (consulta simple sobre response_queue)
getOnboardingStats(recipient: string): { total: number; lastSentAt: string | null; firstInitialAt?: string | null; lastVariant?: 'initial' | 'reminder' | null } { getOnboardingStats(recipient: string): { total: number; lastSentAt: string | null; firstInitialAt?: string | null; lastVariant?: 'initial' | 'reminder' | null } {
if (!recipient) return { total: 0, lastSentAt: null, firstInitialAt: undefined, lastVariant: null }; if (!recipient) return { total: 0, lastSentAt: null, lastVariant: null };
const rows = this.dbInstance.prepare(` const rows = this.getDbInstance().prepare(`
SELECT status, created_at, updated_at, metadata SELECT status, created_at, updated_at, metadata
FROM response_queue FROM response_queue
WHERE recipient = ? AND metadata IS NOT NULL WHERE recipient = ? AND metadata IS NOT NULL
@ -195,7 +202,11 @@ export const ResponseQueue = {
} }
} }
return { total, lastSentAt, firstInitialAt, lastVariant }; const result: { total: number; lastSentAt: string | null; firstInitialAt?: string | null; lastVariant?: 'initial' | 'reminder' | null } = { total, lastSentAt, lastVariant };
if (typeof firstInitialAt !== 'undefined') {
result.firstInitialAt = firstInitialAt;
}
return result;
}, },
// Encolar una reacción con idempotencia (24h) usando metadata canónica // Encolar una reacción con idempotencia (24h) usando metadata canónica
@ -214,20 +225,20 @@ export const ResponseQueue = {
const cutoff = this.futureIso(-24 * 60 * 60 * 1000); const cutoff = this.futureIso(-24 * 60 * 60 * 1000);
// Idempotencia: existe job igual reciente en estados activos? // Idempotencia: existe job igual reciente en estados activos?
const exists = this.dbInstance.prepare(` const exists = this.getDbInstance().prepare(`
SELECT 1 SELECT 1
FROM response_queue FROM response_queue
WHERE metadata = ? WHERE metadata = ?
AND status IN ('queued','processing','sent') AND status IN ('queued','processing','sent')
AND (updated_at > ? OR created_at > ?) AND (updated_at > ? OR created_at > ?)
LIMIT 1 LIMIT 1
`).get(metadata, cutoff, cutoff) as any; `).get(metadata, cutoff, cutoff);
if (exists) { if (exists) {
return; return;
} }
this.dbInstance.prepare(` this.getDbInstance().prepare(`
INSERT INTO response_queue (recipient, message, metadata, next_attempt_at) INSERT INTO response_queue (recipient, message, metadata, next_attempt_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`).run(chatId, '', metadata, this.nowIso()); `).run(chatId, '', metadata, this.nowIso());
@ -255,10 +266,8 @@ export const ResponseQueue = {
} }
// Detectar jobs de reacción // Detectar jobs de reacción
let meta: any = null; const meta = parseQueueMetadata(item.metadata);
try { meta = item.metadata ? JSON.parse(item.metadata) : null; } catch {} if (isReactionMeta(meta)) {
if (meta && meta.kind === 'reaction') {
const reactionUrl = `${baseUrl}/message/sendReaction/${instance}`;
const chatId = String(meta.chatId || ''); const chatId = String(meta.chatId || '');
const messageId = String(meta.messageId || ''); const messageId = String(meta.messageId || '');
const emoji = String(meta.emoji || ''); const emoji = String(meta.emoji || '');
@ -271,32 +280,21 @@ export const ResponseQueue = {
if (meta.participant) { if (meta.participant) {
key.participant = String(meta.participant); key.participant = String(meta.participant);
} }
const payload = { const payload = { key, reaction: emoji };
key, const result = await EvolutionClient.sendReaction(payload);
reaction: emoji if (!result.ok) {
}; const errTxt = result.error || (typeof result.status === 'number' ? `HTTP ${result.status}` : 'unknown_error');
try { console.warn('Send reaction failed:', { status: result.status, body: errTxt });
const response = await fetch(reactionUrl, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(payload),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
const errTxt = body?.slice(0, 200) || `HTTP ${response.status}`;
console.warn('Send reaction failed:', { status: response.status, body: errTxt });
try { Metrics.inc('reactions_failed_total', 1, { emoji: emojiLabel }); } catch {}
return { ok: false, status: response.status, error: errTxt };
}
console.log(`✅ Sent reaction with payload: ${JSON.stringify(payload)}`);
try { Metrics.inc('reactions_sent_total', 1, { emoji: emojiLabel }); } catch {}
return { ok: true, status: response.status };
} catch (err) {
const errMsg = (err instanceof Error ? err.message : String(err));
console.error('Network error sending reaction:', errMsg);
try { Metrics.inc('reactions_failed_total', 1, { emoji: emojiLabel }); } catch {} try { Metrics.inc('reactions_failed_total', 1, { emoji: emojiLabel }); } catch {}
return { ok: false, error: errMsg }; 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 };
if (typeof result.status === 'number') okOut.status = result.status;
return okOut;
} }
// Endpoint típico de Evolution API para texto simple // Endpoint típico de Evolution API para texto simple
@ -367,20 +365,20 @@ export const ResponseQueue = {
} }
} }
const response = await fetch(url, { {
method: 'POST', const result = await EvolutionClient.sendText(payload);
headers: this.getHeaders(), if (!result.ok) {
body: JSON.stringify(payload), 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 (!response.ok) { if (typeof result.status === 'number') out.status = result.status;
const body = await response.text().catch(() => ''); return out;
const errTxt = body?.slice(0, 200) || `HTTP ${response.status}`; }
console.warn('Send failed:', { status: response.status, body: errTxt }); console.log(`✅ Sent message with payload: ${JSON.stringify(payload)}`);
return { ok: false, status: response.status, error: errTxt }; const okOut: { ok: true } & { status?: number } = { ok: true };
if (typeof result.status === 'number') okOut.status = result.status;
return okOut;
} }
console.log(`✅ Sent message with payload: ${JSON.stringify(payload)}`);
return { ok: true, status: response.status };
} catch (err) { } catch (err) {
const errMsg = (err instanceof Error ? err.message : String(err)); const errMsg = (err instanceof Error ? err.message : String(err));
console.error('Network error sending message:', errMsg); console.error('Network error sending message:', errMsg);
@ -390,7 +388,7 @@ export const ResponseQueue = {
claimNextBatch(limit: number): ClaimedItem[] { claimNextBatch(limit: number): ClaimedItem[] {
// Selecciona y marca como 'processing' en una sola sentencia para evitar carreras // Selecciona y marca como 'processing' en una sola sentencia para evitar carreras
const rows = this.dbInstance.prepare(` const rows = this.getDbInstance().prepare(`
UPDATE response_queue UPDATE response_queue
SET status = 'processing', SET status = 'processing',
updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now')
@ -408,7 +406,7 @@ export const ResponseQueue = {
}, },
markSent(id: number, statusCode?: number) { markSent(id: number, statusCode?: number) {
this.dbInstance.prepare(` this.getDbInstance().prepare(`
UPDATE response_queue UPDATE response_queue
SET status = 'sent', SET status = 'sent',
last_status_code = ?, last_status_code = ?,
@ -417,7 +415,7 @@ export const ResponseQueue = {
`).run(statusCode ?? null, id); `).run(statusCode ?? null, id);
// Recalcular métricas agregadas de onboarding si aplica // Recalcular métricas agregadas de onboarding si aplica
try { try {
const row = this.dbInstance.prepare(`SELECT metadata FROM response_queue WHERE id = ?`).get(id) as any; const row = this.getDbInstance().prepare(`SELECT metadata FROM response_queue WHERE id = ?`).get(id) as { metadata?: string | null } | undefined;
let meta: any = null; let meta: any = null;
try { meta = row?.metadata ? JSON.parse(String(row.metadata)) : null; } catch {} try { meta = row?.metadata ? JSON.parse(String(row.metadata)) : null; } catch {}
if (meta && meta.kind === 'onboarding') { if (meta && meta.kind === 'onboarding') {
@ -428,7 +426,7 @@ export const ResponseQueue = {
markFailed(id: number, errorMsg: string, statusCode?: number, attempts?: number) { markFailed(id: number, errorMsg: string, statusCode?: number, attempts?: number) {
const msg = (errorMsg || '').toString().slice(0, 500); const msg = (errorMsg || '').toString().slice(0, 500);
this.dbInstance.prepare(` this.getDbInstance().prepare(`
UPDATE response_queue UPDATE response_queue
SET status = 'failed', SET status = 'failed',
attempts = COALESCE(?, attempts), attempts = COALESCE(?, attempts),
@ -441,7 +439,7 @@ export const ResponseQueue = {
requeueWithBackoff(id: number, nextAttempts: number, nextAttemptAt: string, statusCode?: number | null, errorMsg?: string) { requeueWithBackoff(id: number, nextAttempts: number, nextAttemptAt: string, statusCode?: number | null, errorMsg?: string) {
const msg = (errorMsg || '').toString().slice(0, 500) || null; const msg = (errorMsg || '').toString().slice(0, 500) || null;
this.dbInstance.prepare(` this.getDbInstance().prepare(`
UPDATE response_queue UPDATE response_queue
SET status = 'queued', SET status = 'queued',
attempts = ?, attempts = ?,
@ -456,23 +454,23 @@ export const ResponseQueue = {
setOnboardingAggregatesMetrics(): void { setOnboardingAggregatesMetrics(): void {
try { try {
// Total de mensajes de onboarding enviados // Total de mensajes de onboarding enviados
const sentRow = this.dbInstance.prepare(` const sentRow = this.getDbInstance().prepare(`
SELECT COUNT(*) AS c SELECT COUNT(*) AS c
FROM response_queue FROM response_queue
WHERE status = 'sent' AND metadata LIKE '%"kind":"onboarding"%' WHERE status = 'sent' AND metadata LIKE '%"kind":"onboarding"%'
`).get() as any; `).get() as { c?: number } | undefined;
const sentAbs = Number(sentRow?.c || 0); const sentAbs = Number(sentRow?.c || 0);
// Destinatarios únicos con al menos 1 onboarding enviado // Destinatarios únicos con al menos 1 onboarding enviado
const rcptRow = this.dbInstance.prepare(` const rcptRow = this.getDbInstance().prepare(`
SELECT COUNT(DISTINCT recipient) AS c SELECT COUNT(DISTINCT recipient) AS c
FROM response_queue FROM response_queue
WHERE status = 'sent' AND metadata LIKE '%"kind":"onboarding"%' WHERE status = 'sent' AND metadata LIKE '%"kind":"onboarding"%'
`).get() as any; `).get() as { c?: number } | undefined;
const recipientsAbs = Number(rcptRow?.c || 0); const recipientsAbs = Number(rcptRow?.c || 0);
// Usuarios convertidos: last_command_at > primer onboarding enviado // Usuarios convertidos: last_command_at > primer onboarding enviado
const convRow = this.dbInstance.prepare(` const convRow = this.getDbInstance().prepare(`
SELECT COUNT(*) AS c SELECT COUNT(*) AS c
FROM users u FROM users u
JOIN ( JOIN (
@ -483,7 +481,7 @@ export const ResponseQueue = {
) f ON f.recipient = u.id ) f ON f.recipient = u.id
WHERE u.last_command_at IS NOT NULL WHERE u.last_command_at IS NOT NULL
AND u.last_command_at > f.first_at AND u.last_command_at > f.first_at
`).get() as any; `).get() as { c?: number } | undefined;
const convertedAbs = Number(convRow?.c || 0); const convertedAbs = Number(convRow?.c || 0);
const rate = recipientsAbs > 0 ? Math.max(0, Math.min(1, convertedAbs / recipientsAbs)) : 0; const rate = recipientsAbs > 0 ? Math.max(0, Math.min(1, convertedAbs / recipientsAbs)) : 0;
@ -571,67 +569,23 @@ export const ResponseQueue = {
const startedAt = Date.now(); const startedAt = Date.now();
try { try {
const toIso = (d: Date) => d.toISOString().replace('T', ' ').replace('Z', ''); const res = await cleanupRunOnce(this.getDbInstance(), {
const msPerDay = 24 * 60 * 60 * 1000; retentionDaysSent: this.RETENTION_DAYS_SENT,
retentionDaysFailed: this.RETENTION_DAYS_FAILED,
const sentThresholdIso = toIso(new Date(now.getTime() - this.RETENTION_DAYS_SENT * msPerDay)); batchSize: this.CLEANUP_BATCH,
const failedThresholdIso = toIso(new Date(now.getTime() - this.RETENTION_DAYS_FAILED * msPerDay)); optimize: this.OPTIMIZE_ENABLED,
vacuum: this.VACUUM_ENABLED,
const cleanStatus = (status: 'sent' | 'failed', thresholdIso: string): number => { vacuumEveryNRuns: this.VACUUM_EVERY_N_RUNS,
let deleted = 0; cleanupRunCount: this._cleanupRunCount
const selectStmt = this.dbInstance.prepare(` }, now);
SELECT id
FROM response_queue this._cleanupRunCount = res.nextCleanupRunCount;
WHERE status = ? AND updated_at < ?
ORDER BY updated_at
LIMIT ?
`);
while (true) {
const rows = selectStmt.all(status, thresholdIso, this.CLEANUP_BATCH) as Array<{ id: number }>;
if (!rows || rows.length === 0) break;
const ids = rows.map(r => r.id);
const placeholders = ids.map(() => '?').join(',');
this.dbInstance.prepare(`DELETE FROM response_queue WHERE id IN (${placeholders})`).run(...ids);
deleted += ids.length;
// Si el lote es menor que el batch, no quedan más candidatos
if (rows.length < this.CLEANUP_BATCH) break;
}
return deleted;
};
const deletedSent = cleanStatus('sent', sentThresholdIso);
const deletedFailed = cleanStatus('failed', failedThresholdIso);
const totalDeleted = deletedSent + deletedFailed;
// Mantenimiento ligero tras limpieza
if (this.OPTIMIZE_ENABLED && totalDeleted > 0) {
try {
this.dbInstance.exec('PRAGMA optimize;');
} catch (e) {
console.warn('PRAGMA optimize failed:', e);
}
}
// VACUUM opcional (desactivado por defecto)
if (this.VACUUM_ENABLED && totalDeleted > 0) {
this._cleanupRunCount++;
if (this._cleanupRunCount % Math.max(1, this.VACUUM_EVERY_N_RUNS) === 0) {
try {
this.dbInstance.exec('VACUUM;');
} catch (e) {
console.warn('VACUUM failed:', e);
}
}
}
const tookMs = Date.now() - startedAt; const tookMs = Date.now() - startedAt;
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
console.log(`🧹 Cleanup done in ${tookMs}ms: sent=${deletedSent}, failed=${deletedFailed}, total=${totalDeleted}`); console.log(`🧹 Cleanup done in ${tookMs}ms: sent=${res.deletedSent}, failed=${res.deletedFailed}, total=${res.totalDeleted}`);
} }
return { deletedSent, deletedFailed, totalDeleted, skipped: false }; return { deletedSent: res.deletedSent, deletedFailed: res.deletedFailed, totalDeleted: res.totalDeleted, skipped: false };
} catch (err) { } catch (err) {
console.error('Cleanup error:', err); console.error('Cleanup error:', err);
return { deletedSent: 0, deletedFailed: 0, totalDeleted: 0, skipped: false }; return { deletedSent: 0, deletedFailed: 0, totalDeleted: 0, skipped: false };
@ -647,7 +601,7 @@ export const ResponseQueue = {
const interval = this.CLEANUP_INTERVAL_MS; const interval = this.CLEANUP_INTERVAL_MS;
this._cleanupTimer = setInterval(() => { this._cleanupTimer = setInterval(() => {
this.runCleanupOnce().catch(err => console.error('Scheduled cleanup error:', err)); this.runCleanupOnce().catch((err: unknown) => console.error('Scheduled cleanup error:', err));
}, interval); }, interval);
console.log(`🗓️ Cleanup scheduler started (every ${Math.round(interval / (60 * 60 * 1000))}h)`); console.log(`🗓️ Cleanup scheduler started (every ${Math.round(interval / (60 * 60 * 1000))}h)`);

@ -0,0 +1,74 @@
import type { Database } from 'bun:sqlite';
import { isGroupId } from '../utils/whatsapp';
import { AllowedGroups } from '../services/allowed-groups';
import { ResponseQueue } from '../services/response-queue';
/**
* Publica una reacción al mensaje origen de la tarea si:
* - REACTIONS_ENABLED está activado,
* - scope permite (all o solo grupos),
* - está dentro del TTL configurado (por defecto 14 días),
* - y pasa el gating de grupos en modo 'enforce'.
*
* No lanza errores (no debe bloquear el flujo de completado).
*/
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;
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(() => {});
} catch {
// no-op
}
}

@ -0,0 +1,40 @@
import type { Database } from 'bun:sqlite';
/**
* Calcula el siguiente display_code disponible entre tareas activas
* (y tareas completadas en las últimas 24h), empezando en 1 hasta 9999,
* respetando huecos.
*/
export function pickNextDisplayCode(db: Database): number {
const MAX_DISPLAY_CODE = 9999;
const rows = db
.prepare(`
SELECT display_code
FROM tasks
WHERE display_code IS NOT NULL
AND (
COALESCE(completed, 0) = 0
OR (completed_at IS NOT NULL AND completed_at >= strftime('%Y-%m-%d %H:%M:%f','now','-24 hours'))
)
ORDER BY display_code ASC
`)
.all() as Array<{ display_code: number }>;
let expect = 1;
for (const r of rows) {
const dc = Number(r.display_code || 0);
if (dc < expect) continue;
if (dc === expect) {
expect++;
if (expect > MAX_DISPLAY_CODE) break;
continue;
}
// encontrado hueco
break;
}
if (expect > MAX_DISPLAY_CODE) {
throw new Error('No hay códigos disponibles (límite alcanzado)');
}
return expect;
}

@ -0,0 +1,67 @@
/**
* Mapeadores puros para normalizar filas SQLite a DTOs usados por TaskService.
* Mantienen las mismas formas que consumen comandos, recordatorios y API web.
*/
export function mapTaskListItem(
row: { id: number; description: string; due_date: string | null; group_id: string | null; display_code: number | null },
assignees: string[]
): {
id: number;
description: string;
due_date: string | null;
group_id: string | null;
display_code: number | null;
assignees: string[];
} {
return {
id: Number(row.id),
description: String(row.description || ''),
due_date: row.due_date ? String(row.due_date) : null,
group_id: row.group_id ? String(row.group_id) : null,
display_code: row.display_code != null ? Number(row.display_code) : null,
assignees: Array.isArray(assignees) ? assignees.map(a => String(a)) : []
};
}
export function mapTaskWithGroupNameRow(
row: { id: number; description: string; due_date: string | null; group_id: string | null; display_code: number | null; group_name: string | null }
): {
id: number;
description: string;
due_date: string | null;
group_id: string | null;
group_name: 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,
group_id: row.group_id ? String(row.group_id) : null,
group_name: row.group_name ? String(row.group_name) : null,
display_code: row.display_code != null ? Number(row.display_code) : null
};
}
export function mapTaskDetailsRow(
row: { id?: number; description?: string; due_date?: string | null; group_id?: string | null; display_code?: number | null; completed?: number; completed_at?: string | null }
): {
id: number;
description: string;
due_date: string | null;
group_id: string | null;
display_code: number | null;
completed: number;
completed_at: string | null;
} {
return {
id: Number(row.id),
description: String(row.description || ''),
due_date: row.due_date ? String(row.due_date) : null,
group_id: row.group_id ? String(row.group_id) : null,
display_code: row.display_code != null ? Number(row.display_code) : null,
completed: Number(row.completed || 0),
completed_at: row.completed_at ? String(row.completed_at) : null
};
}

@ -1,9 +1,11 @@
import type { Database } from 'bun:sqlite'; import type { Database } from 'bun:sqlite';
import { db, ensureUserExists } from '../db'; import { ensureUserExists } from '../db';
import { getDb as getGlobalDb } from '../db/locator';
import { AllowedGroups } from '../services/allowed-groups'; import { AllowedGroups } from '../services/allowed-groups';
import { isGroupId } from '../utils/whatsapp'; import { isGroupId } from '../utils/whatsapp';
import { ResponseQueue } from '../services/response-queue'; import { pickNextDisplayCode } from './display-code';
import { Metrics } from '../services/metrics'; import { enqueueCompletionReactionIfEligible } from './complete-reaction';
import { mapTaskListItem, mapTaskWithGroupNameRow, mapTaskDetailsRow } from './mappers';
type CreateTaskInput = { type CreateTaskInput = {
description: string; description: string;
@ -18,49 +20,20 @@ type CreateAssignmentInput = {
}; };
export class TaskService { export class TaskService {
static dbInstance: Database = db;
private static getDb(): Database {
return getGlobalDb();
}
static createTask(task: CreateTaskInput, assignments: CreateAssignmentInput[] = []): number { static createTask(task: CreateTaskInput, assignments: CreateAssignmentInput[] = []): number {
const MAX_DISPLAY_CODE = 9999; const runTx = this.getDb().transaction(() => {
const runTx = this.dbInstance.transaction(() => {
const pickNextDisplayCode = (): number => {
const rows = this.dbInstance
.prepare(`
SELECT display_code
FROM tasks
WHERE display_code IS NOT NULL
AND (
COALESCE(completed, 0) = 0
OR (completed_at IS NOT NULL AND completed_at >= strftime('%Y-%m-%d %H:%M:%f','now','-24 hours'))
)
ORDER BY display_code ASC
`)
.all() as Array<{ display_code: number }>;
let expect = 1;
for (const r of rows) {
const dc = Number(r.display_code || 0);
if (dc < expect) continue;
if (dc === expect) {
expect++;
if (expect > MAX_DISPLAY_CODE) break;
continue;
}
// encontrado hueco
break;
}
if (expect > MAX_DISPLAY_CODE) {
throw new Error('No hay códigos disponibles (límite alcanzado)');
}
return expect;
};
const insertTask = this.dbInstance.prepare(` const insertTask = this.getDb().prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, display_code) INSERT INTO tasks (description, due_date, group_id, created_by, display_code)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`); `);
const ensuredCreator = ensureUserExists(task.created_by, this.dbInstance); const ensuredCreator = ensureUserExists(task.created_by, this.getDb());
if (!ensuredCreator) { if (!ensuredCreator) {
throw new Error('No se pudo asegurar created_by'); throw new Error('No se pudo asegurar created_by');
} }
@ -72,7 +45,6 @@ export class TaskService {
try { try {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (groupIdToInsert && isGroupId(groupIdToInsert) && mode === 'enforce') { if (groupIdToInsert && isGroupId(groupIdToInsert) && mode === 'enforce') {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
if (!AllowedGroups.isAllowed(groupIdToInsert)) { if (!AllowedGroups.isAllowed(groupIdToInsert)) {
groupIdToInsert = null; groupIdToInsert = null;
} }
@ -80,14 +52,14 @@ export class TaskService {
} catch {} } catch {}
if (groupIdToInsert) { if (groupIdToInsert) {
const exists = this.dbInstance.prepare(`SELECT 1 FROM groups WHERE id = ? AND COALESCE(is_community,0) = 0`).get(groupIdToInsert); const exists = this.getDb().prepare(`SELECT 1 FROM groups WHERE id = ? AND COALESCE(is_community,0) = 0`).get(groupIdToInsert);
if (!exists) { if (!exists) {
groupIdToInsert = null; groupIdToInsert = null;
} }
} }
// Elegir display_code global reutilizable entre tareas activas // Elegir display_code global reutilizable entre tareas activas
const displayCode = pickNextDisplayCode(); const displayCode = pickNextDisplayCode(this.getDb());
const runResult = insertTask.run( const runResult = insertTask.run(
task.description, task.description,
@ -96,10 +68,10 @@ export class TaskService {
ensuredCreator, ensuredCreator,
displayCode displayCode
); );
const taskId = Number((runResult as any).lastInsertRowid); const taskId = Number((runResult as { lastInsertRowid?: number | bigint }).lastInsertRowid);
if (assignments.length > 0) { if (assignments.length > 0) {
const insertAssignment = this.dbInstance.prepare(` const insertAssignment = this.getDb().prepare(`
INSERT INTO task_assignments (task_id, user_id, assigned_by) INSERT INTO task_assignments (task_id, user_id, assigned_by)
VALUES (?, ?, ?) VALUES (?, ?, ?)
`); `);
@ -107,13 +79,13 @@ export class TaskService {
// Evitar duplicados por (task_id, user_id) tras asegurar usuarios // Evitar duplicados por (task_id, user_id) tras asegurar usuarios
const seen = new Set<string>(); const seen = new Set<string>();
for (const a of assignments) { for (const a of assignments) {
const ensuredUser = ensureUserExists(a.user_id, this.dbInstance); const ensuredUser = ensureUserExists(a.user_id, this.getDb());
if (!ensuredUser) continue; if (!ensuredUser) continue;
if (seen.has(ensuredUser)) continue; if (seen.has(ensuredUser)) continue;
seen.add(ensuredUser); seen.add(ensuredUser);
const ensuredAssigner = const ensuredAssigner =
ensureUserExists(a.assigned_by || ensuredCreator, this.dbInstance) || ensuredCreator; ensureUserExists(a.assigned_by || ensuredCreator, this.getDb()) || ensuredCreator;
insertAssignment.run(taskId, ensuredUser, ensuredAssigner); insertAssignment.run(taskId, ensuredUser, ensuredAssigner);
} }
@ -134,7 +106,7 @@ export class TaskService {
display_code: number | null; display_code: number | null;
assignees: string[]; assignees: string[];
}> { }> {
const rows = this.dbInstance const rows = this.getDb()
.prepare(` .prepare(`
SELECT id, description, due_date, group_id, display_code SELECT id, description, due_date, group_id, display_code
FROM tasks FROM tasks
@ -146,25 +118,18 @@ export class TaskService {
id ASC id ASC
LIMIT ? LIMIT ?
`) `)
.all(groupId, limit) as any[]; .all(groupId, limit) as Array<{ id: number; description: string; due_date: string | null; group_id: string | null; display_code: number | null }>;
const getAssignees = this.dbInstance.prepare(` const getAssignees = this.getDb().prepare(`
SELECT user_id FROM task_assignments SELECT user_id FROM task_assignments
WHERE task_id = ? WHERE task_id = ?
ORDER BY assigned_at ASC ORDER BY assigned_at ASC
`); `);
return rows.map((r) => { return rows.map((r) => {
const assigneesRows = getAssignees.all(r.id) as any[]; const assigneesRows = getAssignees.all(r.id) as Array<{ user_id: string }>;
const assignees = assigneesRows.map((a) => String(a.user_id)); const assignees = assigneesRows.map((a) => String(a.user_id));
return { return mapTaskListItem(r, assignees);
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,
};
}); });
} }
@ -177,7 +142,7 @@ export class TaskService {
display_code: number | null; display_code: number | null;
assignees: string[]; assignees: string[];
}> { }> {
const rows = this.dbInstance const rows = this.getDb()
.prepare(` .prepare(`
SELECT t.id, t.description, t.due_date, t.group_id, t.display_code SELECT t.id, t.description, t.due_date, t.group_id, t.display_code
FROM tasks t FROM tasks t
@ -190,44 +155,37 @@ export class TaskService {
t.id ASC t.id ASC
LIMIT ? LIMIT ?
`) `)
.all(userId, limit) as any[]; .all(userId, limit) as Array<{ id: number; description: string; due_date: string | null; group_id: string | null; display_code: number | null }>;
const getAssignees = this.dbInstance.prepare(` const getAssignees = this.getDb().prepare(`
SELECT user_id FROM task_assignments SELECT user_id FROM task_assignments
WHERE task_id = ? WHERE task_id = ?
ORDER BY assigned_at ASC ORDER BY assigned_at ASC
`); `);
return rows.map((r) => { return rows.map((r) => {
const assigneesRows = getAssignees.all(r.id) as any[]; const assigneesRows = getAssignees.all(r.id) as Array<{ user_id: string }>;
const assignees = assigneesRows.map((a) => String(a.user_id)); const assignees = assigneesRows.map((a) => String(a.user_id));
return { return mapTaskListItem(r, assignees);
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,
};
}); });
} }
// Contar pendientes del grupo (sin límite) // Contar pendientes del grupo (sin límite)
static countGroupPending(groupId: string): number { static countGroupPending(groupId: string): number {
const row = this.dbInstance const row = this.getDb()
.prepare(` .prepare(`
SELECT COUNT(*) as cnt SELECT COUNT(*) as cnt
FROM tasks FROM tasks
WHERE group_id = ? WHERE group_id = ?
AND COALESCE(completed, 0) = 0 AND completed_at IS NULL AND COALESCE(completed, 0) = 0 AND completed_at IS NULL
`) `)
.get(groupId) as any; .get(groupId) as { cnt?: number } | undefined;
return Number(row?.cnt || 0); return Number(row?.cnt || 0);
} }
// Contar pendientes asignadas al usuario (sin límite) // Contar pendientes asignadas al usuario (sin límite)
static countUserPending(userId: string): number { static countUserPending(userId: string): number {
const row = this.dbInstance const row = this.getDb()
.prepare(` .prepare(`
SELECT COUNT(*) as cnt SELECT COUNT(*) as cnt
FROM tasks t FROM tasks t
@ -235,7 +193,7 @@ export class TaskService {
WHERE a.user_id = ? WHERE a.user_id = ?
AND COALESCE(t.completed, 0) = 0 AND t.completed_at IS NULL AND COALESCE(t.completed, 0) = 0 AND t.completed_at IS NULL
`) `)
.get(userId) as any; .get(userId) as { cnt?: number } | undefined;
return Number(row?.cnt || 0); return Number(row?.cnt || 0);
} }
@ -244,15 +202,15 @@ export class TaskService {
status: 'updated' | 'already' | 'not_found'; status: 'updated' | 'already' | 'not_found';
task?: { id: number; description: string; due_date: string | null; display_code: number | null }; task?: { id: number; description: string; due_date: string | null; display_code: number | null };
} { } {
const ensured = ensureUserExists(completedBy, this.dbInstance); const ensured = ensureUserExists(completedBy, this.getDb());
const existing = this.dbInstance const existing = this.getDb()
.prepare(` .prepare(`
SELECT id, description, due_date, completed, completed_at, display_code, group_id SELECT id, description, due_date, completed, completed_at, display_code, group_id
FROM tasks FROM tasks
WHERE id = ? WHERE id = ?
`) `)
.get(taskId) as any; .get(taskId) as { id?: number; description?: string; due_date?: string | null; completed?: number; completed_at?: string | null; display_code?: number | null; group_id?: string | null } | undefined;
if (!existing) { if (!existing) {
return { status: 'not_found' }; return { status: 'not_found' };
@ -270,7 +228,7 @@ export class TaskService {
}; };
} }
this.dbInstance this.getDb()
.prepare(` .prepare(`
UPDATE tasks UPDATE tasks
SET completed = 1, SET completed = 1,
@ -282,59 +240,7 @@ export class TaskService {
// Fase 2: reacción ✅ al completar dentro del TTL y con gating // Fase 2: reacción ✅ al completar dentro del TTL y con gating
try { try {
const rxEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase(); enqueueCompletionReactionIfEligible(this.getDb(), taskId);
const enabled = ['true','1','yes','on'].includes(rxEnabled);
if (enabled) {
let origin: any = null;
try {
origin = this.dbInstance.prepare(`
SELECT chat_id, message_id, created_at, participant, from_me
FROM task_origins
WHERE task_id = ?
`).get(taskId) as any;
} catch {
origin = this.dbInstance.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);
const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase();
if (scope === 'all' || isGroupId(chatId)) {
// TTL desde REACTIONS_TTL_DAYS (usar tal cual; 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) {
// Gating 'enforce' para grupos
let allowed = true;
if (isGroupId(chatId)) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') {
try { allowed = AllowedGroups.isAllowed(chatId); } catch { allowed = true; }
}
}
if (allowed) {
// 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;
ResponseQueue.enqueueReaction(chatId, String(origin.message_id), '✅', { participant, fromMe })
.catch(() => {});
}
}
}
}
}
} catch {} } catch {}
return { return {
@ -357,7 +263,7 @@ export class TaskService {
display_code: number | null; display_code: number | null;
assignees: string[]; assignees: string[];
}> { }> {
const rows = this.dbInstance const rows = this.getDb()
.prepare(` .prepare(`
SELECT id, description, due_date, group_id, display_code SELECT id, description, due_date, group_id, display_code
FROM tasks FROM tasks
@ -372,21 +278,14 @@ export class TaskService {
id ASC id ASC
LIMIT ? LIMIT ?
`) `)
.all(groupId, limit) as any[]; .all(groupId, limit) as Array<{ id: number; description: string; due_date: string | null; group_id: string | null; display_code: number | null }>;
return rows.map((r) => ({ return rows.map((r) => mapTaskListItem(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: [],
}));
} }
// Contar pendientes sin dueño del grupo (sin límite) // Contar pendientes sin dueño del grupo (sin límite)
static countGroupUnassigned(groupId: string): number { static countGroupUnassigned(groupId: string): number {
const row = this.dbInstance const row = this.getDb()
.prepare(` .prepare(`
SELECT COUNT(*) as cnt SELECT COUNT(*) as cnt
FROM tasks t FROM tasks t
@ -396,7 +295,7 @@ export class TaskService {
SELECT 1 FROM task_assignments a WHERE a.task_id = t.id SELECT 1 FROM task_assignments a WHERE a.task_id = t.id
) )
`) `)
.get(groupId) as any; .get(groupId) as { cnt?: number } | undefined;
return Number(row?.cnt || 0); return Number(row?.cnt || 0);
} }
@ -405,12 +304,12 @@ export class TaskService {
status: 'claimed' | 'already' | 'not_found' | 'completed'; status: 'claimed' | 'already' | 'not_found' | 'completed';
task?: { id: number; description: string; due_date: string | null; display_code: number | null }; task?: { id: number; description: string; due_date: string | null; display_code: number | null };
} { } {
const ensuredUser = ensureUserExists(userId, this.dbInstance); const ensuredUser = ensureUserExists(userId, this.getDb());
if (!ensuredUser) { if (!ensuredUser) {
throw new Error('No se pudo asegurar el usuario'); throw new Error('No se pudo asegurar el usuario');
} }
const existing = this.dbInstance const existing = this.getDb()
.prepare(` .prepare(`
SELECT id, description, due_date, group_id, completed, completed_at, display_code SELECT id, description, due_date, group_id, completed, completed_at, display_code
FROM tasks FROM tasks
@ -434,7 +333,7 @@ export class TaskService {
}; };
} }
const already = this.dbInstance const already = this.getDb()
.prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ?`) .prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ?`)
.get(taskId, ensuredUser); .get(taskId, ensuredUser);
@ -450,12 +349,12 @@ export class TaskService {
}; };
} }
const insertAssignment = this.dbInstance.prepare(` const insertAssignment = this.getDb().prepare(`
INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by) INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by)
VALUES (?, ?, ?) VALUES (?, ?, ?)
`); `);
this.dbInstance.transaction(() => { this.getDb().transaction(() => {
insertAssignment.run(taskId, ensuredUser, ensuredUser); insertAssignment.run(taskId, ensuredUser, ensuredUser);
})(); })();
@ -476,12 +375,12 @@ export class TaskService {
task?: { id: number; description: string; due_date: string | null; display_code: number | null }; task?: { id: number; description: string; due_date: string | null; display_code: number | null };
now_unassigned?: boolean; // true si tras soltar no quedan asignados now_unassigned?: boolean; // true si tras soltar no quedan asignados
} { } {
const ensuredUser = ensureUserExists(userId, this.dbInstance); const ensuredUser = ensureUserExists(userId, this.getDb());
if (!ensuredUser) { if (!ensuredUser) {
throw new Error('No se pudo asegurar el usuario'); throw new Error('No se pudo asegurar el usuario');
} }
const existing = this.dbInstance const existing = this.getDb()
.prepare(` .prepare(`
SELECT id, description, due_date, group_id, completed, completed_at, display_code SELECT id, description, due_date, group_id, completed, completed_at, display_code
FROM tasks FROM tasks
@ -507,12 +406,12 @@ export class TaskService {
// Regla: no permitir soltar si es tarea personal y el usuario es el único asignatario // Regla: no permitir soltar si es tarea personal y el usuario es el único asignatario
try { try {
const stats = this.dbInstance.prepare(` const stats = this.getDb().prepare(`
SELECT COUNT(*) AS cnt, SELECT COUNT(*) AS cnt,
SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS mine SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS mine
FROM task_assignments FROM task_assignments
WHERE task_id = ? WHERE task_id = ?
`).get(ensuredUser, taskId) as any; `).get(ensuredUser, taskId) as { cnt?: number; mine?: number } | undefined;
const cnt = Number(stats?.cnt || 0); const cnt = Number(stats?.cnt || 0);
const mine = Number(stats?.mine || 0) > 0; const mine = Number(stats?.mine || 0) > 0;
if (existing.group_id == null && cnt === 1 && mine) { if (existing.group_id == null && cnt === 1 && mine) {
@ -529,16 +428,16 @@ export class TaskService {
} }
} catch {} } catch {}
const deleteStmt = this.dbInstance.prepare(` const deleteStmt = this.getDb().prepare(`
DELETE FROM task_assignments DELETE FROM task_assignments
WHERE task_id = ? AND user_id = ? WHERE task_id = ? AND user_id = ?
`); `);
const result = deleteStmt.run(taskId, ensuredUser) as any; const result = deleteStmt.run(taskId, ensuredUser) as { changes?: number };
const cntRow = this.dbInstance const cntRow = this.getDb()
.prepare(`SELECT COUNT(*) as cnt FROM task_assignments WHERE task_id = ?`) .prepare(`SELECT COUNT(*) as cnt FROM task_assignments WHERE task_id = ?`)
.get(taskId) as any; .get(taskId) as { cnt?: number } | undefined;
const remaining = Number(cntRow?.cnt || 0); const remaining = Number(cntRow?.cnt || 0);
if (result.changes && result.changes > 0) { if (result.changes && result.changes > 0) {
@ -578,7 +477,7 @@ export class TaskService {
completed: number; completed: number;
completed_at: string | null; completed_at: string | null;
} | null { } | null {
const row = this.dbInstance.prepare(` const row = this.getDb().prepare(`
SELECT SELECT
id, id,
description, description,
@ -589,22 +488,14 @@ export class TaskService {
completed_at completed_at
FROM tasks FROM tasks
WHERE id = ? WHERE id = ?
`).get(taskId) as any; `).get(taskId) as { id?: number; description?: string; due_date?: string | null; group_id?: string | null; completed?: number; completed_at?: string | null; display_code?: number | null } | undefined;
if (!row) return null; if (!row) return null;
return { return mapTaskDetailsRow(row);
id: Number(row.id),
description: String(row.description || ''),
due_date: row.due_date ? String(row.due_date) : null,
group_id: row.group_id ? String(row.group_id) : null,
display_code: row.display_code != null ? Number(row.display_code) : null,
completed: Number(row.completed || 0),
completed_at: row.completed_at ? String(row.completed_at) : null,
};
} }
// Buscar tarea activa por display_code global // Buscar tarea activa por display_code global
static getActiveTaskByDisplayCode(displayCode: number): { id: number; description: string; due_date: string | null; display_code: number | null } | null { static getActiveTaskByDisplayCode(displayCode: number): { id: number; description: string; due_date: string | null; display_code: number | null } | null {
const row = this.dbInstance.prepare(` const row = this.getDb().prepare(`
SELECT id, description, due_date, display_code SELECT id, description, due_date, display_code
FROM tasks FROM tasks
WHERE display_code = ? AND COALESCE(completed, 0) = 0 AND completed_at IS NULL WHERE display_code = ? AND COALESCE(completed, 0) = 0 AND completed_at IS NULL
@ -654,7 +545,7 @@ export class TaskService {
group_name: string | null; group_name: string | null;
display_code: number | null; display_code: number | null;
}> { }> {
const rows = this.dbInstance const rows = this.getDb()
.prepare(` .prepare(`
SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, g.name AS group_name SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, g.name AS group_name
FROM tasks t FROM tasks t
@ -673,20 +564,13 @@ export class TaskService {
t.id ASC t.id ASC
LIMIT ? LIMIT ?
`) `)
.all(limit) as any[]; .all(limit) as Array<{ id: number; description: string; due_date: string | null; group_id: string | null; display_code: number | null; group_name: string | null }>;
return rows.map(r => ({ return rows.map(r => mapTaskWithGroupNameRow(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 ? String(r.group_name) : null,
display_code: r.display_code != null ? Number(r.display_code) : null,
}));
} }
static countAllActive(): number { static countAllActive(): number {
const row = this.dbInstance const row = this.getDb()
.prepare(` .prepare(`
SELECT COUNT(*) AS cnt SELECT COUNT(*) AS cnt
FROM tasks t FROM tasks t
@ -699,7 +583,7 @@ export class TaskService {
AND COALESCE(g2.is_community,0)=0 AND COALESCE(g2.is_community,0)=0
)) ))
`) `)
.get() as any; .get() as { cnt?: number } | undefined;
return Number(row?.cnt || 0); return Number(row?.cnt || 0);
} }
} }

@ -0,0 +1,25 @@
/* Lote 0 shims: ampliar tipos para Bun/Fetch sin tocar lógica de runtime */
declare global {
// Algunos módulos usan HeadersInit, no siempre presente sin lib DOM
type HeadersInit = any;
// Ensanchar Headers de Bun para que sea asignable donde se espera DOM HeadersInit
interface Headers {
toJSON?: any;
count?: any;
getAll?: any;
}
// Añadir timeout soportado por Bun.fetch en algunos usos y permitir httpVersion usado en algunos fetch
interface BunFetchRequestInit {
timeout?: number;
httpVersion?: any;
}
// Evitar 'unknown' en Response.json() en modo estricto
interface Response {
json(): Promise<any>;
}
}
export {};

@ -0,0 +1,16 @@
export function toIsoSqlUTC(d: Date = new Date()): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
export function normalizeTime(input: string | null | undefined): string | null {
const s = (input ?? '').trim();
const m = /^(\d{1,2}):(\d{1,2})$/.exec(s);
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
const hh = String(h).padStart(2, '0');
const mm = String(min).padStart(2, '0');
return `${hh}:${mm}`;
}

@ -0,0 +1,3 @@
import { sha256Hex as coreSha256Hex } from '../../src/utils/crypto';
export const sha256Hex = coreSha256Hex;

@ -0,0 +1,38 @@
import { toIsoSqlUTC } from '../../src/utils/datetime';
export function toIsoSql(d: Date = new Date()): string {
return toIsoSqlUTC(d);
}
export { toIsoSqlUTC };
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')}`;
}
export function addDaysToYMD(ymd: string, days: number, tz: string = 'Europe/Madrid'): string {
const [Y, M, D] = ymd.split('-').map(n => parseInt(n, 10));
const base = new Date(Date.UTC(Y, (M || 1) - 1, D || 1));
base.setUTCDate(base.getUTCDate() + days);
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);
return d;
}

@ -1,5 +1,6 @@
import Database, { type Database as SqliteDatabase } from 'bun:sqlite'; import Database, { type Database as SqliteDatabase } from 'bun:sqlite';
import { initializeDatabase } from '../../src/db'; import { initializeDatabase } from '../../src/db';
import { setDb } from '../../src/db/locator';
// Servicios opcionales para inyección de DB en tests. // Servicios opcionales para inyección de DB en tests.
// Importamos con nombres existentes en la base de código para respetar convenciones. // Importamos con nombres existentes en la base de código para respetar convenciones.
@ -25,12 +26,7 @@ export function makeMemDb(): SqliteDatabase {
* Pensado para usarse en beforeAll/beforeEach de tests que usan estos servicios. * Pensado para usarse en beforeAll/beforeEach de tests que usan estos servicios.
*/ */
export function injectAllServices(db: SqliteDatabase): void { export function injectAllServices(db: SqliteDatabase): void {
try { (TaskService as any).dbInstance = db; } catch {} setDb(db);
try { (CommandService as any).dbInstance = db; } catch {}
try { (ResponseQueue as any).dbInstance = db; } catch {}
try { (IdentityService as any).dbInstance = db; } catch {}
try { (GroupSyncService as any).dbInstance = db; } catch {}
try { (RemindersService as any).dbInstance = db; } catch {}
} }
/** /**
@ -49,7 +45,6 @@ export function resetServices(): void {
* Marca como 'allowed' los groupIds indicados en la DB provista. * Marca como 'allowed' los groupIds indicados en la DB provista.
*/ */
export function seedAllowed(db: SqliteDatabase, groupIds: string[]): void { export function seedAllowed(db: SqliteDatabase, groupIds: string[]): void {
(AllowedGroups as any).dbInstance = db;
for (const gid of groupIds) { for (const gid of groupIds) {
const g = String(gid || '').trim(); const g = String(gid || '').trim();
if (!g) continue; if (!g) continue;

@ -0,0 +1,9 @@
let simulatedQueue: any[] = [];
export const SimulatedResponseQueue = {
async add(responses: any[]) {
simulatedQueue.push(...responses);
},
clear() { simulatedQueue = []; },
get() { return simulatedQueue; }
};

@ -0,0 +1,50 @@
import Database from 'bun:sqlite';
import { describe, it, expect } from 'bun:test';
import { getDb, setDb, withDb, DbNotConfiguredError } from '../../../src/db/locator';
describe('db/locator', () => {
it('getDb lanza si no está configurada', () => {
expect(() => getDb()).toThrow(DbNotConfiguredError);
});
it('setDb y getDb devuelven la misma instancia', () => {
const db = new Database(':memory:');
setDb(db);
expect(getDb()).toBe(db);
try { db.close(); } catch {}
});
it('withDb soporta callback síncrono', () => {
const db = new Database(':memory:');
setDb(db);
const result = withDb(d => {
expect(d).toBe(db);
return 42;
});
expect(result).toBe(42);
try { db.close(); } catch {}
});
it('withDb soporta callback asíncrono', async () => {
const db = new Database(':memory:');
setDb(db);
const result = await withDb(async d => {
expect(d).toBe(db);
await Promise.resolve();
return 'ok';
});
expect(result).toBe('ok');
try { db.close(); } catch {}
});
it('permitir sobrescritura de setDb', () => {
const db1 = new Database(':memory:');
const db2 = new Database(':memory:');
setDb(db1);
setDb(db2);
const got = getDb();
expect(got).toBe(db2);
try { db1.close(); } catch {}
try { db2.close(); } catch {}
});
});

@ -0,0 +1,75 @@
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { Database } from 'bun:sqlite';
import { initializeDatabase } from '../../src/db';
import { setDb, getDb } from '../../src/db/locator';
import { TaskService } from '../../src/tasks/service';
import { ResponseQueue } from '../../src/services/response-queue';
describe('Locator fallback - servicios usan getDb() cuando no hay dbInstance', () => {
let memdb: Database;
let prevDb: Database | null = null;
let hadPrev = false;
let originalEnv: NodeJS.ProcessEnv;
let originalTaskDb: any;
let originalQueueDb: any;
beforeAll(() => {
originalEnv = { ...process.env };
process.env.NODE_ENV = 'test';
process.env.CHATBOT_PHONE_NUMBER = '999999';
// Capturar DB previa del locator (si la hubiera)
try {
prevDb = getDb();
hadPrev = true;
} catch {
prevDb = null;
hadPrev = false;
}
// Crear DB de pruebas y configurarla en el locator global
memdb = new Database(':memory:');
initializeDatabase(memdb);
setDb(memdb);
// Forzar fallback deshabilitando las instancias estáticas
originalTaskDb = (TaskService as any).dbInstance;
(TaskService as any).dbInstance = undefined;
originalQueueDb = (ResponseQueue as any).dbInstance;
(ResponseQueue as any).dbInstance = undefined;
});
afterAll(() => {
// Restaurar instancias estáticas
try { (TaskService as any).dbInstance = originalTaskDb; } catch {}
try { (ResponseQueue as any).dbInstance = originalQueueDb; } catch {}
// Restaurar locator previo si existía; si no, dejamos memdb viva sin cerrar
if (hadPrev && prevDb) {
try { setDb(prevDb); } catch {}
try { memdb.close(); } catch {}
}
process.env = originalEnv;
});
it('TaskService.createTask y countAllActive funcionan vía locator', () => {
const createdBy = '34600123456';
const taskId = TaskService.createTask(
{ description: 'Locator smoke', due_date: null, group_id: null, created_by: createdBy },
[]
);
expect(typeof taskId).toBe('number');
expect(taskId).toBeGreaterThan(0);
const cnt = TaskService.countAllActive();
expect(cnt).toBe(1);
});
it('ResponseQueue.add persiste en DB vía locator', async () => {
await ResponseQueue.add([{ recipient: '321', message: 'hola locator' }]);
const row = memdb.query(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
expect(Number(row?.c || 0)).toBe(1);
});
});

@ -3,6 +3,7 @@ import { Database } from 'bun:sqlite';
import { initializeDatabase } from '../../src/db'; import { initializeDatabase } from '../../src/db';
import { WebhookServer } from '../../src/server'; import { WebhookServer } from '../../src/server';
import { ResponseQueue } from '../../src/services/response-queue'; import { ResponseQueue } from '../../src/services/response-queue';
import { setDb } from '../../src/db/locator';
describe('WebhookServer - DM "activar" (A4)', () => { describe('WebhookServer - DM "activar" (A4)', () => {
let memdb: Database; let memdb: Database;
@ -11,7 +12,7 @@ describe('WebhookServer - DM "activar" (A4)', () => {
memdb = new Database(':memory:'); memdb = new Database(':memory:');
initializeDatabase(memdb); initializeDatabase(memdb);
(WebhookServer as any).dbInstance = memdb; (WebhookServer as any).dbInstance = memdb;
(ResponseQueue as any).dbInstance = memdb; setDb(memdb);
}); });
beforeEach(() => { beforeEach(() => {

@ -5,24 +5,11 @@ import { ResponseQueue } from '../../src/services/response-queue';
import { GroupSyncService } from '../../src/services/group-sync'; import { GroupSyncService } from '../../src/services/group-sync';
import { initializeDatabase, ensureUserExists } from '../../src/db'; import { initializeDatabase, ensureUserExists } from '../../src/db';
import { TaskService } from '../../src/tasks/service'; import { TaskService } from '../../src/tasks/service';
import { setDb, resetDb } from '../../src/db/locator';
// Simulated ResponseQueue for testing (in-memory array)
let simulatedQueue: any[] = [];
let originalAdd: any; let originalAdd: any;
class SimulatedResponseQueue { import { SimulatedResponseQueue } from '../helpers/queue';
static async add(responses: any[]) {
simulatedQueue.push(...responses);
}
static getQueue() {
return simulatedQueue;
}
static clear() {
simulatedQueue = [];
}
}
// Test database instance // Test database instance
let testDb: Database; let testDb: Database;
@ -38,6 +25,7 @@ beforeAll(() => {
afterAll(() => { afterAll(() => {
(ResponseQueue as any).add = originalAdd; (ResponseQueue as any).add = originalAdd;
resetDb();
// Close the test database // Close the test database
testDb.close(); testDb.close();
}); });
@ -52,11 +40,8 @@ beforeEach(() => {
// Inject testDb for WebhookServer to use // Inject testDb for WebhookServer to use
WebhookServer.dbInstance = testDb; WebhookServer.dbInstance = testDb;
// Inject testDb for GroupSyncService to use // Usar el locator global para el resto de servicios
GroupSyncService.dbInstance = testDb; setDb(testDb);
// Inject testDb for TaskService to use
(TaskService as any).dbInstance = testDb;
// Ensure database is initialized (recreates tables if dropped) // Ensure database is initialized (recreates tables if dropped)
initializeDatabase(testDb); initializeDatabase(testDb);
@ -158,7 +143,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
test('should ignore empty message content', async () => { test('should ignore empty message content', async () => {
@ -176,7 +161,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBe(0); expect(SimulatedResponseQueue.get().length).toBe(0);
}); });
test('should handle very long messages', async () => { test('should handle very long messages', async () => {
@ -195,7 +180,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
test('should handle messages with special characters and emojis', async () => { test('should handle messages with special characters and emojis', async () => {
@ -214,7 +199,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
test('should ignore non-/tarea commands', async () => { test('should ignore non-/tarea commands', async () => {
@ -232,7 +217,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBe(0); expect(SimulatedResponseQueue.get().length).toBe(0);
}); });
test('should ignore message with mentions but no command', async () => { test('should ignore message with mentions but no command', async () => {
@ -255,7 +240,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBe(0); expect(SimulatedResponseQueue.get().length).toBe(0);
}); });
test('should ignore media attachment messages', async () => { test('should ignore media attachment messages', async () => {
@ -275,7 +260,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBe(0); expect(SimulatedResponseQueue.get().length).toBe(0);
}); });
test('should process command from extendedTextMessage', async () => { test('should process command from extendedTextMessage', async () => {
@ -295,7 +280,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
test('should process command from image caption when caption starts with a command', async () => { test('should process command from image caption when caption starts with a command', async () => {
@ -315,7 +300,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
test('should handle requests on configured port', async () => { test('should handle requests on configured port', async () => {
@ -339,7 +324,7 @@ describe('WebhookServer', () => {
const server = await WebhookServer.start(); const server = await WebhookServer.start();
const response = await fetch('http://localhost:3007/health'); const response = await fetch('http://localhost:3007/health');
expect(response.status).toBe(200); expect(response.status).toBe(200);
server.stop(); await server.stop();
} finally { } finally {
process.env.PORT = originalPort; process.env.PORT = originalPort;
process.env.EVOLUTION_API_URL = prevEnv.EVOLUTION_API_URL; process.env.EVOLUTION_API_URL = prevEnv.EVOLUTION_API_URL;
@ -373,7 +358,7 @@ describe('WebhookServer', () => {
await WebhookServer.handleRequest(createTestRequest(payload)); await WebhookServer.handleRequest(createTestRequest(payload));
// Check that a response was queued (indicating command processing) // Check that a response was queued (indicating command processing)
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
test('should log command with due date', async () => { test('should log command with due date', async () => {
@ -398,7 +383,7 @@ describe('WebhookServer', () => {
await WebhookServer.handleRequest(createTestRequest(payload)); await WebhookServer.handleRequest(createTestRequest(payload));
// Verify command processing by checking queue // Verify command processing by checking queue
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
}); });
@ -418,7 +403,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
test('should handle multiple dates in command (use last one as due date)', async () => { test('should handle multiple dates in command (use last one as due date)', async () => {
@ -438,7 +423,7 @@ describe('WebhookServer', () => {
await WebhookServer.handleRequest(createTestRequest(payload)); await WebhookServer.handleRequest(createTestRequest(payload));
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
test('should ignore past dates as due dates', async () => { test('should ignore past dates as due dates', async () => {
@ -458,7 +443,7 @@ describe('WebhookServer', () => {
await WebhookServer.handleRequest(createTestRequest(payload)); await WebhookServer.handleRequest(createTestRequest(payload));
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
test('should handle multiple past dates correctly', async () => { test('should handle multiple past dates correctly', async () => {
@ -478,7 +463,7 @@ describe('WebhookServer', () => {
await WebhookServer.handleRequest(createTestRequest(payload)); await WebhookServer.handleRequest(createTestRequest(payload));
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
test('should handle mixed valid and invalid date formats', async () => { test('should handle mixed valid and invalid date formats', async () => {
@ -497,7 +482,7 @@ describe('WebhookServer', () => {
await WebhookServer.handleRequest(createTestRequest(payload)); await WebhookServer.handleRequest(createTestRequest(payload));
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
test('should normalize sender ID before processing', async () => { test('should normalize sender ID before processing', async () => {
@ -515,7 +500,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
test('should ignore messages with invalid sender ID', async () => { test('should ignore messages with invalid sender ID', async () => {
@ -533,7 +518,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBe(0); expect(SimulatedResponseQueue.get().length).toBe(0);
}); });
test('should ensure user exists and use normalized ID', async () => { test('should ensure user exists and use normalized ID', async () => {
@ -551,7 +536,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
// Verify user was created in real database // Verify user was created in real database
const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890'); const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890');
@ -574,7 +559,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBe(0); expect(SimulatedResponseQueue.get().length).toBe(0);
// Verify no user was created // Verify no user was created
const userCount = testDb.query("SELECT COUNT(*) as count FROM users").get(); const userCount = testDb.query("SELECT COUNT(*) as count FROM users").get();
@ -598,7 +583,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
// Verify user was created in real database // Verify user was created in real database
const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890'); const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890');
@ -621,7 +606,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBe(0); expect(SimulatedResponseQueue.get().length).toBe(0);
// Verify no user was created // Verify no user was created
const userCount = testDb.query("SELECT COUNT(*) as count FROM users").get(); const userCount = testDb.query("SELECT COUNT(*) as count FROM users").get();
@ -646,7 +631,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBe(0); expect(SimulatedResponseQueue.get().length).toBe(0);
// Reinitialize database for subsequent tests (force full migration) // Reinitialize database for subsequent tests (force full migration)
testDb.exec('DROP TABLE IF EXISTS schema_migrations'); testDb.exec('DROP TABLE IF EXISTS schema_migrations');
@ -668,7 +653,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
// Verify user was created/updated in database // Verify user was created/updated in database
const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890'); const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890');
@ -695,7 +680,7 @@ describe('WebhookServer', () => {
await WebhookServer.handleRequest(request); await WebhookServer.handleRequest(request);
// Verify that a response was queued, indicating command processing // Verify that a response was queued, indicating command processing
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
test('should handle end-to-end flow with valid user and command processing', async () => { test('should handle end-to-end flow with valid user and command processing', async () => {
@ -720,7 +705,7 @@ describe('WebhookServer', () => {
expect(user).toBeDefined(); expect(user).toBeDefined();
// Verify that a response was queued // Verify that a response was queued
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
}); });
@ -746,7 +731,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBe(0); expect(SimulatedResponseQueue.get().length).toBe(0);
}); });
test('should proceed with messages from active groups', async () => { test('should proceed with messages from active groups', async () => {
@ -764,7 +749,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
test('should accept /t alias and process command', async () => { test('should accept /t alias and process command', async () => {
@ -782,7 +767,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request); const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
}); });
test('should never send responses to the group (DM only policy)', async () => { test('should never send responses to the group (DM only policy)', async () => {
@ -800,7 +785,7 @@ describe('WebhookServer', () => {
const request = createTestRequest(payload); const request = createTestRequest(payload);
await WebhookServer.handleRequest(request); await WebhookServer.handleRequest(request);
const out = SimulatedResponseQueue.getQueue(); const out = SimulatedResponseQueue.get();
expect(out.length).toBeGreaterThan(0); expect(out.length).toBeGreaterThan(0);
for (const r of out) { for (const r of out) {
expect(r.recipient.endsWith('@g.us')).toBe(false); expect(r.recipient.endsWith('@g.us')).toBe(false);
@ -848,7 +833,7 @@ describe('WebhookServer', () => {
const response = await WebhookServer.handleRequest(createTestRequest(payload)); const response = await WebhookServer.handleRequest(createTestRequest(payload));
expect(response.status).toBe(200); expect(response.status).toBe(200);
const out = SimulatedResponseQueue.getQueue(); const out = SimulatedResponseQueue.get();
expect(out.length).toBeGreaterThan(0); expect(out.length).toBeGreaterThan(0);
for (const r of out) { for (const r of out) {
expect(r.recipient.endsWith('@g.us')).toBe(false); expect(r.recipient.endsWith('@g.us')).toBe(false);
@ -872,7 +857,7 @@ describe('WebhookServer', () => {
const response = await WebhookServer.handleRequest(createTestRequest(payload)); const response = await WebhookServer.handleRequest(createTestRequest(payload));
expect(response.status).toBe(200); expect(response.status).toBe(200);
const out = SimulatedResponseQueue.getQueue(); const out = SimulatedResponseQueue.get();
expect(out.length).toBeGreaterThan(0); expect(out.length).toBeGreaterThan(0);
const msg = out.map(x => x.message).join('\n'); const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('No tienes tareas pendientes.'); expect(msg).toContain('No tienes tareas pendientes.');
@ -918,7 +903,7 @@ describe('WebhookServer', () => {
const response = await WebhookServer.handleRequest(createTestRequest(payload)); const response = await WebhookServer.handleRequest(createTestRequest(payload));
expect(response.status).toBe(200); expect(response.status).toBe(200);
const out = SimulatedResponseQueue.getQueue(); const out = SimulatedResponseQueue.get();
expect(out.length).toBeGreaterThan(0); expect(out.length).toBeGreaterThan(0);
const msg = out.map(x => x.message).join('\n'); const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('No respondo en grupos.'); expect(msg).toContain('No respondo en grupos.');
@ -946,7 +931,7 @@ describe('WebhookServer', () => {
const response = await WebhookServer.handleRequest(createTestRequest(payload)); const response = await WebhookServer.handleRequest(createTestRequest(payload));
expect(response.status).toBe(200); expect(response.status).toBe(200);
const out = SimulatedResponseQueue.getQueue(); const out = SimulatedResponseQueue.get();
expect(out.length).toBeGreaterThan(0); expect(out.length).toBeGreaterThan(0);
const msg = out.map(x => x.message).join('\n'); const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('Tus tareas'); expect(msg).toContain('Tus tareas');

@ -3,18 +3,12 @@ import { Database } from 'bun:sqlite';
import { WebhookServer } from '../../../src/server'; import { WebhookServer } from '../../../src/server';
import { initializeDatabase } from '../../../src/db'; import { initializeDatabase } from '../../../src/db';
import { ResponseQueue } from '../../../src/services/response-queue'; import { ResponseQueue } from '../../../src/services/response-queue';
import { setDb, resetDb } from '../../../src/db/locator';
let testDb: Database; let testDb: Database;
let originalAdd: any; let originalAdd: any;
let simulatedQueue: any[] = []; import { SimulatedResponseQueue } from '../../helpers/queue';
const SimulatedResponseQueue = {
async add(responses: any[]) {
simulatedQueue.push(...responses);
},
clear() { simulatedQueue = []; },
get() { return simulatedQueue; }
};
const createTestRequest = (payload: any) => const createTestRequest = (payload: any) =>
new Request('http://localhost:3007', { new Request('http://localhost:3007', {
@ -34,6 +28,7 @@ describe('WebhookServer - /admin aprobación en modo enforce', () => {
afterAll(() => { afterAll(() => {
(ResponseQueue as any).add = originalAdd; (ResponseQueue as any).add = originalAdd;
resetDb();
testDb.close(); testDb.close();
}); });
@ -47,6 +42,7 @@ describe('WebhookServer - /admin aprobación en modo enforce', () => {
SimulatedResponseQueue.clear(); SimulatedResponseQueue.clear();
(ResponseQueue as any).add = SimulatedResponseQueue.add; (ResponseQueue as any).add = SimulatedResponseQueue.add;
WebhookServer.dbInstance = testDb; WebhookServer.dbInstance = testDb;
setDb(testDb);
testDb.exec('DELETE FROM response_queue'); testDb.exec('DELETE FROM response_queue');
testDb.exec('DELETE FROM allowed_groups'); testDb.exec('DELETE FROM allowed_groups');

@ -4,18 +4,12 @@ import { WebhookServer } from '../../../src/server';
import { initializeDatabase } from '../../../src/db'; import { initializeDatabase } from '../../../src/db';
import { ResponseQueue } from '../../../src/services/response-queue'; import { ResponseQueue } from '../../../src/services/response-queue';
import { GroupSyncService } from '../../../src/services/group-sync'; import { GroupSyncService } from '../../../src/services/group-sync';
import { setDb, resetDb } from '../../../src/db/locator';
let testDb: Database; let testDb: Database;
let originalAdd: any; let originalAdd: any;
let simulatedQueue: any[] = []; import { SimulatedResponseQueue } from '../../helpers/queue';
const SimulatedResponseQueue = {
async add(responses: any[]) {
simulatedQueue.push(...responses);
},
clear() { simulatedQueue = []; },
get() { return simulatedQueue; }
};
const createTestRequest = (payload: any) => const createTestRequest = (payload: any) =>
new Request('http://localhost:3007', { new Request('http://localhost:3007', {
@ -35,6 +29,7 @@ describe('WebhookServer - discovery guarda label del grupo si está en caché',
afterAll(() => { afterAll(() => {
(ResponseQueue as any).add = originalAdd; (ResponseQueue as any).add = originalAdd;
resetDb();
testDb.close(); testDb.close();
}); });
@ -47,6 +42,7 @@ describe('WebhookServer - discovery guarda label del grupo si está en caché',
SimulatedResponseQueue.clear(); SimulatedResponseQueue.clear();
(ResponseQueue as any).add = SimulatedResponseQueue.add; (ResponseQueue as any).add = SimulatedResponseQueue.add;
WebhookServer.dbInstance = testDb; WebhookServer.dbInstance = testDb;
setDb(testDb);
// Limpiar tablas relevantes // Limpiar tablas relevantes
testDb.exec('DELETE FROM response_queue'); testDb.exec('DELETE FROM response_queue');

@ -3,18 +3,12 @@ import { Database } from 'bun:sqlite';
import { WebhookServer } from '../../../src/server'; import { WebhookServer } from '../../../src/server';
import { initializeDatabase } from '../../../src/db'; import { initializeDatabase } from '../../../src/db';
import { ResponseQueue } from '../../../src/services/response-queue'; import { ResponseQueue } from '../../../src/services/response-queue';
import { setDb, resetDb } from '../../../src/db/locator';
let testDb: Database; let testDb: Database;
let originalAdd: any; let originalAdd: any;
let simulatedQueue: any[] = []; import { SimulatedResponseQueue } from '../../helpers/queue';
const SimulatedResponseQueue = {
async add(responses: any[]) {
simulatedQueue.push(...responses);
},
clear() { simulatedQueue = []; },
get() { return simulatedQueue; }
};
const createTestRequest = (payload: any) => const createTestRequest = (payload: any) =>
new Request('http://localhost:3007', { new Request('http://localhost:3007', {
@ -34,6 +28,7 @@ describe('WebhookServer - notifica a ADMIN_USERS en descubrimiento (modo discove
afterAll(() => { afterAll(() => {
(ResponseQueue as any).add = originalAdd; (ResponseQueue as any).add = originalAdd;
resetDb();
testDb.close(); testDb.close();
}); });
@ -48,6 +43,7 @@ describe('WebhookServer - notifica a ADMIN_USERS en descubrimiento (modo discove
SimulatedResponseQueue.clear(); SimulatedResponseQueue.clear();
(ResponseQueue as any).add = SimulatedResponseQueue.add; (ResponseQueue as any).add = SimulatedResponseQueue.add;
WebhookServer.dbInstance = testDb; WebhookServer.dbInstance = testDb;
setDb(testDb);
// Limpiar tablas relevantes // Limpiar tablas relevantes
testDb.exec('DELETE FROM response_queue'); testDb.exec('DELETE FROM response_queue');

@ -5,18 +5,12 @@ import { initializeDatabase } from '../../../src/db';
import { ResponseQueue } from '../../../src/services/response-queue'; import { ResponseQueue } from '../../../src/services/response-queue';
import { AllowedGroups } from '../../../src/services/allowed-groups'; import { AllowedGroups } from '../../../src/services/allowed-groups';
import { GroupSyncService } from '../../../src/services/group-sync'; import { GroupSyncService } from '../../../src/services/group-sync';
import { setDb, resetDb } from '../../../src/db/locator';
let testDb: Database; let testDb: Database;
let originalAdd: any; let originalAdd: any;
let simulatedQueue: any[] = []; import { SimulatedResponseQueue } from '../../helpers/queue';
const SimulatedResponseQueue = {
async add(responses: any[]) {
simulatedQueue.push(...responses);
},
clear() { simulatedQueue = []; },
get() { return simulatedQueue; }
};
const createTestRequest = (payload: any) => const createTestRequest = (payload: any) =>
new Request('http://localhost:3007', { new Request('http://localhost:3007', {
@ -36,6 +30,7 @@ describe('WebhookServer - enforce gating (modo=enforce)', () => {
afterAll(() => { afterAll(() => {
(ResponseQueue as any).add = originalAdd; (ResponseQueue as any).add = originalAdd;
resetDb();
testDb.close(); testDb.close();
}); });
@ -48,7 +43,7 @@ describe('WebhookServer - enforce gating (modo=enforce)', () => {
SimulatedResponseQueue.clear(); SimulatedResponseQueue.clear();
(ResponseQueue as any).add = SimulatedResponseQueue.add; (ResponseQueue as any).add = SimulatedResponseQueue.add;
WebhookServer.dbInstance = testDb; WebhookServer.dbInstance = testDb;
(AllowedGroups as any).dbInstance = testDb; setDb(testDb);
// Limpiar tablas relevantes // Limpiar tablas relevantes
testDb.exec('DELETE FROM response_queue'); testDb.exec('DELETE FROM response_queue');

@ -3,18 +3,12 @@ import { Database } from 'bun:sqlite';
import { WebhookServer } from '../../../src/server'; import { WebhookServer } from '../../../src/server';
import { initializeDatabase } from '../../../src/db'; import { initializeDatabase } from '../../../src/db';
import { ResponseQueue } from '../../../src/services/response-queue'; import { ResponseQueue } from '../../../src/services/response-queue';
import { setDb, resetDb } from '../../../src/db/locator';
let testDb: Database; let testDb: Database;
let originalAdd: any; let originalAdd: any;
let simulatedQueue: any[] = []; import { SimulatedResponseQueue } from '../../helpers/queue';
const SimulatedResponseQueue = {
async add(responses: any[]) {
simulatedQueue.push(...responses);
},
clear() { simulatedQueue = []; },
get() { return simulatedQueue; }
};
const createTestRequest = (payload: any) => const createTestRequest = (payload: any) =>
new Request('http://localhost:3007', { new Request('http://localhost:3007', {
@ -34,6 +28,7 @@ describe('WebhookServer - unknown group discovery (mode=discover)', () => {
afterAll(() => { afterAll(() => {
(ResponseQueue as any).add = originalAdd; (ResponseQueue as any).add = originalAdd;
resetDb();
testDb.close(); testDb.close();
}); });
@ -46,6 +41,7 @@ describe('WebhookServer - unknown group discovery (mode=discover)', () => {
SimulatedResponseQueue.clear(); SimulatedResponseQueue.clear();
(ResponseQueue as any).add = SimulatedResponseQueue.add; (ResponseQueue as any).add = SimulatedResponseQueue.add;
WebhookServer.dbInstance = testDb; WebhookServer.dbInstance = testDb;
setDb(testDb);
// Limpiar tablas relevantes // Limpiar tablas relevantes
testDb.exec('DELETE FROM response_queue'); testDb.exec('DELETE FROM response_queue');

@ -5,6 +5,7 @@ import { WebhookServer } from '../../../src/server';
import { ResponseQueue } from '../../../src/services/response-queue'; import { ResponseQueue } from '../../../src/services/response-queue';
import { AllowedGroups } from '../../../src/services/allowed-groups'; import { AllowedGroups } from '../../../src/services/allowed-groups';
import { GroupSyncService } from '../../../src/services/group-sync'; import { GroupSyncService } from '../../../src/services/group-sync';
import { setDb } from '../../../src/db/locator';
function makePayload(event: string, data: any) { function makePayload(event: string, data: any) {
return { return {
@ -31,9 +32,7 @@ describe('WebhookServer E2E - reacciones por comando', () => {
memdb = new Database(':memory:'); memdb = new Database(':memory:');
initializeDatabase(memdb); initializeDatabase(memdb);
(WebhookServer as any).dbInstance = memdb; (WebhookServer as any).dbInstance = memdb;
(ResponseQueue as any).dbInstance = memdb; setDb(memdb);
(AllowedGroups as any).dbInstance = memdb;
(GroupSyncService as any).dbInstance = memdb;
}); });
afterAll(() => { afterAll(() => {

@ -2,6 +2,7 @@ import { describe, it, beforeEach, expect } from 'bun:test';
import { makeMemDb } from '../../helpers/db'; import { makeMemDb } from '../../helpers/db';
import { AdminService } from '../../../src/services/admin'; import { AdminService } from '../../../src/services/admin';
import { AllowedGroups } from '../../../src/services/allowed-groups'; import { AllowedGroups } from '../../../src/services/allowed-groups';
import { setDb } from '../../../src/db/locator';
describe('AdminService - comandos básicos', () => { describe('AdminService - comandos básicos', () => {
const envBackup = process.env; const envBackup = process.env;
@ -10,8 +11,7 @@ describe('AdminService - comandos básicos', () => {
beforeEach(() => { beforeEach(() => {
process.env = { ...envBackup, NODE_ENV: 'test', ADMIN_USERS: '34600123456' }; process.env = { ...envBackup, NODE_ENV: 'test', ADMIN_USERS: '34600123456' };
memdb = makeMemDb(); memdb = makeMemDb();
(AdminService as any).dbInstance = memdb; setDb(memdb);
(AllowedGroups as any).dbInstance = memdb;
AllowedGroups.resetForTests(); AllowedGroups.resetForTests();
}); });

@ -1,11 +1,12 @@
import { describe, it, beforeEach, expect } from 'bun:test'; import { describe, it, beforeEach, expect } from 'bun:test';
import { makeMemDb } from '../../helpers/db'; import { makeMemDb } from '../../helpers/db';
import { AllowedGroups } from '../../../src/services/allowed-groups'; import { AllowedGroups } from '../../../src/services/allowed-groups';
import { setDb } from '../../../src/db/locator';
describe('AllowedGroups service', () => { describe('AllowedGroups service', () => {
beforeEach(() => { beforeEach(() => {
const memdb = makeMemDb(); const memdb = makeMemDb();
(AllowedGroups as any).dbInstance = memdb; setDb(memdb);
AllowedGroups.resetForTests(); AllowedGroups.resetForTests();
}); });

@ -3,9 +3,7 @@ import { Database } from 'bun:sqlite';
import { initializeDatabase } from '../../../src/db'; import { initializeDatabase } from '../../../src/db';
import { MaintenanceService } from '../../../src/services/maintenance'; import { MaintenanceService } from '../../../src/services/maintenance';
function toIso(d: Date): string { import { toIsoSql as toIso } from '../../helpers/dates';
return d.toISOString().replace('T', ' ').replace('Z', '');
}
const envBackup = { ...process.env }; const envBackup = { ...process.env };
let memdb: Database; let memdb: Database;

@ -3,6 +3,7 @@ import { Database } from 'bun:sqlite';
import { initializeDatabase } from '../../../src/db'; import { initializeDatabase } from '../../../src/db';
import { TaskService } from '../../../src/tasks/service'; import { TaskService } from '../../../src/tasks/service';
import { CommandService } from '../../../src/services/command'; import { CommandService } from '../../../src/services/command';
import { setDb } from '../../../src/db/locator';
describe('CommandService - asignación por defecto (sin dueño vs creador)', () => { describe('CommandService - asignación por defecto (sin dueño vs creador)', () => {
let memdb: Database; let memdb: Database;
@ -10,8 +11,7 @@ describe('CommandService - asignación por defecto (sin dueño vs creador)', ()
beforeAll(() => { beforeAll(() => {
memdb = new Database(':memory:'); memdb = new Database(':memory:');
initializeDatabase(memdb); initializeDatabase(memdb);
TaskService.dbInstance = memdb; setDb(memdb);
CommandService.dbInstance = memdb;
}); });
beforeEach(() => { beforeEach(() => {

@ -3,6 +3,7 @@ import { Database } from 'bun:sqlite';
import { initializeDatabase, ensureUserExists } from '../../../src/db'; import { initializeDatabase, ensureUserExists } from '../../../src/db';
import { TaskService } from '../../../src/tasks/service'; import { TaskService } from '../../../src/tasks/service';
import { CommandService } from '../../../src/services/command'; 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; let memdb: Database;
@ -10,8 +11,7 @@ describe('CommandService - /t tomar y /t soltar', () => {
beforeAll(() => { beforeAll(() => {
memdb = new Database(':memory:'); memdb = new Database(':memory:');
initializeDatabase(memdb); initializeDatabase(memdb);
TaskService.dbInstance = memdb; setDb(memdb);
CommandService.dbInstance = memdb;
}); });
beforeEach(() => { beforeEach(() => {

@ -3,24 +3,10 @@ import { Database } from 'bun:sqlite';
import { initializeDatabase } from '../../../src/db'; import { initializeDatabase } from '../../../src/db';
import { TaskService } from '../../../src/tasks/service'; import { TaskService } from '../../../src/tasks/service';
import { CommandService } from '../../../src/services/command'; import { CommandService } from '../../../src/services/command';
import { setDb } from '../../../src/db/locator';
import { ymdInTZ, addDaysToYMD } from '../../helpers/dates';
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')}`;
}
function addDaysToYMD(ymd: string, days: number, tz: string = 'Europe/Madrid'): string {
const [Y, M, D] = ymd.split('-').map(n => parseInt(n, 10));
const base = new Date(Date.UTC(Y, (M || 1) - 1, D || 1));
base.setUTCDate(base.getUTCDate() + days);
return ymdInTZ(base, tz);
}
describe('CommandService - parser de fechas (hoy/mañana y formatos YYYY/YY-MM-DD)', () => { describe('CommandService - parser de fechas (hoy/mañana y formatos YYYY/YY-MM-DD)', () => {
let memdb: Database; let memdb: Database;
@ -28,8 +14,7 @@ describe('CommandService - parser de fechas (hoy/mañana y formatos YYYY/YY-MM-D
beforeAll(() => { beforeAll(() => {
memdb = new Database(':memory:'); memdb = new Database(':memory:');
initializeDatabase(memdb); initializeDatabase(memdb);
TaskService.dbInstance = memdb; setDb(memdb);
CommandService.dbInstance = memdb;
}); });
beforeEach(() => { beforeEach(() => {

@ -3,6 +3,7 @@ import { Database } from 'bun:sqlite';
import { initializeDatabase } from '../../../src/db'; import { initializeDatabase } from '../../../src/db';
import { TaskService } from '../../../src/tasks/service'; import { TaskService } from '../../../src/tasks/service';
import { CommandService } from '../../../src/services/command'; import { CommandService } from '../../../src/services/command';
import { setDb } from '../../../src/db/locator';
describe('CommandService - formato dd/MM en ACK de creación', () => { describe('CommandService - formato dd/MM en ACK de creación', () => {
let memdb: Database; let memdb: Database;
@ -10,8 +11,7 @@ describe('CommandService - formato dd/MM en ACK de creación', () => {
beforeAll(() => { beforeAll(() => {
memdb = new Database(':memory:'); memdb = new Database(':memory:');
initializeDatabase(memdb); initializeDatabase(memdb);
TaskService.dbInstance = memdb; setDb(memdb);
CommandService.dbInstance = memdb;
}); });
beforeEach(() => { beforeEach(() => {

@ -2,19 +2,22 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { makeMemDb } from '../../helpers/db'; import { makeMemDb } from '../../helpers/db';
import { CommandService } from '../../../src/services/command'; import { CommandService } from '../../../src/services/command';
import { AllowedGroups } from '../../../src/services/allowed-groups'; import { AllowedGroups } from '../../../src/services/allowed-groups';
import { setDb, resetDb } from '../../../src/db/locator';
describe('CommandService - gating en modo enforce', () => { describe('CommandService - gating en modo enforce', () => {
const envBackup = process.env; const envBackup = process.env;
let memdb: any;
beforeEach(() => { beforeEach(() => {
process.env = { ...envBackup, NODE_ENV: 'test', GROUP_GATING_MODE: 'enforce' }; process.env = { ...envBackup, NODE_ENV: 'test', GROUP_GATING_MODE: 'enforce' };
const memdb = makeMemDb(); memdb = makeMemDb();
(CommandService as any).dbInstance = memdb; setDb(memdb);
(AllowedGroups as any).dbInstance = memdb; try { AllowedGroups.resetForTests(); } catch {}
}); });
afterEach(() => { afterEach(() => {
process.env = envBackup; process.env = envBackup;
try { resetDb(); memdb.close(); } catch {}
}); });
it('bloquea comandos en grupo no permitido (desconocido)', async () => { it('bloquea comandos en grupo no permitido (desconocido)', async () => {

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save