Skip to content

ТЗ — Wizard/анкеты (cms/wizard)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: referendum Статус: ТЗ к разработке

Назначение и возможности

Многошаговые формы с сохранением сессии (продолжить позже), ветвлением шагов по ответам и итогом, конвертируемым в лид или документ. В отличие от cms/surveys — фокус на сборе структурированных данных одного респондента (сессия = респондент), а не на агрегации мнений многих: surveys считает распределение ответов по всем участникам, wizard доводит одного конкретного посетителя до целевого результата.

  • Шаги на движке полей ядра, конфигурируемая последовательность
  • Сохранение прогресса в сессии/токене — респондент может продолжить позже по ссылке, в том числе с другого устройства (токен самодостаточен, не завязан на cookie/PHP-сессию)
  • Ветвление: следующий шаг зависит от ответа на предыдущий (условные переходы)
  • Индикатор прогресса (шаг N из M), корректно пересчитываемый при ветвлении — не фиксированное число шагов, а длина фактического пути
  • Итог мастера → создание лида через LeadService с полным payload ответов, либо генерация документа
  • Валидация каждого шага перед переходом (FormRequest по whitelist полей шага)
  • Возврат к предыдущему шагу с сохранением уже введённых данных и корректной инвалидацией последующих шагов при изменении ветки
  • Снапшот схемы на момент старта сессии — изменение анкеты админом не ломает уже идущие прохождения

Зависимости и выключение

requires: ядро (LeadService, движок полей, SettingsStore)

Поведение при выключении: мастер недоступен (fallback — заглушка «форма временно недоступна»), незавершённые сессии сохраняются в БД и не удаляются, но не могут быть продолжены до включения модуля; при повторном включении — доступны как есть, это не считается ошибкой (см. «Крайние случаи»).

Модель данных

ТаблицаКлючевые поляПримечание
cms_wizardsid, slug, title, locale, city_id (nullable), lock_version, external_id (nullable)сам мастер
cms_wizard_stepsid, wizard_id, field_type, question, options (json), branch_rules (json), sort_order, external_id (nullable)шаг (движок полей ядра) + правила ветвления
cms_wizard_sessionsid, wizard_id, session_token, current_step_id, answers (json), schema_snapshot (json), status, lead_id (nullable), pii_purged_at (nullable), expires_atпрогресс респондента + снапшот схемы на момент старта

wizard_idconstrained()->cascadeOnDelete()->index(); session_token — unique-индекс (владение сессией по токену, доступ без полного скана); status — PHP Enum (in_progress/completed/expired); options/branch_rules/answers/schema_snapshot — JSONB. lock_version на cms_wizards — optimistic lock конструктора (§4 стандарта): два админа правят один мастер одновременно → 409, не «последний победил». external_id (nullable, unique в рамках таблицы) — ключ идемпотентности legacy-импорта. schema_snapshot — копия дерева шагов и правил ветвления на момент WizardSessionStarted, не меняется после старта сессии (см. «Крайние случаи»). pii_purged_at — отметка обезличивания ответов по ретеншну.

ПДн-паспорт. cms_wizard_sessions.answers почти всегда содержит контактные/личные данные — большинство мастеров существуют именно для того, чтобы в итоге собрать данные для лида (имя, телефон, адрес и т. п.). Срок хранения: session_ttl_days (жизнь незавершённой сессии) + wizard.answer_retention_days_after_complete после завершения, дальше — джоба обезличивания (см. «Фоновая работа») перезаписывает answers плейсхолдерами и выставляет pii_purged_at; schema_snapshot остаётся для статистики по шагам (не содержит ПДн). Участие в «выгрузить всё по субъекту» / «забыть по запросу» (152-ФЗ): по session_token и связанному lead_id выгружаются/обезличиваются обе сущности — сессия через свой хук ретеншна, лид — через ПДн-паспорт ядра (leads).

Входные и выходные данные

Входы:

