258 lines
7.9 KiB
TypeScript
258 lines
7.9 KiB
TypeScript
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<string, unknown> = {};
|
||
|
||
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<string, 1 | -1> = {};
|
||
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<string, { message: string }> };
|
||
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 }
|
||
);
|
||
}
|
||
}
|