# Принятые решения и допущения

ТЗ §4 предписывает при неоднозначности выбирать самое простое надёжное решение и фиксировать его
здесь, не останавливая разработку.

Раздел «Решено заказчиком» — вопросы, по которым получен ответ; менять их можно только новым
решением заказчика. Раздел «Технические решения» — выбор разработчика: его можно пересмотреть,
но нужно понимать цену.

---

## Решено заказчиком

### D1. Кеш и очереди — на MySQL, без Redis

ТЗ §3.1 требовало Redis. Заказчик подтвердил, что это требование появилось в тексте
автоматически и реальной причины за ним нет.

Кеш, очереди, блокировки и rate limiting работают на драйвере `database`. Отдельный сервер кеша
не нужен: нагрузка портала этого не требует, а лишний демон — лишняя точка отказа и лишняя
строчка в инструкции по развёртыванию.

Драйверы задаются в `.env` (`CACHE_STORE`, `QUEUE_CONNECTION`), логика к ним не привязана: имена
очередей собраны в `App\Support\Queues`, кеш используется через фасад. Переход на Redis позже —
`composer require predis/predis` плюс смена двух значений.

### D2. Horizon не используется

ТЗ §3.1 требовало Horizon по той же причине, что и Redis. Horizon — панель мониторинга очередей
Redis; без Redis он бессмыслен, а его зависимости (`ext-pcntl`, `ext-posix`) не существуют на
Windows, где ведётся разработка.

Воркер запускается через `php artisan queue:work`. Контроль очередей — раздел в админке на
Этапе 7: список задач, повторные попытки, `failed_jobs`, ручной перезапуск. Это закрывает
требование §24 «контроль очередей» без внешней панели.

### D3. Тесты — PHPUnit

Заказчик оставил выбор за разработчиком. PHPUnit идёт в скелете Laravel 13 и совпадает с
соседним проектом `teeu`: один инструмент между проектами дешевле в сопровождении.

### D4. База — MySQL, включая тесты

Тесты идут против базы `restomesto_test` в MySQL, а не против sqlite, который Laravel предлагает
по умолчанию. Длины индексов, поведение внешних ключей и JSON-колонок должны совпадать с
production; sqlite молча пропускает то, на чём MySQL падает.

Цена — прогон ~40 с вместо ~10 с. Компромисс осознанный.

### D5. Документация партнёрского API — отдельный документ для Restoplace

[restoplace-api-contract.md](restoplace-api-contract.md) написан как самодостаточная
спецификация: по нему разработчик Restoplace может писать реализацию, не читая остальную
документацию проекта. В нём же перечислены пункты, требующие согласования (§12).

Клиент «РестоМесто» уже написан по этому документу и работает на фикстурах, повторяющих
описанные ответы. Расхождения выявятся сразу при переключении на боевой API.

### D6. Подтверждение брони отправляет Restoplace

ТЗ §9.5 требовал определить, какая система шлёт гостю основное подтверждение. Решение
заказчика: **Restoplace** — там для этого всё настроено.

«РестоМесто» получает успешный ответ, сохраняет бронь и показывает её гостю в личном кабинете;
своих писем о создании брони не отправляет, чтобы гость не получал два письма об одном событии.

Следствия для интерфейса: в кабинете гостя нужен раздел «Мои бронирования» с предстоящими,
прошедшими и отменёнными бронями, номером брони, статусом, депозитом и платёжной ссылкой, пока
она активна. Отмена выполняется через API Restoplace (`POST …/reserves/{id}/cancel`) — локальная
запись обновляется только после его ответа. Реализуется на Этапе 3–4.

### D7. Города — все города России

Справочник `database/data/russian-cities.php` содержит 173 города: административные центры всех
субъектов и города свыше ~100 тысяч жителей. Для каждого — центр, часовой пояс (23 разных
идентификатора IANA), предложный падеж для заголовков и радиус автоопределения.

