П/ВИН

CJM с планом и фактом: как мы чинили баги в воронке

·8 мин чтения

Иногда баг выглядит как «данные не сохраняются» — и это самое страшное описание, потому что за ним может скрываться что угодно. В проекте 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: CASCADE

ON DELETE CASCADE. Вот он, виновник.

Полная цепочка событий:

  1. Пользователь вводит метрику → PUT /api/cjm/metrics → запись в stage_metrics
  2. Через 2 секунды срабатывает автосохранение конструктора
  3. PUT /api/cjm/funnels/[id]/stages делает DELETE FROM stages WHERE funnel_id = X
  4. PostgreSQL каскадно удаляет все записи из stage_metrics где stage_id ссылается на удалённые stages
  5. Тот же эндпоинт вставляет stages обратно с теми же ID
  6. Но 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 вырос ещё один крупный модуль — генератор документов для менеджеров после диагностических звонков. Логика такая: менеджер загружает аудиозапись звонка (или вставляет текст транскрипции), система автоматически генерирует три документа:

  1. Пост-диагностический отчёт — «Точка А» клиента + «Ключевые выводы» по промпту, обученному на методологии школы
  2. Коммерческое предложение — с нужным тарифом (250К / 750К / 1М руб.), персонализированными кейсами и программой
  3. Дорожная карта — помесячный план работы под конкретный бизнес

Под капотом: 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. Пишу о реальных кейсах из продакшена.

Связанный проект

Смотреть в портфолио →