From baa826e36b36f4b0985f1c9720f0aada7b63db3e Mon Sep 17 00:00:00 2001 From: hreyes Date: Wed, 27 May 2026 15:25:37 -0600 Subject: [PATCH] fix/T2026-04-023-respaldo-cliente-no-identificado Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/server/controldesk-pg.ts | 26 +++- src/lib/server/notification-emails-pg.ts | 85 +++++++++++ src/routes/+page.server.ts | 116 +++++++++++++++ src/routes/+page.svelte | 181 ++++++++++++++++++++++- 4 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 src/lib/server/notification-emails-pg.ts diff --git a/src/lib/server/controldesk-pg.ts b/src/lib/server/controldesk-pg.ts index b6885b4..cbef5bd 100644 --- a/src/lib/server/controldesk-pg.ts +++ b/src/lib/server/controldesk-pg.ts @@ -121,36 +121,52 @@ export async function lookupNodeByNodoOrBdName(nodoName: string): Promise String(row.NodoSubNodo ?? '').trim().toLowerCase(); const bdOf = (row: any) => String(row.BDName ?? '').trim().toLowerCase(); for (const row of nodes) { const n = nodoOf(row); const b = bdOf(row); - if (n && n === key) return row; - if (b && b === key) return row; + for (const k of exactKeys) { + if (n && n === k) return row; + if (b && b === k) return row; + } } + // Coincidencia por prefijo: el nodo/BD seguido de un separador (`.`, `_` o `-`). + const startsWithToken = (token: string) => + key.startsWith(`${token}.`) || key.startsWith(`${token}_`) || key.startsWith(`${token}-`); + type Cand = { row: any; len: number }; const cands: Cand[] = []; for (const row of nodes) { const n = nodoOf(row); const b = bdOf(row); - if (n && (key === n || key.startsWith(`${n}_`) || key.startsWith(`${n}-`))) { + if (n && (key === n || startsWithToken(n))) { cands.push({ row, len: n.length }); } - if (b && b !== n && (key === b || key.startsWith(`${b}_`) || key.startsWith(`${b}-`))) { + if (b && b !== n && (key === b || startsWithToken(b))) { cands.push({ row, len: b.length }); } } if (!cands.length) return null; + // Ganar el match más específico (token más largo) para no confundir nodos con prefijo común. cands.sort((a, b) => b.len - a.len); return cands[0].row; } diff --git a/src/lib/server/notification-emails-pg.ts b/src/lib/server/notification-emails-pg.ts new file mode 100644 index 0000000..ca73cfc --- /dev/null +++ b/src/lib/server/notification-emails-pg.ts @@ -0,0 +1,85 @@ +/** + * Correos de notificación adicionales en PostgreSQL (esquema a24c; DDL aprovisionado por la app a24c). + * Dos catálogos paralelos, ambos ligados al nodo por `node_subnode_key`: + * - additional_emails: destinatarios extra para alertas de respaldos / actividad. + * - authority_notification_emails: destinatarios para el ingreso de la autoridad al portal. + * a24c los lee en vivo al notificar (comparación case-insensitive + trim), por eso aquí se usa el + * mismo criterio `lower(btrim(...))` para evitar duplicados que a24c terminaría deduplicando. + */ +import { pgPool } from './db'; + +function schemaName(): string { + const s = 'a24c'; + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s)) return 'a24c'; + return s; +} + +function qAdditional(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."additional_emails"`; +} + +function qAuthority(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."authority_notification_emails"`; +} + +const ROW_EMAIL = ` + id AS "ID", + node_subnode_key AS "NodoSubNodo", + email AS "Correo" +`; + +export async function listAdditionalEmails(): Promise { + const r = await pgPool.query(`SELECT ${ROW_EMAIL} FROM ${qAdditional()} ORDER BY id`); + return r.rows; +} + +export async function listAuthorityEmails(): Promise { + const r = await pgPool.query(`SELECT ${ROW_EMAIL} FROM ${qAuthority()} ORDER BY id`); + return r.rows; +} + +async function emailExists(table: string, nodeSubnodeKey: string, email: string): Promise { + const sql = ` + SELECT 1 + FROM ${table} + WHERE LOWER(BTRIM(node_subnode_key)) = LOWER(BTRIM($1::text)) + AND LOWER(BTRIM(email)) = LOWER(BTRIM($2::text)) + LIMIT 1 + `; + const r = await pgPool.query(sql, [nodeSubnodeKey, email]); + return (r.rowCount ?? 0) > 0; +} + +export async function additionalEmailExists(nodeSubnodeKey: string, email: string): Promise { + return emailExists(qAdditional(), nodeSubnodeKey, email); +} + +export async function authorityEmailExists(nodeSubnodeKey: string, email: string): Promise { + return emailExists(qAuthority(), nodeSubnodeKey, email); +} + +export async function insertAdditionalEmail(nodeSubnodeKey: string, email: string): Promise { + const r = await pgPool.query( + `INSERT INTO ${qAdditional()} (node_subnode_key, email) VALUES ($1, $2) RETURNING ${ROW_EMAIL}`, + [nodeSubnodeKey, email] + ); + return r.rows[0]; +} + +export async function insertAuthorityEmail(nodeSubnodeKey: string, email: string): Promise { + const r = await pgPool.query( + `INSERT INTO ${qAuthority()} (node_subnode_key, email) VALUES ($1, $2) RETURNING ${ROW_EMAIL}`, + [nodeSubnodeKey, email] + ); + return r.rows[0]; +} + +export async function deleteAdditionalEmail(id: number): Promise { + await pgPool.query(`DELETE FROM ${qAdditional()} WHERE id = $1`, [id]); +} + +export async function deleteAuthorityEmail(id: number): Promise { + await pgPool.query(`DELETE FROM ${qAuthority()} WHERE id = $1`, [id]); +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index fc4688d..d19dcb5 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -23,6 +23,22 @@ import { updatePortalUser, deletePortalUser } from '$lib/server/controldesk-pg'; +import { + listAdditionalEmails, + listAuthorityEmails, + additionalEmailExists, + authorityEmailExists, + insertAdditionalEmail, + insertAuthorityEmail, + deleteAdditionalEmail, + deleteAuthorityEmail +} from '$lib/server/notification-emails-pg'; + +// Validación básica de formato de correo (sanitiza la entrada del usuario antes de persistir) +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +function isValidEmail(value: string): boolean { + return value.length <= 100 && EMAIL_REGEX.test(value); +} function parseActivoField(formData: FormData): number { const v = formData.get('Activo'); @@ -81,6 +97,8 @@ export const load: PageServerLoad = async ({ cookies }) => { let alertsData: any[] = []; let basesDeDatosList: any[] = []; let usuariosList: any[] = []; + let additionalEmails: any[] = []; + let authorityEmails: any[] = []; let restoreHistory: Record = {}; let effectivenessByDb: Record = {}; @@ -121,6 +139,8 @@ export const load: PageServerLoad = async ({ cookies }) => { clientsData = await listClientsCatalog(); basesDeDatosList = await listDatabaseNodes(); usuariosList = await listPortalUsers(); + additionalEmails = await listAdditionalEmails(); + authorityEmails = await listAuthorityEmails(); controlDeskOk = true; } catch (e: any) { console.error('Error loading ControlDesk (PostgreSQL):', e); @@ -303,6 +323,8 @@ export const load: PageServerLoad = async ({ cookies }) => { alertsData, basesDeDatosList, usuariosList, + additionalEmails, + authorityEmails, restoreHistory, effectivenessByDb, @@ -561,5 +583,99 @@ export const actions: Actions = { console.error('Error deleting usuario:', e); return { success: false, message: e.message }; } + }, + + // ---- Correos de notificación adicionales (a24c.additional_emails) ---- + + createAdditionalEmail: async ({ request }) => { + try { + const formData = await request.formData(); + const nodoSubNodo = String(formData.get('NodoSubNodo') || '').trim(); + const correo = String(formData.get('Correo') || '').trim(); + + if (!nodoSubNodo || !correo) { + return { success: false, message: 'Faltan campos requeridos' }; + } + if (nodoSubNodo.length > 50) { + return { success: false, message: 'El Nodo/SubNodo excede 50 caracteres' }; + } + if (!isValidEmail(correo)) { + return { success: false, message: 'Formato de correo inválido (máx. 100 caracteres)' }; + } + if (await additionalEmailExists(nodoSubNodo, correo)) { + return { success: false, message: 'Ese correo ya está registrado para este nodo' }; + } + + const row = await insertAdditionalEmail(nodoSubNodo, correo); + return { success: true, row }; + } catch (e: any) { + console.error('Error creating additional email:', e); + return { success: false, message: e.message }; + } + }, + + deleteAdditionalEmail: async ({ request }) => { + try { + const formData = await request.formData(); + const id = Number(formData.get('ID')); + + if (!id) { + return { success: false, message: 'ID requerido' }; + } + + await deleteAdditionalEmail(id); + + return { success: true }; + } catch (e: any) { + console.error('Error deleting additional email:', e); + return { success: false, message: e.message }; + } + }, + + // ---- Correos de notificación de autoridad (a24c.authority_notification_emails) ---- + + createAuthorityEmail: async ({ request }) => { + try { + const formData = await request.formData(); + const nodoSubNodo = String(formData.get('NodoSubNodo') || '').trim(); + const correo = String(formData.get('Correo') || '').trim(); + + if (!nodoSubNodo || !correo) { + return { success: false, message: 'Faltan campos requeridos' }; + } + if (nodoSubNodo.length > 50) { + return { success: false, message: 'El Nodo/SubNodo excede 50 caracteres' }; + } + if (!isValidEmail(correo)) { + return { success: false, message: 'Formato de correo inválido (máx. 100 caracteres)' }; + } + if (await authorityEmailExists(nodoSubNodo, correo)) { + return { success: false, message: 'Ese correo ya está registrado para este nodo' }; + } + + const row = await insertAuthorityEmail(nodoSubNodo, correo); + return { success: true, row }; + } catch (e: any) { + console.error('Error creating authority email:', e); + return { success: false, message: e.message }; + } + }, + + deleteAuthorityEmail: async ({ request }) => { + try { + const formData = await request.formData(); + const id = Number(formData.get('ID')); + + if (!id) { + return { success: false, message: 'ID requerido' }; + } + + await deleteAuthorityEmail(id); + + return { success: true }; + } catch (e: any) { + console.error('Error deleting authority email:', e); + return { success: false, message: e.message }; + } } }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index eced1a7..0062800 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -345,13 +345,86 @@ // Modal grande para crear/editar base de datos (CRUD completo) let showDbCrudModal = $state(false); let isEditingDb = $state(false); - let dbModalTab = $state<'datos' | 'usuarios'>('datos'); + let dbModalTab = $state<'datos' | 'usuarios' | 'correos'>('datos'); // Filtra usuarios asociados a una base de datos por IDNodoSubNodo const getUsuariosForDb = (dbId: number | string): any[] => { return (usuariosList ?? []).filter((u) => String(u.IDNodoSubNodo) === String(dbId)); }; + // ---- Correos de notificación adicionales (a24c.additional_emails / authority_notification_emails) ---- + let additionalEmails = $state(untrack(() => (data.additionalEmails ?? []) as any[])); + let authorityEmails = $state(untrack(() => (data.authorityEmails ?? []) as any[])); + let newAdditionalEmail = $state(''); + let newAuthorityEmail = $state(''); + let emailFormError = $state(''); + + // Match por node_subnode_key con el mismo criterio que a24c (case-insensitive + trim) + const normalizeNodeKey = (value: unknown): string => String(value ?? '').trim().toLowerCase(); + const getAdditionalEmailsForNode = (nodeKey: string): any[] => + (additionalEmails ?? []).filter((e) => normalizeNodeKey(e.NodoSubNodo) === normalizeNodeKey(nodeKey)); + const getAuthorityEmailsForNode = (nodeKey: string): any[] => + (authorityEmails ?? []).filter((e) => normalizeNodeKey(e.NodoSubNodo) === normalizeNodeKey(nodeKey)); + + const submitNotificationEmail = async (kind: 'additional' | 'authority') => { + emailFormError = ''; + const nodeKey = String(formDb.NodoSubNodo ?? '').trim(); + const correo = (kind === 'additional' ? newAdditionalEmail : newAuthorityEmail).trim(); + if (!nodeKey) { + emailFormError = 'Guarda primero la base de datos (falta el Nodo/SubNodo).'; + return; + } + if (!correo) { + emailFormError = 'Escribe un correo.'; + return; + } + + const action = kind === 'additional' ? 'createAdditionalEmail' : 'createAuthorityEmail'; + const form = new FormData(); + form.append('NodoSubNodo', nodeKey); + form.append('Correo', correo); + + try { + const res = await fetch(`?/${action}`, { method: 'POST', body: form }); + const result = parseKitAction(await res.text()); + const errMsg = kitActionErrorMessage(res, result); + if (errMsg) { + emailFormError = errMsg; + return; + } + const row = (result?.type === 'success' ? (result.data as any)?.row : null) ?? null; + if (row) { + if (kind === 'additional') additionalEmails.push(row); + else authorityEmails.push(row); + } + if (kind === 'additional') newAdditionalEmail = ''; + else newAuthorityEmail = ''; + } catch (e) { + emailFormError = 'Error de red al guardar el correo.'; + } + }; + + const deleteNotificationEmail = async (kind: 'additional' | 'authority', row: any) => { + if (!confirm(`¿Eliminar el correo "${row.Correo}"?`)) return; + const action = kind === 'additional' ? 'deleteAdditionalEmail' : 'deleteAuthorityEmail'; + const form = new FormData(); + form.append('ID', String(row.ID)); + try { + const res = await fetch(`?/${action}`, { method: 'POST', body: form }); + const result = parseKitAction(await res.text()); + const errMsg = kitActionErrorMessage(res, result); + if (errMsg) { + alert(errMsg); + return; + } + const list = kind === 'additional' ? additionalEmails : authorityEmails; + const idx = list.findIndex((x) => x.ID === row.ID); + if (idx !== -1) list.splice(idx, 1); + } catch (e) { + alert('Error de red al eliminar el correo.'); + } + }; + // Calcula el siguiente NodoSubNodo sugerido tomando el sufijo numérico de 3 dígitos máximo const getNextNodoSubNodo = (): string => { const list = (basesDeDatosList ?? []) as any[]; @@ -405,6 +478,9 @@ const openEditDbModal = (bd: any) => { isEditingDb = true; dbModalTab = 'datos'; + newAdditionalEmail = ''; + newAuthorityEmail = ''; + emailFormError = ''; formDb = { ID: bd.ID, NodoSubNodo: bd.NodoSubNodo ?? '', @@ -2301,6 +2377,14 @@ people Usuarios ({getUsuariosForDb(formDb.ID).length}) + {/if} @@ -2535,6 +2619,101 @@ {/each} {/if} + + + {#if isEditingDb && dbModalTab === 'correos'} +
+