Города вне списка создаются автоматически при синхронизации каталога: центр вычисляется по
координатам заведений, часовой пояс приходит в поле `timezone` адреса. Так каталог покрывает всю
страну, а справочник не хранит выдуманных координат.

Хранить здесь все 1100+ городов России незачем и вредно: город без единого заведения в каталоге
не нужен, а неточные координаты хуже отсутствующих — по ним геолокация уводит гостя не в тот
город.

### D8. Первый день недели — понедельник

`weekday` в `schedule[]`: 1 = понедельник … 7 = воскресенье (ISO-8601). Зафиксировано в
спецификации API.

### D9. Вход гостя по звонку — схема и поставщик из проекта `teeu`

Обратный звонок: сервис звонит на номер и сбрасывает, код — последние 4 цифры звонившего.
Поставщик — voicepassword.ru, тот же, что в `teeu`, где схема уже работает.

Конфигурация в `config/services.php → flash_call / voicepassword`, драйвер `manual` для
разработки и тестов (код фиксирован, звонок не совершается). Реализовано на Этапе 4; за
интерфейсом, чтобы поставщика можно было заменить (ТЗ §2.3).

**Резервного SMS-канала нет.** ТЗ §11.2 допускал запасной канал, но в `teeu` его тоже нет: там
только `manual` и `voicepassword`, и схема работает. Отдельный поставщик SMS не выбирается —
вводить его без надобности значит платить за второй канал и вести вторую интеграцию. Возможность
осталась: колонка `channel` в `guest_login_challenges` уже различает каналы, а добавление
поставщика — это ещё одна реализация `FlashCallProviderInterface`.

**Ключ** — тот же аккаунт voicepassword, что у `teeu`. Живёт в `.env` сервера или secret storage,
в репозиторий не попадает ни при каких условиях (ТЗ §21). Для разработки он не нужен: там
работает драйвер `manual`.

### D10. Карты — Яндекс

`MAP_PROVIDER=yandex`. Работа идёт через `MapProviderInterface`, поэтому переключение на
OpenStreetMap + Leaflet остаётся возможным без правок доменной логики (ТЗ §2.3). Реализация —
Этап 2.

### D11. Административный кабинет — Filament 5

ТЗ §20.1 допускает Filament при совместимости с версией Laravel; Filament 5 работает с
Laravel 13 (проверено на проекте `teeu`).

Объём админки по ТЗ большой — дашборд, заведения, города, кухни, отзывы, модерация, жалобы,
бронирования, пользователи, SEO, интеграции, настройки, аудит. Filament даёт таблицы, фильтры,
формы, графики и связку с policies из коробки.

Гвард `admin` и роли через `spatie/laravel-permission` уже соответствуют тому, что Filament
ожидает, — переделывать при подключении ничего не придётся. Устанавливается на Этапе 7.

Публичная часть и кабинеты гостя и ресторана остаются на кастомном Blade по дизайну: Filament
там не используется.

---

## Технические решения

### T1. Модели внутри доменов

Модели живут в `App\Domain\<Домен>\Models`, а не в `App\Models`. Это прямое требование ТЗ §3.4
о модульном монолите, и Laravel такую раскладку поддерживает штатно.

Нестандартного здесь ровно два места, оба в `DomainServiceProvider`: разрешение имён фабрик
(`App\Domain\Catalog\Models\Venue` ↔ `Database\Factories\Catalog\VenueFactory`) и morph map с
короткими именами (`venue`, `guest`, `review`). Morph map полезен сам по себе: без него перенос
класса между доменами ломает данные в БД.

### T2. Домены сверх списка ТЗ

К списку из ТЗ §3.4 (он приведён с пометкой «пример разделения») добавлены `GuestCabinet`
(избранное), `Legal` (согласия) и `Notifications` (журнал уведомлений).

Избранное — не аутентификация, и класть его в `GuestAuth` значит смешивать разные зоны
ответственности. Согласия относятся и к гостям, и к ресторанам, поэтому не принадлежат ни одному
из их доменов. Остальные названия совпадают с ТЗ буквально — код должен читаться рядом со
спецификацией.

