import type { Database } from 'bun:sqlite'; import { db } from '../db'; import { TaskService } from '../tasks/service'; import { ResponseQueue } from './response-queue'; import { ContactsService } from './contacts'; 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; reminder_freq: 'daily' | 'weekly' | 'weekdays' | 'off'; reminder_time: string; // 'HH:MM' last_reminded_on: string | null; // 'YYYY-MM-DD' }; export class RemindersService { static dbInstance: Database = db; private static _running = false; private static _timer: any = null; private static get TZ() { return process.env.TZ && process.env.TZ.trim() ? process.env.TZ : 'Europe/Madrid'; } private static ymdInTZ(d: Date): string { const parts = new Intl.DateTimeFormat('en-GB', { timeZone: this.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')}`; } private static hmInTZ(d: Date): string { const parts = new Intl.DateTimeFormat('en-GB', { timeZone: this.TZ, hour: '2-digit', minute: '2-digit', hour12: false, }).formatToParts(d); const get = (t: string) => parts.find(p => p.type === t)?.value || ''; return `${get('hour')}:${get('minute')}`; } private static weekdayShortInTZ(d: Date): string { return new Intl.DateTimeFormat('en-GB', { timeZone: this.TZ, weekday: 'short', }).format(d); // e.g., 'Mon', 'Tue', ... } static start() { if (process.env.NODE_ENV === 'test') return; if (this._running) return; this._running = true; // Arranca un tick cada minuto this._timer = setInterval(() => { this.runOnce().catch(err => { console.error('RemindersService runOnce error:', err); }); }, 60_000); // Primer tick diferido para no bloquear el arranque setTimeout(() => this.runOnce().catch(() => {}), 5_000); } static stop() { this._running = false; if (this._timer) { clearInterval(this._timer); this._timer = null; } } static async runOnce(now: Date = new Date()): Promise { 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 FROM user_preferences WHERE reminder_freq IN ('daily', 'weekly', 'weekdays') `).all() as UserPreference[]; // Determinar si aplicar gating por grupos const enforce = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase() === 'enforce'; if (enforce) { try { (AllowedGroups as any).dbInstance = this.dbInstance; // Evitar falsos positivos por caché obsoleta entre operaciones previas del test AllowedGroups.clearCache?.(); } catch {} } for (const pref of rows) { // Evitar duplicado el mismo día if (pref.last_reminded_on === todayYMD) continue; // Verificar hora alcanzada y ventana de gracia if (!pref.reminder_time) continue; const [nowH, nowM] = String(nowHM).split(':'); const [cfgH, cfgM] = String(pref.reminder_time).split(':'); const nowMin = (parseInt(nowH || '0', 10) || 0) * 60 + (parseInt(nowM || '0', 10) || 0); const cfgMin = (parseInt(cfgH || '0', 10) || 0) * 60 + (parseInt(cfgM || '0', 10) || 0); // 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; // Semanal: solo lunes (Mon) if (pref.reminder_freq === 'weekly' && weekday !== 'Mon') continue; try { // Obtener una lista amplia para filtrar correctamente por grupos permitidos const allPending = TaskService.listUserPending(pref.user_id, 1000); const filtered = enforce ? allPending.filter(t => !t.group_id || AllowedGroups.isAllowed(t.group_id)) : allPending; const total = filtered.length; const items = filtered.slice(0, 10); if (items.length === 0) { // No enviar si no hay tareas; no marcamos last_reminded_on para permitir enviar si aparecen más tarde hoy continue; } // Construir mensaje similar a "/t ver mis" const formatDDMM = (ymd?: string | null): string | null => { if (!ymd) return null; const parts = String(ymd).split('-'); if (parts.length >= 3) { const [Y, M, D] = parts; if (D && M) return `${D}/${M}`; } return String(ymd); }; const byGroup = new Map(); for (const t of items) { const key = t.group_id || '(sin grupo)'; const arr = byGroup.get(key) || []; arr.push(t); byGroup.set(key, arr); } const sections: string[] = []; sections.push(pref.reminder_freq === 'weekly' ? `${ICONS.reminder} RECORDATORIO SEMANAL — TUS TAREAS` : `${ICONS.reminder} RECORDATORIO DIARIO — TUS TAREAS`); for (const [groupId, arr] of byGroup.entries()) { const groupName = (groupId && GroupSyncService.activeGroupsCache.get(groupId)) || (groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo'); sections.push(bold(groupName)); const rendered = await Promise.all(arr.map(async (t) => { const names = await Promise.all( (t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid) ); const owner = (t.assignees?.length || 0) === 0 ? `${ICONS.unassigned} sin responsable` : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; const isOverdue = t.due_date ? t.due_date < todayYMD : false; const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`; })); sections.push(...rendered); } // Si hay más tareas de las listadas (tope), añadir resumen if (total > items.length) { sections.push(italic(`… y ${total - items.length} más`)); } // (Etapa 3) Sección opcional de "sin responsable" filtrada por membresía activa + snapshot fresca. const includeUnassigned = String(process.env.REMINDERS_INCLUDE_UNASSIGNED_FROM_MEMBERSHIP || '').toLowerCase() === 'true'; if (includeUnassigned) { let memberGroups = GroupSyncService.getFreshMemberGroupsForUser(pref.user_id); if (enforce) { memberGroups = memberGroups.filter(gid => AllowedGroups.isAllowed(gid)); } for (const gid of memberGroups) { const unassigned = TaskService.listGroupUnassigned(gid, 10); if (unassigned.length > 0) { const groupName = (gid && GroupSyncService.activeGroupsCache.get(gid)) || gid; sections.push(bold(`${groupName} — Sin responsable`)); const renderedUnassigned = unassigned.map((t) => { const isOverdue = t.due_date ? t.due_date < todayYMD : false; const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${ICONS.unassigned} sin responsable`; }); sections.push(...renderedUnassigned); const totalUnassigned = TaskService.countGroupUnassigned(gid); if (totalUnassigned > unassigned.length) { sections.push(italic(`… y ${totalUnassigned - unassigned.length} más`)); } } } } await ResponseQueue.add([{ recipient: pref.user_id, message: sections.join('\n') }]); // Marcar como enviado hoy this.dbInstance.prepare(` INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at) VALUES (?, COALESCE((SELECT reminder_freq FROM user_preferences WHERE user_id = ?), 'daily'), COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30'), ?, strftime('%Y-%m-%d %H:%M:%f', 'now')) ON CONFLICT(user_id) DO UPDATE SET last_reminded_on = excluded.last_reminded_on, updated_at = excluded.updated_at `).run(pref.user_id, pref.user_id, pref.user_id, todayYMD); } catch (e) { console.error('RemindersService: error al procesar usuario', pref.user_id, e); } } } }