codigo0/backend/scripts/migrate-app-content-v2.js

550 lines
18 KiB
JavaScript
Raw Normal View History

2026-01-19 08:10:16 +00:00
/**
* Script de migración V2: Importa contenido real de la app al backend
*
* Lee procedures.ts y drugs.ts usando import dinámico
*/
import { query } from '../config/database.js';
import 'dotenv/config';
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Mapeo de prioridades (app → BD)
const priorityMap = {
'critico': 'critica',
'alto': 'alta',
'medio': 'media',
'bajo': 'baja'
};
// Mapeo de categorías de procedimientos → clinical_context
const procedureCategoryMap = {
'soporte_vital': {
'rcp': 'RCP',
'via_aerea': 'VIA_AEREA',
'shock': 'SHOCK'
},
'patologias': 'OTROS',
'escena': 'OTROS'
};
/**
* Obtiene ID del admin
*/
async function getAdminId() {
// Intentar primero en tes_content
let result = await query(
`SELECT id FROM tes_content.users WHERE role = 'super_admin' LIMIT 1`
);
// Si no existe, buscar en emerges_content (schema anterior)
if (result.rows.length === 0) {
result = await query(
`SELECT id FROM emerges_content.users WHERE role = 'super_admin' LIMIT 1`
);
}
// Si aún no existe, crear uno temporal
if (result.rows.length === 0) {
console.log('⚠️ No se encontró usuario admin, creando uno temporal...');
const tempResult = await query(
`INSERT INTO tes_content.users (id, email, username, password_hash, role, is_active)
VALUES (gen_random_uuid(), 'admin@emerges-tes.local', 'admin', 'temp', 'super_admin', true)
RETURNING id`
);
return tempResult.rows[0].id;
}
return result.rows[0].id;
}
/**
* Carga procedimientos desde el archivo TypeScript
* Usa require para importar el módulo
*/
async function loadProcedures() {
try {
// Intentar importar directamente
const proceduresModule = await import('../../src/data/procedures.ts');
return proceduresModule.procedures || [];
} catch (error) {
console.log('⚠️ No se pudo importar directamente, usando datos hardcodeados...');
// Datos hardcodeados como fallback
return getHardcodedProcedures();
}
}
/**
* Carga fármacos desde el archivo TypeScript
*/
async function loadDrugs() {
try {
const drugsModule = await import('../../src/data/drugs.ts');
return drugsModule.drugs || [];
} catch (error) {
console.log('⚠️ No se pudo importar directamente, usando datos hardcodeados...');
return getHardcodedDrugs();
}
}
/**
* Procedimientos hardcodeados (fallback)
*/
function getHardcodedProcedures() {
return [
{
id: 'rcp-adulto-svb',
title: 'RCP Adulto - Soporte Vital Básico',
shortTitle: 'RCP Adulto SVB',
category: 'soporte_vital',
subcategory: 'rcp',
priority: 'critico',
ageGroup: 'adulto',
steps: [
'Garantizar seguridad de la escena',
'Comprobar consciencia: estimular y preguntar "¿Se encuentra bien?"',
'Si no responde, llamar inmediatamente al 112',
'Abrir vía aérea: maniobra frente-mentón',
'Comprobar respiración: VER-OÍR-SENTIR (máx. 10 segundos)',
'Si no respira normal: iniciar RCP',
'Iniciar compresiones torácicas: 30 compresiones',
'Dar 2 ventilaciones de rescate',
'Continuar ciclos 30:2 sin interrupción',
'Solicitar DEA cuando esté disponible',
],
warnings: [
'Profundidad compresiones: 5-6 cm',
'Frecuencia: 100-120 compresiones/min',
'Permitir descompresión completa',
'Minimizar interrupciones (<10 seg)',
'Cambiar reanimador cada 2 min',
],
keyPoints: [
'Compresiones de calidad salvan vidas',
'No interrumpir para pulso hasta que haya signos de vida',
'La desfibrilación precoz aumenta supervivencia',
],
equipment: ['DEA', 'Bolsa-mascarilla', 'Cánula orofaríngea'],
drugs: ['Adrenalina 1mg'],
},
{
id: 'rcp-adulto-sva',
title: 'RCP Adulto - Soporte Vital Avanzado',
shortTitle: 'RCP Adulto SVA',
category: 'soporte_vital',
subcategory: 'rcp',
priority: 'critico',
ageGroup: 'adulto',
steps: [
'Continuar RCP 30:2 mientras se prepara monitorización',
'Colocar monitor/desfibrilador y analizar ritmo',
'Ritmo desfibrilable (FV/TVSP): descarga 150-200J bifásico',
'Reiniciar RCP inmediatamente 2 minutos',
'Obtener acceso IV/IO',
'Administrar adrenalina 1mg IV cada 3-5 min (tras 3ª descarga si DF)',
'Considerar amiodarona 300mg IV si FV/TVSP refractaria',
'Asegurar vía aérea avanzada cuando sea posible',
'Buscar y tratar causas reversibles (4H y 4T)',
'Si ROSC: cuidados post-parada',
],
warnings: [
'Minimizar interrupciones de compresiones',
'Adrenalina en ritmos no DF: lo antes posible',
'Amiodarona: 150mg adicionales si persiste FV/TVSP',
'Capnografía: objetivo ETCO2 >10 mmHg',
],
keyPoints: [
'4H: Hipoxia, Hipovolemia, Hipo/Hiperpotasemia, Hipotermia',
'4T: Neumotórax a Tensión, Taponamiento, Tóxicos, TEP',
],
equipment: ['Monitor/Desfibrilador', 'Material IOT', 'Acceso venoso'],
drugs: ['Adrenalina', 'Amiodarona', 'Atropina'],
},
{
id: 'rcp-pediatrico',
title: 'RCP Pediátrico - SVB',
shortTitle: 'RCP Pediátrico',
category: 'soporte_vital',
subcategory: 'rcp',
priority: 'critico',
ageGroup: 'pediatrico',
steps: [
'Garantizar seguridad de la escena',
'Comprobar consciencia',
'Si no responde, llamar inmediatamente al 112',
'Abrir vía aérea: maniobra frente-mentón',
'Comprobar respiración (máx. 10 segundos)',
'Si no respira normal: iniciar RCP',
'Dar 5 ventilaciones de rescate iniciales',
'Comprobar signos de vida/pulso (máx. 10 seg)',
'Si no hay signos de vida: 15 compresiones torácicas',
'Continuar con ciclos 15:2',
],
warnings: [
'Lactante (<1 año): compresiones con 2 dedos',
'Niño (1-8 años): talón de una mano',
'Profundidad: 1/3 del tórax (4cm lactante, 5cm niño)',
'Frecuencia: 100-120/min',
],
keyPoints: [
'La causa más frecuente es respiratoria',
'Las 5 ventilaciones iniciales son cruciales',
'Ratio 15:2 para profesionales',
],
},
{
id: 'obstruccion-via-aerea',
title: 'Obstrucción de Vía Aérea - OVACE',
shortTitle: 'OVACE',
category: 'soporte_vital',
subcategory: 'via_aerea',
priority: 'critico',
ageGroup: 'todos',
steps: [
'Valorar gravedad: ¿Puede toser, hablar, respirar?',
'OBSTRUCCIÓN LEVE: animar a toser, vigilar',
'OBSTRUCCIÓN GRAVE consciente: 5 golpes interescapulares',
'Si no se resuelve: 5 compresiones abdominales (Heimlich)',
'Alternar 5 golpes + 5 compresiones hasta resolución',
'Si pierde consciencia: iniciar RCP',
'Antes de ventilar: revisar boca y extraer objeto visible',
],
warnings: [
'En embarazadas y obesos: compresiones torácicas',
'Lactantes: 5 golpes en espalda + 5 compresiones torácicas',
'NO hacer barrido digital a ciegas',
'Derivar siempre tras maniobras de Heimlich',
],
keyPoints: [
'La tos es el mecanismo más efectivo',
'No interferir si la tos es efectiva',
],
},
{
id: 'shock-hemorragico',
title: 'Shock Hemorrágico',
shortTitle: 'Shock Hemorrágico',
category: 'soporte_vital',
subcategory: 'shock',
priority: 'critico',
ageGroup: 'adulto',
steps: [
'Control de hemorragia externa: presión directa',
'Torniquete si hemorragia en extremidad no controlable',
'Oxigenoterapia alto flujo',
'Canalizar 2 vías IV gruesas (14-16G)',
'Fluidos: cristaloides tibios (objetivo TAS 80-90 mmHg)',
'Posición Trendelenburg si no hay TCE',
'Evitar hipotermia: mantas térmicas',
'Traslado urgente a hospital útil',
'Considerar ácido tranexámico 1g IV',
],
warnings: [
'Hipotensión permisiva: TAS 80-90 mmHg',
'Excepto en TCE: mantener TAS >90 mmHg',
'Evitar sobrecarga de fluidos',
'Torniquete: anotar hora de colocación',
],
keyPoints: [
'Clase I: <15% pérdida, FC normal, TA normal',
'Clase II: 15-30%, taquicardia, TA normal',
'Clase III: 30-40%, taquicardia, hipotensión',
'Clase IV: >40%, bradicardia, shock severo',
],
equipment: ['Torniquete', 'Agentes hemostáticos', 'Mantas térmicas'],
drugs: ['Ácido tranexámico', 'Cristaloides'],
},
];
}
/**
* Fármacos hardcodeados (fallback)
*/
function getHardcodedDrugs() {
return [
{
id: 'oxigeno',
genericName: 'Oxígeno (O₂)',
tradeName: 'Oxígeno medicinal',
category: 'oxigenoterapia',
presentation: 'Gas medicinal. Balas de 2L, 5L, 10L, 15L. Concentración variable según dispositivo.',
adultDose: 'Mascarilla con reservorio: 10-15 L/min (FiO₂ ~85%). Mascarilla simple: 5-10 L/min (FiO₂ ~40-60%). Gafas nasales: 1-6 L/min (FiO₂ 24-44%).',
pediatricDose: 'Ajustar por respuesta. Gafas nasales: 1-4 L/min. Mascarilla simple: 5-8 L/min. En lactantes, evitar flujos >4L/min por riesgo de retinopatía.',
routes: ['Inhalatoria'],
indications: [
'Hipoxia (SpO₂ <94%)',
'Parada cardiorrespiratoria',
'Ictus',
'Síndrome Coronario Agudo',
'Trauma grave',
],
contraindications: [
'En EPOC conocida con riesgo de hipercapnia: usar Venturi 28% y titular a SpO₂ 88-92%',
],
notes: [
'NO es un fármaco inocuo',
'Humedecer si uso prolongado >2h',
'En EPOC conocida: usar Venturi 28% y titular a SpO₂ 88-92%',
],
criticalPoints: [
'Terapia, no placebo. Usarlo con indicación y precaución en EPOC',
'En EPOC conocida con riesgo de hipercapnia, usar Venturi 28% y titular a SpO₂ 88-92%',
],
},
{
id: 'adrenalina',
genericName: 'Adrenalina (Epinefrina)',
tradeName: 'Adrenalina Braun®',
category: 'cardiovascular',
presentation: 'ANAFILAXIA: Ampolla 1 mg/1 ml (1:1000). PCR: Ampolla 1 mg/10 ml (1:10.000). ¡LEER ETIQUETA EN VOZ ALTA!',
adultDose: 'ANAFILAXIA: 0.5 mg IM (0.5 ml de ampolla 1:1000). Repetir a los 5 min si no mejora. PCR: 1 mg IV/IO (10 ml de ampolla 1:10.000) cada 3-5 min.',
pediatricDose: 'ANAFILAXIA: 0.01 mg/kg IM (Máx. 0.5 mg). Ej: 20kg → 0.2 mg = 0.2 ml. PCR: 0.01 mg/kg IV/IO (o según protocolo local).',
routes: ['IM', 'IV', 'IO'],
dilution: 'ANAFILAXIA: Sin diluir. PCR: Sin diluir (usar ampolla 1:10.000 directamente).',
indications: [
'Anafilaxia grave: Reacción alérgica sistémica con afectación respiratoria (estridor, sibilancias, disnea) y/o cardiovascular (hipotensión, taquicardia, colapso)',
'Parada cardiorrespiratoria: Cualquier ritmo (FV/TVSP, AESP, ACR) como parte del algoritmo SVA, una vez establecida vía IV/IO',
],
contraindications: [
'No hay contraindicaciones absolutas en emergencias vitales',
'Paciente anciano o con cardiopatía isquémica: El beneficio en anafilaxia grave supera el riesgo. Administrar y monitorizar estrechamente',
],
sideEffects: ['Temblor', 'Taquicardia', 'Palidez', 'HTA', 'Arritmias'],
notes: [
'⚠️ CONCENTRACIÓN CRÍTICA: 1:1000 (1 mg/ml) para Anafilaxia IM. 1:10.000 (0.1 mg/ml) para PCR IV/IO',
'ANAFILAXIA: Fármaco salvavidas. Administración IM precoz es la intervención más importante. No esperar',
],
criticalPoints: [
'⚠️ ERROR CRÍTICO: Confundir 1:1000 con 1:10.000. Administrar 1 mg/ml (1:1000) por vía IV en PCR equivale a 10 mg (LETAL)',
'Anafilaxia: 1:1000 IM en el MUSLO. 0.5 mg adultos, 0.01 mg/kg niños. Repetir a los 5 min si no mejora',
'PCR: 1:10.000 IV/IO. 1 mg adultos, 0.01 mg/kg niños. Cada 3-5 min',
],
},
];
}
/**
* Inserta un procedimiento en la BD
*/
async function insertProcedure(procedure, adminId) {
// Determinar clinical_context
let clinicalContext = 'OTROS';
if (procedure.subcategory && procedureCategoryMap[procedure.category]?.[procedure.subcategory]) {
clinicalContext = procedureCategoryMap[procedure.category][procedure.subcategory];
}
// Construir contenido JSONB
const content = {
steps: procedure.steps.map((step, index) => ({
id: `step-${index + 1}`,
order: index + 1,
text: step,
critical: false
})),
warnings: procedure.warnings || [],
keyPoints: procedure.keyPoints || [],
equipment: procedure.equipment || [],
drugs: procedure.drugs || []
};
const result = await query(`
INSERT INTO tes_content.content_items (
id, type, slug, title, short_title, description,
clinical_context, level, priority, status,
source_guideline, version, latest_version,
content, tags, category,
created_by, updated_by
) VALUES (
gen_random_uuid(),
'protocol',
$1,
$2,
$3,
$4,
$5::tes_content.clinical_context,
'operativo'::tes_content.usage_type,
$6::tes_content.priority,
'published'::tes_content.content_status,
'INTERNO'::tes_content.source_guideline,
'1.0.0',
'1.0.0',
$7::jsonb,
$8::text[],
$9,
$10,
$10
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
short_title = EXCLUDED.short_title,
description = EXCLUDED.description,
content = EXCLUDED.content,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING id, slug, title
`, [
procedure.id,
procedure.title,
procedure.shortTitle,
`Protocolo operativo: ${procedure.title}`,
clinicalContext,
priorityMap[procedure.priority] || 'media',
JSON.stringify(content),
[procedure.category, procedure.subcategory].filter(Boolean),
procedure.category,
adminId
]);
return result.rows[0];
}
/**
* Inserta un fármaco en la BD
*/
async function insertDrug(drug, adminId) {
// Construir contenido JSONB
const content = {
presentation: drug.presentation,
adultDose: drug.adultDose,
pediatricDose: drug.pediatricDose || null,
routes: drug.routes,
dilution: drug.dilution || null,
indications: drug.indications,
contraindications: drug.contraindications,
sideEffects: drug.sideEffects || [],
antidote: drug.antidote || null,
notes: drug.notes || [],
criticalPoints: drug.criticalPoints || []
};
const result = await query(`
INSERT INTO tes_content.content_items (
id, type, slug, title, short_title, description,
clinical_context, level, priority, status,
source_guideline, version, latest_version,
content, tags, category,
created_by, updated_by
) VALUES (
gen_random_uuid(),
'drug',
$1,
$2,
$3,
$4,
'FARMACOLOGIA'::tes_content.clinical_context,
'operativo'::tes_content.usage_type,
'alta'::tes_content.priority,
'published'::tes_content.content_status,
'INTERNO'::tes_content.source_guideline,
'1.0.0',
'1.0.0',
$5::jsonb,
$6::text[],
$7,
$8,
$8
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
short_title = EXCLUDED.short_title,
description = EXCLUDED.description,
content = EXCLUDED.content,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING id, slug, title
`, [
drug.id,
drug.genericName,
drug.tradeName,
`Fármaco: ${drug.genericName} (${drug.tradeName})`,
JSON.stringify(content),
[drug.category, 'farmacologia'].filter(Boolean),
drug.category,
adminId
]);
return result.rows[0];
}
/**
* Función principal
*/
async function migrateAppContent() {
try {
console.log('🔄 Iniciando migración de contenido de la app al backend...\n');
// Verificar conexión
await query('SELECT 1');
console.log('✅ Conexión a base de datos establecida\n');
// Obtener admin ID
const adminId = await getAdminId();
console.log(`✅ Usuario admin encontrado: ${adminId}\n`);
// Cargar procedimientos
console.log('📋 Cargando procedimientos...');
const procedures = await loadProcedures();
console.log(` Encontrados ${procedures.length} procedimientos\n`);
// Migrar procedimientos
console.log('💾 Migrando procedimientos a la base de datos...');
for (const procedure of procedures) {
try {
const result = await insertProcedure(procedure, adminId);
console.log(`${result.slug}: ${result.title}`);
} catch (error) {
console.error(` ❌ Error migrando ${procedure.id}:`, error.message);
}
}
console.log('');
// Cargar fármacos
console.log('💊 Cargando fármacos...');
const drugs = await loadDrugs();
console.log(` Encontrados ${drugs.length} fármacos\n`);
// Migrar fármacos
console.log('💾 Migrando fármacos a la base de datos...');
for (const drug of drugs) {
try {
const result = await insertDrug(drug, adminId);
console.log(`${result.slug}: ${result.title}`);
} catch (error) {
console.error(` ❌ Error migrando ${drug.id}:`, error.message);
}
}
console.log('');
// Resumen
console.log('📊 Resumen de migración:');
const stats = await query(`
SELECT
type,
COUNT(*) as total,
COUNT(CASE WHEN status = 'published' THEN 1 END) as published
FROM tes_content.content_items
WHERE type IN ('protocol', 'drug')
GROUP BY type
`);
stats.rows.forEach(row => {
console.log(` ${row.type}: ${row.total} total, ${row.published} publicados`);
});
console.log('\n✅ Migración completada exitosamente!');
} catch (error) {
console.error('❌ Error durante la migración:', error);
process.exit(1);
}
}
// Ejecutar
migrateAppContent();