### T3. Три guard-а вместо общей таблицы `users`

`guest`, `restaurant`, `admin` — три независимых guard-а с тремя провайдерами. Таблицы `users`
нет. Множественные guard-ы — штатная возможность Laravel, а не обход ограничений.

У этих ролей разные способы входа (звонок и OAuth / одноразовый код на email / пароль и 2FA) и
разные модели угроз. Общая таблица означала бы общие поля, общие политики и общий радиус
поражения при ошибке.

Следствие: `sessions.user_id` Laravel заполняет только для guard-а по умолчанию, поэтому колонки
«тип владельца» в `sessions` нет — она осталась бы пустой и вводила в заблуждение. Завершение
всех сессий администратора реализовано через `admin_users.sessions_invalidated_token`.

### T4. Приём webhook сделан на Этапе 1, обработка — позже

ТЗ §28 относит webhook к Этапу 3, но требования §5.4 к приёму (подпись, отсечение просроченных,
дедупликация, журнал, быстрый ответ) от домена не зависят и реализованы сразу.

Доменные обработчики подключаются на Этапах 2–3, когда появится что обновлять. События до этого
момента копятся в `restoplace_webhook_events` со статусом `received` — их можно переобработать.

### T5. Content-Security-Policy введена на Этапе 8

Базовые заголовки безопасности включены с Этапа 1, CSP — на Этапе 8, когда стал известен итоговый
набор источников. Введённая вслепую политика либо ломает страницы, либо настолько широкая, что
бессмысленна.

Инлайновых `<script>` в шаблонах нет вовсе, поэтому `script-src` обходится без `'unsafe-inline'` —
это и есть главная защита от XSS: внедрённый в разметку скрипт не выполнится.

Две уступки сделаны сознательно:

- **`script-src 'unsafe-eval'`** — этого требует Alpine: выражения в `x-on` и `x-data` он
  вычисляет через `Function()`. CSP-сборка Alpine поддерживает только простые обращения к
  свойствам, а у нас есть выражения вида `if (! confirm(…)) $event.preventDefault()`. Переписывать
  их ради снятия `unsafe-eval` невыгодно: он заметно слабее `unsafe-inline` — не позволяет
  выполнить внедрённую строку, а лишь даёт уже загруженному своему коду вычислять свои выражения.
- **`style-src 'unsafe-inline'`** — inline-атрибуты `style` ставит и Alpine (`x-show`, `x-cloak`),
  и шкалы распределения оценок. Внедрение стиля несравнимо безопаснее внедрения скрипта.

Политика проверена в браузере на живой Яндекс.Карте, форме бронирования и админке: нарушений нет.
Режим `CSP_REPORT_ONLY=true` оставлен для проверки будущих правок на боевом трафике.

### T6. HTML администратора — санитайзер, не регулярные выражения

Содержимое статических страниц и SEO-текстов пишет администратор, и там нужен форматирующий HTML.
Вывод идёт через `symfony/html-sanitizer` с allowlist тегов, схем ссылок и атрибутов
(`App\Support\Html\HtmlSanitizer`).

«Доверенный автор» — не гарантия: аккаунт контент-менеджера могут увести, а вставленный из
внешнего редактора фрагмент легко приносит `onerror` или `javascript:`. Разбирать HTML
регулярными выражениями для этого нельзя — нужен настоящий парсер.

### T7. Транслитерация slug

`Str::slug($value, '-', 'ru')` с поправками: `ё → e`, `й → y`, `ъ`/`ь` → пусто, `& → -and-`,
`№ → n`. Результаты: `Дрова на Баумана → drova-na-baumana`,
`Щёлковский Двор → schelkovskiy-dvor`, `Пушкинъ → pushkin`.

Правило зафиксировано тестами: менять его после запуска нельзя без массовых 301-редиректов.

### T8. Slug заведения не меняется при переименовании