+ Correos que reciben notificaciones además del correo principal + ({formDb.CorreoNotificacion || '—'}) para el nodo + {formDb.NodoSubNodo}. +

+ + {#if emailFormError} +
+ {emailFormError} +
+ {/if} + + +
+
+

Correos adicionales

+

Alertas de respaldos y actividad.

+
+
+
{ e.preventDefault(); submitNotificationEmail('additional'); }}> + + +
+ {#each [getAdditionalEmailsForNode(formDb.NodoSubNodo)] as rows} + {#if rows.length === 0} +

Sin correos adicionales.

+ {:else} +
    + {#each rows as row (row.ID)} +
  • + {row.Correo} + +
  • + {/each} +
+ {/if} + {/each} +
+
+ + +
+
+

Correos de autoridad

+

Aviso cuando la autoridad ingresa al portal web.

+
+
+
{ e.preventDefault(); submitNotificationEmail('authority'); }}> + + +
+ {#each [getAuthorityEmailsForNode(formDb.NodoSubNodo)] as rows} + {#if rows.length === 0} +

Sin correos de autoridad.

+ {:else} +
    + {#each rows as row (row.ID)} +
  • + {row.Correo} + +
  • + {/each} +
+ {/if} + {/each} +
+
+
+ {/if} {/if}