414 lines
16 KiB
TypeScript
414 lines
16 KiB
TypeScript
/**
|
|
* 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<string[]> {
|
|
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<UserContact[]> {
|
|
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<RecipientWithRole[]> {
|
|
const ids = await getUserIdsContribuyente(pool, contribuyenteId);
|
|
const byRole = new Map<string, NotificationRole>();
|
|
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<NotificationRole | null> {
|
|
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<RecipientWithRole[]> {
|
|
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<string, NotificationRole>();
|
|
(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<ContribuyenteInfo[]> {
|
|
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<VentanaRecordatorio, number> = {
|
|
'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<RecordatorioRow>(`
|
|
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 };
|
|
}
|