ИсточникДанные/поляЧем валидируется
POST /api/v1/wizards/{slug}/sessions (публичный, rate-limit)пусто/опциональные UTM-параметры для payload лидаrate-limit по IP, wizard.accept_new_sessions (kill-switch)
POST /api/v1/wizards/sessions/{token}/steps (публичный)только поля текущего шага сессии (whitelist по field_type из schema_snapshot)FormRequest, движок полей ядра, Idempotency-Key на завершающем сабмите
GET /api/v1/wizards/sessions/{token} (публичный, владелец токена)tokenпроверка session_token, expires_at, status
CRUD /api/v1/admin/wizards… (админка)title, slug, steps[], branch_rulesFormRequest, ReDoS-валидатор регулярных выражений условий ветвления, wizard.manage, lock_version при апдейте
cms:wizard:import-legacy --source=<профиль>старые анкеты/вопросы/переходы донорамаппинг профиля, --dry-run с отчётом расхождений

Выходы:

ПотребительДанныеФормат
Респондент (ответ API на шаг)следующий шаг (схема поля, вопрос, опции) или финальный экранконверт {data, meta}, meta.progress — фактический шаг/длина пути
LeadService::create() (сервис ядра)полный payload answers + wizard_id, session_tokenвызов сервиса при WizardCompleted и wizard.create_lead_on_complete=true
Filament (конструктор, список сессий)wizards, steps, sessions (мета без ответов / с ответами — по праву)таблицы, карточка сессии
Подписчики событийWizardSessionStarted/WizardCompleted/WizardSessionExpiredpayload события
Legacy-импорт (отчёт)сколько прочитано/создано/обновлено/пропущено и почемупострочный отчёт, скачиваемый

Настройки (группа wizard)

КлючТипДефолтaffectsPageCacheОписание
wizard.session_ttl_daysint7нетСрок жизни незавершённой сессии до истечения
wizard.create_lead_on_completebooltrueнетАвтосоздание лида по завершении мастера
wizard.allow_resumebooltrueнетРазрешить продолжение по сохранённой ссылке
wizard.max_steps_per_wizardint30нетЛимит числа шагов в одном мастере (защита конструктора)
wizard.answer_retention_days_after_completeint30нетХранение ответов после завершения сессии до обезличивания
wizard.accept_new_sessionsbooltrueнетKill-switch: приём новых сессий (false — при инциденте; уже начатые сессии дорабатывают)

API

МетодПутьДоступНазначение
GET/api/v1/wizards/{slug}publicСхема первого шага мастера
POST/api/v1/wizards/{slug}/sessionspublic (rate-limit)Старт новой сессии
POST/api/v1/wizards/sessions/{token}/stepspublicОтправка ответа шага, получение следующего
GET/api/v1/wizards/sessions/{token}public (владелец токена)Продолжение сохранённой сессии
CRUD/api/v1/admin/wizards…admin (wizard.manage)Конструктор шагов и правил ветвления

Завершающий вызов POST .../steps, приводящий к WizardCompleted (создаёт лид), обязан приниматься с заголовком Idempotency-Key — конвенция ядра для мутаций с внешним эффектом (§7 стандарта, §API ядра): повторный запрос с тем же ключом не создаёт второй лид. GET /wizards/sessions/{token} при expires_at в прошлом отдаёт 404 с телом, отличимым от «токен не найден вовсе» (см. «Крайние случаи»), не тихий редирект на первый шаг.

Компоненты

Блоки (BlockRegistry): «Wizard» (пошаговая форма с индикатором прогресса), версия _v, demo-props для playground. Filament: конструктор шагов (drag&drop, редактор правил ветвления с превью пути — какой шаг откроется при каком ответе), список сессий с фильтром по мастеру/этапу/статусу, карточка сессии (ответы — под wizard.view-answers). Команды: cms:wizard:expire-sessions --json, cms:wizard:purge-answers --json, cms:wizard:import-legacy --source=<профиль> --dry-run --json.

