import { NextRequest, NextResponse } from 'next/server'; import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi'; import type { FunnelDefinition, ScreenDefinition, TypographyVariant } from '@/lib/funnel/types'; /** * Нормализует TypographyVariant - удаляет объект если text пустой */ function normalizeTypography(typography: TypographyVariant | undefined): TypographyVariant | undefined { if (!typography) return undefined; // Если text пустой или только пробелы, удаляем весь объект if (!typography.text || typography.text.trim() === '') { return undefined; } return typography; } /** * Нормализует данные воронки перед сохранением в MongoDB * Удаляет пустые текстовые поля которые не пройдут валидацию */ function normalizeFunnelData(funnelData: FunnelDefinition): FunnelDefinition { return { ...funnelData, screens: funnelData.screens.map((screen): ScreenDefinition => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const normalizedScreen: any = { ...screen }; // Нормализуем subtitle (опциональное поле) if ('subtitle' in normalizedScreen) { const normalized = normalizeTypography(normalizedScreen.subtitle); if (normalized === undefined) { delete normalizedScreen.subtitle; } else { normalizedScreen.subtitle = normalized; } } // Нормализуем description (для info и soulmate экранов) if ('description' in normalizedScreen) { const normalized = normalizeTypography(normalizedScreen.description); if (normalized === undefined) { delete normalizedScreen.description; } else { normalizedScreen.description = normalized; } } return normalizedScreen as ScreenDefinition; }), }; } // GET /api/funnels - получить список всех воронок export async function GET(request: NextRequest) { if (!isAdminApiEnabled()) { return adminApiDisabledResponse(); } try { const [{ default: connectMongoDB }, { default: FunnelModel }] = await Promise.all([ import('@/lib/mongodb'), import('@/lib/models/Funnel'), ]); await connectMongoDB(); const { searchParams } = new URL(request.url); const status = searchParams.get('status'); const search = searchParams.get('search'); const limit = parseInt(searchParams.get('limit') || '20'); const page = parseInt(searchParams.get('page') || '1'); const sortBy = searchParams.get('sortBy') || 'updatedAt'; const sortOrder = searchParams.get('sortOrder') || 'desc'; // Строим фильтр const filter: Record = {}; if (status && ['draft', 'published', 'archived'].includes(status)) { filter.status = status; } if (search) { filter.$or = [ { name: { $regex: search, $options: 'i' } }, { description: { $regex: search, $options: 'i' } }, { 'funnelData.meta.title': { $regex: search, $options: 'i' } }, { 'funnelData.meta.description': { $regex: search, $options: 'i' } } ]; } // Строим сортировку const sort: Record = {}; sort[sortBy] = sortOrder === 'desc' ? -1 : 1; // Выполняем запрос с пагинацией const skip = (page - 1) * limit; const [funnels, total] = await Promise.all([ FunnelModel .find(filter) .select('-funnelData.screens -funnelData.defaultTexts') // Исключаем тяжелые данные, но оставляем meta .sort(sort) .skip(skip) .limit(limit) .lean(), FunnelModel.countDocuments(filter) ]); return NextResponse.json({ funnels, pagination: { current: page, total: Math.ceil(total / limit), count: funnels.length, totalItems: total } }); } catch (error) { console.error('GET /api/funnels error:', error); return NextResponse.json( { error: 'Failed to fetch funnels' }, { status: 500 } ); } } // POST /api/funnels - создать новую воронку export async function POST(request: NextRequest) { if (!isAdminApiEnabled()) { return adminApiDisabledResponse(); } try { const [ { default: connectMongoDB }, { default: FunnelModel }, { default: FunnelHistoryModel }, ] = await Promise.all([ import('@/lib/mongodb'), import('@/lib/models/Funnel'), import('@/lib/models/FunnelHistory'), ]); await connectMongoDB(); const body = await request.json(); const { name, description, funnelData, status = 'draft' } = body; // Валидация if (!name || !funnelData) { return NextResponse.json( { error: 'Name and funnel data are required' }, { status: 400 } ); } if (!funnelData.meta || !funnelData.meta.id || !Array.isArray(funnelData.screens)) { return NextResponse.json( { error: 'Invalid funnel data structure' }, { status: 400 } ); } // Проверяем уникальность funnelData.meta.id const existingFunnel = await FunnelModel.findOne({ 'funnelData.meta.id': funnelData.meta.id }); if (existingFunnel) { return NextResponse.json( { error: 'Funnel with this ID already exists' }, { status: 409 } ); } // Нормализуем данные перед сохранением (удаляем пустые текстовые поля) const normalizedData = normalizeFunnelData(funnelData as FunnelDefinition); // Создаем воронку const funnel = new FunnelModel({ name, description, funnelData: normalizedData, status, version: 1, usage: { totalViews: 0, totalCompletions: 0 } }); const savedFunnel = await funnel.save(); // Создаем базовую точку в истории const sessionId = `create-${Date.now()}`; await FunnelHistoryModel.create({ funnelId: String(savedFunnel._id), sessionId, funnelSnapshot: normalizedData, actionType: 'create', sequenceNumber: 0, description: 'Воронка создана', isBaseline: true }); return NextResponse.json({ _id: savedFunnel._id, name: savedFunnel.name, description: savedFunnel.description, status: savedFunnel.status, version: savedFunnel.version, createdAt: savedFunnel.createdAt, updatedAt: savedFunnel.updatedAt, usage: savedFunnel.usage, funnelData: savedFunnel.funnelData }, { status: 201 }); } catch (error) { console.error('POST /api/funnels error:', error); // Обработка ошибок валидации Mongoose if (error instanceof Error && error.name === 'ValidationError') { const validationError = error as Error & { errors: Record }; const details = []; // Собираем все ошибки валидации for (const field in validationError.errors) { const fieldError = validationError.errors[field]; details.push(`${field}: ${fieldError.message}`); } return NextResponse.json( { error: 'Ошибка валидации данных воронки', details: details.join('; ') }, { status: 400 } ); } if (error instanceof Error && error.message.includes('duplicate key')) { return NextResponse.json( { error: 'Funnel with this name already exists' }, { status: 409 } ); } return NextResponse.json( { error: 'Failed to create funnel' }, { status: 500 } ); } }