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 экранов) // ⚠️ TypographyVariant НЕ содержит поле 'show', удаляем его если есть if ('description' in normalizedScreen) { const normalized = normalizeTypography(normalizedScreen.description); if (normalized === undefined) { delete normalizedScreen.description; } else { normalizedScreen.description = normalized; // Удаляем поле 'show' если оно есть (TypographyVariant не содержит его) 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?: unknown; overrides?: unknown }) => ({ conditions: variant.conditions || [], overrides: variant.overrides || {}, })); } // Удаляем variables из экранов, которые не поддерживают это поле // variables поддерживается только в info экранах if ('variables' in normalizedScreen && normalizedScreen.template !== 'info') { delete normalizedScreen.variables; } return normalizedScreen as ScreenDefinition; }), }; } interface RouteParams { params: Promise<{ id: string; }>; } // No normalization needed: we require `progressbars` for loaders // GET /api/funnels/[id] - получить конкретную воронку export async function GET(request: NextRequest, { params }: RouteParams) { if (!isAdminApiEnabled()) { return adminApiDisabledResponse(); } try { const { id } = await params; const [{ default: connectMongoDB }, { default: FunnelModel }] = await Promise.all([ import('@/lib/mongodb'), import('@/lib/models/Funnel'), ]); await connectMongoDB(); const funnel = await FunnelModel.findById(id); if (!funnel) { return NextResponse.json( { error: 'Funnel not found' }, { status: 404 } ); } return NextResponse.json({ _id: funnel._id, name: funnel.name, description: funnel.description, status: funnel.status, version: funnel.version, createdAt: funnel.createdAt, updatedAt: funnel.updatedAt, publishedAt: funnel.publishedAt, usage: funnel.usage, funnelData: funnel.funnelData }); } catch (error) { console.error('GET /api/funnels/[id] error:', error); return NextResponse.json( { error: 'Failed to fetch funnel' }, { status: 500 } ); } } // PUT /api/funnels/[id] - обновить воронку export async function PUT(request: NextRequest, { params }: RouteParams) { if (!isAdminApiEnabled()) { return adminApiDisabledResponse(); } try { const { id } = await params; 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, sessionId, actionDescription } = body; // Валидация if (funnelData && (!funnelData.meta || !funnelData.meta.id || !Array.isArray(funnelData.screens))) { return NextResponse.json( { error: 'Invalid funnel data structure' }, { status: 400 } ); } const funnel = await FunnelModel.findById(id); if (!funnel) { return NextResponse.json( { error: 'Funnel not found' }, { status: 404 } ); } // Сохраняем предыдущее состояние для истории const previousData = funnel.funnelData; // Обновляем поля if (name !== undefined) funnel.name = name; if (description !== undefined) funnel.description = description; // Логика версионирования: // - При сохранении (без смены статуса) - версия НЕ увеличивается // - При публикации - версия увеличивается и меняется статус const isPublishing = status === 'published' && funnel.status !== 'published'; if (status !== undefined) funnel.status = status; if (funnelData !== undefined) { // Нормализуем данные перед сохранением (удаляем пустые текстовые поля) const normalizedData = normalizeFunnelData(funnelData as FunnelDefinition); funnel.funnelData = normalizedData; // Увеличиваем версию только при публикации if (isPublishing) { funnel.version += 1; funnel.publishedAt = new Date(); } } funnel.lastModifiedBy = 'current-user'; // TODO: заменить на реального пользователя const savedFunnel = await funnel.save(); // Создаем запись в истории, если обновлялась структура воронки if (funnelData && sessionId) { // Получаем текущий номер последовательности const lastHistoryEntry = await FunnelHistoryModel .findOne({ funnelId: id, sessionId }) .sort({ sequenceNumber: -1 }); const nextSequenceNumber = (lastHistoryEntry?.sequenceNumber || -1) + 1; // Нормализуем данные для истории const normalizedDataForHistory = normalizeFunnelData(funnelData as FunnelDefinition); await FunnelHistoryModel.create({ funnelId: id, sessionId, funnelSnapshot: normalizedDataForHistory, actionType: status === 'published' ? 'publish' : 'update', sequenceNumber: nextSequenceNumber, description: actionDescription || 'Воронка обновлена', isBaseline: status === 'published', changeDetails: { action: 'update-funnel', previousValue: previousData, newValue: normalizedDataForHistory } }); // Очищаем старые записи истории (оставляем 100) const KEEP_ENTRIES = 100; const entriesToDelete = await FunnelHistoryModel .find({ funnelId: id, sessionId }) .sort({ sequenceNumber: -1 }) .skip(KEEP_ENTRIES) .select('_id'); if (entriesToDelete.length > 0) { const idsToDelete = entriesToDelete.map((entry: { _id: unknown }) => entry._id); await FunnelHistoryModel.deleteMany({ _id: { $in: idsToDelete } }); } } return NextResponse.json({ _id: savedFunnel._id, name: savedFunnel.name, description: savedFunnel.description, status: savedFunnel.status, version: savedFunnel.version, createdAt: savedFunnel.createdAt, updatedAt: savedFunnel.updatedAt, publishedAt: savedFunnel.publishedAt, usage: savedFunnel.usage, funnelData: savedFunnel.funnelData }); } catch (error) { console.error('PUT /api/funnels/[id] 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 } ); } // Обработка других ошибок const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка'; return NextResponse.json( { error: 'Ошибка сохранения воронки', details: errorMessage }, { status: 500 } ); } } // DELETE /api/funnels/[id] - удалить воронку export async function DELETE(request: NextRequest, { params }: RouteParams) { if (!isAdminApiEnabled()) { return adminApiDisabledResponse(); } try { const { id } = await params; const [ { default: connectMongoDB }, { default: FunnelModel }, { default: FunnelHistoryModel }, ] = await Promise.all([ import('@/lib/mongodb'), import('@/lib/models/Funnel'), import('@/lib/models/FunnelHistory'), ]); await connectMongoDB(); const funnel = await FunnelModel.findById(id); if (!funnel) { return NextResponse.json( { error: 'Funnel not found' }, { status: 404 } ); } // Проверяем статус - опубликованные воронки нельзя удалять напрямую if (funnel.status === 'published') { return NextResponse.json( { error: 'Cannot delete published funnel. Archive it first.' }, { status: 400 } ); } // Удаляем воронку и всю связанную историю await Promise.all([ FunnelModel.findByIdAndDelete(id), FunnelHistoryModel.deleteMany({ funnelId: id }) ]); return NextResponse.json({ message: 'Funnel deleted successfully' }); } catch (error) { console.error('DELETE /api/funnels/[id] error:', error); return NextResponse.json( { error: 'Failed to delete funnel' }, { status: 500 } ); } }