Фронтенд-бюджет: блок «Wizard» — минимум JS (переключение шага + зеркало серверной валидации на клиенте, без тяжёлых зависимостей), индикатор прогресса на CSS (не canvas и не JS-библиотека), резерв высоты между шагами исключает CLS при переходе, полная клавиатурная доступность (Tab между полями шага, Enter — переход к следующему шагу как submit, фокус на первое поле нового шага).

Демо-контент: сидер демо-мастера (4–5 шагов с одним ветвлением) — обязателен для галереи блоков /_gallery и playground без ручного заполнения.

События и обмен

СобытиеКогдаPayload
WizardSessionStartedначата новая сессияwizard_id, session_token
WizardCompletedмастер пройден до концаwizard_id, session_token, lead_id (nullable)
WizardSessionExpiredсессия истекла по TTLwizard_id, session_token

При WizardCompleted и wizard.create_lead_on_complete=true модуль явно вызываетLeadService::create() (канал 4, сервис-вызов ядра — не событие и не побочный эффект слушателя): создание лида — прямая часть завершения мастера, а не реакция стороннего подписчика. Слушает: —. Provides-контрактов не реализует, FilterBus не использует.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
LeadService (ядро)сервис-вызов (requires ядро)outWizardCompletedLeadService::create() с payload answers, если create_lead_on_complete=true
SettingsStore (ядро)сервис-вызовinчтение группы wizard из кеша группы на каждом переходе (0 запросов к БД настроек)
FieldTypeRegistry (ядро)сервис-вызовinрезолв типа поля и его валидатора для текущего шага
Подписчики WizardCompleted/WizardSessionExpiredшина событийoutлюбой модуль может подписаться (например cms/analytics — конверсия по шагам), сам wizard об этом не знает

Фоновая работа

Джоба cms:wizard:expire-sessions (очередь wizard) — по расписанию через ScheduleRegistrar ядра помечает сессии старше session_ttl_days как expired; идемпотентна (повторный прогон не переоткрывает уже помеченные). Джоба cms:wizard:purge-answers (та же очередь) — обезличивает answers сессий старше session_ttl_days + answer_retention_days_after_complete, выставляет pii_purged_at; идемпотентна (уже обезличенные пропускаются).

Производительность и кеш

  • Ожидаемые объёмы: десятки мастеров на проект, сотни–тысячи сессий в месяц на активном сайте с лидогенерацией через формы;
  • горячий путь — переход между шагами (POST .../steps): бюджет 1 запрос на чтение текущей сессии + 1 запрос на запись прогресса (upsert answers/current_step_id); схема шагов и правила ветвления читаются из кеша (тег wizard), не из БД на каждом переходе — резолвятся один раз при старте сессии в schema_snapshot;
  • критичные индексы: session_token unique (доступ к сессии без полного скана), wizard_id (список сессий мастера), (wizard_id, status) — фильтр по этапу/статусу в списке незавершённых сессий в админке;
  • теги кеша: wizard — схема шагов и правил ветвления мастера, инвалидируется при сохранении в конструкторе; сессии респондентов не кешируются и не попадают в общий page-cache (персональные данные, §10 стандарта) — читаются напрямую из БД строго по session_token.

Безопасность

Границы входа: FormRequest-whitelist на каждый шаг (только поля текущего шага, не всей анкеты — резолвится из schema_snapshot), rate-limit на старт сессии и на отправку шага (защита от перебора токенов методом POST). Доступ к продолжению сессии — только по session_token: генерируется криптографически стойким генератором, ≥256 бит энтропии (32 случайных байта, base62), не инкрементный и не предсказуемый по времени/IP. TTL токена = session_ttl_days, после истечения не принимается (expires_at проверяется на каждый запрос). Ротация токена при подозрительной активности не предусмотрена осознанно: профиль риска ниже, чем у аутентификационного токена — компрометация даёт доступ только к ответам одной конкретной незавершённой анкеты, не к привилегированному аккаунту, а TTL и так ограничивает окно жизни; при повышенных требованиях — модуль-потребитель добавляет капчу на старт сессии через provides: captcha-provider ядра, а не сам wizard. Завершающий сабмит, создающий лид, требует Idempotency-Key (см. «API», «Крайние случаи»). Rich-текст свободных ответов — санитизация двойным барьером при выводе в админке.

