diff --git a/README.md b/README.md index 1699481..3194a9b 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Variables clave: - GROUP_GATING_MODE: off | discover | enforce. - WHATSAPP_COMMUNITY_ID (para sincronización de grupos). - TZ (por defecto Europe/Madrid). +- REMINDERS_GRACE_MINUTES (ventana de gracia tras la hora; por defecto 60). - ALLOWED_GROUPS (semilla inicial), NOTIFY_ADMINS_ON_DISCOVERY. - METRICS_ENABLED, PORT. - Rate limit: RATE_LIMIT_PER_MIN, RATE_LIMIT_BURST. diff --git a/docs/operations.md b/docs/operations.md index 531eb2a..c094ef6 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -15,6 +15,7 @@ Variables de entorno (principales) - GROUP_MEMBERS_SYNC_INTERVAL_MS: intervalo de sync de miembros (default 6h; min 10s en dev). - GROUP_MEMBERS_INACTIVE_RETENTION_DAYS: días para borrar miembros inactivos (default 180). - TZ: zona horaria para recordatorios (default Europe/Madrid). +- REMINDERS_GRACE_MINUTES: minutos de gracia tras la hora programada para enviar recordatorios atrasados (por defecto 60). - GROUP_GATING_MODE: 'off' | 'discover' | 'enforce' (control de acceso por grupos; por defecto 'off'). Ej.: GROUP_GATING_MODE='discover' - ADMIN_USERS: lista separada por comas de IDs/JIDs autorizados para /admin (se normalizan a dígitos). Ej.: ADMIN_USERS='34600123456, 5554443333, +34 600 111 222' - ALLOWED_GROUPS: lista separada por comas de group_id@g.us para sembrado inicial en arranque. Ej.: ALLOWED_GROUPS='12345-67890@g.us, 11111-22222@g.us' @@ -35,7 +36,7 @@ Schedulers - GroupSyncService.startGroupsScheduler() y .startMembersScheduler() - Saltan en test; intervalos controlados por env. - RemindersService.start() - - Tick cada minuto, filtra por zona horaria y preferencias. + - Tick cada minuto, filtra por zona horaria y preferencias, con ventana de gracia configurable (60 min por defecto) tras la hora programada. - MaintenanceService.start() - Tarea diaria; borra miembros inactivos según retención. diff --git a/src/services/reminders.ts b/src/services/reminders.ts index 9666b8c..977ce11 100644 --- a/src/services/reminders.ts +++ b/src/services/reminders.ts @@ -7,6 +7,7 @@ import { GroupSyncService } from './group-sync'; import { ICONS } from '../utils/icons'; import { codeId, formatDDMM, bold, italic } from '../utils/formatting'; import { AllowedGroups } from './allowed-groups'; +import { Metrics } from './metrics'; type UserPreference = { user_id: string; @@ -82,6 +83,8 @@ export class RemindersService { const todayYMD = this.ymdInTZ(now); const nowHM = this.hmInTZ(now); const weekday = this.weekdayShortInTZ(now); // 'Mon'..'Sun' + const graceRaw = Number(process.env.REMINDERS_GRACE_MINUTES); + const GRACE_MIN = Number.isFinite(graceRaw) && graceRaw >= 0 ? Math.min(Math.floor(graceRaw), 180) : 60; const rows = this.dbInstance.prepare(` SELECT user_id, reminder_freq, reminder_time, last_reminded_on @@ -103,8 +106,27 @@ export class RemindersService { // Evitar duplicado el mismo día if (pref.last_reminded_on === todayYMD) continue; - // Verificar hora alcanzada - if (!pref.reminder_time || nowHM < pref.reminder_time) continue; + // Verificar hora alcanzada y ventana de gracia + if (!pref.reminder_time) continue; + const [nowH, nowM] = String(nowHM).split(':'); + const [cfgH, cfgM] = String(pref.reminder_time).split(':'); + const nowMin = (parseInt(nowH || '0', 10) || 0) * 60 + (parseInt(nowM || '0', 10) || 0); + const cfgMin = (parseInt(cfgH || '0', 10) || 0) * 60 + (parseInt(cfgM || '0', 10) || 0); + + // Antes de la hora programada + if (nowMin < cfgMin) continue; + + // Sólo incrementar métrica si es un día válido para el usuario + const isValidDay = !( + (pref.reminder_freq === 'weekdays' && (weekday === 'Sat' || weekday === 'Sun')) || + (pref.reminder_freq === 'weekly' && weekday !== 'Mon') + ); + + // Fuera de ventana de gracia: saltar + if (nowMin > cfgMin + GRACE_MIN) { + try { if (isValidDay) Metrics.inc('reminders_skipped_outside_window_total'); } catch {} + continue; + } // Laborables: solo de lunes a viernes if (pref.reminder_freq === 'weekdays' && (weekday === 'Sat' || weekday === 'Sun')) continue; diff --git a/tests/unit/services/reminders.test.ts b/tests/unit/services/reminders.test.ts index 35ba108..8f6e180 100644 --- a/tests/unit/services/reminders.test.ts +++ b/tests/unit/services/reminders.test.ts @@ -16,6 +16,7 @@ describe('RemindersService', () => { beforeEach(() => { process.env.NODE_ENV = 'test'; process.env.TZ = 'Europe/Madrid'; + process.env.REMINDERS_GRACE_MINUTES = '60'; memdb = new Database(':memory:'); initializeDatabase(memdb); @@ -195,4 +196,35 @@ describe('RemindersService', () => { expect(countQueued()).toBe(0); expect(getLastReminded()).toBeNull(); }); + + it('no envía fuera de la ventana de gracia tras reinicio tardío', async () => { + insertPref('daily', '08:30', null); + // 08:30 local + 90 min ≈ 10:00 local => 08:00Z en CEST + const now = new Date('2025-09-08T08:00:00.000Z'); + + // Crear una tarea asignada después de la hora configurada + TaskService.createTask( + { description: 'Tarea fuera de ventana', due_date: '2025-09-20', group_id: null, created_by: USER }, + [{ user_id: USER, assigned_by: USER }] + ); + + await RemindersService.runOnce(now); + expect(countQueued()).toBe(0); + expect(getLastReminded()).toBeNull(); + }); + + it('envía dentro de la ventana de gracia si hay tareas', async () => { + insertPref('daily', '08:30', null); + // 08:30 local + 45 min ≈ 09:15 local => 07:15Z en CEST + const now = new Date('2025-09-08T07:15:00.000Z'); + + TaskService.createTask( + { description: 'Tarea dentro de ventana', due_date: '2025-09-20', group_id: null, created_by: USER }, + [{ user_id: USER, assigned_by: USER }] + ); + + await RemindersService.runOnce(now); + expect(countQueued()).toBe(1); + expect(getLastReminded()).toBe('2025-09-08'); + }); });