w-funnel/scripts/sync-funnels-from-db.mjs
dev.daminik00 0ceb254f4e hh
2025-09-29 06:10:56 +02:00

421 lines
12 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) {
if (screen.icon?.type === 'image' && screen.icon.value?.startsWith('/api/images/')) {
imageUrls.add(screen.icon.value);
}
}
}
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);
await fs.writeFile(localPath, image.data);
// Создаем маппинг: старый URL → новый локальный путь
imageMapping[imageUrl] = `/images/${filename}`;
console.log(`💾 Downloaded ${filename}`);
} 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) {
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}`);
}
}
}
}
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;
}
}
async function saveFunnelToFile(funnel) {
const funnelId = funnel.funnelData.meta.id;
const fileName = `${funnelId}.json`;
const filePath = path.join(funnelsDir, fileName);
try {
// Сохраняем только funnelData (структуру воронки)
const funnelContent = JSON.stringify(funnel.funnelData, 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();