Тема
ТЗ — FAQ (cms/faq)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: несколько проектов Статус: ТЗ к разработке
🔄 Ревизия (корпоративный MVP, 15.07.2026): FAQ-наборы — кандидат в пресет типа контента на движке типов контента; доменный FAQ-блок остаётся точкой вывода на странице. Разделы ниже — свойства/шаблоны пресета. Почему только «кандидат», а не решённый пресет:
cms/faqуже реализован в коде полигона (эталонный модуль фазы 3, рабочие таблицыcms_faq_*) — перенос на движок означает data-миграцию работающего модуля, а не переписывание ТЗ с нуля; решение — при ревизии пресетов (content-types-engine §16).⚠️ Статус тела ТЗ: разделы ниже (модель
cms_faq_*, роуты/api/v1/faq, Filament-ресурс, праваfaq.manage, тесты) описывают целевые правила через историческую реализацию собственными таблицами; актуальные хранение/админку/API даёт движок (cms_content_entries,ContentEntryResource,/api/v1/content/{slug}) — предмет ревизии (content-types-engine §16, Волна G).cms/faq— текущий эталон фабрики модуля другой архитектуры, поэтому переезд на движок здесь особенно аккуратен.
Назначение и возможности
Вопрос-ответ с группами вопросов, привязкой к страницам и услугам, блок FAQ для вставки в контент и разметка Schema.org FAQPage для расширенных сниппетов в поиске.
- Группы вопросов (категории FAQ) с сортировкой
- Вопрос-ответ с rich-текстом в ответе (блочный контент или HTML из админки)
- Привязка группы/вопроса к конкретной странице или услуге (полиморфно)
- Глобальные вопросы (без привязки) для общей страницы «Частые вопросы»
- Блок FAQ (BlockRegistry) для вставки в любую страницу через конструктор
- Schema.org
FAQPageавтогенерация из вопросов текущей страницы - Поиск по вопросам (простой, без выделенного индекса)
Зависимости и выключение
requires: ядро. suggests: — (в перспективе cms/search для полнотекстового поиска по вопросам вместо ILIKE, см. «Крайние случаи»). provides: не реализует.
Поведение при выключении: блок FAQ на страницах отдаёт fallback-заглушку, разметка Schema.org FAQPage не генерируется — вопросы и группы сохраняются в БД.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_faq_groups | id, slug, title, sort_order, locale, city_id (nullable), lock_version | категория вопросов |
cms_faq_items | id, group_id, question, answer, sort_order, faqable_type (nullable), faqable_id (nullable), locale, external_id (nullable), lock_version | вопрос-ответ, опциональная полиморфная привязка; external_id — ключ идемпотентности legacy-импорта |
group_id — constrained()->cascadeOnDelete()->index(); faqable_type+faqable_id — составной индекс (nullable — глобальные вопросы без привязки), без FK-constraint (чужая сущность из другого модуля, §4 «Обмена данными» — только чтение id, см. «Крайние случаи»); (group_id, sort_order) — индекс под сортировку списка; external_id — уникален в рамках source (частичный уникальный индекс, nullable). lock_version (optimistic lock) — на группах и вопросах для конкурентного редактирования.
ПДн-паспорт: модуль ПДн не хранит — вопросы и ответы справочные, привязок к персональным данным пользователей нет; хуки ядра «выгрузить всё по субъекту» / «забыть по запросу» (152-ФЗ) не применимы.
Входные и выходные данные
Входы (whitelist-принцип: всё, что не перечислено ниже, модуль обязан отвергать):
| Источник | Поля | Чем валидируется |
|---|---|---|
| Admin-форма группы (Filament) | title, slug, locale, city_id, sort_order | FormRequest-whitelist, движок полей |
Admin-форма/API вопроса (/api/v1/admin/faq) | group_id, question, answer (rich-text/HTML), sort_order, faqable_type, faqable_id, locale | FormRequest-whitelist, санитайзер rich-text (двойной барьер), проверка совпадения site_id/locale вопроса и faqable-сущности |
| Drag&drop сортировка (RelationManager) | массив {id, sort_order, lock_version} | FormRequest-whitelist, optimistic lock (409 при конфликте версии) |
Публичный GET /api/v1/faq | filter[faqable_type], filter[faqable_id], filter[locale], cursor | whitelist фильтров FormRequest; неизвестный параметр → 422 |
| Блок FAQ (пропсы блока, конструктор страниц) | group_id либо faqable-ссылка, заголовок, флаг Schema.org | схема полей блока (FieldTypeRegistry), версия _v |
Legacy-импорт cms:faq:import-legacy | вопрос, ответ, группа, external_id из донорского экспорта | маппер источника + идемпотентность по external_id, --dry-run отчёт расхождений |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
Публичный GET /api/v1/faq | группы + вопросы (опубликованные) | конверт {data, meta}, keyset-пагинация |
| Блок FAQ (рендер на странице) | аккордеон вопрос-ответ | HTML/Blade + инлайн JSON-LD FAQPage |
Страница «Частые вопросы» (faq.global_page_slug) | все глобальные группы/вопросы | HTML + JSON-LD FAQPage |
EventBus → CacheTags | факт FaqItemSaved | событие (payload см. «События и обмен») |
| Filament admin | список/форма групп и вопросов | HTML admin UI |
cms:faq:import-legacy --dry-run | отчёт импорта | JSON/CLI: создано/обновлено/пропущено + причины |
Настройки (группа faq)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
faq.schema_enabled | bool | true | да | Генерация Schema.org FAQPage — kill-switch: аварийное отключение разметки без выключения модуля (блок и данные остаются) |
faq.max_items_per_block | int | 20 | нет | Максимум вопросов в одном блоке FAQ (без пагинации внутри блока, см. «Крайние случаи») |
faq.global_page_slug | string | faq | да | Slug общей страницы «Частые вопросы» |
faq.max_groups_per_site | int | 50 | нет | Лимит числа групп на сайт (защита админки от захламления) |
faq.max_items_per_group | int | 200 | нет | Лимит вопросов в одной группе; достижение — понятная ошибка в Filament, не тихое обрезание |
faq.empty_group_alert_days | int | 30 | нет | Порог алерта «пустая группа» (см. «Фоновая работа») |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/faq | public | Список групп с вопросами (фильтр по привязке к сущности) |
| GET/POST/PUT/DELETE | /api/v1/admin/faq… | admin (faq.manage) | CRUD групп и вопросов; PUT на сортировку — 409 при конфликте lock_version |
Компоненты
Блоки (BlockRegistry): «FAQ» (аккордеон вопрос-ответ с автогенерацией Schema.org) — демо-props и сидер demo-данных для галереи /_gallery и playground (2–3 группы, 5–8 вопросов, без ручного ввода).
Filament: ресурс групп с RelationManager вопросов, drag&drop сортировка (конфликт по lock_version — человеческое сообщение, не молчаливая перезапись), мягкое предупреждение при совпадении текста вопроса в одной группе (не блокирует сохранение), массовое перемещение вопросов между группами (bulk action), подтверждение удаления группы с превью затронутых вопросов.
Фронтенд-бюджет блока: аккордеон — CSS-transition (max-height/ grid-template-rows), без сторонних JS-библиотек, без CLS (резерв высоты заголовка вопроса до раскрытия); aria-expanded, aria-controls на каждом вопросе, фокус управляем с клавиатуры (Tab/Enter/Space); JSON-LD Schema.org — инлайн в разметку страницы, не блокирует рендер.
Команды: cms:faq:prune-orphans --dry-run --json (см. «Фоновая работа», «Крайние случаи»), cms:faq:import-legacy --source=<профиль> --dry-run --json (см. «Донорский код»).
События и обмен
| Событие | Когда | Payload |
|---|---|---|
FaqItemSaved | вопрос создан/изменён | faq_item_id, group_id, faqable_type, faqable_id |
Слушает: —. Provides-контрактов не реализует, FilterBus не использует.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/faq → CacheTags (свой кеш) | событие FaqItemSaved (канал 1) | faq → собственный подписчик | инвалидация тега faq на блоке FAQ и странице «Частые вопросы» |
Ядро (страницы/услуги через faqable_type/faqable_id) | нет канала — полиморфная ссылка на id без FK | faq → ядро (только чтение id, без вызова сервиса владельца) | связь не формализована через requires/событие — источник «сирот» при удалении родителя (см. «Крайние случаи», п.5) |
| Другие модули CMS | — | потребителей FaqItemSaved пока нет | ожидаемо на старте, не анти-паттерн: событие издаётся на будущее расширение (например, реиндексация в cms/search) |
Фоновая работа
Фоновой работы (очередей, джобов) нет — все операции синхронные (CRUD, рендер блока).
Эксплуатационный ранбук (метрики и восстановительные команды — актуальны уже сейчас, без ожидания появления джобов):
- Метрика: количество вопросов без Schema.org-покрытия (не входящих ни в рендер блока, ни в глобальную страницу) — сигнал «мёртвого» контента.
- Алерт: группа пуста (0 вопросов) дольше
faq.empty_group_alert_days(дефолт 30) — кандидат на удаление или наполнение. - Восстановительная команда
cms:faq:prune-orphans --dry-run --json(см. «Крайние случаи», п.5) — идемпотентна, безопасна на живом сайте, без даунтайма. - Бэкап/рестор:
cms_faq_groups/cms_faq_items— обычные таблицы, попадают в общий бэкап БД целиком; после рестора ничего не пересоздаётся (денормализованных агрегатов и поисковых индексов у модуля нет).
Производительность и кеш
- Ожидаемые объёмы: типовой сайт — 3–15 групп, 5–40 вопросов в группе (сотни вопросов суммарно); каталог услуг с FAQ на каждой услуге — до 1000+ вопросов на инсталляцию.
- Горячие пути: рендер блока FAQ на странице (выборка группы/
faqable+items), рендер страницыfaq.global_page_slug(все глобальные группы разом). - Бюджет запросов: рендер одного блока — 1 запрос (
->with('items'), без N+1); чтение настроек группыfaq— 0 запросов (кеш settings-store). - Критичные индексы:
group_id(FK+index), составной(faqable_type, faqable_id),(group_id, sort_order)под сортировку списка, частичный уникальныйexternal_idв рамкахsource. - Теги кеша:
faq— общий тег на блоке FAQ и странице «Частые вопросы»; инвалидация целиком поFaqItemSaved(при объёме 1000+ вопросов это даёт лишние пересчёты на несвязанных страницах — при росте объёма разумно перейти на точечные тегиfaq:group:{id}/faq:faqable:{type}:{id}, пока не реализовано, зафиксировано как направление доработки). - Влияет на page-cache страниц с блоком FAQ и на
faq.global_page_slug(affectsPageCache: дауschema_enabledиglobal_page_slug).
Безопасность
Границы входа (5 границ ядра): FormRequest-whitelist на CRUD групп/вопросов (админка) и на публичный filter[…] (faqable_type, faqable_id, locale) — неизвестный параметр → 422. Rich-текст ответа — санитизация двойным барьером (на сохранении и на выводе, стандартный санитайзер ядра); доверенный HTML ({!! !!}) из админки — только под studio-ролью (матрица ниже). Поиск по вопросам — ILIKE/LIKE по полю question, не пользовательский regex — ReDoS-валидатор ядра не требуется; если поиск в будущем перейдёт на regex-фильтр по вопросам, обязателен RegexGuard::assertSafeRules (§11 стандарта). Публичный API — только чтение опубликованного контента, rate-limit не обязателен (нет мутаций, нет ПДн).
ПДн-паспорт: см. «Модель данных» — ПДн не хранит.
Матрица ролей:
| Permission / действие | Админ | Менеджер | Редактор | Studio |
|---|---|---|---|---|
faq.view (публичное + просмотр в админке) | ✅ | ✅ | ✅ | ✅ |
faq.manage (CRUD групп/вопросов, drag&drop) | ✅ | ✅ | ✅ | ✅ |
| Массовое перемещение/удаление вопросов | ✅ | ✅ | — | ✅ |
| Удаление группы с вопросами (cascade) | ✅ | — | — | ✅ |
Доверенный HTML в ответе ({!! !!}) | — | — | — | ✅ |
Права: faq.view, faq.manage.
UX-требования
Админ:
- Пустой список групп → «Ещё нет вопросов, добавьте первый» с кнопкой создания.
- Пустая группа (0 вопросов) — видна в списке с пометкой «0 вопросов» и подсказкой наполнить, но на публичной странице/блоке не рендерится (см. «Крайние случаи», п.7).
- Массовые действия: массовое перемещение вопросов между группами (bulk action в RelationManager), массовое удаление с подтверждением.
- Подтверждение удаления группы с вопросами: модальное окно «В группе N вопросов, они будут удалены безвозвратно» — с точным числом (не общей фразой).
- Человеческие ошибки: конфликт
lock_versionпри параллельном drag&drop → «Кто-то уже изменил порядок вопросов, обновите список» (409), не молчаливая перезапись; невалидный HTML в ответе → «Тег<script>запрещён», не общий «422 Unprocessable Entity».
Посетитель:
- Аккордеон доступен с клавиатуры:
aria-expanded,aria-controlsна каждом вопросе, фокус — по Tab, раскрытие — Enter/Space. - Раскрытие/закрытие без layout shift (CSS-transition высоты, не JS-reflow всего блока); воспринимаемый отклик — мгновенный (
<100мс). - JSON-LD Schema.org не блокирует рендер видимого контента.
Крайние случаи и типовые баги
- Дубли вопросов в одной группе → модуль не проверяет уникальность
question— осознанное решение (ответственность редактора, не платформы); в Filament — мягкое предупреждение (не блокирующее) при совпадении текста вопроса в той же группе. - Schema.org FAQPage шире показанных вопросов → разметка обязана строго соответствовать вопросам, реально отрендеренным в блоке (с учётом
faq.max_items_per_block): если группа содержит больше вопросов, чем показано, в JSON-LD попадают только показанные, не вся группа — иначе разметка не соответствует видимому контенту (риск ручных санкций поисковика). - Гонка drag&drop у двух админов →
sort_orderправится параллельно двумя сессиями → сущности несутlock_version(optimistic lock, §4 стандарта); конфликт отдаёт 409 и сообщение «кто-то уже изменил порядок, обновите список», не тихая перезапись «последний победил». - Удаление группы с вопросами →
cascadeOnDelete()удаляет все вопросы группы; если часть вопросов привязана (faqable_*) к живым опубликованным страницам, блок FAQ на них теряет контент без предупреждения — Filament обязан показать явное предупреждение с числом вопросов и списком затронутыхfaqable-сущностей перед подтверждением. - Вопрос привязан к удалённой сущности →
faqable_type/faqable_id— полиморфная связь без FK constraint (нельзя иначе: сущность из чужого модуля, §4 «Обмена данными» — «FK на чужую сущность допустим только на её первичный id»); при удалении родительской страницы/услуги вопрос становится сиротой в БД. События удаленияPageDeleted/ContentEntryDeletedканонизированы ревизией ядра 14.07.2026 (п. 3): слушатель модуля переводит вопросы удалённой сущности в глобальные (дефолт) либо удаляет — настройкаfaq.orphan_strategy; командаcms:faq:prune-orphans --dry-runостаётся страховкой для пропущенных событий. - Глобальный вопрос одновременно на общей странице и в блоке другой страницы → вопрос без
faqable_*попадает и наfaq.global_page_slug, и как часть блока «FAQ» на другой странице (блок настроен поgroup_id, не по конкретной сущности) → Schema.org FAQPage генерируется на обоих URL с одинаковым вопросом — потенциальный дубль для поисковика. Решение: на «неканонических» страницах (неfaq.global_page_slug) генерировать JSON-LD сmainEntityOfPage, явно указывающим на канонический URL глобальной страницы, а не на текущий. - Пустая группа (0 вопросов) → показывается в списке групп в админке (чтобы её можно было наполнить), но не рендерится на публичной странице/в блоке — пустой аккордеон и пустой Schema.org хуже, чем отсутствие блока.
- Огромная группа (200+ вопросов) при
max_items_per_block=20→ в блок попадают первые 20 поsort_order(ASC); пагинации внутри блока нет — зафиксировано как ограничение: для показа всех вопросов группы редактор использует отдельную страницу (global_page_slugили свою страницу-справочник), не блок. - Смена
faq.global_page_slugна уже проиндексированный slug → старый URL уходит в 404 без редиректа, если не создать 301.RedirectServiceядра канонизирован ревизией 14.07.2026 (п. 12): слушательSettingChangedна сменуfaq.global_page_slugсоздаёт 301-редирект через него; собственная таблица редиректов модулю запрещена. - Поиск по вопросам «простой» →
ILIKE/LIKEбез индекса на больших объёмах (1000+ вопросов) деградирует линейно; заявлено как осознанное ограничение («без выделенного индекса»). При росте объёма — не дорабатыватьILIKE, а подключатьsuggests: cms/searchчерез provides-контрактsearch-provider. faqable-привязка к сущности другой локали/сайта (мультисайт/мультиязычность) → вопрос сlocale=ru, аfaqableуказывает на сущность с другойlocaleилиsite_id— измерения обязаны совпадать; FormRequest валидирует совпадениеsite_id/localeвопроса иfaqable-сущности при сохранении, иначе 422, не тихая рассинхронизация.- Доверенный HTML, сохранённый под studio-ролью, редактируется не-studio-ролью → HTML в ответе остаётся как есть при чтении, но каждое новое сохранение (независимо от роли автора предыдущей версии) проходит санитайзер заново — редактор без права на
{!! !!}не может ни добавить новый небезопасный HTML, ни случайно «прокинуть» его повторным сохранением формы.
Донорский код
| Что взять | Путь |
|---|---|
| Модель групп/вопросов, блок FAQ | несколько проектов (пути не выданы) |
Legacy-импорт
cms:faq:import-legacy --source=<профиль> [--dry-run] [--json] — маппинг вопросов-ответов из донорских проектов (несколько CMS/самописных таблиц) на cms_faq_groups/ cms_faq_items; идемпотентен по external_id (nullable-колонка на cms_faq_items, частичный уникальный индекс в рамках source, повторный прогон обновляет существующие записи, не дублирует). --dry-run — отчёт: сколько групп/вопросов создано/обновлено/ пропущено и почему, построчные ошибки скачиваемы, молчаливый пропуск запрещён (§16 стандарта). Прогон на копии донорских данных — часть приёмки модуля.
Тесты и приёмка
- [ ] Контрактный тест
/api/v1/faqс фильтром по привязанной сущности - [ ] При выключении модуля блок FAQ отдаёт fallback без 500
- [ ] Schema.org FAQPage валиден и соответствует ровно вопросам, реально показанным в блоке (не всей группе при
max_items_per_block) - [ ] Права
faq.manageразграничены от публичного чтения; доверенный HTML — только studio-роль - [ ] Нет N+1 при построении блока FAQ (
->with('items')) - [ ] Инвалидация кеша страницы по тегу
faqприFaqItemSaved - [ ] Параллельный drag&drop у двух сессий → второй запрос получает 409 по
lock_version, не тихую перезаписьsort_order - [ ] Удаление группы с вопросами: Filament показывает предупреждение с числом затронутых вопросов перед подтверждением
- [ ]
faqable-валидация: сущность из другогоsite_id/localeотклоняется 422 - [ ]
cms:faq:prune-orphans --dry-runнаходит вопросы-сироты (несуществующийfaqable_id) без изменения данных - [ ]
cms:faq:import-legacy --dry-runдаёт корректный отчёт; повторный прогон не дублирует записи (идемпотентность поexternal_id) - [ ] Пустая группа не рендерится в блоке/на публичной странице, но видна в админке
- [ ] Rich-text HTML санитизируется на каждом сохранении независимо от роли автора предыдущей версии
- [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут (
/api/v1/faq, admin CRUD) - [ ] Тестовая БД только
faq_test;migrate:fresh/refresh/reset/db:wipeзапрещены