#!/usr/bin/env node import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import { execSync } from 'child_process'; import mongoose from 'mongoose'; import dotenv from 'dotenv'; // Load environment variables dotenv.config({ path: '.env.local' }); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.dirname(__dirname); const funnelsDir = path.join(projectRoot, 'public', 'funnels'); // MongoDB connection URI const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/witlab-funnel'; // Mongoose schemas (same as in import script) const FunnelDataSchema = new mongoose.Schema({ meta: { id: { type: String, required: true }, version: String, title: String, description: String, firstScreenId: String }, defaultTexts: { nextButton: { type: String, default: 'Next' }, continueButton: { type: String, default: 'Continue' } }, screens: [mongoose.Schema.Types.Mixed] }, { _id: false }); const FunnelSchema = new mongoose.Schema({ funnelData: { type: FunnelDataSchema, required: true }, name: { type: String, required: true, trim: true, maxlength: 200 }, description: { type: String, trim: true, maxlength: 1000 }, status: { type: String, enum: ['draft', 'published', 'archived'], default: 'draft', required: true }, version: { type: Number, default: 1, min: 1 }, createdBy: { type: String, default: 'system' }, lastModifiedBy: { type: String, default: 'system' }, usage: { totalViews: { type: Number, default: 0, min: 0 }, totalCompletions: { type: Number, default: 0, min: 0 }, lastUsed: Date } }, { timestamps: true, collection: 'funnels' }); const Funnel = mongoose.model('Funnel', FunnelSchema); // Schema for images const ImageSchema = new mongoose.Schema({ filename: { type: String, required: true, unique: true, trim: true }, originalName: { type: String, required: true, trim: true }, mimetype: { type: String, required: true }, size: { type: Number, required: true }, data: { type: Buffer, required: true }, uploadedAt: { type: Date, default: Date.now }, uploadedBy: { type: String, default: 'admin' }, funnelId: { type: String, index: { sparse: true } }, description: { type: String, maxlength: 500 } }, { timestamps: true, collection: 'images' }); const Image = mongoose.model('Image', ImageSchema); async function downloadImagesFromDatabase(funnels) { const imagesDir = path.join(projectRoot, 'public', 'images'); try { // Создаем папку для изображений await fs.mkdir(imagesDir, { recursive: true }); console.log('📁 Created images directory'); // Собираем все ссылки на изображения из воронок const imageUrls = new Set(); for (const funnel of funnels) { for (const screen of funnel.funnelData.screens) { // Проверяем основной icon экрана (info экраны) if (screen.icon?.type === 'image' && screen.icon.value?.startsWith('/api/images/')) { imageUrls.add(screen.icon.value); } // Проверяем image экрана (email экраны) if (screen.image?.src?.startsWith('/api/images/')) { imageUrls.add(screen.image.src); } // Проверяем soulmatePortraitsDelivered (soulmate экраны) if (screen.soulmatePortraitsDelivered?.image?.startsWith('/api/images/')) { imageUrls.add(screen.soulmatePortraitsDelivered.image); } if (screen.soulmatePortraitsDelivered?.mediaUrl?.startsWith('/api/images/')) { imageUrls.add(screen.soulmatePortraitsDelivered.mediaUrl); } // Проверяем icon и image в вариантах экрана if (screen.variants && Array.isArray(screen.variants)) { for (const variant of screen.variants) { // icon в вариантах (info экраны) // В вариантах может не быть поля type, проверяем только value if (variant.overrides?.icon?.value?.startsWith('/api/images/')) { imageUrls.add(variant.overrides.icon.value); } // image в вариантах (email экраны) if (variant.overrides?.image?.src?.startsWith('/api/images/')) { imageUrls.add(variant.overrides.image.src); } // soulmatePortraitsDelivered в вариантах (soulmate экраны) if (variant.overrides?.soulmatePortraitsDelivered?.image?.startsWith('/api/images/')) { imageUrls.add(variant.overrides.soulmatePortraitsDelivered.image); } if (variant.overrides?.soulmatePortraitsDelivered?.mediaUrl?.startsWith('/api/images/')) { imageUrls.add(variant.overrides.soulmatePortraitsDelivered.mediaUrl); } } } } } if (imageUrls.size === 0) { console.log('ℹ️ No images to download'); return {}; } console.log(`🖼️ Found ${imageUrls.size} images to download`); // Скачиваем каждое изображение из БД const imageMapping = {}; for (const imageUrl of imageUrls) { const filename = imageUrl.replace('/api/images/', ''); try { const image = await Image.findOne({ filename }).lean(); if (image) { const localPath = path.join(imagesDir, filename); // Преобразуем MongoDB Binary в Buffer let buffer; if (Buffer.isBuffer(image.data)) { buffer = image.data; } else if (image.data?.buffer) { // BSON Binary объект имеет свойство buffer buffer = Buffer.from(image.data.buffer); } else if (image.data instanceof Uint8Array) { buffer = Buffer.from(image.data); } else { // Fallback - пробуем напрямую преобразовать buffer = Buffer.from(image.data); } await fs.writeFile(localPath, buffer); // Создаем маппинг: старый URL → новый локальный путь imageMapping[imageUrl] = `/images/${filename}`; console.log(`💾 Downloaded ${filename} (${buffer.length} bytes)`); } else { console.warn(`⚠️ Image not found in database: ${filename}`); } } catch (error) { console.error(`❌ Error downloading ${filename}:`, error.message); } } return imageMapping; } catch (error) { console.error('❌ Error downloading images:', error.message); return {}; } } function updateImageUrlsInFunnels(funnels, imageMapping) { for (const funnel of funnels) { for (const screen of funnel.funnelData.screens) { // Обновляем основной icon экрана (info экраны) if (screen.icon?.type === 'image' && screen.icon.value && imageMapping[screen.icon.value]) { const oldUrl = screen.icon.value; const newUrl = imageMapping[oldUrl]; screen.icon.value = newUrl; console.log(`🔗 Updated image URL: ${oldUrl} → ${newUrl}`); } // Обновляем image экрана (email экраны) if (screen.image?.src && imageMapping[screen.image.src]) { const oldUrl = screen.image.src; const newUrl = imageMapping[oldUrl]; screen.image.src = newUrl; console.log(`🔗 Updated image URL: ${oldUrl} → ${newUrl}`); } // Обновляем soulmatePortraitsDelivered (soulmate экраны) if (screen.soulmatePortraitsDelivered?.image && imageMapping[screen.soulmatePortraitsDelivered.image]) { const oldUrl = screen.soulmatePortraitsDelivered.image; const newUrl = imageMapping[oldUrl]; screen.soulmatePortraitsDelivered.image = newUrl; console.log(`🔗 Updated soulmate image URL: ${oldUrl} → ${newUrl}`); } if (screen.soulmatePortraitsDelivered?.mediaUrl && imageMapping[screen.soulmatePortraitsDelivered.mediaUrl]) { const oldUrl = screen.soulmatePortraitsDelivered.mediaUrl; const newUrl = imageMapping[oldUrl]; screen.soulmatePortraitsDelivered.mediaUrl = newUrl; console.log(`🔗 Updated soulmate mediaUrl: ${oldUrl} → ${newUrl}`); } // Обновляем icon и image в вариантах экрана if (screen.variants && Array.isArray(screen.variants)) { for (const variant of screen.variants) { // icon в вариантах (info экраны) // В вариантах может не быть поля type, проверяем только value if (variant.overrides?.icon?.value && imageMapping[variant.overrides.icon.value]) { const oldUrl = variant.overrides.icon.value; const newUrl = imageMapping[oldUrl]; variant.overrides.icon.value = newUrl; console.log(`🔗 Updated variant image URL: ${oldUrl} → ${newUrl}`); } // image в вариантах (email экраны) if (variant.overrides?.image?.src && imageMapping[variant.overrides.image.src]) { const oldUrl = variant.overrides.image.src; const newUrl = imageMapping[oldUrl]; variant.overrides.image.src = newUrl; console.log(`🔗 Updated variant image URL: ${oldUrl} → ${newUrl}`); } // soulmatePortraitsDelivered в вариантах (soulmate экраны) if (variant.overrides?.soulmatePortraitsDelivered?.image && imageMapping[variant.overrides.soulmatePortraitsDelivered.image]) { const oldUrl = variant.overrides.soulmatePortraitsDelivered.image; const newUrl = imageMapping[oldUrl]; variant.overrides.soulmatePortraitsDelivered.image = newUrl; console.log(`🔗 Updated variant soulmate image URL: ${oldUrl} → ${newUrl}`); } if (variant.overrides?.soulmatePortraitsDelivered?.mediaUrl && imageMapping[variant.overrides.soulmatePortraitsDelivered.mediaUrl]) { const oldUrl = variant.overrides.soulmatePortraitsDelivered.mediaUrl; const newUrl = imageMapping[oldUrl]; variant.overrides.soulmatePortraitsDelivered.mediaUrl = newUrl; console.log(`🔗 Updated variant soulmate mediaUrl: ${oldUrl} → ${newUrl}`); } } } } } } async function connectDB() { try { await mongoose.connect(MONGODB_URI); console.log('✅ Connected to MongoDB'); } catch (error) { console.error('❌ MongoDB connection failed:', error.message); process.exit(1); } } async function ensureFunnelsDir() { try { await fs.access(funnelsDir); } catch { await fs.mkdir(funnelsDir, { recursive: true }); console.log('📁 Created funnels directory'); } } async function clearFunnelsDir() { try { const files = await fs.readdir(funnelsDir); for (const file of files) { if (file.endsWith('.json')) { await fs.unlink(path.join(funnelsDir, file)); } } console.log('🧹 Cleared existing JSON files'); } catch (error) { console.error('⚠️ Error clearing funnels directory:', error.message); } } async function getLatestPublishedFunnels() { try { // Группируем по funnelData.meta.id и берем последнюю версию каждой опубликованной воронки const latestFunnels = await Funnel.aggregate([ // Фильтруем только опубликованные воронки { $match: { status: 'published' } }, // Сортируем по версии в убывающем порядке { $sort: { 'funnelData.meta.id': 1, version: -1 } }, // Группируем по ID воронки и берем первый документ (с наибольшей версией) { $group: { _id: '$funnelData.meta.id', latestFunnel: { $first: '$$ROOT' } } }, // Заменяем корневой документ на latestFunnel { $replaceRoot: { newRoot: '$latestFunnel' } } ]); console.log(`📊 Found ${latestFunnels.length} latest published funnels`); return latestFunnels; } catch (error) { console.error('❌ Error fetching funnels:', error.message); throw error; } } /** * Нормализует данные воронки перед сохранением * Удаляет лишние поля которые не соответствуют типам TypeScript */ function normalizeFunnelData(funnelData) { return { ...funnelData, screens: funnelData.screens.map((screen) => { const normalizedScreen = { ...screen }; // Удаляем поле 'show' из description (TypographyVariant не содержит его) // Поле 'show' есть только у TitleDefinition и SubtitleDefinition if (normalizedScreen.description && typeof normalizedScreen.description === 'object') { if ('show' in normalizedScreen.description) { delete normalizedScreen.description.show; } } // Удаляем специфичные для других шаблонов поля // Каждый шаблон должен содержать только свои поля switch (normalizedScreen.template) { case 'form': // fields нужно только для form экранов break; case 'email': // email имеет emailInput, а не fields if ('fields' in normalizedScreen) delete normalizedScreen.fields; if ('list' in normalizedScreen) delete normalizedScreen.list; break; case 'list': // list нужно только для list экранов if ('fields' in normalizedScreen) delete normalizedScreen.fields; break; case 'loaders': // progressbars нужно только для loaders if ('fields' in normalizedScreen) delete normalizedScreen.fields; if ('list' in normalizedScreen) delete normalizedScreen.list; break; default: // Для остальных шаблонов (info, date, coupon, soulmate) удаляем специфичные поля if ('fields' in normalizedScreen) delete normalizedScreen.fields; if ('list' in normalizedScreen) delete normalizedScreen.list; if ('progressbars' in normalizedScreen) delete normalizedScreen.progressbars; break; } // Нормализуем variants - добавляем пустой overrides если его нет if ('variants' in normalizedScreen && Array.isArray(normalizedScreen.variants)) { normalizedScreen.variants = normalizedScreen.variants.map((variant) => ({ conditions: variant.conditions || [], overrides: variant.overrides || {}, })); } // Удаляем variables из экранов, которые не поддерживают это поле // variables поддерживается только в info экранах if ('variables' in normalizedScreen && normalizedScreen.template !== 'info') { delete normalizedScreen.variables; } return normalizedScreen; }), }; } async function saveFunnelToFile(funnel) { const funnelId = funnel.funnelData.meta.id; const fileName = `${funnelId}.json`; const filePath = path.join(funnelsDir, fileName); try { // Нормализуем данные перед сохранением (удаляем лишние поля) const normalizedData = normalizeFunnelData(funnel.funnelData); // Сохраняем только funnelData (структуру воронки) const funnelContent = JSON.stringify(normalizedData, null, 2); await fs.writeFile(filePath, funnelContent, 'utf8'); console.log(`💾 Saved ${fileName} (v${funnel.version})`); } catch (error) { console.error(`❌ Error saving ${fileName}:`, error.message); throw error; } } async function bakeFunnels() { try { console.log('🔥 Baking funnels...'); execSync('npm run bake:funnels', { cwd: projectRoot, stdio: 'inherit' }); console.log('✅ Funnels baked successfully'); } catch (error) { console.error('❌ Error baking funnels:', error.message); throw error; } } // Парсим аргументы командной строки const args = process.argv.slice(2); const options = { funnelIds: [], dryRun: false, cleanFiles: false, // По умолчанию сохраняем файлы }; // Парсим опции for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--dry-run') { options.dryRun = true; } else if (arg === '--clean-files') { options.cleanFiles = true; } else if (arg === '--funnel-ids') { // Следующий аргумент должен содержать ID воронок через запятую const idsArg = args[++i]; if (idsArg) { options.funnelIds = idsArg.split(',').map(id => id.trim()); } } else if (arg === '--help' || arg === '-h') { console.log(` 🔄 Sync Funnels from Database Usage: npm run sync:funnels [options] Options: --dry-run Show what would be synced without actually doing it --clean-files Delete JSON files after baking (default: keep files) --funnel-ids Sync only specific funnel IDs (comma-separated) --help, -h Show this help message Examples: npm run sync:funnels # Sync all and keep JSON files npm run sync:funnels -- --dry-run # Preview what would be synced npm run sync:funnels -- --clean-files # Sync all and clean up JSON files npm run sync:funnels -- --funnel-ids funnel-test,ru-career-accelerator `); process.exit(0); } } // Обновляем функцию syncFunnels для поддержки опций async function syncFunnelsWithOptions() { if (options.dryRun) { console.log('🔍 DRY RUN MODE - No actual changes will be made\n'); } console.log('🚀 Starting funnel sync from database...\n'); try { // 1. Подключаемся к базе данных await connectDB(); // 2. Создаем/очищаем папку для воронок (только если не dry-run) await ensureFunnelsDir(); if (!options.dryRun) { await clearFunnelsDir(); } // 3. Получаем последние версии всех опубликованных воронок const allFunnels = await getLatestPublishedFunnels(); // Фильтруем по указанным ID если они заданы let funnels = allFunnels; if (options.funnelIds.length > 0) { funnels = allFunnels.filter(funnel => options.funnelIds.includes(funnel.funnelData.meta.id) ); console.log(`🎯 Filtering to ${funnels.length} specific funnels: ${options.funnelIds.join(', ')}`); } if (funnels.length === 0) { console.log('ℹ️ No published funnels found matching criteria'); return; } // 4. Загружаем изображения из базы данных let imageMapping = {}; if (!options.dryRun) { imageMapping = await downloadImagesFromDatabase(funnels); if (Object.keys(imageMapping).length > 0) { updateImageUrlsInFunnels(funnels, imageMapping); } } else { console.log('🔍 Would download images from database and update URLs'); } // 5. Сохраняем каждую воронку в JSON файл for (const funnel of funnels) { if (options.dryRun) { console.log(`🔍 Would save ${funnel.funnelData.meta.id}.json (v${funnel.version})`); } else { await saveFunnelToFile(funnel); } } // 6. Запекаем воронки в TypeScript if (!options.dryRun) { await bakeFunnels(); } else { console.log('🔍 Would bake funnels to TypeScript'); } // 7. Удаляем JSON файлы после запекания (только если указано) if (!options.dryRun && options.cleanFiles) { await clearFunnelsDir(); console.log('🧹 Cleaned up JSON files as requested'); } else if (!options.dryRun) { console.log('📁 Keeping JSON files (use --clean-files to remove them)'); } else if (options.dryRun && options.cleanFiles) { console.log('🔍 Would clean up JSON files'); } else if (options.dryRun) { console.log('🔍 Would keep JSON files'); } console.log('\n🎉 Funnel sync completed successfully!'); console.log(`📈 ${options.dryRun ? 'Would sync' : 'Synced'} ${funnels.length} funnels from database`); } catch (error) { console.error('\n💥 Sync failed:', error.message); process.exit(1); } finally { await mongoose.disconnect(); console.log('🔌 Disconnected from MongoDB'); } } // Запускаем скрипт с опциями syncFunnelsWithOptions();