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:
@@ -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);
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "4.11.0",
|
||||
"version": "4.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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();
|
||||
|
||||
Reference in New Issue
Block a user