feature/boton-avisos

This commit is contained in:
2026-05-28 12:29:11 -06:00
parent baa826e36b
commit 9d32bc13ae
9 changed files with 414 additions and 9 deletions

View File

@@ -178,7 +178,8 @@ export async function lookupAlertClientData(nodoName: string): Promise<any | nul
pu.is_authority_client AS "ClienteAutoridad",
pu.full_name AS "Nombre",
pu.username AS "Usuario",
dn.notification_email AS "CorreoNotificacion"
dn.notification_email AS "CorreoNotificacion",
dn.node_subnode_key AS "NodoSubNodo"
FROM ${qUsers()} pu
LEFT JOIN ${qNodes()} dn ON pu.database_node_id = dn.id
WHERE pu.username = $1

View File

@@ -0,0 +1,166 @@
/**
* Servicio SMTP genérico. Si SMTP_HOST no está configurado, las funciones no hacen nada.
* Porta el patrón de backend/core/mail.py de a24c a Node.js/nodemailer.
*/
import nodemailer from 'nodemailer';
export interface SendEmailOptions {
toAddrs: string[];
subject: string;
plainBody: string;
htmlBody?: string;
fromDisplayName?: string;
highImportance?: boolean;
}
function buildTransporter() {
const host = (process.env.SMTP_HOST ?? '').trim();
if (!host) return null;
const port = parseInt(process.env.SMTP_PORT ?? '587', 10);
const user = (process.env.SMTP_USER ?? '').trim();
const pass = (process.env.SMTP_PASSWORD ?? '').trim();
const useTls = (process.env.SMTP_USE_TLS ?? 'true').toLowerCase() !== 'false';
return nodemailer.createTransport({
host,
port,
secure: port === 465,
...(port !== 465 && { requireTLS: useTls }),
...(user && { auth: { user, pass } })
});
}
export async function sendSmtpEmail(opts: SendEmailOptions): Promise<void> {
const host = (process.env.SMTP_HOST ?? '').trim();
if (!host || opts.toAddrs.length === 0) return;
const fromAddr = (process.env.SMTP_FROM || process.env.SMTP_USER || '').trim();
if (!fromAddr) {
console.warn('[email-service] SMTP_FROM o SMTP_USER debe configurarse para enviar correo');
return;
}
const transporter = buildTransporter();
if (!transporter) return;
const from = opts.fromDisplayName?.trim()
? `"${opts.fromDisplayName.trim()}" <${fromAddr}>`
: fromAddr;
const headers: Record<string, string> = {};
if (opts.highImportance) {
headers['Importance'] = 'high';
headers['X-Priority'] = '1';
headers['X-MSMail-Priority'] = 'High';
}
await transporter.sendMail({
from,
to: opts.toAddrs.join(', '),
subject: opts.subject,
text: opts.plainBody,
...(opts.htmlBody ? { html: opts.htmlBody } : {}),
headers
});
}
/** Genera HTML de alerta de respaldo compatible con Gmail/Outlook (tablas, sin flex). */
export function buildBackupAlertHtml(alerts: BackupAlertRow[]): string {
const rows = alerts
.map((a) => {
const badgeColor =
a.daysWithout === null
? '#6b7280'
: a.daysWithout >= 7
? '#b91c1c'
: a.daysWithout >= 3
? '#b45309'
: '#047857';
const badgeBg =
a.daysWithout === null
? '#f3f4f6'
: a.daysWithout >= 7
? '#fef2f2'
: a.daysWithout >= 3
? '#fffbeb'
: '#ecfdf5';
const diasLabel =
a.daysWithout === null ? 'Sin datos' : `${a.daysWithout} día${a.daysWithout !== 1 ? 's' : ''}`;
const ultimaRest = a.last_restore_date
? new Date(a.last_restore_date).toLocaleString('es-MX')
: 'Nunca';
return `
<tr style="border-bottom:1px solid #e2e8f0;">
<td style="padding:8px 12px;font-family:sans-serif;font-size:12px;color:#1e293b;">${escHtml(a.visible_name)}</td>
<td style="padding:8px 12px;font-family:sans-serif;font-size:12px;color:#475569;">${escHtml(a.clientName ?? 'N/D')}</td>
<td style="padding:8px 12px;font-family:sans-serif;font-size:12px;color:#475569;">${ultimaRest}</td>
<td style="padding:8px 12px;text-align:center;">
<span style="display:inline-block;padding:2px 8px;border-radius:9999px;font-family:sans-serif;font-size:11px;font-weight:600;color:${badgeColor};background:${badgeBg};border:1px solid ${badgeColor}30;">
${diasLabel}
</span>
</td>
</tr>`;
})
.join('');
return `<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f8fafc;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;padding:24px 0;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1);">
<!-- Encabezado -->
<tr>
<td style="background:#b91c1c;padding:20px 24px;">
<p style="margin:0;font-family:sans-serif;font-size:18px;font-weight:700;color:#ffffff;">
Alerta de respaldo &mdash; Aduanasoft
</p>
<p style="margin:4px 0 0;font-family:sans-serif;font-size:12px;color:#fecaca;">
${alerts.length} base${alerts.length !== 1 ? 's' : ''} de datos sin restaurar recientemente
</p>
</td>
</tr>
<!-- Tabla de alertas -->
<tr>
<td style="padding:20px 24px;">
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:6px;overflow:hidden;">
<thead>
<tr style="background:#f1f5f9;">
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:left;text-transform:uppercase;">Base de datos</th>
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:left;text-transform:uppercase;">Cliente</th>
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:left;text-transform:uppercase;">Última restauración</th>
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:center;text-transform:uppercase;">Días sin sync</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</td>
</tr>
<!-- Pie -->
<tr>
<td style="padding:12px 24px 20px;border-top:1px solid #f1f5f9;">
<p style="margin:0;font-family:sans-serif;font-size:11px;color:#94a3b8;">
Este mensaje fue generado automáticamente por el Panel de Control de Bases de Datos Aduanasoft.
Por favor no responda a este correo.
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
export interface BackupAlertRow {
visible_name: string;
clientName: string | null;
last_restore_date: string | null;
daysWithout: number | null;
}
function escHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}