diff --git a/.env.example b/.env.example index 4d2bfcb..203db81 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ PANEL_MSSQL_PASSWORD=Clave.2025 # PostgreSQL - Usuarios del panel + catálogo ControlDesk (tablas a24c.* las crea otra app) DB_POSTGRES_HOST=localhost -DB_POSTGRES_PORT=5432 +DB_POSTGRES_PORT=5434 DB_POSTGRES_USER=postgres DB_POSTGRES_PASS=Control. DB_POSTGRES_DB=CONTROLDESK @@ -17,3 +17,12 @@ JWT_SECRET=change-this-secret-in-production-please-use-a-long-random-string # Ruta de backups BACKUP_PATH=D:/BackupSFTP/ + +# SMTP para envío de avisos de alertas críticas +# Si SMTP_HOST está vacío los botones "Enviar avisos" no hacen nada (sin error). +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= +SMTP_USE_TLS=true diff --git a/.gitignore b/.gitignore index cc90b69..b0b298e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ build # Docker docker-compose.override.yml + +# Respaldos sintéticos para pruebas locales +local-backups/ diff --git a/docker-compose.yml b/docker-compose.yml index 3b078be..b4df038 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,8 @@ services: - PANEL_MSSQL_DOCKER=${PANEL_MSSQL_DOCKER:-true} - IN_DOCKER=true - # PostgreSQL: catálogo ControlDesk (a24c.*) + usuarios del panel - - DB_POSTGRES_HOST=postgres + # PostgreSQL: apunta a la a24c local (contenedor a24c-postgres publicado en el host :5432) + - DB_POSTGRES_HOST=host.docker.internal - DB_POSTGRES_PORT=5432 - DB_POSTGRES_USER=${DB_POSTGRES_USER} - DB_POSTGRES_PASS=${DB_POSTGRES_PASS} @@ -27,10 +27,18 @@ services: - PORT=3000 - ORIGIN=https://localhost:3000 - NODE_ENV=production + + # SMTP para envío de avisos (opcional — vacío deshabilita el envío real sin errores) + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM=${SMTP_FROM:-} + - SMTP_USE_TLS=${SMTP_USE_TLS:-true} volumes: # Map the actual backup folder from host to the container's backup path - # Windows path D:/BackupSFTP/ mapped to /data/backups inside container (read-only) - - D:/BackupSFTP:/data/backups:ro + # Local dev: carpeta escribible del repo con respaldos sintéticos (read-only en el contenedor) + - ./local-backups:/data/backups:ro extra_hosts: - "host.docker.internal:host-gateway" depends_on: @@ -48,7 +56,7 @@ services: POSTGRES_DB: ${DB_POSTGRES_DB} PGDATA: /var/lib/postgresql/data/pgdata ports: - - "5432:5432" + - "5434:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./database/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql diff --git a/package-lock.json b/package-lock.json index 0acd543..4039522 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "express": "^4.18.2", "jsonwebtoken": "^9.0.3", "mssql": "^10.0.2", + "nodemailer": "^8.0.9", "pg": "^8.18.0" }, "devDependencies": { @@ -26,6 +27,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/mssql": "^9.1.5", "@types/node": "^22.0.0", + "@types/nodemailer": "^8.0.0", "@types/pg": "^8.16.0", "autoprefixer": "^10.4.24", "postcss": "^8.5.6", @@ -1556,6 +1558,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", @@ -4964,6 +4976,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.9.tgz", + "integrity": "sha512-5ofa7BUN8+C+Hckh5V2GjeeOGRQBx0CJQA6KxrvuZfC8iU4/q7sLn8XrtEEhJkjV6HdyIiQs7Bba6bTao8JhkA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index 76bacaa..6e04699 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/mssql": "^9.1.5", "@types/node": "^22.0.0", + "@types/nodemailer": "^8.0.0", "@types/pg": "^8.16.0", "autoprefixer": "^10.4.24", "postcss": "^8.5.6", @@ -36,6 +37,7 @@ "express": "^4.18.2", "jsonwebtoken": "^9.0.3", "mssql": "^10.0.2", + "nodemailer": "^8.0.9", "pg": "^8.18.0" }, "type": "module" diff --git a/src/lib/server/controldesk-pg.ts b/src/lib/server/controldesk-pg.ts index cbef5bd..6dd3215 100644 --- a/src/lib/server/controldesk-pg.ts +++ b/src/lib/server/controldesk-pg.ts @@ -178,7 +178,8 @@ export async function lookupAlertClientData(nodoName: string): Promise { + 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 = {}; + 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 ` + + ${escHtml(a.visible_name)} + ${escHtml(a.clientName ?? 'N/D')} + ${ultimaRest} + + + ${diasLabel} + + + `; + }) + .join(''); + + return ` + + + + + +
+ + + + + + + + + + + + + +
+

+ Alerta de respaldo — Aduanasoft +

+

+ ${alerts.length} base${alerts.length !== 1 ? 's' : ''} de datos sin restaurar recientemente +

+
+ + + + + + + + + + ${rows} +
Base de datosClienteÚltima restauraciónDías sin sync
+
+

+ Este mensaje fue generado automáticamente por el Panel de Control de Bases de Datos Aduanasoft. + Por favor no responda a este correo. +