Матрица ролей:

Действиеadminменеджерредакторstudio
Просмотр списка мастеров
Просмотр сессий без ответов (wizard.view)
Просмотр ответов сессии (wizard.view-answers, ПДн)
CRUD конструктора шагов (wizard.manage)
Редактирование правил ветвления
Удаление мастера/сессий

Права: wizard.view, wizard.view-answers, wizard.manage.

UX-требования

Админ:

  • пустой список мастеров — «мастеров ещё нет, создать первый» с призывом к действию, не голая таблица;
  • список незавершённых сессий с фильтром по этапу/статусу — менеджеру важно видеть, на каком шаге сейчас застряли пользователи, без открытия каждой карточки;
  • попытка удалить шаг, на который ссылается правило ветвления другого шага — понятная ошибка с указанием, какое именно правило и в каком шаге ссылается, а не 500/тихий отказ (защита от битой логики конструктора);
  • редактор правил ветвления с превью пути: выбор варианта ответа сразу подсвечивает, какой шаг откроется дальше — не нужно гонять тестовую сессию, чтобы проверить логику.

Посетитель:

  • индикатор прогресса корректен даже при ветвлении — «шаг 3 из 10» недопустимо, если выбранная ветка короче; показывается либо фактическая длина пройденного пути, либо примерный процент без ложной точности;
  • возврат назад сохраняет уже введённые ответы — не сброс формы; поля предзаполняются прежним значением;
  • ссылка на продолжение работает с любого устройства — сохранение по session_token в URL/БД, не по cookie или серверной PHP-сессии браузера, где мастер был начат.

Крайние случаи и типовые баги

  • сохранение прогресса между устройствамиsession_token в БД самодостаточен и не зависит от cookie/PHP-сессии: ссылка на продолжение, открытая на другом устройстве или после закрытия вкладки, восстанавливает сессию по токену из URL, не по браузерному состоянию;
  • возврат назад меняет ответ, от которого зависит ветка — правила ветвления пересчитываются от нового ответа; ответы на шаги, пройденные по уже неактуальной ветке, не удаляются физически (остаются в answers для аудита), но перестают участвовать в определении текущего пути — при повторном достижении развилки путь строится заново из актуальных ответов, индикатор прогресса пересчитывается вместе с ним;
  • админ меняет анкету, пока сессия активна (добавил/удалил шаг, поправил правило ветвления) — сессия работает по schema_snapshot, снятому на старте (WizardSessionStarted), а не по live-схеме: изменения конструктора применяются только к новым сессиям, начатые прохождения не рассинхронизируются посреди пути. Конструктор в Filament показывает предупреждение при редактировании шага с активными сессиями («N сессий используют текущую версию, изменения их не затронут»). ⚠️ Противоречие: донорский код referendum применял изменения анкеты к активным прохождениям на лету (без снапшота). Разрешение: в CMS v2 — снапшот на старте сессии; донорская логика ветвления переносится как основа расчёта пути, но без live-подхвата схемы — иначе несогласованные ответы у пользователей, начавших прохождение до правки;
  • LeadService недоступен или create_lead_on_complete=false — мастер всё равно считается завершённым (status=completed, событие WizardCompleted с lead_id=null), ответы сохраняются целиком; лид не создаётся и это не ошибка сессии, а штатный режим (документ/экспорт может быть настроен отдельно потребителем события);
  • истёкшая сессия — попытка продолжить по expires_at в прошлом отдаёт понятную ошибку «ссылка на продолжение больше не действительна» (404 с отличимым телом от «токена не существует»), не молчаливый сброс на первый шаг — респондент не должен терять ощущение, что часть ответов уже была принята;
  • двойная отправка одного шага (двойной клик/повтор запроса) — переход шага идемпотентен по паре (session_token, current_step_id): повторный POST с теми же ответами не двигает current_step_id дальше второй раз и не портит индикатор прогресса; завершающий сабмит дополнительно защищён Idempotency-Key — повторный вызов не создаёт второй лид через LeadService;
  • конкурентное прохождение одной сессии с двух вкладок — на уровне сессии применяется «последний ответ шага побеждает» (upsert answers/current_step_id без optimistic lock), в отличие от админского конструктора, где нужен lock_version: здесь оба «редактора» — один и тот же респондент, конфликт не бизнес-критичен, а строгая блокировка только ухудшит UX (одна из вкладок получит 409 на собственном прохождении);
  • выключение модуля посреди активной сессии — незавершённая сессия не считается ошибкой и не удаляется; при повторном включении модуля доступна для продолжения как есть (см. «Зависимости и выключение»);
  • отсутствие измерения city/locale (сайт без городов/мультиязычности) — city_id/locale в cms_wizards nullable, мастер работает в обоих режимах; контрактный тест гоняется с измерением и без (§4 стандарта);
  • kill-switch включён (wizard.accept_new_sessions=false) во время инцидентаPOST .../sessions отдаёт понятную ошибку «приём заявок временно приостановлен», уже идущие сессии продолжают работать штатно (allow_resume), это не выключение модуля целиком;
  • legacy-импорт с уже существующим external_id — повторный прогон import-legacy обновляет шаг/мастер, а не создаёт дубликат; --dry-run показывает расхождение до применения.

