diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index d84b558..95e74b1 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -126,5 +126,26 @@ export const migrations: Migration[] = [ ON response_queue (status, updated_at); `); } + }, + { + version: 4, + name: 'user-preferences-reminders', + checksum: 'v4-user-preferences-2025-09-07', + up: (db: Database) => { + db.exec(` + CREATE TABLE IF NOT EXISTS user_preferences ( + user_id TEXT PRIMARY KEY, + reminder_freq TEXT NOT NULL DEFAULT 'off' CHECK (reminder_freq IN ('off','daily','weekly')), + reminder_time TEXT NOT NULL DEFAULT '08:30', + last_reminded_on TEXT NULL, + updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_user_prefs_freq_time + ON user_preferences (reminder_freq, reminder_time); + `); + } } ]; diff --git a/src/server.ts b/src/server.ts index e2d6fa5..f60add6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,6 +10,7 @@ import { ensureUserExists, db } from './db'; import { ContactsService } from './services/contacts'; import { Migrator } from './db/migrator'; import { RateLimiter } from './services/rate-limit'; +import { RemindersService } from './services/reminders'; // Bun is available globally when running under Bun runtime declare global { @@ -313,6 +314,8 @@ export class WebhookServer { // 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); } diff --git a/src/services/command.ts b/src/services/command.ts index 52f1041..aa5b30d 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -157,7 +157,8 @@ export class CommandService { '- Crear: /t n Descripción mañana @Ana', '- Ver grupo: /t ver grupo', '- Ver mis: /t ver mis', - '- Completar: /t x 123' + '- Completar: /t x 123', + '- Configurar recordatorios: /t configurar daily|weekly|off' ].join('\n'); return [{ recipient: context.sender, @@ -509,6 +510,43 @@ export class CommandService { }]; } + if (action === 'configurar') { + const optRaw = (tokens[2] || '').toLowerCase(); + const map: Record = { + 'daily': 'daily', + 'diario': 'daily', + 'diaria': 'daily', + 'semanal': 'weekly', + 'weekly': 'weekly', + 'off': 'off', + 'apagar': 'off', + 'ninguno': 'off' + }; + const freq = map[optRaw]; + if (!freq) { + return [{ + recipient: context.sender, + message: 'Uso: /t configurar daily|weekly|off' + }]; + } + const ensured = ensureUserExists(context.sender, this.dbInstance); + if (!ensured) { + throw new Error('No se pudo asegurar el usuario'); + } + this.dbInstance.prepare(` + INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at) + VALUES (?, ?, COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30'), NULL, strftime('%Y-%m-%d %H:%M:%f', 'now')) + ON CONFLICT(user_id) DO UPDATE SET + reminder_freq = excluded.reminder_freq, + updated_at = excluded.updated_at + `).run(ensured, freq, ensured); + const label = freq === 'daily' ? 'diario' : freq === 'weekly' ? 'semanal (lunes 08:30)' : 'apagado'; + return [{ + recipient: context.sender, + message: `✅ Recordatorios: ${label}` + }]; + } + if (action !== 'nueva') { return [{ recipient: context.sender, diff --git a/src/services/reminders.ts b/src/services/reminders.ts new file mode 100644 index 0000000..0026013 --- /dev/null +++ b/src/services/reminders.ts @@ -0,0 +1,173 @@ +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'; + +type UserPreference = { + user_id: string; + reminder_freq: 'daily' | 'weekly' | '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') + `).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; + + // 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' ? 'Resumen semanal — tus tareas' : 'Resumen 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(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 + ? '👥 sin dueño' + : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; + const datePart = t.due_date ? ` — 📅 ${formatDDMM(t.due_date)}` : ''; + return `- ${t.id}) “*${t.description || '(sin descripción)'}*”${datePart} — ${owner}`; + })); + sections.push(...rendered); + } + + if (total > items.length) { + sections.push(`… y ${total - items.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); + } + } + } +}