w-funnel/src/app/api/funnels/route.ts
dev.daminik00 29c9ad92fe add funnel
2025-10-05 23:43:14 +02:00

258 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

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 }
);
}
}