Тема
ТЗ — Wizard/анкеты (cms/wizard)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: referendum Статус: ТЗ к разработке
Назначение и возможности
Многошаговые формы с сохранением сессии (продолжить позже), ветвлением шагов по ответам и итогом, конвертируемым в лид или документ. В отличие от cms/surveys — фокус на сборе структурированных данных одного респондента (сессия = респондент), а не на агрегации мнений многих: surveys считает распределение ответов по всем участникам, wizard доводит одного конкретного посетителя до целевого результата.
- Шаги на движке полей ядра, конфигурируемая последовательность
- Сохранение прогресса в сессии/токене — респондент может продолжить позже по ссылке, в том числе с другого устройства (токен самодостаточен, не завязан на cookie/PHP-сессию)
- Ветвление: следующий шаг зависит от ответа на предыдущий (условные переходы)
- Индикатор прогресса (шаг N из M), корректно пересчитываемый при ветвлении — не фиксированное число шагов, а длина фактического пути
- Итог мастера → создание лида через
LeadServiceс полным payload ответов, либо генерация документа - Валидация каждого шага перед переходом (FormRequest по whitelist полей шага)
- Возврат к предыдущему шагу с сохранением уже введённых данных и корректной инвалидацией последующих шагов при изменении ветки
- Снапшот схемы на момент старта сессии — изменение анкеты админом не ломает уже идущие прохождения
Зависимости и выключение
requires: ядро (LeadService, движок полей, SettingsStore)
Поведение при выключении: мастер недоступен (fallback — заглушка «форма временно недоступна»), незавершённые сессии сохраняются в БД и не удаляются, но не могут быть продолжены до включения модуля; при повторном включении — доступны как есть, это не считается ошибкой (см. «Крайние случаи»).
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_wizards | id, slug, title, locale, city_id (nullable), lock_version, external_id (nullable) | сам мастер |
cms_wizard_steps | id, wizard_id, field_type, question, options (json), branch_rules (json), sort_order, external_id (nullable) | шаг (движок полей ядра) + правила ветвления |
cms_wizard_sessions | id, wizard_id, session_token, current_step_id, answers (json), schema_snapshot (json), status, lead_id (nullable), pii_purged_at (nullable), expires_at | прогресс респондента + снапшот схемы на момент старта |
wizard_id — constrained()->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_rules | FormRequest, 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/WizardSessionExpired | payload события |
| Legacy-импорт (отчёт) | сколько прочитано/создано/обновлено/пропущено и почему | построчный отчёт, скачиваемый |
Настройки (группа wizard)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
wizard.session_ttl_days | int | 7 | нет | Срок жизни незавершённой сессии до истечения |
wizard.create_lead_on_complete | bool | true | нет | Автосоздание лида по завершении мастера |
wizard.allow_resume | bool | true | нет | Разрешить продолжение по сохранённой ссылке |
wizard.max_steps_per_wizard | int | 30 | нет | Лимит числа шагов в одном мастере (защита конструктора) |
wizard.answer_retention_days_after_complete | int | 30 | нет | Хранение ответов после завершения сессии до обезличивания |
wizard.accept_new_sessions | bool | true | нет | Kill-switch: приём новых сессий (false — при инциденте; уже начатые сессии дорабатывают) |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/wizards/{slug} | public | Схема первого шага мастера |
| POST | /api/v1/wizards/{slug}/sessions | public (rate-limit) | Старт новой сессии |
| POST | /api/v1/wizards/sessions/{token}/steps | public | Отправка ответа шага, получение следующего |
| 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 | сессия истекла по TTL | wizard_id, session_token |
При WizardCompleted и wizard.create_lead_on_complete=true модуль явно вызываетLeadService::create() (канал 4, сервис-вызов ядра — не событие и не побочный эффект слушателя): создание лида — прямая часть завершения мастера, а не реакция стороннего подписчика. Слушает: —. Provides-контрактов не реализует, FilterBus не использует.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
LeadService (ядро) | сервис-вызов (requires ядро) | out | WizardCompleted → LeadService::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 запрос на запись прогресса (upsertanswers/current_step_id); схема шагов и правила ветвления читаются из кеша (тегwizard), не из БД на каждом переходе — резолвятся один раз при старте сессии вschema_snapshot; - критичные индексы:
session_tokenunique (доступ к сессии без полного скана),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_wizardsnullable, мастер работает в обоих режимах; контрактный тест гоняется с измерением и без (§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запрещены