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.

206 lines
7.9 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';
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<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', '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<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(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);
}
}
}
}