Синхронизация задаёт slug один раз — при создании адреса. Если заведение переименовали в
Restoplace, slug остаётся прежним.

Автоматическая смена slug рвала бы все внешние ссылки и накопленные позиции при каждой правке
названия в чужой системе. Сменить slug может администратор, и тогда со старого адреса
автоматически ставится 301 (ТЗ §18.2).

### T9. Фильтр «открыто сейчас» считается в PHP

У каждого адреса свой часовой пояс, а смены через полночь (18:00–02:00) в SQL выражаются
нечитаемо и с риском ошибки. Выбираются только `id`, пояс и график — на городах с сотнями
заведений это незаметно.

### T10. Пустая полная выдача не скрывает каталог

Если полный обход вернул ноль адресов, «пропавшие» заведения не деактивируются: это почти
наверняка сбой на стороне API, а не одновременное отключение всех ресторанов. Событие пишется в
журнал синхронизации (ТЗ §6.4).

### T11. Объекты карты вынесены из реактивного состояния Alpine

Alpine оборачивает всё, что попало в `x-data`, в реактивный Proxy. Внутренности Яндекс.Карт этого
не переживают: библиотека сравнивает объекты по идентичности и обращается к приватным полям.
Экземпляры карты и кластеризатора хранятся в замыкании компонента, реактивными остаются только
`loading` и `error`.

### T12. Страница брони доступна по UUID без авторизации

Гость может бронировать без аккаунта — ТЗ §9 этого не требует. Ссылка из подтверждения
(`/booking/{uuid}`) — единственное, что у него есть, поэтому она и открывает страницу.

UUID v4 непредсказуем, а сверх им же введённых данных страница ничего не показывает. Полноценный
список броней в кабинете появится вместе с авторизацией на Этапе 4; брони авторизованного гостя
уже привязываются к аккаунту.

### T13. В кеш кладутся массивы, а не объекты

Laravel 13 по умолчанию запрещает десериализацию классов из кеша
(`cache.serializable_classes => false`) — защита от gadget-chain при утечке `APP_KEY`.

Ослаблять настройку не стали: у всех DTO есть `fromArray()` / `toArray()`, и хранение массивов
ничего не усложняет. Цена ошибки здесь высокая: закешированный объект возвращается как
`__PHP_Incomplete_Class`, кеш перестаёт работать молча, и заметить это можно очень нескоро.

### T14. Статус брони выводится одним способом

И при создании, и при сверке статус берётся у Restoplace через `BookingStatusMapper`. Своя логика
включается только для неизвестных значений.

Два независимых вывода статуса приводили бы к тому, что бронь «переключается» сама собой при
первой же сверке. Депозит при этом имеет приоритет: пока он не внесён, бронь не подтверждена, что
бы ни вернул источник.

### T15. Собственные клиенты VK ID и Яндекс вместо Socialite

Оба входа — обычный Authorization Code + PKCE, около сотни строк на провайдера. Реализация лежит
за `SocialAuthProviderInterface`, наружу выходит только `SocialUserData`.

Socialite не подключён сознательно. Он не покрывает VK ID (OAuth 2.1 с обязательным PKCE и
`device_id` в обмене кода), поэтому пришлось бы всё равно писать свой драйвер — но уже внутри
чужой абстракции, добавив зависимость и не убрав кода. Плюс собственный клиент даёт драйвер
`fake`: кабинет проверяется локально без регистрации приложений в VK и Яндексе, ровно как
`RESTOPLACE_DRIVER=fake` для каталога.

Цена решения: при смене API провайдера чинить придётся самим. Она невелика — оба клиента
покрыты тестами на разбор ответа.

### T16. Телефон от VK и Яндекса считается подтверждённым, email — нет

Для VK ID телефон — логин аккаунта, у Яндекса `default_phone` привязывается с подтверждением. Оба
годятся как доказательство владения номером и включают объединение аккаунтов (ТЗ §11.4).

