w-funnel/scripts/sync-funnels-from-db.mjs
2025-10-21 22:07:55 +02:00

597 lines
21 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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