feat: implementar ventana de gracia para recordatorios y métricas

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
main
borja 3 weeks ago
parent 51a623450d
commit cae5a7f1f6

@ -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.

@ -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.

@ -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;

@ -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');
});
});

Loading…
Cancel
Save