Email не годится: признака подтверждения провайдеры не дают, а ошибочное слияние отдаёт
постороннему чужие брони и отзывы. Ошибка «не объединили» исправляется входом по телефону,
ошибка «объединили лишнее» — не исправляется никак.

Если провайдер изменит поведение, правится один флаг в его классе — домен об этом не знает.

### T17. Занятый номер не переносится между аккаунтами

Гость, вошедший через VK или Яндекс без номера, может подтвердить телефон в профиле. Если номер
уже принадлежит другому аккаунту — отказ с предложением войти по нему.

Перенос номера означал бы объединение двух историй броней, отзывов и избранного. Это отдельная
операция со своими правилами разрешения конфликтов, а не побочный эффект правки профиля. До
появления такого требования дешевле и честнее отказать.

### T18. Прежние брони подбираются к аккаунту по подтверждённому номеру

Бронировать можно без входа (ТЗ §9). После подтверждения номера звонком гость становится
доказанным владельцем броней с этим номером, и они привязываются к аккаунту.

Условие ровно одно: `contact_phone` совпадает с подтверждённым номером, а бронь ещё ничья.
Совпадение имени или email не годится — по ним легко получить чужие брони.

### T19. Согласие гостя без аккаунта привязывается к брони

`consents.subject` полиморфен, и для брони без входа субъектом становится сама бронь
(`subject_type = booking`). Галочку гость поставил, и запись об этом должна остаться.

Иначе доказательства согласия есть только у вошедших, а бронировать можно и без входа — то есть
именно у той части гостей, где спор вероятнее.

### T20. Мягко удалённый аккаунт восстанавливается при входе по тому же номеру

Телефон уникален глобально, включая удалённые записи. Без восстановления вход по такому номеру
падал бы на ограничении базы, а не выдавал понятную ошибку.

Гость доказал владение номером, поэтому возврат в свой аккаунт — ожидаемое поведение. Полное
обезличивание (ТЗ §22) очищает телефон, и после него номер освобождается.

### T21. Опубликованный текст отзыва живёт в `reviews`, правка — в `review_versions`

`reviews.rating` и `reviews.body` хранят то, что **опубликовано**. Правка создаёт запись в
`review_versions` со статусом `pending_ai` и содержимое отзыва не трогает; после одобрения версия
переносится в `reviews`, и `version` растёт.

Так выполняется требование ТЗ §13.5 «предыдущая опубликованная версия остаётся видимой, пока
решение не принято», и при этом публичный запрос остаётся тривиальным — `reviews` со статусом
`published`. Обратный вариант (хранить в `reviews` последнее присланное) заставлял бы каждую
страницу заведения выбирать нужную версию джойном и давал бы мгновенную подмену текста в обход
модерации.

Пока по версии нет решения, вторая правка не принимается: два решения на один отзыв означали бы,
что итог зависит от порядка выполнения задач в очереди.

### T22. Статус отзыва назначает домен, а не модель

`ReviewModerationProviderInterface` отвечает только на вопрос «нарушает ли текст правила» и
возвращает уверенность. Переводит это в статус `ApplyModerationDecisionAction`.

Иначе смена поставщика меняла бы правила публикации. Отсюда же три исхода вместо двух:
недоступность ИИ и неуверенное «одобряю» (ниже настраиваемого порога) дают `manual_review` —
отзыв не публикуется и не отклоняется. Опубликовать без проверки нельзя: одно оскорбление на
странице заведения дороже, чем задержка на разбор.

### T23. Драйвер `fake` для ИИ-модерации

`AI_DRIVER=fake` — детерминированная заглушка на словах-маркерах, по умолчанию в разработке и
тестах. Внешнего вызова нет, но очередь, запись решения, смена статуса, пересчёт рейтинга и письмо
ресторану работают по-настоящему — тот же приём, что `RESTOPLACE_DRIVER=fake`.

Обращаться к платной языковой модели на каждом прогоне тестов нельзя, а её ответ нельзя считать
воспроизводимым: тест на публикацию не должен зависеть от настроения модели.

