/** * Notificaciones email automáticas (Option B — por evento). * * Cron diario 8:30 AM (`notifications.job.ts`) llama a las dos funciones * principales de este servicio para cada tenant activo: * * - `processNewAlertas(pool, tenantId)`: detecta alertas que aparecen por * primera vez (no están en `alertas_notificadas`) y manda un email * batched al supervisor + auxiliares + clientes del contribuyente. * Las alertas que dejaron de estar activas se marcan `resuelta_at`. * * - `processProximosRecordatorios(pool, tenantId)`: detecta recordatorios * cuya `fecha_limite` cae en las ventanas 3 días / 1 día / mismo día * y manda email a los responsables (cliente + auxiliar; si no hay * auxiliar también supervisor; si owner es supervisor sin auxiliares * también owner). Cada ventana se envía una sola vez (columnas * `email_3d_at`, `email_1d_at`, `email_0d_at`). * * Decisión MVP: una alerta solo se notifica una vez. Si vuelve a activarse * después de resolverse, no re-notifica (sería opt-in al borrar la fila * cuando `resuelta_at` se setea). */ import type { Pool } from 'pg'; import { prisma } from '../config/database.js'; import { generarAlertasAutomaticas, type AlertaAuto } from './alertas-auto.service.js'; import { emailService } from './email/email.service.js'; import type { AlertaItem } from './email/templates/alertas-nuevas.js'; import type { VentanaRecordatorio } from './email/templates/recordatorio-proximo.js'; import { filterRecipientsByRole, type RecipientWithRole, type EmailType, type NotificationRole, } from './notification-preferences.service.js'; const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000'; // ──────────────────────────────────────────────────────────────────────── // Resolución de destinatarios // ──────────────────────────────────────────────────────────────────────── interface UserContact { userId: string; email: string; active: boolean; } /** * Resuelve user IDs ligados a un contribuyente (supervisor + auxiliares de * carteras donde aparece + clientes con acceso). Retorna lista deduplicada. */ async function getUserIdsContribuyente( pool: Pool, contribuyenteId: string, ): Promise<{ supervisor: string | null; auxiliares: string[]; clientes: string[] }> { const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); const { rows } = await pool.query<{ supervisor_user_id: string | null; auxiliar_user_ids: string[]; cliente_user_ids: string[]; }>(` SELECT eg.supervisor_user_id, COALESCE(( SELECT array_agg(DISTINCT c.auxiliar_user_id) FILTER (WHERE c.auxiliar_user_id IS NOT NULL) FROM cartera_entidades ce JOIN carteras c ON c.id = ce.cartera_id WHERE ce.entidad_id = eg.id ), ARRAY[]::uuid[]) AS auxiliar_user_ids, COALESCE(( SELECT array_agg(DISTINCT user_id) FROM cliente_accesos WHERE entidad_id = eg.id ), ARRAY[]::uuid[]) AS cliente_user_ids FROM entidades_gestionadas eg WHERE eg.id = $1::uuid `, [safeId]); if (rows.length === 0) { return { supervisor: null, auxiliares: [], clientes: [] }; } const r = rows[0]; return { supervisor: r.supervisor_user_id ?? null, auxiliares: r.auxiliar_user_ids ?? [], clientes: r.cliente_user_ids ?? [], }; } /** Owners activos del tenant (BD central). */ async function getOwnerUserIds(tenantId: string): Promise { const owners = await prisma.tenantMembership.findMany({ where: { tenantId, isOwner: true, active: true }, select: { userId: true }, }); return owners.map(o => o.userId); } /** Resuelve emails para una lista de userIds; filtra inactivos. */ async function getUserContacts(userIds: string[]): Promise { if (userIds.length === 0) return []; const users = await prisma.user.findMany({ where: { id: { in: userIds }, active: true }, select: { id: true, email: true, active: true }, }); return users.map(u => ({ userId: u.id, email: u.email, active: u.active })); } /** * Destinatarios de una alerta: supervisor + auxiliares + clientes del * contribuyente. Retorna emails con su rol para poder filtrar por * preferencias de notificación. */ async function recipientsForAlerta( pool: Pool, tenantId: string, contribuyenteId: string, ): Promise { const ids = await getUserIdsContribuyente(pool, contribuyenteId); const byRole = new Map(); if (ids.supervisor) byRole.set(ids.supervisor, 'supervisor'); ids.auxiliares.forEach(id => byRole.set(id, 'auxiliar')); ids.clientes.forEach(id => byRole.set(id, 'cliente')); const contacts = await getUserContacts([...byRole.keys()]); return contacts .filter(c => byRole.has(c.userId)) .map(c => ({ email: c.email, role: byRole.get(c.userId)! })); } async function getUserRole( tenantId: string, userId: string, ): Promise { const m = await prisma.tenantMembership.findFirst({ where: { userId, tenantId, active: true }, include: { rol: { select: { nombre: true } } }, }); if (!m) return null; const role = m.rol.nombre; if (role === 'owner' || role === 'supervisor' || role === 'auxiliar' || role === 'cliente') { return role; } return null; } /** * Destinatarios de un recordatorio. Los recordatorios del despacho son * tenant-level (no atados a contribuyente). Retorna emails con rol para * filtrado por preferencias. * * Públicos: clientes + auxiliares + supervisores + owners. * Privados: solo el creador. */ async function recipientsForRecordatorio( pool: Pool, tenantId: string, recordatorio: { creadoPor: string; privado: boolean }, ): Promise { if (recordatorio.privado) { const role = await getUserRole(tenantId, recordatorio.creadoPor); if (!role) return []; const contacts = await getUserContacts([recordatorio.creadoPor]); return contacts.map(c => ({ email: c.email, role })); } // Recordatorio público: lee universos relevantes del tenant. const { rows: [r] } = await pool.query<{ auxiliar_user_ids: string[]; supervisor_user_ids: string[]; cliente_user_ids: string[]; }>(` SELECT COALESCE(( SELECT array_agg(DISTINCT auxiliar_user_id) FROM carteras WHERE auxiliar_user_id IS NOT NULL ), ARRAY[]::uuid[]) AS auxiliar_user_ids, COALESCE(( SELECT array_agg(DISTINCT supervisor_user_id) FROM ( SELECT supervisor_user_id FROM entidades_gestionadas WHERE supervisor_user_id IS NOT NULL UNION SELECT supervisor_user_id FROM carteras WHERE supervisor_user_id IS NOT NULL ) sup ), ARRAY[]::uuid[]) AS supervisor_user_ids, COALESCE(( SELECT array_agg(DISTINCT user_id) FROM cliente_accesos ), ARRAY[]::uuid[]) AS cliente_user_ids `); const byRole = new Map(); (r?.auxiliar_user_ids ?? []).forEach(id => byRole.set(id, 'auxiliar')); (r?.supervisor_user_ids ?? []).forEach(id => byRole.set(id, 'supervisor')); (r?.cliente_user_ids ?? []).forEach(id => byRole.set(id, 'cliente')); // Owners siempre se consideran owner aunque también aparezcan como supervisor. const owners = await getOwnerUserIds(tenantId); owners.forEach(id => byRole.set(id, 'owner')); const contacts = await getUserContacts([...byRole.keys()]); return contacts .filter(c => byRole.has(c.userId)) .map(c => ({ email: c.email, role: byRole.get(c.userId)! })); } // ──────────────────────────────────────────────────────────────────────── // Procesamiento de alertas // ──────────────────────────────────────────────────────────────────────── interface ContribuyenteInfo { entidadId: string; rfc: string; nombre: string; } /** Lista contribuyentes activos del tenant. */ async function listContribuyentes(pool: Pool): Promise { const { rows } = await pool.query<{ entidad_id: string; rfc: string; nombre: string }>(` SELECT eg.id AS entidad_id, c.rfc, eg.nombre FROM entidades_gestionadas eg JOIN contribuyentes c ON c.entidad_id = eg.id WHERE eg.active = true AND eg.tipo = 'CONTRIBUYENTE' `); return rows.map(r => ({ entidadId: r.entidad_id, rfc: r.rfc, nombre: r.nombre })); } function mapAlertaToItem(a: AlertaAuto): AlertaItem { return { alertaId: a.id, nivel: a.prioridad === 'alta' ? 'high' : a.prioridad === 'media' ? 'medium' : 'low', titulo: a.titulo, mensaje: a.mensaje, }; } /** * Para un (tenant, contribuyente): * 1. Genera alertas activas vía `generarAlertasAutomaticas`. * 2. Inserta filas nuevas en `alertas_notificadas` (ON CONFLICT DO NOTHING). * 3. Marca como resueltas las alertas previamente notificadas que NO están * activas hoy (UPDATE resuelta_at). * 4. Si hay alertas nuevas, envía email batched a los responsables. */ async function processAlertasContribuyente( pool: Pool, tenantId: string, tenant: { rfc: string; nombre: string }, contribuyente: ContribuyenteInfo, ): Promise<{ nuevas: number; resueltas: number }> { const alertasActivas = await generarAlertasAutomaticas(pool, tenantId, contribuyente.entidadId); const activosIds = alertasActivas.map(a => a.id); // Re-notificación tras 30 días (D7, 2026-04-26): borra registros de // alertas que estuvieron resueltas más de 30 días. Si la alerta vuelve // a aparecer ahora, el INSERT siguiente la detecta como "nueva" y // vuelve a notificar. Si nunca se resolvió (resuelta_at IS NULL) o se // resolvió hace menos de 30 días, la fila se conserva y el INSERT no // dispara email. await pool.query(` DELETE FROM alertas_notificadas WHERE contribuyente_id = $1::uuid AND resuelta_at IS NOT NULL AND resuelta_at < NOW() - INTERVAL '30 days' `, [contribuyente.entidadId]); // Detecta alertas nuevas: INSERT con ON CONFLICT DO NOTHING. RETURNING id // solo trae las filas insertadas (no las que chocaron con el UNIQUE), // así sabemos cuáles eran realmente nuevas. Tras la re-notificación de // 30 días, una alerta puede volver a notificarse si reapareció después // de >30 días resuelta. const nuevas: AlertaAuto[] = []; for (const a of alertasActivas) { const { rows } = await pool.query<{ id: number }>(` INSERT INTO alertas_notificadas (alerta_id, contribuyente_id) VALUES ($1, $2::uuid) ON CONFLICT (alerta_id, COALESCE(contribuyente_id::text, '')) DO NOTHING RETURNING id `, [a.id, contribuyente.entidadId]); if (rows.length > 0) nuevas.push(a); } // Marca como resueltas las alertas previamente notificadas que ya no // aparecen activas hoy. Informativo (no genera email). let resueltas = 0; const updateQuery = activosIds.length > 0 ? `UPDATE alertas_notificadas SET resuelta_at = NOW() WHERE contribuyente_id = $1::uuid AND resuelta_at IS NULL AND alerta_id <> ALL($2::text[])` : `UPDATE alertas_notificadas SET resuelta_at = NOW() WHERE contribuyente_id = $1::uuid AND resuelta_at IS NULL`; const params: any[] = activosIds.length > 0 ? [contribuyente.entidadId, activosIds] : [contribuyente.entidadId]; const { rowCount } = await pool.query(updateQuery, params); resueltas = rowCount ?? 0; if (nuevas.length === 0) { return { nuevas: 0, resueltas }; } // Envía email batched a los responsables del contribuyente, filtrando por // preferencias de rol para alertas_nuevas. const recipientsWithRole = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId); const recipients = await filterRecipientsByRole(pool, 'alertas_nuevas', recipientsWithRole); if (recipients.length === 0) { console.warn(`[Notifications] Sin destinatarios para alertas de ${contribuyente.rfc} (tenant ${tenant.rfc})`); return { nuevas: nuevas.length, resueltas }; } await emailService.sendAlertasNuevas(recipients, { contribuyenteRfc: contribuyente.rfc, contribuyenteNombre: contribuyente.nombre, despachoNombre: tenant.nombre, alertas: nuevas.map(mapAlertaToItem), link: `${FRONTEND_URL}/alertas`, }); return { nuevas: nuevas.length, resueltas }; } /** Procesa todas las alertas del tenant — itera contribuyentes activos. */ export async function processNewAlertas( pool: Pool, tenantId: string, tenant: { rfc: string; nombre: string }, ): Promise<{ contribuyentes: number; nuevasTotal: number }> { const contribuyentes = await listContribuyentes(pool); let nuevasTotal = 0; for (const c of contribuyentes) { try { const { nuevas } = await processAlertasContribuyente(pool, tenantId, tenant, c); nuevasTotal += nuevas; } catch (err: any) { console.error(`[Notifications] Error procesando alertas de ${c.rfc} (tenant ${tenant.rfc}):`, err.message || err); } } return { contribuyentes: contribuyentes.length, nuevasTotal }; } // ──────────────────────────────────────────────────────────────────────── // Procesamiento de recordatorios próximos // ──────────────────────────────────────────────────────────────────────── interface RecordatorioRow { id: number; titulo: string; descripcion: string | null; notas: string | null; fecha_limite: string; privado: boolean; creado_por: string; email_3d_at: Date | null; email_1d_at: Date | null; email_0d_at: Date | null; } const VENTANA_DIAS: Record = { '3d': 3, '1d': 1, '0d': 0, }; /** * Procesa recordatorios cuya `fecha_limite` cae en alguna ventana (3d/1d/0d) * y que aún no tienen email enviado para esa ventana específica. Manda email * y marca la columna correspondiente. */ export async function processProximosRecordatorios( pool: Pool, tenantId: string, tenant: { rfc: string; nombre: string }, ): Promise<{ enviados: number }> { let enviados = 0; for (const ventana of (['3d', '1d', '0d'] as const)) { const dias = VENTANA_DIAS[ventana]; const col = `email_${ventana}_at`; const { rows } = await pool.query(` SELECT id, titulo, descripcion, notas, fecha_limite::text AS fecha_limite, privado, creado_por, email_3d_at, email_1d_at, email_0d_at FROM recordatorios WHERE completado = false AND fecha_limite = (CURRENT_DATE + ${dias})::date AND ${col} IS NULL AND (serie_id IS NOT NULL OR recurrencia = 'unica') `); for (const r of rows) { try { const recipientsWithRole = await recipientsForRecordatorio(pool, tenantId, { creadoPor: r.creado_por, privado: r.privado, }); const recipients = await filterRecipientsByRole(pool, 'recordatorio_proximo', recipientsWithRole); if (recipients.length === 0) { console.warn(`[Notifications] Recordatorio ${r.id} (${tenant.rfc}) sin destinatarios — skip ${ventana}`); continue; } await emailService.sendRecordatorioProximo(recipients, { titulo: r.titulo, descripcion: r.descripcion, notas: r.notas, fechaLimite: r.fecha_limite, ventana, despachoNombre: tenant.nombre, link: `${FRONTEND_URL}/calendario`, }); // Marca columna de ventana enviada. await pool.query(`UPDATE recordatorios SET ${col} = NOW() WHERE id = $1`, [r.id]); enviados++; } catch (err: any) { console.error(`[Notifications] Error en recordatorio ${r.id} (${tenant.rfc}, ${ventana}):`, err.message || err); } } } return { enviados }; }