Initial commit - Horux Despachos NL
This commit is contained in:
390
apps/api/src/services/notifications.service.ts
Normal file
390
apps/api/src/services/notifications.service.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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. Si el owner del tenant es supervisor, ya queda incluido
|
||||
* (no se duplica).
|
||||
*/
|
||||
async function recipientsForAlerta(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
contribuyenteId: string,
|
||||
): Promise<string[]> {
|
||||
const ids = await getUserIdsContribuyente(pool, contribuyenteId);
|
||||
const userIds = new Set<string>();
|
||||
if (ids.supervisor) userIds.add(ids.supervisor);
|
||||
ids.auxiliares.forEach(id => userIds.add(id));
|
||||
ids.clientes.forEach(id => userIds.add(id));
|
||||
const contacts = await getUserContacts([...userIds]);
|
||||
return [...new Set(contacts.map(c => c.email))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Destinatarios de un recordatorio. Los recordatorios del despacho son
|
||||
* tenant-level (no atados a contribuyente). Para públicos: clientes con
|
||||
* algún acceso + auxiliares de cualquier cartera; si no hay auxiliares,
|
||||
* supervisores; si owner aparece como supervisor, también recibe.
|
||||
*
|
||||
* Privados: solo el creador.
|
||||
*/
|
||||
async function recipientsForRecordatorio(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
recordatorio: { creadoPor: string; privado: boolean },
|
||||
): Promise<string[]> {
|
||||
if (recordatorio.privado) {
|
||||
const contacts = await getUserContacts([recordatorio.creadoPor]);
|
||||
return [...new Set(contacts.map(c => c.email))];
|
||||
}
|
||||
|
||||
// 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 auxiliares = r?.auxiliar_user_ids ?? [];
|
||||
const supervisores = r?.supervisor_user_ids ?? [];
|
||||
const clientes = r?.cliente_user_ids ?? [];
|
||||
const owners = await getOwnerUserIds(tenantId);
|
||||
|
||||
// Regla del owner: clientes y auxiliares siempre. Si no hay auxiliares,
|
||||
// agregar supervisores. Si owner es supervisor y no hay auxiliares,
|
||||
// owner queda incluido vía la lista de supervisores.
|
||||
const userIds = new Set<string>();
|
||||
clientes.forEach(id => userIds.add(id));
|
||||
auxiliares.forEach(id => userIds.add(id));
|
||||
if (auxiliares.length === 0) {
|
||||
supervisores.forEach(id => userIds.add(id));
|
||||
// Solo si owner aparece como supervisor (intersección):
|
||||
for (const ownerId of owners) {
|
||||
if (supervisores.includes(ownerId)) userIds.add(ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
const contacts = await getUserContacts([...userIds]);
|
||||
return [...new Set(contacts.map(c => c.email))];
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// 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.
|
||||
const recipients = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId);
|
||||
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
|
||||
`);
|
||||
|
||||
for (const r of rows) {
|
||||
try {
|
||||
const recipients = await recipientsForRecordatorio(pool, tenantId, {
|
||||
creadoPor: r.creado_por,
|
||||
privado: r.privado,
|
||||
});
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user