feat: version 4.12.0 -- Conexiones externas: pacientes de SAGA, empleados de nomina y medicos

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-29 17:06:10 -05:00
parent fabe5b530d
commit 0ee9ed65bf
43 changed files with 1402 additions and 69 deletions
@@ -0,0 +1,77 @@
-- Conexiones externas (conectores de solo lectura, contrato v1 en data/conectores/README.md),
-- empleados importados de nómina y vínculo de pacientes con el sistema origen. Idempotente.
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS external_connections (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
base_url VARCHAR(255) NOT NULL,
token_encrypted TEXT NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_by INT NULL,
edit_version INT UNSIGNED NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_ext_conn_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Una fuente por destino: qué tabla del conector alimenta Pacientes o Empleados, cómo (importar o
-- consultar en vivo), el mapeo de campos y el estado de la última sincronización.
CREATE TABLE IF NOT EXISTS external_sources (
id INT AUTO_INCREMENT PRIMARY KEY,
purpose ENUM('patients','employees') NOT NULL UNIQUE,
connection_id INT NOT NULL,
source_key VARCHAR(64) NOT NULL,
table_name VARCHAR(64) NOT NULL,
mode ENUM('import','live') NOT NULL DEFAULT 'import',
field_map_json TEXT NOT NULL,
filter_json TEXT NULL,
changed_field VARCHAR(64) NULL,
last_full_sync_at DATETIME NULL,
last_sync_at DATETIME NULL,
last_max_recno INT UNSIGNED NULL,
last_changed_value VARCHAR(40) NULL,
last_result_json TEXT NULL,
edit_version INT UNSIGNED NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_ext_source_conn FOREIGN KEY (connection_id) REFERENCES external_connections(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS employees (
id INT AUTO_INCREMENT PRIMARY KEY,
identification VARCHAR(40) NOT NULL UNIQUE,
last_names VARCHAR(120) NOT NULL DEFAULT '',
first_names VARCHAR(120) NOT NULL DEFAULT '',
phone VARCHAR(40) NULL,
phone2 VARCHAR(40) NULL,
birth_date DATE NULL,
position VARCHAR(120) NULL,
department_name VARCHAR(120) NULL,
hire_date DATE NULL,
leave_date DATE NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
in_source TINYINT(1) NOT NULL DEFAULT 1,
user_id INT NULL UNIQUE,
synced_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_employee_name (last_names, first_names),
CONSTRAINT fk_employee_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Origen del empleado: nómina (se sincroniza) o proveedor de SAGA cargado a mano (médicos que facturan;
-- la sincronización de nómina no los inactiva). Correo: los proveedores lo tienen, la nómina no.
ALTER TABLE employees ADD COLUMN IF NOT EXISTS origin ENUM('payroll','provider') NOT NULL DEFAULT 'payroll' AFTER is_active;
ALTER TABLE employees ADD COLUMN IF NOT EXISTS email VARCHAR(160) NULL AFTER phone2;
-- Pacientes vinculados al origen (SAGA): source_name guarda el nombre tal como viene (SAGA no
-- separa nombres y apellidos ni respeta un orden); mientras recepción no lo separe a mano, el
-- nombre visible es ese. Manda el origen en identificación/teléfono/correo/nombre de origen.
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS external_source VARCHAR(20) NULL AFTER notes;
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS external_id VARCHAR(40) NULL AFTER external_source;
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS source_name VARCHAR(160) NULL AFTER external_id;
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS name_split TINYINT(1) NOT NULL DEFAULT 1 AFTER source_name;
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS synced_at DATETIME NULL AFTER name_split;
ALTER TABLE clinic_patients MODIFY phone VARCHAR(40) NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS uq_clinic_patient_external ON clinic_patients (external_source, external_id);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "project-linkedcorp-backend",
"version": "4.11.0",
"version": "4.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "project-linkedcorp-backend",
"version": "4.11.0",
"version": "4.12.0",
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "4.11.0",
"version": "4.12.0",
"private": true,
"type": "module",
"main": "src/server.js",
+2
View File
@@ -30,6 +30,7 @@ import inventoryIngestRoutes from './modules/devices/ingest.js';
import aiRoutes from './modules/ai/routes.js';
import meetingsRoutes from './modules/meetings/routes.js';
import clinicRoutes from './modules/clinic/routes.js';
import integrationRoutes from './modules/integrations/routes.js';
export const app = express();
@@ -76,6 +77,7 @@ app.use('/api/devices', devicesRoutes);
app.use('/api/ai', aiRoutes);
app.use('/api/meetings', meetingsRoutes);
app.use('/api/clinic', clinicRoutes);
app.use('/api/integrations', integrationRoutes);
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
+19
View File
@@ -0,0 +1,19 @@
import { scheduledPatientSync } from '../modules/integrations/service.js';
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
// Pacientes desde el sistema origen (modo importar): completa lunes a viernes a las 05:00 y
// incremental cada 15 min de 06:00 a 21:00 (ver integrations/service.scheduledPatientSync).
// Mismo patrón que los demás jobs: dentro del proceso del backend, sin cron del SO.
export function startIntegrationSyncJob() {
const run = async () => {
try {
const result = await scheduledPatientSync();
if (result) console.log(`[integrations] pacientes ${result.full ? 'completa' : 'incremental'}: ${result.received} recibidos, ${result.created} nuevos, ${result.updated} actualizados, ${result.deactivated} inactivados`);
} catch (err) {
if (err.status !== 409) console.error('[integrations] error sincronizando pacientes:', err.message);
}
};
setTimeout(run, 30000);
setInterval(run, CHECK_INTERVAL_MS);
}
+6
View File
@@ -3,6 +3,7 @@ import { requireAuth } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import { asyncHandler, ok } from '../../utils/http.js';
import * as clinic from './service.js';
import * as integrations from '../integrations/service.js';
const router = Router();
router.use(requireAuth, requireModule('clinic'));
// Configuracion (solo admin/superadmin): no exige ser personal de la agenda, por eso va antes
@@ -46,6 +47,11 @@ write('post', '/blocks', (body, user, staff) => clinic.saveBlock(body, user, sta
write('put', '/blocks/:id', (body, user, staff, n) => clinic.saveBlock(body, user, staff, n));
router.delete('/blocks/:id', asyncHandler(async (req, res) => ok(res, await clinic.deleteBlock(req.params.id, req.query, req.currentUser, req.clinicStaff))));
get('/patients', (q, s) => clinic.patients(q, s));
// Pacientes del sistema origen (Conexiones externas): estado de la fuente, búsqueda en vivo e
// importación del elegido. Solo recepción (gestiona pacientes).
get('/patients/source', async (q, s) => { clinic.requireReception(s); return integrations.patientSourceInfo(); });
get('/patients/external', async (q, s) => { clinic.requireReception(s); return integrations.searchExternalPatients(q.search); });
write('post', '/patients/external/:id/import', async (body, user, staff, n) => { clinic.requireReception(staff); return integrations.importExternalPatient(n, user); });
router.get('/patients/:id/appointments', asyncHandler(async (req, res) => ok(res, await clinic.patientHistory(req.params.id, req.clinicStaff))));
write('post', '/patients', (body, user, staff) => clinic.savePatient(body, user, staff));
write('put', '/patients/:id', (body, user, staff, n) => clinic.savePatient(body, user, staff, n));
+7 -4
View File
@@ -37,6 +37,9 @@ const audit = async (user, action, entityType, entityId, connection = pool) => l
connection
});
const roleAdmin = user => ['admin', 'superadmin'].includes(user.roleCode);
export function requireReception(staff) {
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción gestiona pacientes');
}
export function requireConfig(user) {
if (!roleAdmin(user)) throw new HttpError(403, 'Solo administración configura la agenda');
}
@@ -88,9 +91,9 @@ export async function patients(q, staff) {
pageSize = q.pageSize === undefined ? 20 : Number(q.pageSize);
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 50 || !Number.isSafeInteger((page - 1) * pageSize)) throw new HttpError(400, 'Paginación inválida');
const like = `%${search}%`;
const where = 'is_active=1 AND (first_names LIKE ? OR last_names LIKE ? OR identification LIKE ? OR phone LIKE ?)';
const [count] = await exec(pool, `SELECT COUNT(*) AS n FROM clinic_patients WHERE ${where}`, [like, like, like, like]);
const items = await exec(pool, `SELECT * FROM clinic_patients WHERE ${where} ORDER BY last_names,first_names LIMIT ? OFFSET ?`, [like, like, like, like, pageSize, (page - 1) * pageSize]);
const where = 'is_active=1 AND (first_names LIKE ? OR last_names LIKE ? OR source_name LIKE ? OR identification LIKE ? OR phone LIKE ?)';
const [count] = await exec(pool, `SELECT COUNT(*) AS n FROM clinic_patients WHERE ${where}`, [like, like, like, like, like]);
const items = await exec(pool, `SELECT * FROM clinic_patients WHERE ${where} ORDER BY last_names,first_names LIMIT ? OFFSET ?`, [like, like, like, like, like, pageSize, (page - 1) * pageSize]);
return {
items,
total: count.n,
@@ -122,7 +125,7 @@ export async function savePatient(raw, user, staff, patientId = null) {
const data = patientData(raw);
let result;
try {
if (patientId) result = await exec(pool, 'UPDATE clinic_patients SET first_names=?,last_names=?,identification=?,phone=?,email=?,birth_date=?,notes=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [...data, id(patientId), version(raw.editVersion)]);else result = await exec(pool, 'INSERT INTO clinic_patients (first_names,last_names,identification,phone,email,birth_date,notes,created_by) VALUES (?,?,?,?,?,?,?,?)', [...data, user.id]);
if (patientId) result = await exec(pool, 'UPDATE clinic_patients SET first_names=?,last_names=?,identification=?,phone=?,email=?,birth_date=?,notes=?,name_split=1,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [...data, id(patientId), version(raw.editVersion)]);else result = await exec(pool, 'INSERT INTO clinic_patients (first_names,last_names,identification,phone,email,birth_date,notes,created_by) VALUES (?,?,?,?,?,?,?,?)', [...data, user.id]);
} catch (e) {
if (e.code === 'ER_DUP_ENTRY') throw new HttpError(409, 'Identificación ya registrada');
throw e;
@@ -0,0 +1,53 @@
import { HttpError } from '../../utils/http.js';
// Cliente del contrato v1 de conectores (data/conectores/README.md). Solo GET, con token y tiempo
// máximo; los errores del conector se traducen a mensajes para el administrador.
const TIMEOUT_MS = 60000;
export function normalizeBaseUrl(raw) {
let url;
try {
url = new URL(String(raw || '').trim());
} catch {
throw new HttpError(400, 'URL del conector inválida');
}
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new HttpError(400, 'URL del conector inválida');
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
}
export async function connectorGet(conn, path, query = {}) {
const url = new URL(`${conn.baseUrl}${path}`);
for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
let res;
try {
res = await fetch(url, { headers: { Authorization: `Bearer ${conn.token}` }, signal: AbortSignal.timeout(TIMEOUT_MS) });
} catch (err) {
throw new HttpError(502, err.name === 'TimeoutError' ? 'El conector no respondió a tiempo' : 'No se pudo conectar con el conector');
}
let body = null;
try { body = await res.json(); } catch { /* respuesta no JSON */ }
if (!res.ok) {
const reason = res.status === 401 ? 'token inválido' : res.status === 403 ? 'IP no permitida' : body?.error || `HTTP ${res.status}`;
throw new HttpError(502, `Conector: ${reason}`);
}
return body;
}
// Recorre todas las páginas de una consulta de filas (máximo 1000 por página en el conector).
// Si el origen cambia entre páginas (otra versión del archivo), se reinicia la lectura para no
// duplicar ni saltar filas.
export async function connectorAllRows(conn, source, table, query = {}) {
for (let attempt = 0; attempt < 3; attempt++) {
const rows = [];
let offset = 0, page, version = null, stable = true;
do {
page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(source)}/tables/${encodeURIComponent(table)}/rows`, { ...query, limit: 1000, offset });
if (version !== null && page.version !== version) { stable = false; break; }
version = page.version;
rows.push(...page.rows);
offset += page.rows.length;
} while (page.rows.length && offset < page.total);
if (stable) return { rows, maxRecno: page.maxRecno, version };
}
throw new HttpError(503, 'El origen cambió durante la lectura; se reintentará');
}
@@ -0,0 +1,22 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { asyncHandler, ok } from '../../utils/http.js';
import * as svc from './service.js';
// Conexiones externas. Permisos en el servicio: configuración solo superadmin; empleados
// (listar y "Actualizar empleados") admin y superadmin.
const router = Router();
router.use(requireAuth);
const h = (fn, status = 200) => asyncHandler(async (req, res) => ok(res, await fn(req), status));
router.get('/connections', h((req) => svc.listConnections(req.currentUser)));
router.post('/connections', h((req) => svc.saveConnection(req.body, req.currentUser), 201));
router.put('/connections/:id', h((req) => svc.saveConnection(req.body, req.currentUser, req.params.id)));
router.post('/connections/:id/test', h((req) => svc.testConnection(req.params.id, req.currentUser)));
router.get('/connections/:id/browse', h((req) => svc.browse(req.params.id, req.query, req.currentUser)));
router.get('/sources', h((req) => svc.listSources(req.currentUser)));
router.put('/sources/:purpose', h((req) => svc.saveSource(req.params.purpose, req.body, req.currentUser)));
router.post('/sources/patients/sync', h((req) => svc.syncPatients({ full: !!req.body?.full, user: req.currentUser })));
router.get('/employees', h((req) => svc.listEmployees(req.currentUser, req.query)));
router.post('/employees/sync', h((req) => svc.syncEmployees(req.currentUser)));
export default router;
+390
View File
@@ -0,0 +1,390 @@
import { pool } from '../../config/db.js';
import { HttpError } from '../../utils/http.js';
import { encrypt, decrypt } from '../../utils/crypto.js';
import { logAudit } from '../../utils/audit.js';
import { requireEditVersion } from '../../utils/editVersion.js';
import { guayaquilNow } from '../meetings/recurrence.js';
import { connectorGet, connectorAllRows, normalizeBaseUrl } from './connector.js';
// Conexiones externas (ver docs/CONEXIONES-EXTERNAS.md). El sistema nunca escribe en el origen:
// solo lee por el conector y copia/consulta lo que el superadmin mapeó.
const exec = async (db, sql, params = []) => (await db.query(sql, params))[0];
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
const TARGETS = {
employees: { required: ['identification'], fields: ['identification', 'lastNames', 'firstNames', 'phone', 'phone2', 'birthDate', 'position', 'department', 'hireDate', 'leaveDate', 'active'] },
patients: { required: ['externalId', 'fullName'], fields: ['externalId', 'identification', 'fullName', 'phone', 'email'] }
};
const isSuperadmin = (user) => user.roleCode === 'superadmin';
const isAdmin = (user) => ['admin', 'superadmin'].includes(user.roleCode);
export function requireSuperadmin(user) {
if (!isSuperadmin(user)) throw new HttpError(403, 'Solo el superadmin configura las conexiones externas');
}
export function requireAdmin(user) {
if (!isAdmin(user)) throw new HttpError(403, 'No tienes permisos para esta acción');
}
const num = (v) => {
const n = Number(v);
if (!Number.isSafeInteger(n) || n < 1) throw new HttpError(400, 'ID inválido');
return n;
};
const purposeOf = (p) => {
if (!TARGETS[p]) throw new HttpError(400, 'Destino inválido');
return p;
};
const clip = (v, max) => {
if (v === null || v === undefined) return null;
const s = String(v).replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
return s ? s.slice(0, max) : null;
};
const dateOnly = (v) => (typeof v === 'string' && /^\d{4}-\d\d-\d\d/.test(v) ? v.slice(0, 10) : null);
const truthy = (v) => v === true || v === 1 || /^(1|s|si|sí|true|t|y|activo)$/i.test(String(v ?? '').trim());
// --- Conexiones ---
async function loadConnection(id) {
const [row] = await exec(pool, 'SELECT * FROM external_connections WHERE id=?', [num(id)]);
if (!row) throw new HttpError(404, 'Conexión no encontrada');
return row;
}
const clientFor = (row) => ({ baseUrl: row.base_url, token: decrypt(row.token_encrypted) });
const publicConnection = (r) => ({ id: r.id, name: r.name, base_url: r.base_url, is_active: !!r.is_active, edit_version: r.edit_version, created_at: r.created_at, updated_at: r.updated_at });
export async function listConnections(user) {
requireSuperadmin(user);
return (await exec(pool, 'SELECT * FROM external_connections ORDER BY name')).map(publicConnection);
}
export async function saveConnection(raw, user, connId = null) {
requireSuperadmin(user);
const name = clip(raw.name, 120);
if (!name) throw new HttpError(400, 'El nombre es obligatorio');
const baseUrl = normalizeBaseUrl(raw.baseUrl);
const token = raw.token ? String(raw.token).trim() : '';
if (connId) {
const before = await loadConnection(connId);
const version = requireEditVersion(Number(raw.editVersion));
const result = await exec(pool, `UPDATE external_connections SET name=?,base_url=?,is_active=?${token ? ',token_encrypted=?' : ''},edit_version=edit_version+1 WHERE id=? AND edit_version=?`,
[name, baseUrl, raw.isActive === false ? 0 : 1, ...(token ? [encrypt(token)] : []), before.id, version]);
if (!result.affectedRows) throw new HttpError(409, 'Conexión modificada por otra persona');
await logAudit({ userId: user.id, action: 'update', entityType: 'external_connection', entityId: before.id, detail: { name, baseUrl, tokenChanged: !!token } });
return publicConnection(await loadConnection(before.id));
}
if (!token) throw new HttpError(400, 'El token es obligatorio');
const result = await exec(pool, 'INSERT INTO external_connections (name,base_url,token_encrypted,created_by) VALUES (?,?,?,?)', [name, baseUrl, encrypt(token), user.id]);
await logAudit({ userId: user.id, action: 'create', entityType: 'external_connection', entityId: result.insertId, detail: { name, baseUrl } });
return publicConnection(await loadConnection(result.insertId));
}
export async function testConnection(connId, user) {
requireSuperadmin(user);
const row = await loadConnection(connId);
const health = await connectorGet(clientFor(row), '/v1/health');
await logAudit({ userId: user.id, action: 'test', entityType: 'external_connection', entityId: row.id });
return health;
}
// Exploración para el superadmin: fuentes, tablas, estructura y una muestra de filas.
export async function browse(connId, q, user) {
requireSuperadmin(user);
const conn = clientFor(await loadConnection(connId));
if (!q.source) return connectorGet(conn, '/v1/sources');
if (!ID_RE.test(q.source)) throw new HttpError(400, 'Fuente inválida');
const base = `/v1/sources/${encodeURIComponent(q.source)}/tables`;
if (!q.table) return connectorGet(conn, base);
if (!ID_RE.test(q.table)) throw new HttpError(400, 'Tabla inválida');
const [schema, sample] = await Promise.all([
connectorGet(conn, `${base}/${encodeURIComponent(q.table)}/schema`),
connectorGet(conn, `${base}/${encodeURIComponent(q.table)}/rows`, { limit: 20 })
]);
await logAudit({ userId: user.id, action: 'preview', entityType: 'external_connection', entityId: Number(connId), detail: { source: q.source, table: q.table } });
return { schema, sample: sample.rows };
}
// --- Fuentes (qué alimenta Pacientes / Empleados) ---
const parseSource = (r) => r && ({ ...r, field_map: JSON.parse(r.field_map_json), filter: r.filter_json ? JSON.parse(r.filter_json) : {}, last_result: r.last_result_json ? JSON.parse(r.last_result_json) : null });
async function sourceRow(purpose) {
const [row] = await exec(pool, 'SELECT * FROM external_sources WHERE purpose=?', [purposeOf(purpose)]);
return parseSource(row);
}
export async function listSources(user) {
requireAdmin(user);
const rows = (await exec(pool, 'SELECT s.*, c.name AS connection_name FROM external_sources s JOIN external_connections c ON c.id=s.connection_id')).map(parseSource);
// El admin solo ve el estado (para "Actualizar empleados"); el mapeo es del superadmin.
return isSuperadmin(user) ? rows : rows.map((r) => ({ purpose: r.purpose, mode: r.mode, connection_name: r.connection_name, last_sync_at: r.last_sync_at, last_full_sync_at: r.last_full_sync_at, last_result: r.last_result }));
}
export async function saveSource(purpose, raw, user) {
requireSuperadmin(user);
const p = purposeOf(purpose);
const conn = await loadConnection(raw.connectionId);
const sourceKey = String(raw.sourceKey || ''), tableName = String(raw.tableName || '');
if (!ID_RE.test(sourceKey) || !ID_RE.test(tableName)) throw new HttpError(400, 'Fuente o tabla inválida');
const mode = p === 'employees' ? 'import' : raw.mode === 'live' ? 'live' : 'import';
// El mapeo se valida contra la estructura real que publica el conector.
const schema = await connectorGet(clientFor(conn), `/v1/sources/${encodeURIComponent(sourceKey)}/tables/${encodeURIComponent(tableName)}/schema`);
const known = new Set(schema.fields.map((f) => f.name));
const map = {};
for (const target of TARGETS[p].fields) {
const field = raw.fieldMap?.[target];
if (!field) continue;
if (!known.has(field)) throw new HttpError(400, `Campo inexistente en la tabla: ${field}`);
map[target] = field;
}
for (const r of TARGETS[p].required) if (!map[r]) throw new HttpError(400, `Falta mapear el campo obligatorio: ${r}`);
const filter = {};
for (const [field, value] of Object.entries(raw.filter || {})) {
if (!known.has(field)) throw new HttpError(400, `Campo de filtro inexistente: ${field}`);
if (String(value).trim()) filter[field] = String(value).trim().slice(0, 60);
}
const changedField = raw.changedField ? String(raw.changedField) : null;
if (changedField && schema.fields.find((f) => f.name === changedField)?.type !== 'T') throw new HttpError(400, 'El campo de cambios debe ser de fecha y hora');
const before = await sourceRow(p);
if (p === 'patients' && before && before.field_map.externalId !== map.externalId) {
const [{ n }] = await exec(pool, 'SELECT COUNT(*) AS n FROM clinic_patients WHERE external_source=?', [patientKey(before)]);
if (n) throw new HttpError(409, 'No se puede cambiar el campo "código en el origen" con pacientes ya importados');
}
const sameTable = before && before.connection_id === conn.id && before.source_key === sourceKey && before.table_name === tableName;
if (before) {
const result = await exec(pool, `UPDATE external_sources SET connection_id=?,source_key=?,table_name=?,mode=?,field_map_json=?,filter_json=?,changed_field=?,
${sameTable ? '' : 'last_full_sync_at=NULL,last_sync_at=NULL,last_max_recno=NULL,last_changed_value=NULL,'}edit_version=edit_version+1 WHERE id=? AND edit_version=?`,
[conn.id, sourceKey, tableName, mode, JSON.stringify(map), JSON.stringify(filter), changedField, before.id, requireEditVersion(Number(raw.editVersion))]);
if (!result.affectedRows) throw new HttpError(409, 'Fuente modificada por otra persona');
} else {
await exec(pool, 'INSERT INTO external_sources (purpose,connection_id,source_key,table_name,mode,field_map_json,filter_json,changed_field) VALUES (?,?,?,?,?,?,?,?)',
[p, conn.id, sourceKey, tableName, mode, JSON.stringify(map), JSON.stringify(filter), changedField]);
}
await logAudit({ userId: user.id, action: before ? 'update' : 'create', entityType: 'external_source', entityId: null, detail: { purpose: p, sourceKey, tableName, mode, map, filter } });
return sourceRow(p);
}
async function sourceClient(purpose) {
const src = await sourceRow(purpose);
if (!src) throw new HttpError(409, purpose === 'employees' ? 'No hay fuente de nómina configurada' : 'No hay fuente de pacientes configurada');
const conn = await loadConnection(src.connection_id);
if (!conn.is_active) throw new HttpError(409, 'La conexión está desactivada');
return { src, conn: clientFor(conn) };
}
const eqQuery = (filter) => Object.fromEntries(Object.entries(filter || {}).map(([k, v]) => [`eq.${k}`, v]));
const fieldsQuery = (src) => [...new Set([...Object.values(src.field_map), ...(src.changed_field ? [src.changed_field] : [])])].join(',');
async function finishSync(src, values, result) {
await exec(pool, `UPDATE external_sources SET last_sync_at=?,${values.full ? 'last_full_sync_at=?,' : ''}last_max_recno=?,last_changed_value=?,last_result_json=? WHERE id=?`,
[guayaquilNow(), ...(values.full ? [guayaquilNow()] : []), values.maxRecno ?? src.last_max_recno, values.changedValue ?? src.last_changed_value, JSON.stringify(result), src.id]);
}
// --- Empleados (nómina): siempre importados, a pedido (admin/superadmin) ---
const running = new Set();
async function exclusive(key, fn) {
if (running.has(key)) throw new HttpError(409, 'Ya hay una sincronización en curso');
running.add(key);
try { return await fn(); } finally { running.delete(key); }
}
export async function syncEmployees(user) {
requireAdmin(user);
return exclusive('employees', async () => {
const { src, conn } = await sourceClient('employees');
const m = src.field_map;
const { rows, maxRecno } = await connectorAllRows(conn, src.source_key, src.table_name, { fields: fieldsQuery(src), ...eqQuery(src.filter) });
const now = guayaquilNow();
const seen = [];
let created = 0, updated = 0, protectedRows = 0;
// Empleados con usuario en el sistema (p. ej. médicos) no se actualizan ni se inactivan solos.
const known = await exec(pool, 'SELECT identification, user_id FROM employees');
const withUser = new Set(known.filter((e) => e.user_id).map((e) => e.identification));
const existed = new Set(known.map((e) => e.identification));
for (const r of rows) {
const identification = clip(r[m.identification], 40);
if (!identification) continue;
seen.push(identification);
if (withUser.has(identification)) { protectedRows++; continue; }
const leave = m.leaveDate ? dateOnly(r[m.leaveDate]) : null;
const active = m.active ? truthy(r[m.active]) : !leave;
const values = [clip(m.lastNames && r[m.lastNames], 120) || '', clip(m.firstNames && r[m.firstNames], 120) || '', clip(m.phone && r[m.phone], 40), clip(m.phone2 && r[m.phone2], 40),
m.birthDate ? dateOnly(r[m.birthDate]) : null, clip(m.position && r[m.position], 120), clip(m.department && r[m.department], 120), m.hireDate ? dateOnly(r[m.hireDate]) : null, leave, active ? 1 : 0];
const result = await exec(pool, `INSERT INTO employees (identification,last_names,first_names,phone,phone2,birth_date,position,department_name,hire_date,leave_date,is_active,in_source,synced_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?) ON DUPLICATE KEY UPDATE last_names=VALUES(last_names),first_names=VALUES(first_names),phone=VALUES(phone),phone2=VALUES(phone2),birth_date=VALUES(birth_date),
position=VALUES(position),department_name=VALUES(department_name),hire_date=VALUES(hire_date),leave_date=VALUES(leave_date),is_active=VALUES(is_active),in_source=1,origin='payroll'`,
[identification, ...values, now]);
// MySQL informa 2 filas afectadas solo cuando el registro existente cambió de verdad.
if (!existed.has(identification)) created++;
else if (result.affectedRows === 2) updated++;
}
const synced = seen.filter((x) => !withUser.has(x));
if (synced.length) await exec(pool, 'UPDATE employees SET synced_at=? WHERE identification IN (?)', [now, synced]);
// Los que ya no están en la nómina quedan inactivos (no se borran: pueden tener usuario).
const gone = seen.length
? await exec(pool, "UPDATE employees SET in_source=0,is_active=0 WHERE in_source=1 AND origin='payroll' AND user_id IS NULL AND identification NOT IN (?)", [seen])
: { affectedRows: 0 };
const result = { at: now, full: true, total: rows.length, created, updated, removed: gone.affectedRows, protected: protectedRows };
await finishSync(src, { full: true, maxRecno }, result);
await logAudit({ userId: user.id, action: 'sync', entityType: 'employees', entityId: null, detail: result });
return result;
});
}
export async function listEmployees(user, q = {}) {
requireAdmin(user);
const onlyFree = q.available === '1';
return exec(pool, `SELECT e.*, u.name AS user_name FROM employees e LEFT JOIN users u ON u.id=e.user_id
WHERE 1=1 ${onlyFree ? 'AND e.is_active=1 AND e.user_id IS NULL' : ''} ORDER BY e.last_names, e.first_names`);
}
export async function requireFreeEmployee(employeeId) {
const [e] = await exec(pool, 'SELECT id FROM employees WHERE id=? AND is_active=1 AND user_id IS NULL', [num(employeeId)]);
if (!e) throw new HttpError(409, 'Ese empleado ya tiene usuario, está inactivo o no existe');
}
// Vincula un usuario recién creado con su empleado (desde "Nuevo usuario" → Desde nómina).
export async function linkEmployeeToUser(employeeId, userId, connection = pool) {
const result = await exec(connection, 'UPDATE employees SET user_id=? WHERE id=? AND user_id IS NULL', [userId, num(employeeId)]);
if (!result.affectedRows) throw new HttpError(409, 'Ese empleado ya tiene usuario o no existe');
}
// --- Pacientes (clientes del origen) ---
const patientKey = (src) => `src${src.id}`;
function patientValues(src, r) {
const m = src.field_map;
const email = clip(m.email && r[m.email], 160);
return {
externalId: clip(r[m.externalId], 40),
identification: clip(m.identification && r[m.identification], 40),
fullName: clip(r[m.fullName], 120),
phone: clip(m.phone && r[m.phone], 40) || '',
email: email && /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/.test(email) ? email : null
};
}
// Upsert de un lote: manda el origen (cédula, teléfono, correo, nombre de origen). El nombre visible
// solo se reemplaza mientras recepción no lo haya separado a mano (name_split=0).
async function upsertPatients(src, rows, now) {
const key = patientKey(src);
const stats = { created: 0, updated: 0, linked: 0, unchanged: 0, skipped: 0, identConflicts: 0 };
const seen = new Set();
if (!rows.length) return { stats, seen };
// Lotes chicos (incremental, importar uno): solo se precarga lo del lote; la completa trae todo.
const small = rows.length <= 2000;
const batchIds = rows.map((r) => clip(r[src.field_map.externalId], 40)).filter(Boolean);
const batchIdents = src.field_map.identification ? rows.map((r) => clip(r[src.field_map.identification], 40)).filter(Boolean) : [];
const existing = new Map((small && !batchIds.length ? [] : await exec(pool, `SELECT id,external_id,identification,source_name,phone,email,is_active,name_split FROM clinic_patients WHERE external_source=?${small ? ' AND external_id IN (?)' : ''}`, small ? [key, batchIds] : [key])).map((p) => [p.external_id, p]));
const byIdent = new Map((small && !batchIdents.length ? [] : await exec(pool, `SELECT id,identification,external_id FROM clinic_patients WHERE identification IS NOT NULL${small ? ' AND identification IN (?)' : ''}`, small ? [batchIdents] : [])).map((p) => [p.identification, p]));
for (const r of rows) {
const v = patientValues(src, r);
if (v.externalId) seen.add(v.externalId);
if (!v.externalId || !v.fullName) { stats.skipped++; continue; }
// Código repetido en el lote (duplicado en el origen): se procesa una sola vez.
if (existing.get(v.externalId)?.batch) { stats.skipped++; continue; }
const owner = v.identification ? byIdent.get(v.identification) : null;
const cur = existing.get(v.externalId);
// La cédula es única: si otro paciente ya la tiene (duplicado en el origen), no se repite.
const identFor = (selfId) => (v.identification && owner && owner.id !== selfId ? (stats.identConflicts++, null) : v.identification);
if (cur) {
const ident = identFor(cur.id);
if (cur.source_name === v.fullName && (cur.identification || null) === ident && cur.phone === v.phone && (cur.email || null) === v.email && cur.is_active) { cur.batch = true; stats.unchanged++; continue; }
await exec(pool, 'UPDATE clinic_patients SET source_name=?,first_names=IF(name_split=0,?,first_names),identification=?,phone=?,email=?,is_active=1,synced_at=? WHERE id=?',
[v.fullName, v.fullName, ident, v.phone, v.email, now, cur.id]);
if (ident) byIdent.set(ident, { id: cur.id, identification: ident });
cur.batch = true;
stats.updated++;
} else if (owner && !owner.external_id) {
// Paciente creado a mano con la misma cédula: se vincula y manda el origen (sin tocar su nombre separado).
await exec(pool, 'UPDATE clinic_patients SET external_source=?,external_id=?,source_name=?,phone=?,email=?,is_active=1,synced_at=? WHERE id=?',
[key, v.externalId, v.fullName, v.phone, v.email, now, owner.id]);
owner.external_id = v.externalId;
existing.set(v.externalId, { id: owner.id, batch: true });
stats.linked++;
} else {
const result = await exec(pool, `INSERT INTO clinic_patients (first_names,last_names,identification,phone,email,external_source,external_id,source_name,name_split,synced_at)
VALUES (?,'',?,?,?,?,?,?,0,?)`, [v.fullName, identFor(null), v.phone, v.email, key, v.externalId, v.fullName, now]);
if (v.identification && !owner) byIdent.set(v.identification, { id: result.insertId, identification: v.identification, external_id: v.externalId });
existing.set(v.externalId, { id: result.insertId, batch: true });
stats.created++;
}
}
return { stats, seen };
}
export async function syncPatients({ full = false, user = null } = {}) {
if (user) requireSuperadmin(user);
return exclusive('patients', async () => {
const { src, conn } = await sourceClient('patients');
if (src.mode !== 'import') throw new HttpError(409, 'La fuente de pacientes está en modo consulta en vivo');
const doFull = full || !src.last_full_sync_at;
const query = { fields: fieldsQuery(src), ...eqQuery(src.filter) };
if (!doFull) {
query.afterRecno = src.last_max_recno ?? 0;
if (src.changed_field && src.last_changed_value) Object.assign(query, { changedField: src.changed_field, changedSince: src.last_changed_value });
}
const { rows, maxRecno } = await connectorAllRows(conn, src.source_key, src.table_name, query);
const now = guayaquilNow();
const { stats, seen } = await upsertPatients(src, rows, now);
let deactivated = 0;
if (doFull && seen.size) {
// Los que ya no están en el origen (borrados) quedan inactivos; su historial se conserva.
deactivated = (await exec(pool, 'UPDATE clinic_patients SET is_active=0 WHERE external_source=? AND is_active=1 AND external_id NOT IN (?)', [patientKey(src), [...seen]])).affectedRows;
}
let changedValue = src.last_changed_value;
if (src.changed_field) for (const r of rows) { const c = r[src.changed_field]; if (c && (!changedValue || String(c) > changedValue)) changedValue = String(c).slice(0, 40); }
const result = { at: now, full: doFull, received: rows.length, ...stats, deactivated };
await finishSync(src, { full: doFull, maxRecno, changedValue }, result);
if (user) await logAudit({ userId: user.id, action: 'sync', entityType: 'clinic_patients', entityId: null, detail: result });
return result;
});
}
// Modo consulta en vivo: buscar en el origen y traer al sistema solo el paciente elegido.
export async function patientSourceInfo() {
const [src] = await exec(pool, "SELECT s.mode, s.last_sync_at FROM external_sources s JOIN external_connections c ON c.id=s.connection_id WHERE s.purpose='patients' AND c.is_active=1");
return src ? { configured: true, mode: src.mode, last_sync_at: src.last_sync_at } : { configured: false };
}
export async function searchExternalPatients(text) {
const { src, conn } = await sourceClient('patients');
const m = src.field_map;
const term = String(text || '').trim();
if (term.length < 3) return [];
const page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(src.source_key)}/tables/${encodeURIComponent(src.table_name)}/rows`, {
q: term.slice(0, 60), qFields: [m.fullName, m.identification, m.phone].filter(Boolean).join(','), fields: fieldsQuery(src), limit: 20, ...eqQuery(src.filter)
});
const ids = page.rows.map((r) => clip(r[m.externalId], 40)).filter(Boolean);
const local = ids.length ? new Map((await exec(pool, 'SELECT id,external_id FROM clinic_patients WHERE external_source=? AND external_id IN (?)', [patientKey(src), ids])).map((p) => [p.external_id, p.id])) : new Map();
return page.rows.map((r) => {
const v = patientValues(src, r);
return { external_id: v.externalId, identification: v.identification, full_name: v.fullName, phone: v.phone, email: v.email, local_id: local.get(v.externalId) || null };
}).filter((p) => p.external_id && p.full_name);
}
export async function importExternalPatient(externalId, user) {
const { src, conn } = await sourceClient('patients');
const ext = clip(externalId, 40);
if (!ext) throw new HttpError(400, 'Paciente externo inválido');
const page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(src.source_key)}/tables/${encodeURIComponent(src.table_name)}/rows`, {
[`eq.${src.field_map.externalId}`]: ext, fields: fieldsQuery(src), limit: 1, ...eqQuery(src.filter)
});
if (!page.rows.length) throw new HttpError(404, 'El paciente ya no existe en el sistema origen');
await upsertPatients(src, page.rows, guayaquilNow());
const [row] = await exec(pool, 'SELECT * FROM clinic_patients WHERE external_source=? AND external_id=?', [patientKey(src), ext]);
if (!row) throw new HttpError(409, 'No se pudo traer el paciente');
await logAudit({ userId: user.id, action: 'import', entityType: 'clinic_patient', entityId: row.id, detail: { externalId: ext } });
return row;
}
// Programación (job): completa lunes a viernes a las 05:00; incremental cada 15 min de 06:00 a 21:00.
export async function scheduledPatientSync() {
const src = await sourceRow('patients');
if (!src || src.mode !== 'import') return null;
const now = guayaquilNow();
const weekday = new Date(`${now.slice(0, 10)}T12:00:00Z`).getUTCDay();
const hour = Number(now.slice(11, 13));
const fullToday = src.last_full_sync_at && String(src.last_full_sync_at).slice(0, 10) === now.slice(0, 10);
if (!src.last_full_sync_at || (weekday >= 1 && weekday <= 5 && hour === 5 && !fullToday)) return syncPatients({ full: true });
const minutesSince = src.last_sync_at ? (Date.parse(`${now.replace(' ', 'T')}Z`) - Date.parse(`${String(src.last_sync_at).replace(' ', 'T')}Z`)) / 60000 : Infinity;
if (hour >= 6 && hour < 21 && minutesSince >= 15) return syncPatients({ full: false });
return null;
}
+12 -1
View File
@@ -1,5 +1,6 @@
import crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
import { linkEmployeeToUser, requireFreeEmployee } from '../integrations/service.js';
import * as repo from './repository.js';
import { HttpError } from '../../utils/http.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
@@ -57,10 +58,20 @@ export async function createUser(payload, actor) {
const existing = await repo.findByEmail(payload.email);
if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email');
await checkRole(payload.roleId, actor);
// "Desde nómina": el empleado debe existir, estar activo y no tener usuario (se valida antes de crear).
if (payload.employeeId) await requireFreeEmployee(payload.employeeId);
const passwordHash = await bcrypt.hash(payload.password, 12);
const user = await repo.create({ ...payload, passwordHash });
await logAudit({ userId: actor.id, action: 'create', entityType: 'user', entityId: user.id });
if (payload.employeeId) {
try {
await linkEmployeeToUser(payload.employeeId, user.id);
} catch (err) {
await repo.remove(user.id);
throw err;
}
}
await logAudit({ userId: actor.id, action: 'create', entityType: 'user', entityId: user.id, detail: payload.employeeId ? { employeeId: Number(payload.employeeId) } : undefined });
return user;
}
@@ -0,0 +1,42 @@
// Carga como empleados (origin=provider) a proveedores de SAGA que trabajan en la clínica pero no
// están en nómina (médicos que facturan). Lee del conector configurado en Conexiones externas, así
// no se guardan datos personales en el repositorio.
// node src/scripts/import-provider-employees.js --position=MEDICO 137+031 142+033 809 ...
// Cada argumento es una persona; "+" une códigos duplicados de la misma persona (el primero da el
// nombre; teléfono y correo se toman del primero que los tenga). Si la cédula ya existe como
// empleado (p. ej. de nómina) solo completa teléfono y correo vacíos.
import 'dotenv/config';
import { pool } from '../config/db.js';
import { decrypt } from '../utils/crypto.js';
import { connectorGet } from '../modules/integrations/connector.js';
const SOURCE = 'clientes', TABLE = 'PROVEDOR';
const args = process.argv.slice(2);
const position = (args.find((a) => a.startsWith('--position=')) || '--position=MEDICO').split('=')[1];
const people = args.filter((a) => !a.startsWith('--')).map((a) => a.split('+'));
if (!people.length) { console.error('Uso: import-provider-employees.js [--position=X] COD[+COD] ...'); process.exit(1); }
const [conn] = (await pool.query("SELECT c.base_url, c.token_encrypted FROM external_sources s JOIN external_connections c ON c.id=s.connection_id WHERE s.purpose='patients' AND c.is_active=1"))[0];
if (!conn) { console.error('No hay conexión activa (Configuración → Conexiones externas)'); process.exit(1); }
const client = { baseUrl: conn.base_url, token: decrypt(conn.token_encrypted) };
const valid = (v) => v && !/^9+$/.test(v);
for (const codes of people) {
const rows = [];
for (const code of codes) {
const page = await connectorGet(client, `/v1/sources/${SOURCE}/tables/${TABLE}/rows`, { [`eq.PROVCOD`]: code, limit: 1 });
if (page.rows[0]) rows.push(page.rows[0]);
}
if (!rows.length) { console.log(`${codes.join('+')}: no existe en proveedores`); continue; }
const words = String(rows[0].PROVNOM).replace(/^DRA?\.?\s+/i, '').trim().split(/\s+/);
const identification = String(rows[0].PROVRUC || '').slice(0, 10);
if (!/^\d{10}$/.test(identification)) { console.log(`${codes.join('+')}: cédula inválida`); continue; }
const phone = rows.map((r) => r.PROVTEL).find(valid) || null;
const email = rows.map((r) => r.PROVEMAIL).find((e) => e && /@/.test(e)) || null;
const last = words.slice(0, 2).join(' '), first = words.slice(2).join(' ');
const [res] = await pool.query(`INSERT INTO employees (identification,last_names,first_names,phone,email,position,is_active,origin,in_source,synced_at)
VALUES (?,?,?,?,?,?,1,'provider',0,NOW()) ON DUPLICATE KEY UPDATE phone=COALESCE(phone,VALUES(phone)),email=COALESCE(email,VALUES(email))`,
[identification, last, first, phone, email, position]);
console.log(`${codes.join('+')}: ${last} ${first} → ${res.affectedRows === 1 ? 'creado' : res.affectedRows === 2 ? 'ya existía (completado)' : 'ya existía'}`);
}
await pool.end();
+2
View File
@@ -2,6 +2,7 @@ import { app } from './app.js';
import { env } from './config/env.js';
import { startRecurringTaskReminderJob } from './jobs/recurringTaskReminders.js';
import { startMeetingReminderJob } from './jobs/meetingReminders.js';
import { startIntegrationSyncJob } from './jobs/integrationSync.js';
app.listen(env.port, () => {
console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`);
@@ -9,3 +10,4 @@ app.listen(env.port, () => {
startRecurringTaskReminderJob();
startMeetingReminderJob();
startIntegrationSyncJob();