fix/T2026-04-023-respaldo-cliente-no-identificado

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 15:25:37 -06:00
parent 35b8c7a32e
commit baa826e36b
4 changed files with 402 additions and 6 deletions

View File

@@ -121,36 +121,52 @@ export async function lookupNodeByNodoOrBdName(nodoName: string): Promise<any |
/**
* Asocia el nombre de archivo de respaldo (sin extensión) a una fila de `database_nodes`.
* Cubre: igualdad sin mayúsculas y prefijos tipo `NODO001_full_20240414` → nodo `NODO001`.
* Cubre:
* - igualdad sin mayúsculas;
* - el primer segmento separado por punto como token de nodo
* (ej. `08037natm001.KNOWNWORLD.000` → nodo `08037NATM001`; el sufijo
* `.KNOWNWORLD` = nombre lógico de la BD origen y `.000` = secuencia se ignoran);
* - prefijos con separadores `.`, `_` o `-` (ej. `NODO001_full_20240414` → `NODO001`).
*/
export function matchNodeRowFromBackupStem(stem: string, nodes: any[]): any | null {
const raw = String(stem ?? '').trim();
if (!raw || !nodes?.length) return null;
const key = raw.toLowerCase();
// Candidatos para igualdad exacta: el stem completo y su primer segmento (token de nodo).
const head = key.split('.')[0];
const exactKeys = head && head !== key ? [key, head] : [key];
const nodoOf = (row: any) => 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;
}

View File

@@ -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<any[]> {
const r = await pgPool.query(`SELECT ${ROW_EMAIL} FROM ${qAdditional()} ORDER BY id`);
return r.rows;
}
export async function listAuthorityEmails(): Promise<any[]> {
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<boolean> {
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<boolean> {
return emailExists(qAdditional(), nodeSubnodeKey, email);
}
export async function authorityEmailExists(nodeSubnodeKey: string, email: string): Promise<boolean> {
return emailExists(qAuthority(), nodeSubnodeKey, email);
}
export async function insertAdditionalEmail(nodeSubnodeKey: string, email: string): Promise<any> {
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<any> {
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<void> {
await pgPool.query(`DELETE FROM ${qAdditional()} WHERE id = $1`, [id]);
}
export async function deleteAuthorityEmail(id: number): Promise<void> {
await pgPool.query(`DELETE FROM ${qAuthority()} WHERE id = $1`, [id]);
}