597 lines
21 KiB
JavaScript
Executable File
597 lines
21 KiB
JavaScript
Executable File
#!/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 <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();
|