Files
HoruxDespachosNuevo/apps/api/src/services/notifications.service.ts

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 };
}