Маркеры ищутся как отдельные слова, а не подстроки: `мат` находится внутри «вни**мат**ельные», и
заглушка отклоняла безобидный отзыв. `\b` для кириллицы не работает — границы заданы через
`\p{L}`.

### T24. Рейтинг пересчитывается целиком, а не приращением

`RecalculateVenueRatingAction` считает средний балл и распределение по фактическому состоянию
таблицы отзывов.

Инкремент расходится с реальностью после первой же потерянной задачи, скрытия отзыва
администратором или отката правки, и разошедшийся рейтинг потом невозможно объяснить. Цена —
один агрегатный запрос на изменение статуса отзыва, что происходит редко.

### T25. Ответы и жалобы ресторана переносятся в Этап 6

ТЗ §14 относит к отзывам ответ заведения и жалобу на отзыв. Обе операции выполняет сотрудник
ресторана под guard-ом `restaurant`, а кабинет заведения — Этап 6.

На Этапе 5 сделан публичный показ ответа (включая скрытие ответа администратором) и письмо
ресторану о новом отзыве. Создание ответов и жалоб появится вместе с кабинетом: писать интерфейс,
в который сейчас невозможно войти, — это заглушка, а не работающий код.

### T26. Обработка изображений на GD, без пакета

`imagick` в среде проекта нет, а операций ровно четыре: поворот по EXIF, уменьшение по большей
стороне, сохранение прозрачности и кодирование в WebP. `App\Support\Images\ImageProcessor` делает
их на GD примерно в сотне строк.

Пакет обработки изображений сюда не тянется: он принёс бы собственный жизненный цикл, свои
несовместимые версии и обёртки над теми же вызовами GD.

Ключевое свойство реализации — **файл собирается заново из пикселей**. EXIF, геометки и
ICC-профили исходника не переносятся не потому, что мы их вычищаем, а потому, что переносить их
нечем (ТЗ §16.2, §21). Оригинал удаляется сразу после успешной обработки.

### T27. Права кабинета заведения не хранятся отдельно

`restaurant_venue_accesses` — не список прав, а кеш ответа на вопрос «совпадает ли контактный
email адреса с email пользователя». Пересчитывается в двух точках: при входе и при синхронизации
адреса, если email изменился.

Второе обязательно. Сессия вошедшего живёт дольше обхода каталога, и без отзыва прямо в момент
синхронизации бывший управляющий продолжал бы редактировать карточку и отвечать на отзывы после
того, как заведение сменило контакт (ТЗ §15.2, §21.2).

Проверку выполняет `VenuePolicy` — единственное место с этим правилом. Вызывается через
`Gate::forUser()`: guard по умолчанию — `guest`, и обычный `authorize()` взял бы не того
пользователя.

### T28. Чужой адрес отдаёт 404, а не 403

И в кабинете заведения, и в кабинете гостя запрос к чужому объекту неотличим от запроса к
несуществующему. 403 подтверждал бы, что объект есть, и перебор идентификаторов превращался бы в
инвентаризацию заведений и броней (ТЗ §21).

### T29. Журнал правок ведёт наблюдатель модели, а не экраны админки

`AuditableObserver` подключён к справочникам, настройкам и SEO-моделям и пишет
в `audit_logs` любое их изменение, если действует администратор.

Одну и ту же запись правят из ресурса Filament, из массового действия и из
консоли. Вызовы журналирования, расставленные по экранам, покрывают первый
случай, забывают второй и третий, а потом расходятся между собой. Условие
здесь одно, и оно в одном месте.

Доменные действия — модерация отзыва, разбор жалобы, обезличивание гостя,
смена slug — пишут в журнал сами, под осмысленными именами: «отзыв
отмодерирован» полезнее, чем «у отзыва изменилось поле status».

### T30. Заглушки в `audit_logs` требуют полной morph map

`getMorphClass()` при включённой карте (`enforceMorphMap`) бросает исключение
для неизвестной модели. Значит, запись в журнал уронила бы само действие,
которое журналируется, — молча превратив «жалоба разобрана» в ошибку 500.

