w-funnel/src/app/api/funnels/[id]/route.ts
dev.daminik00 aa956adebb add funnel
2025-10-06 02:41:09 +02:00

368 lines
13 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 экранов)
// ⚠️ 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<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 }
);
}
// Обработка других ошибок
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 }
);
}
}