| - - |
Estimado ${nombre},
+Detectamos que la base de datos ${bd} no se ha sincronizado correctamente en las últimas 24 horas.
+La última restauración registrada fue: ${fecha}.
+ ${SCAIIWEB_H4} +Por favor, recuerde nunca cerrar la aplicación ni apagar su equipo. Revise la conexión e intente realizar una sincronización manual desde el botón Backup manual, o contacte al soporte técnico si es necesario.
`; + return renderSyncEmail(inner); +} + +/** Correo de "sincronización restablecida" (kind=resolved): misma plantilla legacy, mensaje positivo. */ +export function buildBackupResolvedHtml(alerts: BackupAlertRow[]): string { + const a = alerts[0]; + const nombre = escHtml(a?.clientName ?? a?.visible_name ?? ''); + const bd = escHtml(a?.visible_name ?? ''); + const fecha = escHtml(formatFechaEs(a?.last_restore_date)); + const inner = ` +Estimado ${nombre},
+La base de datos ${bd} volvió a sincronizarse correctamente.
+La última restauración registrada fue: ${fecha}.
+ ${SCAIIWEB_H4} +No se requiere ninguna acción de su parte. Gracias por mantener su equipo y la aplicación en funcionamiento.
`; + return renderSyncEmail(inner); +} + export interface BackupAlertRow { visible_name: string; clientName: string | null; diff --git a/src/routes/api/alerts/send/+server.ts b/src/routes/api/alerts/send/+server.ts index 7447048..0ef63ae 100644 --- a/src/routes/api/alerts/send/+server.ts +++ b/src/routes/api/alerts/send/+server.ts @@ -1,9 +1,11 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { pgPool } from '$lib/server/db'; -import { sendSmtpEmail, buildBackupAlertHtml } from '$lib/server/email-service'; +import { sendSmtpEmail, buildBackupAlertHtml, buildBackupResolvedHtml, formatFechaEs } from '$lib/server/email-service'; interface AlertPayload { + /** ID del nodo en a24c.database_nodes; se hace echo en SendResult para reconciliación del cooldown en a24c. */ + database_node_id?: number; /** Clave del nodo (de clientData.NodoSubNodo). Puede estar vacía si el cliente no fue identificado. */ node_subnode_key: string; visible_name: string; @@ -11,11 +13,17 @@ interface AlertPayload { mainEmail: string | null; last_restore_date: string | null; daysWithout: number | null; + /** Tipo de notificación. Ausente => 'overdue' por compatibilidad. */ + kind?: 'overdue' | 'resolved'; } interface SendResult { + /** Echo del id recibido (o null si no vino) para que a24c reconcilie el cooldown. */ + database_node_id: number | null; node_subnode_key: string; visible_name: string; + /** Echo del kind efectivo aplicado. */ + kind: 'overdue' | 'resolved'; recipients: number; status: 'sent' | 'no_recipients' | 'error'; error?: string; @@ -83,40 +91,55 @@ export const POST: RequestHandler = async ({ request }) => { let failed = 0; for (const alert of alerts) { + const kind: 'overdue' | 'resolved' = alert.kind === 'resolved' ? 'resolved' : 'overdue'; + const nodeId = Number.isInteger(alert.database_node_id) ? (alert.database_node_id as number) : null; try { const additional = await getAdditionalEmails(alert.node_subnode_key, alert.visible_name); const toAddrs = dedupeEmails([alert.mainEmail, ...additional]); if (toAddrs.length === 0) { - details.push({ node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, recipients: 0, status: 'no_recipients' }); + details.push({ database_node_id: nodeId, node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, kind, recipients: 0, status: 'no_recipients' }); failed++; continue; } - const htmlBody = buildBackupAlertHtml([{ + const rowData = { visible_name: alert.visible_name, clientName: alert.clientName, last_restore_date: alert.last_restore_date, daysWithout: alert.daysWithout - }]); + }; + const nombre = alert.clientName ?? alert.visible_name; + const fecha = formatFechaEs(alert.last_restore_date); - const diasLabel = alert.daysWithout !== null ? `${alert.daysWithout} días` : 'sin datos'; - const plainBody = `Alerta de respaldo — Aduanasoft\n\nBase de datos: ${alert.visible_name}\nCliente: ${alert.clientName ?? 'N/D'}\nÚltima restauración: ${alert.last_restore_date ? new Date(alert.last_restore_date).toLocaleString('es-MX') : 'Nunca'}\nDías sin sincronizar: ${diasLabel}\n\nEste mensaje fue generado automáticamente por el Panel de Control de Bases de Datos Aduanasoft.`; + let subject: string; + let htmlBody: string; + let plainBody: string; + + if (kind === 'resolved') { + subject = `Sincronización Restablecida - ${nombre}`; + htmlBody = buildBackupResolvedHtml([rowData]); + plainBody = `Sincronización Restablecida\n\nEstimado ${nombre},\n\nLa base de datos ${alert.visible_name} volvió a sincronizarse correctamente.\nLa última restauración registrada fue: ${fecha}.\n\nConsulta la última sincronización desde SCAIIWeb: https://a24.aduanasoft.com/SCAIIWeb\n\nNo se requiere ninguna acción de su parte.\n\n© 2024 TransmitirAS. Todos los derechos reservados.`; + } else { + subject = `Alerta de Sincronización - ${nombre}`; + htmlBody = buildBackupAlertHtml([rowData]); + plainBody = `Notificación de Sincronización\n\nEstimado ${nombre},\n\nDetectamos que la base de datos ${alert.visible_name} no se ha sincronizado correctamente en las últimas 24 horas.\nLa última restauración registrada fue: ${fecha}.\n\nConsulta la última sincronización desde SCAIIWeb: https://a24.aduanasoft.com/SCAIIWeb\n\nPor favor, recuerde nunca cerrar la aplicación ni apagar su equipo. Revise la conexión e intente realizar una sincronización manual desde el botón Backup manual, o contacte al soporte técnico si es necesario.\n\n© 2024 TransmitirAS. Todos los derechos reservados.`; + } await sendSmtpEmail({ toAddrs, - subject: `Alerta de respaldo: ${alert.visible_name}`, + subject, plainBody, htmlBody, - fromDisplayName: 'Aduanasoft · Panel de Bases', - highImportance: (alert.daysWithout ?? 0) >= 7 + fromDisplayName: 'TransmitirAS Notificaciones', + highImportance: false }); - details.push({ node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, recipients: toAddrs.length, status: 'sent' }); + details.push({ database_node_id: nodeId, node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, kind, recipients: toAddrs.length, status: 'sent' }); sent++; } catch (err) { const msg = err instanceof Error ? err.message : String(err); - details.push({ node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, recipients: 0, status: 'error', error: msg }); + details.push({ database_node_id: nodeId, node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, kind, recipients: 0, status: 'error', error: msg }); failed++; } } diff --git a/src/routes/api/alerts/send/server.test.ts b/src/routes/api/alerts/send/server.test.ts new file mode 100644 index 0000000..f61582f --- /dev/null +++ b/src/routes/api/alerts/send/server.test.ts @@ -0,0 +1,173 @@ +/** + * Pruebas del endpoint POST /api/alerts/send (sin autenticación — el control es de red). + * + * - Ruteo overdue vs resolved (email-service mockeado). + * - Dedupe principal + adicionales (pgPool mockeado). + * - Echo de database_node_id / kind en details (reconciliación de a24c). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// vi.mock se iza al tope del archivo; las factories no pueden ver variables de +// módulo, así que los mocks se crean con vi.hoisted. +const { queryMock, sendSmtpEmail, buildBackupAlertHtml, buildBackupResolvedHtml, formatFechaEs } = vi.hoisted(() => ({ + queryMock: vi.fn(), + sendSmtpEmail: vi.fn(), + buildBackupAlertHtml: vi.fn(() => '