CJM с планом и фактом: как мы чинили баги в воронке
Иногда баг выглядит как «данные не сохраняются» — и это самое страшное описание, потому что за ним может скрываться что угодно. В проекте v0-german мы столкнулись именно с таким случаем: пользователь вводит число в ячейку таблицы, видит зелёную галочку, обновляет страницу — и данных нет. Полный ноль. Казалось бы, типичная проблема с API. Но root cause оказался куда коварнее.
В этой статье я разберу три вещи: как мы нашли причину (спойлер: это ON DELETE CASCADE + автосохранение), как потом перешли от "просто сохраняй цифры" к полноценной архитектуре план/факт, и как сверху выросла система генерации документов для менеджеров. Получился интересный путь от одного бага до полноценного продуктового модуля.
Контекст проекта
v0-german — это внутренняя платформа для школы бизнес-консалтинга Германа Юна. Основной модуль — CJM (Customer Journey Map), визуальный конструктор воронок продаж. Воронка состоит из этапов: источник трафика → лендинг → CRM → диагностика → продажа. На каждом этапе есть метрики: лиды, затраты, конверсия, клики.
Техстек: Next.js 14 с App Router, Supabase как основная база (PostgreSQL под капотом), TypeScript везде, деплой через Dokploy на собственный сервер.
Конструктор воронки хранит этапы (stages) в таблице stages, а числовые метрики — в отдельной таблице stage_metrics с внешним ключом на stages.id. Казалось бы, стандартная нормализация. Но именно это нас и подловило.
Проблема: данные исчезают после сохранения
Пользователь заходит на /cjm, переключается на вкладку «Таблица», кликает на ячейку, вводит число, жмёт Enter. Появляется зелёная галочка — признак успешного сохранения. Обновляет страницу. Ячейка снова пустая.
Первым делом я проверил таблицу stage_metrics в базе напрямую:
SELECT COUNT(*) FROM stage_metrics;
-- count: 0Полностью пустая. Ни одной записи. При этом API работал — я вручную сделал PUT запрос и запись появилась. Значит проблема не в API-эндпоинте метрик. Что-то их удаляет после сохранения.
Поиск root cause
Я решил не гадать, а посмотреть на все операции, которые затрагивают таблицу stages. Нашёл эндпоинт PUT /api/cjm/funnels/[id]/stages — он вызывается автосохранением каждые ~2 секунды при любом изменении в конструкторе.
Вот что делал этот эндпоинт:
// БЫЛО: stages/route.ts (строка 22)
const { error: delError } = await supabaseCjm
.from("stages")
.delete()
.eq("funnel_id", funnelId)
// ... потом INSERT новых stages
const { error: insertError } = await supabaseCjm
.from("stages")
.insert(newStages)Полный DELETE + INSERT всех stages при каждом автосохранении. Само по себе это антипаттерн (лучше использовать UPSERT), но терпимо — если бы не одна деталь.
Проверяю схему внешнего ключа:
SELECT
tc.constraint_name,
rc.delete_rule
FROM information_schema.table_constraints tc
JOIN information_schema.referential_constraints rc
ON tc.constraint_name = rc.constraint_name
WHERE tc.table_name = 'stage_metrics';
-- constraint_name: stage_metrics_stage_id_fkey
-- delete_rule: CASCADEON DELETE CASCADE. Вот он, виновник.
Полная цепочка событий:
- Пользователь вводит метрику →
PUT /api/cjm/metrics→ запись вstage_metrics✓ - Через 2 секунды срабатывает автосохранение конструктора
PUT /api/cjm/funnels/[id]/stagesделаетDELETE FROM stages WHERE funnel_id = X- PostgreSQL каскадно удаляет все записи из
stage_metricsгдеstage_idссылается на удалённые stages - Тот же эндпоинт вставляет stages обратно с теми же ID
- Но
stage_metricsуже пуста — каскад безвозвратно удалил данные
Пользователь видит галочку, думает что всё сохранилось. Через 2 секунды данные тихо исчезают.
Решение: UPSERT вместо DELETE+INSERT
Исправление было принципиальным: никогда не удалять все stages оптом. Нужно удалять только те, которые реально убрали из воронки, а остальные — обновлять через UPSERT.
// СТАЛО: stages/route.ts
export async function PUT(request: Request, { params }: RouteParams) {
const { funnelId, stages: incomingStages } = await request.json()
// 1. Получаем существующие stage IDs
const { data: existingStages } = await supabaseCjm
.from("stages")
.select("id")
.eq("funnel_id", funnelId)
const existingIds = new Set(existingStages?.map(s => s.id) ?? [])
const incomingIds = new Set(incomingStages.map((s: Stage) => s.id))
// 2. Удаляем только реально убранные stages
const toDelete = [...existingIds].filter(id => !incomingIds.has(id))
if (toDelete.length > 0) {
await supabaseCjm
.from("stages")
.delete()
.in("id", toDelete)
}
// 3. UPSERT оставшихся
const { error } = await supabaseCjm
.from("stages")
.upsert(incomingStages, { onConflict: "id" })
if (error) return NextResponse.json({ error }, { status: 500 })
return NextResponse.json({ ok: true })
}Теперь каскадное удаление срабатывает только когда пользователь действительно убирает этап из воронки — что и является ожидаемым поведением.
Заодно исправил EditableCell — компонент всегда показывал зелёную галочку, не ожидая реального ответа от сервера:
// БЫЛО
onChange(value) // fire and forget
setStatus('success') // сразу
// СТАЛО
try {
await onChange(value) // ждём промис
setStatus('success')
} catch (e) {
setStatus('error')
setErrorMessage(e instanceof Error ? e.message : 'Ошибка сохранения')
}Архитектура план/факт
После фикса бага мы вернулись к более глобальной задаче: пользователи хотели видеть не только фактические метрики, но и плановые — чтобы сравнивать «что хотели» с «что получилось».
Структура таблицы требовала доработки. Добавили колонку type с enum plan | fact и обновили unique constraint:
-- Миграция
ALTER TABLE stage_metrics
ADD COLUMN type TEXT NOT NULL DEFAULT 'fact'
CHECK (type IN ('plan', 'fact'));
-- Обновляем unique constraint
ALTER TABLE stage_metrics
DROP CONSTRAINT stage_metrics_stage_id_date_key;
ALTER TABLE stage_metrics
ADD CONSTRAINT stage_metrics_stage_id_date_type_key
UNIQUE (stage_id, date, type);
-- Существующие записи = факт
UPDATE stage_metrics SET type = 'fact';API-эндпоинт теперь принимает параметр type:
// GET /api/cjm/metrics?funnelId=X&date=2026-03&type=plan
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const funnelId = searchParams.get('funnelId')
const date = searchParams.get('date')
const type = searchParams.get('type') ?? 'fact'
const { data } = await supabaseCjm
.from('stage_metrics')
.select('*')
.eq('funnel_id', funnelId)
.eq('date', date)
.eq('type', type)
return NextResponse.json(data)
}Вид таблицы переписан: теперь для каждого этапа три колонки — План, Факт, Δ (дельта). Дельта считается на клиенте:
const delta = fact !== null && plan !== null
? ((fact - plan) / Math.abs(plan)) * 100
: null
const deltaColor = delta === null
? 'text-gray-400'
: delta >= 0 ? 'text-green-600' : 'text-red-600'Плюс — данные о плановых метриках мигрировали из JSON-поля stages.metrics (где они хранились раньше как legacy) в нормализованную таблицу stage_metrics с type = 'plan'. 14 записей успешно перенесены.
Генератор документов
Параллельно с фиксами CJM вырос ещё один крупный модуль — генератор документов для менеджеров после диагностических звонков. Логика такая: менеджер загружает аудиозапись звонка (или вставляет текст транскрипции), система автоматически генерирует три документа:
- Пост-диагностический отчёт — «Точка А» клиента + «Ключевые выводы» по промпту, обученному на методологии школы
- Коммерческое предложение — с нужным тарифом (250К / 750К / 1М руб.), персонализированными кейсами и программой
- Дорожная карта — помесячный план работы под конкретный бизнес
Под капотом: OpenAI Whisper для транскрибации аудио, GPT-4o для генерации текста, @react-pdf/renderer для сборки PDF-файлов прямо в API route без Puppeteer и Chromium.
Поток данных через визард:
Шаг 1: Данные клиента (имя, бизнес, даты)
↓
Шаг 2: Загрузка аудио или текста транскрипции
↓ [Whisper API если аудио]
Шаг 3: Генерация отчёта диагностики [GPT-4o]
↓
Шаг 4: Генерация КП + дорожной карты [GPT-4o]
↓
Шаг 5: Скачивание PDF / .pen для презентации
Отдельным шагом добавили интеграцию с Pencil API для генерации слайд-презентаций. Таймаут на этот эндпоинт выставили явно — export const maxDuration = 120 — потому что Vercel по умолчанию режет запросы на 60 секунд, а GPT + сборка PDF иногда занимают больше.
Результаты
После деплоя фикса CASCADE-бага:
- Данные в
stage_metricsначали накапливаться корректно - Автосохранение конструктора перестало затрагивать метрики
EditableCellтеперь реально ждёт ответа от сервера перед показом статуса
По git-истории за последние дни: 20+ коммитов, реализованы Tasks 1–9 из плана plan/fact, мигрированы данные, добавлен генератор документов с 5-шаговым визардом.
Особенно приятная цифра — переход от DELETE+INSERT к UPSERT сократил количество SQL-операций при автосохранении с O(n) удалений + O(n) вставок до O(k) удалений (где k — реально убранные этапы, обычно 0) + один UPSERT.
Выводы
Главный урок этой истории — ON DELETE CASCADE это не просто «удобная фича», это контракт, который должны понимать все, кто пишет эндпоинты, работающие с родительскими таблицами. Мы нарушили этот контракт, делая полный DELETE в автосохранении. Баг был немым: никаких ошибок, красивая зелёная галочка — и тихая потеря данных через 2 секунды.
Второй урок: «fire and forget» в UI — зло. EditableCell показывал успех до получения ответа от сервера. Это нормально для оптимистичного обновления UI, но тогда нужен явный механизм отката при ошибке. У нас его не было — галочка показывалась всегда. Теперь компонент честно await-ит промис и показывает реальный статус.
Третий урон — антипаттерн DELETE+INSERT для обновления списков. Он кажется простым («удали всё, вставь заново»), но ломает внешние ключи, каскады, аудит-логи и любые другие зависимости, о которых ты мог забыть. UPSERT с явным списком на удаление — правильный подход, даже если он немного сложнее.
Наконец, важно документировать архитектурные решения в момент их принятия. Мы потеряли дизайн-документ для plan/fact из-за обрыва сессии — и следующий сеанс начался с повторного исследования кодовой базы. KNOWLEDGE.md и docs/plans/ в репозитории — не бюрократия, а реальная экономия времени при работе с AI-ассистентами и при онбординге новых людей в проект.
Полезные ссылки

Фулстек-разработчик, строю SaaS-продукты и автоматизации на Next.js, Python и AI. Пишу о реальных кейсах из продакшена.
Связанный проект
Смотреть в портфолио →