You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
176 lines
6.2 KiB
TypeScript
176 lines
6.2 KiB
TypeScript
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';
|
|
|
|
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<void> {
|
|
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<string, typeof items>();
|
|
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(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 `- ${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);
|
|
}
|
|
}
|
|
}
|
|
}
|