+
+
+ +`; +} + +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, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0062800..ffdf793 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -154,8 +154,41 @@ let historyRangeFrom = $state(''); let historyRangeTo = $state(''); - // Búsqueda en alertas críticas + // Búsqueda y envío de avisos en alertas críticas let alertsSearch = $state(''); + let sendingAlerts = $state(false); + let sendingAlertKey = $state(null); // clave de alerta individual en proceso + let alertSendResult = $state<{ sent: number; failed: number } | null>(null); + let alertSendResultTimer = $state | null>(null); + + function showAlertSendToast(result: { sent: number; failed: number }) { + if (alertSendResultTimer) clearTimeout(alertSendResultTimer); + alertSendResult = result; + alertSendResultTimer = setTimeout(() => { alertSendResult = null; }, 5000); + } + + async function sendAlertNotifications(alerts: any[]) { + if (!alerts.length) return; + const payload = alerts.map((a) => ({ + node_subnode_key: String(a.clientData?.NodoSubNodo ?? ''), + visible_name: String(a.visible_name ?? ''), + clientName: a.clientData?.Nombre ?? null, + mainEmail: a.clientData?.CorreoNotificacion ?? null, + last_restore_date: a.last_restore_date ? new Date(a.last_restore_date).toISOString() : null, + daysWithout: a.daysWithout ?? null + })); + try { + const res = await fetch('/api/alerts/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ alerts: payload }) + }); + const json = await res.json(); + showAlertSendToast({ sent: json.sent ?? 0, failed: json.failed ?? 0 }); + } catch { + showAlertSendToast({ sent: 0, failed: alerts.length }); + } + } // Búsqueda en Gestión de Bases de Datos let databasesSearch = $state(''); @@ -1954,7 +1987,7 @@
-
+
+ {alertsListLive.length} alertas + {#if alertSendResult} + + {alertSendResult.failed === 0 ? 'check_circle' : 'warning'} + Enviados {alertSendResult.sent} · Sin correo {alertSendResult.failed} + + {/if}
@@ -2003,6 +2055,7 @@ Cliente Correo Días sin sincronizar + Aviso @@ -2027,6 +2080,23 @@ {/if} + + + {/each} diff --git a/src/routes/api/alerts/send/+server.ts b/src/routes/api/alerts/send/+server.ts new file mode 100644 index 0000000..7447048 --- /dev/null +++ b/src/routes/api/alerts/send/+server.ts @@ -0,0 +1,125 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { pgPool } from '$lib/server/db'; +import { sendSmtpEmail, buildBackupAlertHtml } from '$lib/server/email-service'; + +interface AlertPayload { + /** Clave del nodo (de clientData.NodoSubNodo). Puede estar vacía si el cliente no fue identificado. */ + node_subnode_key: string; + visible_name: string; + clientName: string | null; + mainEmail: string | null; + last_restore_date: string | null; + daysWithout: number | null; +} + +interface SendResult { + node_subnode_key: string; + visible_name: string; + recipients: number; + status: 'sent' | 'no_recipients' | 'error'; + error?: string; +} + +/** + * Busca correos adicionales por node_subnode_key (de clientData.NodoSubNodo). + * Si nodeSubnodeKey está vacío usa visibleName como fallback resolviendo por database_name. + */ +async function getAdditionalEmails(nodeSubnodeKey: string, visibleName: string): Promise { + // Búsqueda directa por node_subnode_key (caso normal) + if (nodeSubnodeKey.trim()) { + const r = await pgPool.query( + `SELECT email FROM "a24c"."additional_emails" + WHERE LOWER(BTRIM(node_subnode_key)) = LOWER(BTRIM($1::text))`, + [nodeSubnodeKey] + ); + const rows = (r.rows as { email: string }[]).map((row) => row.email.trim()).filter(Boolean); + if (rows.length > 0) return rows; + } + // Fallback: resolver nodo desde database_name = visibleName + const r = await pgPool.query( + `SELECT ae.email + FROM "a24c"."additional_emails" ae + WHERE LOWER(BTRIM(ae.node_subnode_key)) = ( + SELECT LOWER(BTRIM(dn.node_subnode_key)) + FROM "a24c"."database_nodes" dn + WHERE LOWER(BTRIM(dn.database_name)) = LOWER(BTRIM($1::text)) + OR LOWER(BTRIM(dn.node_subnode_key)) = LOWER(BTRIM($1::text)) + LIMIT 1 + )`, + [visibleName] + ); + return (r.rows as { email: string }[]).map((row) => row.email.trim()).filter(Boolean); +} + +function dedupeEmails(emails: (string | null | undefined)[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const e of emails) { + const norm = (e ?? '').trim().toLowerCase(); + if (norm && !seen.has(norm)) { + seen.add(norm); + result.push((e ?? '').trim()); + } + } + return result; +} + +export const POST: RequestHandler = async ({ request }) => { + let body: { alerts: AlertPayload[] }; + try { + body = await request.json(); + } catch { + return json({ error: 'Body inválido' }, { status: 400 }); + } + + const alerts = body?.alerts; + if (!Array.isArray(alerts) || alerts.length === 0) { + return json({ error: 'Se requiere al menos una alerta' }, { status: 400 }); + } + + const details: SendResult[] = []; + let sent = 0; + let failed = 0; + + for (const alert of alerts) { + 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' }); + failed++; + continue; + } + + const htmlBody = buildBackupAlertHtml([{ + visible_name: alert.visible_name, + clientName: alert.clientName, + last_restore_date: alert.last_restore_date, + daysWithout: alert.daysWithout + }]); + + 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.`; + + await sendSmtpEmail({ + toAddrs, + subject: `Alerta de respaldo: ${alert.visible_name}`, + plainBody, + htmlBody, + fromDisplayName: 'Aduanasoft · Panel de Bases', + highImportance: (alert.daysWithout ?? 0) >= 7 + }); + + details.push({ node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, 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 }); + failed++; + } + } + + return json({ sent, failed, details }); +};