Отсюда правило: любая модель, способная стать объектом действия
администратора, добавляется в `DomainServiceProvider::MORPH_MAP`. Это поймал
тест, а не боевой запуск, — но только потому, что тест был.

### T31. Карта сайта собирается запросом и кешируется на час

Отдельной таблицы или файла на диске нет: `sitemap.xml` строится из каталога
и кешируется. Карта запрашивается роботами регулярно, а меняется вместе с
каталогом, то есть редко.

В неё попадает только то, что отдаёт 200 и открыто для индексации. Скрытые
заведения, страницы с фильтрами и пустые посадочные страницы кухонь
исключены: робот, потративший обход на страницу, которую сам же отбросит, —
это потраченный впустую краулинговый бюджет.

### T32. Города в SEO-шаблонах стоят в предложном падеже

Шаблон `Рестораны в {city_name}` давал «Рестораны в Казань». Переменная
`{city_prepositional}` берёт из справочника форму «в Казани» (решение
заказчика D7), а при её отсутствии откатывается к именительному — это лучше,
чем пустое место в Title.

Заметил тест, а не глаз: в фикстурах шаблон отрисовывался с городом, у
которого падежная форма совпадала с именительным.

### T33. Резервная копия — только база, штатным `mysqldump`

Копируется база, медиа — нет: в production оно лежит в объектном хранилище со своей
избыточностью и версионированием (ТЗ §3.3), и дублировать его значит платить дважды за одно и то
же.

Формат — обычный дамп `mysqldump`, а не собственный. Восстанавливать копию будет человек под
давлением времени, и `mysql < dump.sql` он вспомнит без документации; свой формат в такой момент
превращается в квест.

Пароль передаётся через переменную окружения процесса, а не аргументом: аргументы видны в списке
процессов всей машине.

Диск по умолчанию локальный, но на бою его нужно заменить на отдельный бакет. Копия на том же
сервере, что и база, спасает от ошибки оператора и не спасает от потери сервера — а это два
разных сценария, и второй дороже.

### T34. Email не нормализуется агрессивно

Обрезаются пробелы и опускается регистр; плюс-теги и точки в локальной части сохраняются.

Для Gmail `a.b@` и `ab@` — один адрес, для большинства корпоративных серверов — разные.
Ошибочно объединить два ресторана хуже, чем не объединить: это выдало бы постороннему доступ к
чужому кабинету.

---

## Открытые вопросы

К заказчику:

1. **Тексты юридических документов** — политика конфиденциальности, пользовательское соглашение,
   согласие на обработку ПДн. Сейчас в `StaticPageSeeder` заглушки-каркасы с версией 1.0.
   Требуются до публичного запуска.
2. **Удаление аккаунта гостем** (ТЗ §22). Обезличивание в схеме предусмотрено
   (`guests.anonymized_at`), но кто его инициирует — сам гость из кабинета или поддержка по
   обращению — не определено. Пока реализуется как операция администратора на Этапе 7.

К Restoplace — перечислены в [restoplace-api-contract.md](restoplace-api-contract.md) §12:
формат курсора, максимальный `limit`, срок хранения `Idempotency-Key`, схема подписи webhook и
передача секрета, политика повторной доставки, `nearest_available_slots` в ошибке 409,
ограничения по IP и частоте запросов.

### Имя поставщика не показывается пользователям

Решение заказчика (6 августа 2026): «Restoplace» не упоминается в тексте, который видят
гость и заведение. В интерфейсе вместо него — «система бронирования» или нейтральная
формулировка без источника.

Где имя осталось намеренно:

- комментарии в коде, документация, названия классов и переменных окружения — там оно
  необходимо, чтобы понимать, о какой интеграции речь;
- админка `/developer` — внутренний инструмент оператора; скрывать источник данных там
  значит усложнять разбор инцидентов;
- консольные команды и журналы — их читает только разработчик.