Донорский код

Что взятьПуть
Многошаговая логика, ветвление, сессииreferendum/app/

Legacy-импорт. Команда cms:wizard:import-legacy --source=referendum маппит старые анкеты донора (вопросы/варианты ответов/переходы) на cms_wizards/cms_wizard_steps новой схемы; идемпотентна по external_id (повторный прогон обновляет существующие записи, не дублирует), поддерживает --dry-run с построчным отчётом расхождений. Сессии респондентов из донора не переносятся как «продолжаемые» (нет процесса передачи чужого session_token) — переносится структура анкет; при необходимости сохранить исторически завершённые прохождения — отдельный разовый ETL в cms_wizard_sessions со status=completed и lead_id=null.

Тесты и приёмка

  • [ ] Контрактный тест: ветвление по ответу ведёт на корректный следующий шаг
  • [ ] При выключении модуля мастер отдаёт fallback-заглушку без 500
  • [ ] Продолжение сессии по токену восстанавливает ранее введённые ответы, в том числе «с другого устройства» (без cookie исходной сессии)
  • [ ] Истёкшие сессии (expires_at) не позволяют продолжить, помечаются WizardSessionExpired, ошибка отличима от «токен не найден»
  • [ ] Возврат назад с изменением ответа, влияющего на ветку, пересчитывает путь и не оставляет противоречивых «зависших» ответов по старой ветке
  • [ ] Изменение анкеты админом во время активной сессии не ломает уже идущее прохождение (schema_snapshot соблюдается)
  • [ ] Повторный POST того же шага (двойной клик) не двигает прогресс дважды; повторный завершающий сабмит с тем же Idempotency-Key не создаёт второй лид
  • [ ] wizard.accept_new_sessions=false блокирует новые сессии, не мешая продолжению уже начатых
  • [ ] Legacy-импорт идемпотентен по external_id, --dry-run не пишет в БД
  • [ ] Права wizard.manage/wizard.view-answers разграничены от публичного прохождения и от просмотра списка без ответов
  • [ ] Нет N+1 при загрузке схемы шагов с правилами ветвления (->with('steps'))
  • [ ] Валидация каждого шага строго по whitelist полей этого шага (FormRequest)
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут (схема, старт сессии, шаг, продолжение, admin CRUD)
  • [ ] Тестовая БД только wizard_test; migrate:fresh/refresh/reset/db:wipe запрещены

Внутренняя база знаний студии