/** * 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();