Тема
ТЗ — Конструктор форм (cms/form-builder)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
🔄 Ревизия (волна F корп-MVP, 15.07.2026, реализация): построен срез поверх ядрового контракта
FormRepository(создан этой же волной — «⚠️ Противоречие» ниже закрыто): таблица пресетов + 4 системных (contact/callback/rfq/question,cms:form-builder:sync-presets), сервис «форма из пресета», Filament-конструктор полей формы (типы, реально рендерящиеся публичным партиалом ядра: text/tel/email/url/number/textarea/select/bool; лимитmax_fields_per_form; optimistic-lock), уведомления получателям (settings.notify_emails) черезNotificationDispatchнаLeadCreated. Ядро волной F: schema-driven блокform(BC-fallback), динамические rules сабмита, per-form throttle, шов капчи,success_text. Вне среза: собственный API модуля, сабмишен→запись типа контента, per-city получатели, legacy-импорт, правоform-builder.*(переиспользованforms.manageядра — осознанно). Ограничение:LeadCreatedне несёт payload лида — имя отправителя в уведомлении недоступно (вопрос ядра).
🔄 Ревизия (корпоративный MVP, 15.07.2026): по пивоту на движок типов контента сабмишен формы может уходить не только в
LeadServiceядра, но и записью в тип контента — граница гибкая, не жёсткая (content-types-engine §1). Выбор цели — настройкаform-builder.submission_target(см. «Настройки»); та же валидация сабмита ядра (rate-limit, honeypot, капча,consent_at) действует для обеих целей.
Назначение и возможности
Тонкая надстройка-редактор над ядровыми cms_forms/LeadService, а не свой конвейер лидов. Ядро уже владеет данными формы (cms_forms), данными заявки (cms_leads), движком полей, honeypot/капчей, rate-limit и POST /api/v1/forms/{slug}/submit — весь приём и защита сабмита живут там и модулем не переопределяются. Пробел, который ядро осознанно оставляет: у него есть таблица + голый CRUD API (/api/v1/admin/forms), но нет ни продуктового Filament-конструктора форм для клиента, ни готовых типовых шаблонов. cms/form-builder закрывает именно этот пробел:
- готовые формы-пресеты («Контакты», «Заказ», «Запрос КП», «Обратный звонок») — декларативные шаблоны схемы формы, старт в один клик вместо сборки с нуля;
- конструктор произвольных полей — repeater типов движка полей поверх схемы формы (текст/телефон/email/textarea/select/чекбокс согласия/…), без кода на каждый новый тип;
- Filament-ресурс «Формы» — редактирование, предпросмотр, настройка получателей и маркировки лида для формы, привязанной к странице/блоку;
- дополнительные props рендера ядрового блока «форма» (макет в одну/две колонки, текст кнопки, текст успеха) — оформление, не данные.
Модуль не хранит сабмишены сам: они уходят в лиды ядра ИЛИ записью типа контента (настройка form-builder.submission_target, см. «Настройки») — ни для одного из вариантов form-builder не вводит свою публичную точку приёма сабмита и не реализует honeypot/капчу/throttle заново. Если понадобится что-то ещё — это повод пересмотреть контракт ядра, а не обойти его в модуле (анти-паттерн «свой мини-фреймворк там, где есть механизм ядра», §standard).
Зависимости и выключение
requires: ядро (cms/core-contracts: движок полей FieldTypeRegistry, LeadService, запись/чтение cms_forms через контракт ядра — см. «⚠️ Противоречие» ниже) · suggests: cms/notifications-bus (уведомление о заявке — модуль форм только указывает получателей, отправка — бас), cms/integration-yandex / cms/integration-google (провайдер капчи — сам виджет и проверка капчи остаются на ядре и провайдере, form-builder лишь предлагает выбор в UI), cms/multicity (получатели уведомлений и таргетинг формы по текущему городу посетителя из RequestContext, по аналогии с городским таргетингом в cms/banners/cms/popups).
Без cms/multicity — получатели формы не варьируются по городу, письмо всегда уходит на общий список (nullable-измерение, не отдельная ветка кода). Без cms/notifications-bus — уведомление о новой заявке уходит в log-fallback ядра (заявка всё равно создана и видна в Filament-ресурсе лидов ядра). Без провайдера капчи — форма остаётся с honeypot ядра, поле капчи в конструкторе скрыто/недоступно к выбору.
Выключение модуля: блок «форма» деградирует к базовому фиксированному рендеру ядра (без дополнительных props макета) — форма остаётся кликабельной и рабочей, сабмит продолжает идти через ядровый POST /api/v1/forms/{slug}/submit независимо от состояния form-builder. Пресеты и Filament-конструктор недоступны, существующие формы и заявки не меняются и не удаляются. Публичный сайт не падает.
Модель данных
Модуль не владеет cms_forms/cms_leads — это ядро (§4 стандарта: «чужие таблицы — только чтение через сервисы владельца»). Единственная своя таблица — декларативный каталог пресетов:
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_form_builder_presets | id, slug, name, schema (jsonb), is_system (bool), site_id (nullable), lock_version, external_id (nullable) | шаблон формы: набор Field-деклараций движка полей + дефолтные настройки (маркировка лида, антиспам-профиль) |
slug уникален; is_system — системные пресеты (контакт/заказ/запрос КП/обратный звонок), поставляемые сидером, не удаляемые из UI, только клонируемые в пользовательский пресет; site_id nullable — при cms/multisite пресет либо общий (null), либо принадлежит конкретному сайту; lock_version — optimistic lock на конкурентную правку пресета в админке; external_id — только legacy-импорт (§16 стандарта). Никакого хардкода форм в коде: даже системные пресеты — строки таблицы из сидера, не PHP-массивы в классе.
Поля конкретной формы (какие есть у неё поля, получатели, маркировка лида, антиспам) хранятся в cms_forms ядра — form-builder только пишет туда через контракт ядра.
⚠️ Противоречие. Канонический реестр cms/core-contracts (core.md) даёт ContentRepository/TaxonomyRepository для чтения контента ядра, но не декларирует явный контракт для программной записи в cms_forms (только HTTP /api/v1/admin/forms и Filament — оба реализация самого ядра). Form-builder не может писать в чужую таблицу SQL напрямую (§4 стандарта) и не может звать собственный HTTP /api/v1/admin/forms изнутри PHP («HTTP к самому себе» запрещён §5 стандарта). Предложение: симметричный ядру контракт FormRepository (создать/обновить/прочитать декларацию формы, аналогично ContentRepository) в cms/core-contracts — нужна ревизия ядра до старта разработки модуля; до тех пор пункт дублируется в открытых вопросах.
ПДн-паспорт: cms_form_builder_presets не содержит ПДн (это шаблоны схемы, не данные посетителей). Данные посетителя (ФИО, телефон, email из сабмита) целиком живут в cms_leads ядра — ретеншн, «выгрузить всё по субъекту» и «забыть по запросу» (152-ФЗ) реализует PrivacyRegistry ядра, form-builder к этим данным доступа не имеет и в экспорт/забывание не встраивается (декларация «ПДн не храню»).
Входные и выходные данные
Вход:
| Источник | Поля | Валидация |
|---|---|---|
Admin CRUD пресета (/api/v1/admin/form-builder/presets) | slug, name, schema, is_system | FormRequest-whitelist; schema — валидируется движком полей (FieldTypeRegistry) поэлементно, недопустимый тип поля → 422; is_system=true — только studio-роль; lock_version сверяется — иначе 409 |
«Создать форму из пресета» (/api/v1/admin/form-builder/forms/{form}/apply-preset) | preset_slug | пресет обязан существовать; применяется к форме через контракт записи ядра (см. «⚠️ Противоречие»), не поверх существующих полей молча — предпросмотр разницы перед подтверждением |
| Filament-конструктор полей формы (repeater) | набор Field-деклараций (тип/label/required/rules/…) | rules движка полей на каждое поле; лимит form-builder.max_fields_per_form; запрещённые для формы типы полей (стилевые — как и в схеме блока, §Q9 движка полей) отклоняются на регистрации |
| Настройка получателей/маркировки формы | recipients[] (email), lead_type (строка → cms_leads.type), antispam_profile | email — формат + существование хотя бы одного получателя перед публикацией формы; lead_type — из whitelist значений, объявленных настройкой form-builder.available_lead_types |
| Публичный сабмит формы | — | не вход модуля — целиком ядровый POST /api/v1/forms/{slug}/submit (rate-limit, honeypot, капча, consent_at — всё ядро); form-builder только сконфигурировал схему и получателей заранее |
При form-builder.submission_target=content_entry тот же ядровый сабмит (валидация, honeypot, капча, consent_at не меняются) вместо LeadService::create() записывает результат через API движка типов контента (POST /api/v1/content/{type}, content-types-engine §1) — теми же rules движка полей, что и обычная запись типа; целевой тип — только из whitelist form-builder.content_entry_targets (см. «Настройки»), произвольный тип не принимается.
Выход:
| Потребитель | Данные | Формат |
|---|---|---|
| Блок «форма» (рендер ядра + props form-builder) | схема полей формы (через FieldEngine ядра) + макет (layout, submit_text, success_text) | props блока, версия _v, demo-props в галерее |
| Filament-предпросмотр | рендер формы «как увидит посетитель» без реального сабмита | HTML-превью в модальном окне ресурса |
Публичный API GET /api/v1/forms/{slug} | схема формы для headless-рендера | не эндпоинт модуля — ядровый, form-builder лишь наполнил cms_forms |
Admin API /api/v1/admin/form-builder/... | CRUD пресетов, применение пресета к форме | конверт {data, meta} |
| Legacy-импорт | отчёт прогона | --json: создано/обновлено/пропущено/ошибки построчно |
Всё, что не перечислено как вход, модуль отвергает (whitelist-принцип §11 стандарта): в частности, form-builder не принимает произвольный сабмит формы — эта граница закрыта ядром, дублировать или обходить её модулю запрещено.
Настройки (группа form-builder)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
form-builder.available_presets | array | системные 4 (contact, order, rfq, callback) | нет | Какие пресеты предлагать в «Создать из пресета» |
form-builder.available_lead_types | array | ["contact","order","rfq","callback"] | нет | Whitelist значений маркировки lead_type, доступных при настройке формы |
form-builder.default_recipients | array (email) | [] | нет | Получатели по умолчанию для новой формы, если явно не указаны |
form-builder.default_antispam_profile | string | honeypot_only | нет | Дефолт для новых форм: honeypot_only/captcha_required (сам honeypot/капча — механизм ядра, здесь только выбор профиля по умолчанию) |
form-builder.max_fields_per_form | int | 30 | нет | Лимит полей в конструкторе (защита от неюзабельной формы и раздутого payload) |
form-builder.kill_switch | bool | false | ✅ | Аварийное отключение расширенного рендера блока «форма»: откат к базовому фиксированному шаблону ядра без выключения модуля |
form-builder.submission_target | enum: lead|content_entry | lead | нет | Куда уходит сабмишен формы: лид ядра (LeadService) или запись типа контента через API движка (см. «Назначение и возможности», ревизия 15.07.2026) |
form-builder.content_entry_targets | array (slug типов контента) | [] | нет | Whitelist типов контента, разрешённых как цель сабмишена при submission_target=content_entry; защита от записи в произвольный тип |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET/POST/PUT/DELETE | /api/v1/admin/form-builder/presets… | form-builder.manage | CRUD пресетов (cms_form_builder_presets) |
| POST | /api/v1/admin/form-builder/forms/{form}/apply-preset | form-builder.manage | Применение пресета к существующей/новой ядровой форме |
| GET | /api/v1/admin/form-builder/forms/{form}/preview | form-builder.manage | Рендер предпросмотра формы (без реального сабмита) |
Публичной точки приёма сабмита модуль не вводит — используется только ядровый POST /api/v1/forms/{slug}/submit. CRUD форм по существу (cms_forms) остаётся за ядровым /api/v1/admin/forms…; form-builder добавляет вокруг него UX-слой (пресеты, предпросмотр), не параллельный API той же сущности.
Компоненты
Filament: ресурс «Формы» (навигационная группа модуля) — конструктор полей формы (repeater типов движка полей: text/tel/email/textarea/select/checkbox-согласие/…), выбор пресета как старт («Создать из пресета» с превью разницы), настройка получателей уведомлений и маркировки лида (lead_type), выбор антиспам-профиля (ссылка на настройки ядра forms.captcha_*, не дублирование), предпросмотр формы в модальном окне, afterSave() → инвалидация тега form-builder (и уведомление core о смене схемы формы, если требуется сброс page-cache страниц с этим блоком). Ресурс «Пресеты» — CRUD шаблонов, is_system — только для чтения/клонирования у обычных ролей.
Блок: дополнительные props поверх ядрового блока «форма» — макет (layout: single-column/two-column), текст кнопки, текст сообщения об успехе; версия _v, demo-props, fallback на базовый рендер ядра при выключении/сбое form-builder (без CLS — резерв высоты полей формы), доступность с клавиатуры (label на каждое поле, порядок табуляции по порядку полей в схеме).
Команды: cms:form-builder:seed-presets --json (пересоздание системных пресетов — идемпотентно), cms:form-builder:import-legacy --source=<профиль> --json. Демо-сидер: 4 системных пресета + одна демо-форма на пресете «Контакты» для галереи блоков/playground.
События и обмен
Модуль не издаёт новых доменных фактов помимо стандартного SettingChanged (настройки группы form-builder) — создание/статус заявки остаётся LeadCreated/LeadStatusChanged ядра, form-builder их не порождает и не переопределяет.
Слушает: LeadCreated — только чтобы (опционально) обогатить письмо в notifications-bus данными формы-источника (название формы, пресет), если форма была создана через form-builder; при выключенном notifications-bus — обработчик не регистрируется (log-fallback ядра, form-builder ни на что не влияет).
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms_forms (ядро) | сервис-вызов cms/core-contracts (см. «⚠️ Противоречие») | form-builder → ядро | запись схемы полей/получателей/маркировки формы через контракт ядра, не SQL |
LeadService (ядро) | сервис-вызов requires (канал 4) | ядро → лид создан вне form-builder | form-builder не вызывает LeadService::create() напрямую — сабмит целиком обрабатывает ядровый роут; факт LeadCreated form-builder только слушает |
LeadCreated (ядро) | шина событий (канал 1) | ядро → form-builder | опциональное обогащение уведомления данными пресета/формы-источника |
cms/notifications-bus (suggests) | provides-контракт notification-channel через NotificationDispatch | form-builder → бас | указание получателей формы транслируется в адресатов уведомления о заявке; не установлен — log-fallback ядра |
cms/integration-yandex/integration-google (suggests) | provides-контракт captcha-provider | форма → провайдер | form-builder предлагает выбор капчи в UI; сама проверка — ядро+провайдер, не модуль |
cms/multicity (suggests) | сервис-вызов RequestContext | ядро → form-builder | получатели/таргетинг формы по текущему городу; не установлен — измерение nullable, получатели общие |
Настройка form-builder.kill_switch | settings-store | form-builder → рендер блока | включена — блок «форма» рендерится базовым ядровым шаблоном без доп. props |
Фоновая работа
Джобы отсутствуют кроме легаси-импорта (батч-командой, не очередью на горячем пути). Отправка уведомления о заявке, throttle и очередь доставки — целиком notifications-bus и ядро; form-builder только конфигурирует получателей на входе.
Производительность и кеш
Горячий путь — рендер блока «форма» на публичной странице: form-builder не добавляет собственных запросов сверх того, что уже делает ядровый рендер блока (схема формы читается и кешируется ядром по тегу формы); дополнительные props (layout, тексты) хранятся в props самого блока (JSONB ревизии страницы), отдельного запроса не требуют — 0 лишних запросов на горячем пути (контрактный тест).
Тег кеша form-builder объявляется через CacheTags и инвалидируется при изменении пресета или при применении пресета к форме (само изменение схемы формы инвалидирует ядровый тег формы — form-builder не трогает чужие теги, только свой). Список пресетов в Filament («Создать из пресета») кешируется по тегу form-builder группы настроек — 0 запросов на построение UI-списка на горячем пути админки.
Безопасность
Границы входа: FormRequest-whitelist на CRUD пресетов и на настройки формы (получатели, маркировка, антиспам-профиль); schema пресета и формы валидируется движком полей поэлементно — произвольный JSON без прогонки через FieldTypeRegistry не принимается. Публичный сабмит формы не является границей входа модуля — вся фильтрация (honeypot, капча, rate-limit, санитизация rich-text полей) — стандартизированные обработчики ядра, form-builder их не обходит и не дублирует.
is_system=true у пресета — только studio-роль (защита от порчи системных шаблонов клиентом). Email получателей — валидируются форматом на входе; список получателей не попадает в публичный рендер формы (только в конфигурацию, читаемую сервером при отправке уведомления).
Матрица ролей:
| Действие | admin | менеджер | редактор | studio |
|---|---|---|---|---|
| Создание формы из пресета | ✅ | ✅ | — | ✅ |
| Конструктор произвольных полей формы | ✅ | ✅ | ✅ (без смены получателей/маркировки) | ✅ |
| Настройка получателей/маркировки лида/антиспам-профиля | ✅ | ✅ | — | ✅ |
CRUD пресета пользовательского (is_system=false) | ✅ | — | — | ✅ |
CRUD системного пресета (is_system=true) | — | — | — | ✅ |
| Удаление формы с историей заявок | ✅ (с подтверждением) | — | — | ✅ |
form-builder.kill_switch (аварийное отключение) | — | — | — | ✅ |
Права: form-builder.view, form-builder.manage.
UX-требования
Админ:
- Пустой список форм — не пустая таблица, а подсказка «Создайте первую форму из готового шаблона» с кнопками по числу системных пресетов.
- «Создать из пресета» на уже существующей форме показывает предпросмотр разницы схемы перед применением (какие поля добавятся/изменятся), не тихую перезапись.
- Ошибка превышения
form-builder.max_fields_per_form— конкретный текст («Достигнут лимit 30 полей — уберите неиспользуемые перед добавлением нового»), не generic «Error». - Форма без получателей уведомлений — предупреждение при попытке публикации («Некому получать заявки с этой формы — укажите email»), не тихая публикация в никуда.
- Конфликт редактирования пресета (
lock_version) — человеческое сообщение «Пресет изменён другим пользователем, обновите страницу». - Массовое действие в списке пресетов: клонировать системный пресет в пользовательский (стартовая точка для кастомизации без риска сломать системный).
Посетитель:
- Форма не создаёт CLS: высота полей зарезервирована по схеме до полной загрузки стилей.
- Ошибка валидации сабмита (ядро) сохраняет введённые значения — form-builder не переопределяет это поведение, только не должен его ломать своими доп. props макета.
- Форма доступна с клавиатуры: каждое поле схемы — с
label, порядок табуляции совпадает с порядком полей в конструкторе.
Крайние случаи и типовые баги
- Применение пресета к форме, у которой уже есть кастомные поля — предпросмотр разницы обязателен (см. UX); слепое слияние без подтверждения — незачёт.
- Удаление формы с историей заявок — заявки в
cms_leadsне удаляются (ссылка формы на лида мягкая/поslug/id формы на момент сабмита, не каскадное удаление); form-builder не инициирует и не блокирует удаление формы ядром, только предупреждает в UI числом связанных заявок. - Удаление пресета, уже применённого к формам ранее — не влияет на существующие формы (пресет — разовый шаблон-донор полей на момент применения, не постоянная связь).
- Превышение
form-builder.max_fields_per_formпри конструировании — понятная ошибка на попытке добавить поле сверх лимита, не тихое обрезание списка при сохранении. - Форма без обязательного поля email/телефона — конфигурируемо самим клиентом (form-builder не навязывает обязательные поля, это решение автора формы), в отличие от фиксированного дефекта «нет поля email» у голого ядрового блока формы.
- Honeypot заполнен ботом → сабмит ядра отдаёт фейковый success — form-builder об этом не узнаёт и не должен: поведение целиком на стороне ядра, конструктор его не видит и не логирует отдельно.
- Конкурентная правка одной формы двумя менеджерами (репитер полей) —
lock_versionформы (ядро) отдаёт 409 при сохранении второго; form-builder показывает то же человеческое сообщение, что и остальная админка ядра. cms/notifications-busвыключен посреди работы формы — заявки продолжают создаваться (LeadCreatedядра независим), уведомление уходит в log-fallback; form-builder не блокирует сабмит и не считает это ошибкой формы.- Выключенный
cms/multicityпри выбранном в конструкторе таргетинге получателей по городу — условие города игнорируется, получатели общие (nullable-измерение, оба режима покрыты контрактным тестом, как вcms/banners/cms/popups). - Системный пресет случайно удалён из
available_presetsнастройкой, но остаётся единственным пресетом сis_system=trueв БД — данные не теряются, просто временно не предлагается в UI; восстановление — правка настройки, не пересоздание пресета. - ⚠️ Противоречие: запись схемы формы в
cms_formsядра у form-builder не имеет канонического PHP-контракта вcms/core-contracts(см. «Модель данных») — до ревизии ядра (добавлениеFormRepository, симметричноContentRepository) конструктор технически не может безопасно писать в чужую таблицу ни SQL, ни HTTP-к-себе. Блокер реализации, не блокер ТЗ — дублируется в открытых вопросах.
Донорский код
Прямого одного донора нет (★): паттерн пресетов близок к конструкторам форм в CMS-практике студии (типовые формы «контакт/заказ/КП/звонок» повторяются в большинстве проектов вручную) — form-builder формализует то, что раньше собиралось руками в каждом проекте поверх universal.
Legacy-импорт: cms:form-builder:import-legacy --source=<профиль> — маппинг старых форм донорских проектов (набор полей → декларации движка полей, получатели, тип заявки) на cms_form_builder_presets + запись целевой формы в cms_forms ядра через контракт записи; идемпотентен по external_id (повторный прогон обновляет, не дублирует); --dry-run выводит отчёт расхождений без записи. Прогон на копии боевых данных донора — часть приёмки модуля (после закрытия «⚠️ Противоречие» выше).
Тесты и приёмка
- [ ] Применение системного пресета создаёт форму со схемой, валидной для движка полей
- [ ] Применение пресета к форме с существующими полями требует подтверждения (превью разницы), не сливает поля молча
- [ ] Права
form-builder.manageразграничены от голого CRUD/api/v1/admin/formsядра; матрица ролей покрыта тестом - [ ]
is_system=trueнедоступен для CRUD никому, кроме studio-роли - [ ] Превышение
form-builder.max_fields_per_form— человеческая ошибка, не 500 и не тихое обрезание - [ ] При выключении form-builder блок «форма» рендерится базовым шаблоном ядра без 500
- [ ] Публичный сабмит формы работает идентично независимо от состояния form-builder (ядровый роут не зависит от модуля)
- [ ] Удаление формы с историей заявок не удаляет и не портит заявки в
cms_leads - [ ] Конкурентная правка пресета/формы → 409 по
lock_version - [ ] Выключенный
cms/multicity/cms/notifications-busне блокирует конструктор и сабмит (log-fallback/nullable-измерение, оба режима в тестах) - [ ] Legacy-импорт идемпотентен по
external_id,--dry-runне пишет в БД - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут модуля (пресеты, apply-preset, preview)
- [ ] Тестовая БД только
form-builder_test;migrate:fresh/refresh/reset/db:wipeзапрещены