/** * SCRIPT DE SINCRONIZACIÓN MASIVA DE CONTENIDO * * Sincroniza contenido desde archivos locales (procedures.ts, drugs.ts, guides-index.ts) * hacia la base de datos PostgreSQL de forma masiva e inteligente. * * Características: * - ✅ Migración masiva (no uno por uno) * - ✅ Idempotente (puede ejecutarse múltiples veces) * - ✅ Modo dry-run (ver qué haría sin ejecutar) * - ✅ Detección de cambios (solo actualiza lo necesario) * - ✅ Genera reportes detallados * - ✅ Maneja relaciones bidireccionales * - ✅ Soporta mejoras incrementales * * Uso: * node backend/scripts/sync-content-to-db.js # Ejecutar * node backend/scripts/sync-content-to-db.js --dry-run # Ver qué haría * node backend/scripts/sync-content-to-db.js --type=protocols # Solo protocolos * node backend/scripts/sync-content-to-db.js --force # Forzar actualización */ import { query } from '../config/database.js'; import { readFile } from 'fs/promises'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { createHash } from 'crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Configuración const DRY_RUN = process.argv.includes('--dry-run'); const FORCE = process.argv.includes('--force'); const TYPE_FILTER = process.argv.find(arg => arg.startsWith('--type='))?.split('=')[1]; // Estadísticas const stats = { protocols: { created: 0, updated: 0, skipped: 0, errors: 0 }, drugs: { created: 0, updated: 0, skipped: 0, errors: 0 }, guides: { created: 0, updated: 0, skipped: 0, errors: 0 }, checklists: { created: 0, updated: 0, skipped: 0, errors: 0 }, relations: { created: 0, skipped: 0, errors: 0 }, }; /** * Obtener usuario admin para operaciones */ async function getAdminUser() { const result = await query( `SELECT id FROM tes_content.users WHERE role = 'super_admin' LIMIT 1` ); if (result.rows.length === 0) { throw new Error('No se encontró usuario admin. Ejecuta seed-admin.js primero.'); } return result.rows[0].id; } /** * Calcular hash de contenido para detectar cambios */ function calculateContentHash(content) { const contentString = JSON.stringify(content, Object.keys(content).sort()); return createHash('sha256').update(contentString).digest('hex'); } /** * Mapear prioridad local a prioridad BD */ function mapPriority(priority) { const map = { 'critico': 'critica', 'alto': 'alta', 'medio': 'media', 'bajo': 'baja', }; return map[priority] || 'media'; } /** * Mapear contexto clínico desde categoría */ function mapClinicalContext(category, subcategory) { const contextMap = { 'soporte_vital': 'RCP', 'patologias': 'OTROS', 'escena': 'ABCDE', 'trauma': 'TRAUMA', 'shock': 'SHOCK', 'ictus': 'ICTUS', 'via_aerea': 'VIA_AEREA', 'oxigenoterapia': 'OXIGENOTERAPIA', }; if (subcategory === 'rcp') return 'RCP'; if (subcategory === 'ovace') return 'OVACE'; if (subcategory === 'shock') return 'SHOCK'; if (subcategory === 'ictus') return 'ICTUS'; return contextMap[category] || 'OTROS'; } /** * Sincronizar un protocolo */ async function syncProtocol(procedure, adminId) { try { const content = { steps: procedure.steps || [], warnings: procedure.warnings || [], keyPoints: procedure.keyPoints || [], equipment: procedure.equipment || [], drugs: procedure.drugs || [], category: procedure.category, subcategory: procedure.subcategory, ageGroup: procedure.ageGroup || 'adulto', }; const contentHash = calculateContentHash(content); const clinicalContext = mapClinicalContext(procedure.category, procedure.subcategory); const priority = mapPriority(procedure.priority); // Verificar si existe const existing = await query( `SELECT id, content, updated_at FROM tes_content.content_items WHERE slug = $1 AND type = 'protocol'`, [procedure.id] ); if (existing.rows.length > 0) { const existingItem = existing.rows[0]; const existingContent = existingItem.content; const existingHash = calculateContentHash(existingContent); // Si el hash es igual y no es force, saltar if (contentHash === existingHash && !FORCE) { stats.protocols.skipped++; return { action: 'skipped', id: existingItem.id }; } // Actualizar if (!DRY_RUN) { await query( `UPDATE tes_content.content_items SET title = $1, short_title = $2, description = $3, clinical_context = $4, level = 'operativo', priority = $5::tes_content.priority, content = $6::jsonb, tags = $7::text[], category = $8, subcategory = $9, age_group = $10, updated_by = $11, updated_at = NOW() WHERE slug = $12 AND type = 'protocol'`, [ procedure.title, procedure.shortTitle || null, `Protocolo operativo: ${procedure.title}`, clinicalContext || null, priority || 'media', JSON.stringify(content), [procedure.category, procedure.subcategory].filter(Boolean), procedure.category || null, procedure.subcategory || null, procedure.ageGroup || 'adulto', adminId, procedure.id, ] ); } stats.protocols.updated++; return { action: 'updated', id: existingItem.id }; } // Crear nuevo if (!DRY_RUN) { 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, subcategory, age_group, created_by, updated_by ) VALUES ( $1, 'protocol', $2, $3, $4, $5, $6, 'operativo', $7::tes_content.content_priority, 'published'::tes_content.content_status, 'INTERNO', 1, 1, $8::jsonb, $9::text[], $10, $11, $12, $13, $13 ) RETURNING id, slug, title`, [ procedure.id, // Usar el id como primary key procedure.id, // slug procedure.title, procedure.shortTitle || null, `Protocolo operativo: ${procedure.title}`, clinicalContext || null, priority || 'media', JSON.stringify(content), [procedure.category, procedure.subcategory].filter(Boolean), procedure.category || null, procedure.subcategory || null, procedure.ageGroup || 'adulto', adminId, ] ); stats.protocols.created++; return { action: 'created', id: result.rows[0].id }; } stats.protocols.created++; return { action: 'created', id: null }; } catch (error) { console.error(`❌ Error sincronizando protocolo ${procedure.id}:`, error.message); stats.protocols.errors++; return { action: 'error', error: error.message }; } } /** * Sincronizar un fármaco */ async function syncDrug(drug, adminId) { try { const content = { genericName: drug.genericName, tradeName: drug.tradeName, category: drug.category, presentation: drug.presentation, adultDose: drug.adultDose, pediatricDose: drug.pediatricDose, routes: drug.routes || [], dilution: drug.dilution, indications: drug.indications || [], contraindications: drug.contraindications || [], sideEffects: drug.sideEffects, antidote: drug.antidote, notes: drug.notes, criticalPoints: drug.criticalPoints, source: drug.source, }; const contentHash = calculateContentHash(content); const priority = mapPriority('medio'); // Fármacos generalmente media prioridad // Verificar si existe const existing = await query( `SELECT id, content FROM tes_content.content_items WHERE slug = $1 AND type = 'drug'`, [drug.id] ); if (existing.rows.length > 0) { const existingItem = existing.rows[0]; const existingHash = calculateContentHash(existingItem.content); if (contentHash === existingHash && !FORCE) { stats.drugs.skipped++; return { action: 'skipped', id: existingItem.id }; } if (!DRY_RUN) { await query( `UPDATE tes_content.content_items SET title = $1, short_title = $2, description = $3, clinical_context = 'FARMACOLOGIA'::tes_content.clinical_context, level = 'referencia'::tes_content.usage_type, priority = $4::tes_content.priority, content = $5::jsonb, tags = $6::text[], category = $7, updated_by = $8, updated_at = NOW() WHERE id = $9`, [ drug.genericName, drug.tradeName, `Fármaco: ${drug.genericName}`, priority, JSON.stringify(content), [drug.category, ...(drug.indications || [])].filter(Boolean), drug.category, adminId, existingItem.id, ] ); } stats.drugs.updated++; return { action: 'updated', id: existingItem.id }; } if (!DRY_RUN) { 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, 'referencia'::tes_content.usage_type, $5::tes_content.priority, 'published'::tes_content.content_status, 'INTERNO'::tes_content.source_guideline, '1.0.0', '1.0.0', $6::jsonb, $7::text[], $8, $9, $9 ) RETURNING id, slug, title`, [ drug.id, drug.genericName, drug.tradeName, `Fármaco: ${drug.genericName}`, priority, JSON.stringify(content), [drug.category, ...(drug.indications || [])].filter(Boolean), drug.category, adminId, ] ); stats.drugs.created++; return { action: 'created', id: result.rows[0].id }; } stats.drugs.created++; return { action: 'created', id: null }; } catch (error) { console.error(`❌ Error sincronizando fármaco ${drug.id}:`, error.message); stats.drugs.errors++; return { action: 'error', error: error.message }; } } /** * Sincronizar una guía */ async function syncGuide(guide, adminId) { try { const content = { titulo: guide.titulo, descripcion: guide.descripcion, icono: guide.icono, secciones: guide.secciones || [], protocoloOperativo: guide.protocoloOperativo, scormAvailable: guide.scormAvailable || false, }; const contentHash = calculateContentHash(content); // Verificar si existe const existing = await query( `SELECT id, content FROM tes_content.content_items WHERE slug = $1 AND type = 'guide'`, [guide.id] ); if (existing.rows.length > 0) { const existingItem = existing.rows[0]; const existingHash = calculateContentHash(existingItem.content); if (contentHash === existingHash && !FORCE) { stats.guides.skipped++; return { action: 'skipped', id: existingItem.id }; } if (!DRY_RUN) { await query( `UPDATE tes_content.content_items SET title = $1, description = $2, clinical_context = 'OTROS'::tes_content.clinical_context, level = 'formativo'::tes_content.usage_type, priority = 'alta'::tes_content.priority, content = $3::jsonb, tags = $4::text[], updated_by = $5, updated_at = NOW() WHERE id = $6`, [ guide.titulo, guide.descripcion, JSON.stringify(content), ['guia', 'formativo', guide.id], adminId, existingItem.id, ] ); } stats.guides.updated++; return { action: 'updated', id: existingItem.id }; } if (!DRY_RUN) { const result = await query( `INSERT INTO tes_content.content_items ( id, type, slug, title, description, clinical_context, level, priority, status, source_guideline, version, latest_version, content, tags, created_by, updated_by ) VALUES ( gen_random_uuid(), 'guide', $1, $2, $3, 'OTROS'::tes_content.clinical_context, 'formativo'::tes_content.usage_type, 'alta'::tes_content.priority, 'published'::tes_content.content_status, 'INTERNO'::tes_content.source_guideline, '1.0.0', '1.0.0', $4::jsonb, $5::text[], $6, $6 ) RETURNING id, slug, title`, [ guide.id, guide.titulo, guide.descripcion, JSON.stringify(content), ['guia', 'formativo', guide.id], adminId, ] ); stats.guides.created++; return { action: 'created', id: result.rows[0].id }; } stats.guides.created++; return { action: 'created', id: null }; } catch (error) { console.error(`❌ Error sincronizando guía ${guide.id}:`, error.message); stats.guides.errors++; return { action: 'error', error: error.message }; } } /** * Sincronizar relaciones bidireccionales desde protocol-guide-manual-mapping */ async function syncRelations(adminId) { try { console.log('📋 Sincronizando relaciones bidireccionales...'); // Verificar si existe tabla content_relations const tableExists = await query(` SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'tes_content' AND table_name = 'content_relations' ) `); if (!tableExists.rows[0].exists) { console.log(' ⚠️ Tabla content_relations no existe aún'); console.log(' ℹ️ Ejecuta las migraciones primero (ver VALIDACION_TECNICA_FASE_B_C.md)'); stats.relations.skipped++; return; } // Importar mapping try { const mappingModule = await import('../../src/data/protocol-guide-manual-mapping.ts?t=' + Date.now()); const mappings = mappingModule.protocolGuideManualMapping || []; let relationsCreated = 0; for (const mapping of mappings) { if (!mapping.protocoloId || !mapping.guiaId) continue; // Buscar IDs de contenido const protocolResult = await query( `SELECT id FROM tes_content.content_items WHERE slug = $1 AND type = 'protocol'`, [mapping.protocoloId] ); const guideResult = await query( `SELECT id FROM tes_content.content_items WHERE slug = $1 AND type = 'guide'`, [mapping.guiaId] ); if (protocolResult.rows.length === 0 || guideResult.rows.length === 0) { continue; // Contenido no existe aún } const protocolId = protocolResult.rows[0].id; const guideId = guideResult.rows[0].id; // Verificar si relación ya existe const existing = await query( `SELECT id FROM tes_content.content_relations WHERE source_content_id = $1 AND target_content_id = $2 AND relation_type = 'protocol_to_guide'`, [protocolId, guideId] ); if (existing.rows.length > 0) { continue; // Ya existe } // Crear relación bidireccional if (!DRY_RUN) { // Relación protocolo → guía await query( `INSERT INTO tes_content.content_relations (source_content_id, target_content_id, relation_type, is_bidirectional, created_by) VALUES ($1, $2, 'protocol_to_guide', true, $3)`, [protocolId, guideId, adminId] ); // Relación guía → protocolo (bidireccional) await query( `INSERT INTO tes_content.content_relations (source_content_id, target_content_id, relation_type, is_bidirectional, created_by) VALUES ($1, $2, 'guide_to_protocol', true, $3)`, [guideId, protocolId, adminId] ); } relationsCreated++; } stats.relations.created = relationsCreated; console.log(` ✅ ${relationsCreated} relaciones creadas`); } catch (error) { console.log(' ⚠️ No se pudieron cargar relaciones automáticamente'); console.log(' ℹ️ Las relaciones se pueden gestionar desde el admin panel'); } } catch (error) { console.error('❌ Error sincronizando relaciones:', error.message); stats.relations.errors++; } } /** * Extraer array de strings desde código TypeScript */ function extractArray(str) { if (!str) return []; return str .split(',') .map(s => s.trim().replace(/^['"]|['"]$/g, '')) .filter(Boolean); } /** * Cargar procedures desde archivo */ async function loadProcedures() { const proceduresPath = join(__dirname, '../../src/data/procedures.ts'); const content = await readFile(proceduresPath, 'utf-8'); const procedures = []; const procedureRegex = /{\s*id:\s*['"]([^'"]+)['"],\s*title:\s*['"]([^'"]+)['"],\s*shortTitle:\s*['"]([^'"]+)['"],\s*category:\s*['"]([^'"]+)['"],\s*(?:subcategory:\s*['"]([^'"]*)?['"],\s*)?priority:\s*['"]([^'"]+)['"],\s*ageGroup:\s*['"]([^'"]+)['"],\s*steps:\s*\[([\s\S]*?)\],\s*warnings:\s*\[([\s\S]*?)\],\s*(?:keyPoints:\s*\[([\s\S]*?)\],)?\s*(?:equipment:\s*\[([\s\S]*?)\],)?\s*(?:drugs:\s*\[([\s\S]*?)\],)?\s*}/g; let match; while ((match = procedureRegex.exec(content)) !== null) { const [, id, title, shortTitle, category, subcategory, priority, ageGroup, stepsStr, warningsStr, keyPointsStr, equipmentStr, drugsStr] = match; procedures.push({ id, title, shortTitle, category, subcategory: subcategory || null, priority, ageGroup, steps: extractArray(stepsStr), warnings: extractArray(warningsStr), keyPoints: keyPointsStr ? extractArray(keyPointsStr) : [], equipment: equipmentStr ? extractArray(equipmentStr) : [], drugs: drugsStr ? extractArray(drugsStr) : [], }); } return procedures; } /** * Cargar drugs desde archivo */ async function loadDrugs() { const drugsPath = join(__dirname, '../../src/data/drugs.ts'); const content = await readFile(drugsPath, 'utf-8'); const drugs = []; const drugBlockRegex = /{\s*id:\s*['"]([^'"]+)['"],\s*(?:source:\s*['"]([^'"]*)?['"],\s*)?genericName:\s*['"]([^'"]+)['"],\s*tradeName:\s*['"]([^'"]+)['"],\s*category:\s*['"]([^'"]+)['"],\s*presentation:\s*['"]([^'"]+)['"],\s*adultDose:\s*['"]([^'"]+)['"],\s*(?:pediatricDose:\s*['"]([^'"]*)?['"],)?\s*routes:\s*\[([\s\S]*?)\],\s*(?:dilution:\s*['"]([^'"]*)?['"],)?\s*indications:\s*\[([\s\S]*?)\],\s*contraindications:\s*\[([\s\S]*?)\],\s*(?:sideEffects:\s*\[([\s\S]*?)\],)?\s*(?:antidote:\s*['"]([^'"]*)?['"],)?\s*(?:notes:\s*\[([\s\S]*?)\],)?\s*(?:criticalPoints:\s*\[([\s\S]*?)\],)?\s*(?:source:\s*['"]([^'"]*)?['"])?\s*}/g; let match; while ((match = drugBlockRegex.exec(content)) !== null) { const [, id, source1, genericName, tradeName, category, presentation, adultDose, pediatricDose, routesStr, dilution, indicationsStr, contraindicationsStr, sideEffectsStr, antidote, notesStr, criticalPointsStr, source2] = match; drugs.push({ id, genericName, tradeName, category, presentation, adultDose, pediatricDose: pediatricDose || null, routes: extractArray(routesStr), dilution: dilution || null, indications: extractArray(indicationsStr), contraindications: extractArray(contraindicationsStr), sideEffects: sideEffectsStr ? extractArray(sideEffectsStr) : [], antidote: antidote || null, notes: notesStr ? extractArray(notesStr) : [], criticalPoints: criticalPointsStr ? extractArray(criticalPointsStr) : [], source: source2 || source1 || null, }); } return drugs; } /** * Cargar guides desde archivo */ async function loadGuides() { const guidesPath = join(__dirname, '../../src/data/guides-index.ts'); const content = await readFile(guidesPath, 'utf-8'); const guides = []; const guidePattern = /id:\s*["']([^"']+)["'],\s*titulo:\s*["']([^"']+)["'],\s*descripcion:\s*["']([^"']+)["'],\s*icono:\s*["']([^"']+)["']/g; let match; while ((match = guidePattern.exec(content)) !== null) { const [, id, titulo, descripcion, icono] = match; // Buscar scormAvailable después del icono const afterIcono = content.substring(match.index + match[0].length); const scormMatch = afterIcono.match(/scormAvailable:\s*(true|false)/); const scormAvailable = scormMatch ? scormMatch[1] === 'true' : false; // Buscar secciones (simplificado - extraer estructura básica) const seccionesMatch = afterIcono.match(/secciones:\s*\[([\s\S]*?)\]/); const secciones = seccionesMatch ? [] : []; // Por ahora vacío, se puede mejorar guides.push({ id, titulo, descripcion, icono, scormAvailable, secciones: secciones, protocoloOperativo: null, // Se puede extraer si existe }); } return guides; } /** * Cargar contenido desde archivos locales */ async function loadLocalContent() { const content = { procedures: [], drugs: [], guides: [], }; try { // Cargar procedures if (!TYPE_FILTER || TYPE_FILTER === 'protocols') { content.procedures = await loadProcedures(); } // Cargar drugs if (!TYPE_FILTER || TYPE_FILTER === 'drugs') { content.drugs = await loadDrugs(); } // Cargar guides if (!TYPE_FILTER || TYPE_FILTER === 'guides') { content.guides = await loadGuides(); } } catch (error) { console.error('❌ Error cargando contenido local:', error.message); throw error; } return content; } /** * Función principal */ async function main() { console.log('🚀 SCRIPT DE SINCRONIZACIÓN MASIVA DE CONTENIDO\n'); console.log('═══════════════════════════════════════════════\n'); if (DRY_RUN) { console.log('⚠️ MODO DRY-RUN: No se realizarán cambios en la BD\n'); } if (FORCE) { console.log('⚡ MODO FORCE: Se actualizarán todos los items (incluso sin cambios)\n'); } if (TYPE_FILTER) { console.log(`🔍 FILTRO: Solo sincronizando tipo "${TYPE_FILTER}"\n`); } try { // 1. Obtener usuario admin console.log('👤 Obteniendo usuario admin...'); const adminId = await getAdminUser(); console.log(` ✅ Usuario admin: ${adminId}\n`); // 2. Cargar contenido local console.log('📂 Cargando contenido desde archivos locales...'); const localContent = await loadLocalContent(); console.log(` ✅ Protocolos: ${localContent.procedures.length}`); console.log(` ✅ Fármacos: ${localContent.drugs.length}`); console.log(` ✅ Guías: ${localContent.guides.length}\n`); // 3. Sincronizar protocolos if (localContent.procedures.length > 0 && (!TYPE_FILTER || TYPE_FILTER === 'protocols')) { console.log('📋 Sincronizando protocolos...'); for (const procedure of localContent.procedures) { const result = await syncProtocol(procedure, adminId); if (result.action === 'created') { console.log(` ✅ Creado: ${procedure.title}`); } else if (result.action === 'updated') { console.log(` 🔄 Actualizado: ${procedure.title}`); } else if (result.action === 'skipped') { console.log(` ⏭️ Saltado: ${procedure.title} (sin cambios)`); } } console.log(''); } // 4. Sincronizar fármacos if (localContent.drugs.length > 0 && (!TYPE_FILTER || TYPE_FILTER === 'drugs')) { console.log('💊 Sincronizando fármacos...'); for (const drug of localContent.drugs) { const result = await syncDrug(drug, adminId); if (result.action === 'created') { console.log(` ✅ Creado: ${drug.genericName}`); } else if (result.action === 'updated') { console.log(` 🔄 Actualizado: ${drug.genericName}`); } else if (result.action === 'skipped') { console.log(` ⏭️ Saltado: ${drug.genericName} (sin cambios)`); } } console.log(''); } // 5. Sincronizar guías if (localContent.guides.length > 0 && (!TYPE_FILTER || TYPE_FILTER === 'guides')) { console.log('📚 Sincronizando guías...'); for (const guide of localContent.guides) { const result = await syncGuide(guide, adminId); if (result.action === 'created') { console.log(` ✅ Creado: ${guide.titulo}`); } else if (result.action === 'updated') { console.log(` 🔄 Actualizado: ${guide.titulo}`); } else if (result.action === 'skipped') { console.log(` ⏭️ Saltado: ${guide.titulo} (sin cambios)`); } } console.log(''); } // 6. Sincronizar relaciones (opcional) if (!TYPE_FILTER) { await syncRelations(adminId); console.log(''); } // 7. Mostrar resumen console.log('═══════════════════════════════════════════════'); console.log('📊 RESUMEN DE SINCRONIZACIÓN\n'); const totalCreated = stats.protocols.created + stats.drugs.created + stats.guides.created; const totalUpdated = stats.protocols.updated + stats.drugs.updated + stats.guides.updated; const totalSkipped = stats.protocols.skipped + stats.drugs.skipped + stats.guides.skipped; const totalErrors = stats.protocols.errors + stats.drugs.errors + stats.guides.errors; console.log('Protocolos:'); console.log(` ✅ Creados: ${stats.protocols.created}`); console.log(` 🔄 Actualizados: ${stats.protocols.updated}`); console.log(` ⏭️ Saltados: ${stats.protocols.skipped}`); console.log(` ❌ Errores: ${stats.protocols.errors}\n`); console.log('Fármacos:'); console.log(` ✅ Creados: ${stats.drugs.created}`); console.log(` 🔄 Actualizados: ${stats.drugs.updated}`); console.log(` ⏭️ Saltados: ${stats.drugs.skipped}`); console.log(` ❌ Errores: ${stats.drugs.errors}\n`); console.log('Guías:'); console.log(` ✅ Creadas: ${stats.guides.created}`); console.log(` 🔄 Actualizadas: ${stats.guides.updated}`); console.log(` ⏭️ Saltadas: ${stats.guides.skipped}`); console.log(` ❌ Errores: ${stats.guides.errors}\n`); console.log('═══════════════════════════════════════════════'); console.log(`TOTAL: ${totalCreated} creados, ${totalUpdated} actualizados, ${totalSkipped} saltados, ${totalErrors} errores\n`); if (DRY_RUN) { console.log('⚠️ MODO DRY-RUN: No se realizaron cambios reales\n'); } else { console.log('✅ Sincronización completada\n'); } } catch (error) { console.error('\n❌ Error en sincronización:', error); process.exit(1); } } // Ejecutar main();