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'; 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 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[]; for (const pref of rows) { // 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; // 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 { const items = TaskService.listUserPending(pref.user_id, 10); const total = TaskService.countUserPending(pref.user_id); if (!items || items.length === 0 || total === 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); } 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) { const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(pref.user_id); 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); } } } }