# Технический контекст KILN website

Актуальность проверена 27 сентября 2026 года в ветке
`feat/menu-postgres-yandex-feed`.

## Назначение и границы

Сайт — Next.js-приложение ресторана: публичные страницы и меню, корзина и
checkout, бронирование, личный кабинет, административная и операторская панели,
а также интерфейс Telegram Mini App `/ma`.

Сайт не реализует протоколы СБИС, Достависты, Telegram/MAX, SMS и SMTP. Этими
интеграциями владеет `/srv/kiln-bot`; сайт обращается к нему только со своего
backend по контракту `/srv/kiln-bot/api_site_contract.md` с `X-Api-Key`.

## Стек и основные точки входа

- Next.js 16 App Router, React 19, TypeScript, Node.js 22.
- PostgreSQL 17; схема Drizzle в `src/db/schema.ts`, последовательные SQL-
  миграции в `drizzle/`, runner — `scripts/migrate-db.mjs`.
- Маршруты и server API — `src/app`; UI — `src/components`.
- Статический конфиг ресторана и feature flags — `src/config/site.ts`.
- Работа с ботом и DTO — `src/lib/bot-api.ts`.
- Меню и снимки СБИС — `src/lib/menu-db.ts`, `src/lib/sbis-catalog-sync.ts`.
- Аутентификация и роли — `src/lib/auth.ts`, `admin-auth.ts`,
  `operator-auth.ts`.
- Публичные assets — `public/`; загружаемые изображения production —
  `/var/www/html/kiln/uploads`, примонтированный в контейнер.
- Шапка использует `public/brand/kiln-logo-content.webp`; favicon использует
  SVG с вилкой и ножом и согласованные растровые fallback-иконки `*-v2.png`.

## Владение данными

| Данные | Источник истины |
| --- | --- |
| Цена, наличие, остаток, вес из СБИС | СБИС через бот |
| График, способы получения, зоны и тарифы | СБИС через бот |
| Заказ, бронь, оплата, доставка и переписка | Бот/MariaDB и внешние сервисы |
| Редакционные название, описание, фото, аллергены | Сайт/PostgreSQL |
| Привязки карточек и снимок каталога СБИС | Сайт/PostgreSQL |
| Пользователи сайта, роли, сессии, согласия и сохранённые адреса | Сайт/PostgreSQL |
| Telegram/MAX-привязки и Mini App-сессии | Бот/MariaDB |
| Рейтинг и количество отзывов | Кэш данных Яндекса с last-good fallback |

PostgreSQL также содержит попытки создания заказов, аудит административных
операций, настройки сайта и очередь обработки жизненного цикла ПДн. Завершённые
документы нельзя изменять вслед за карточкой меню.
Каталог различает `sbis_id` номенклатуры и служебный `position_id` строки
прайса. При переходе со старой идентичности синхронизация атомарно закрывает
прежнюю активную привязку и создаёт новую, не удаляя историю; неоднозначность
останавливает транзакцию и требует ручного разбора.

## Основные потоки

### Заказ

Сайт получает каталог и условия из бота, валидирует форму, сохраняет один
`client_ref` и вызывает бот. Бот повторно проверяет СБИС и либо создаёт заказ,
либо сохраняет заявку на подтверждение рестораном. Сайт показывает состояние и
polling по тому же `client_ref`; платёжная ссылка всегда приходит от бота.
Личный кабинет также использует этот стабильный reference, поэтому pending-
заявка остаётся на той же странице после подтверждения. Страница опрашивает
статус каждые пять секунд, пока заказ не завершён. `client_ref` служит только
для маршрутизации: интерфейс показывает номер СБИС, а до его появления —
«Заявка на заказ». История получает от бота сохранённый состав и сумму; сайт
добавляет фотографии по устойчивой связи `sbis_id` с локальной карточкой.
Pending-заявка показывает клиенту и оператору снимок отправленной корзины и
сумму позиций (не финальный итог СБИС). До подтверждения рестораном клиент
может вернуть этот снимок в корзину и заменить состав с тем же `client_ref`;
после начала подтверждения изменение отклоняется без создания дубля.

Бот возвращает `can_pay`, `can_cancel`, `can_change_due` и
`can_edit_items`; сайт не вычисляет эти разрешения самостоятельно. Отмена
неоплаченного заказа — серверный переход состояния в СБИС/боте, а не удаление
записи: завершённые и оплаченные заказы остаются неизменяемой историей. Кнопка
связи открывает чат в подробной карточке заказа.

Payment state ортогонален operational status. Бот нормализует полную оплату
по свежему документу СБИС; после неё polling получает `paid=true`,
`can_pay=false`, `pay_url=null`, даже если заказ уже находится в приготовлении,
поиске курьера либо recovery. Сайт не интерпретирует сырой `PayState` и не
запускает приготовление, печать или Достависту из браузера.
MariaDB timestamps приходят как ISO 8601 UTC (`Z`), а ресторанные due/eta и
время событий СБИС — с `+03:00`; UI форматирует их строго в
`Europe/Moscow`. При `stale_payment_recovery` клиентский status берётся из
этапа СБИС, а внутренний `needs_operator` остаётся только операторским сигналом.

Адресная книга хранится в `customer_addresses`, принадлежит пользователю сайта
и использует мягкое удаление через `archived_at`. В checkout сохранённый адрес
показывается строкой; выбор, добавление, редактирование и архивирование находятся
в модальном окне. Гость продолжает вводить адрес в форму без сохранения.
Стоимость доставки запрашивается у бота после выбора адреса; зона и тариф
остаются данными СБИС, а геокодирование выполняет сервис доставки.
Публичный `/api/delivery/quote` передаёт боту явный `quote_mode=checkout` и
subtotal блюд. HTTP 400 `min_sum` разбирается как корректный бизнес-результат с
`minOrder`, `deliveryFee`, `freeFrom`, `shortfall` и total; остальные 4xx/5xx —
ошибка расчёта. Quote связан ключом нормализованного адреса и subtotal, поэтому
при их смене прежний тариф исчезает немедленно. Checkout разрешает доставку
только при актуальном успешном quote и `subtotal >= minOrder`; стоимость
доставки в minimum не входит. Самовывоз игнорирует delivery minimum, а адрес
получает из централизованных contact settings.

Каталожная проекция запускается demand-driven с окном 45 секунд. Бот отделяет
30-секундный stop-list cache от 120-секундных метаданных каталога; поэтому
нормальный верхний возраст availability на общей витрине около 75 секунд.
`available=null`/ошибка stop-list не перезаписывает последнюю успешную
availability в PostgreSQL. Открытые `/cart` и `/checkout` дополнительно
вызывают `/api/cart/availability` сразу и каждые 30 секунд, включая сравнение
запрошенного количества с `stock_left`. Этот снимок — UX: сервер заказа не
отклоняет заявку только из-за потенциально устаревшей PostgreSQL-проекции, а
бот делает свежую целевую проверку СБИС непосредственно перед созданием.

Показ необязательного маркетингового согласия управляется ключом
`site_settings.marketing_enabled`; значение по умолчанию — `false`.

Административная вкладка «Настройки» читает и меняет среду Достависты через
защищённый site route `/api/admin/settings/dostavista`. GET требует admin
session, PATCH дополнительно требует admin CSRF; операторской роли route и UI
недоступны. Route передаёт внутренний ID администратора боту и никогда не
принимает URL или credentials из браузера. UI получает только mode
`test|production` и boolean настроенности каждой среды. Переключение на
production требует подтверждения; badge в операторском списке и карточке
заказа остаётся read-only и показывает `delivery_provider_env` конкретной
доставки. Владельцем endpoint, секретов, callback validation и delivery
lifecycle остаётся бот.

Форма входа оператора не содержит рабочего email в HTML/клиентском state.
`/api/operator/request` и `/api/operator/verify` выбирают фиксированный адрес
на сервере; браузер отправляет только пустой request и шестизначный код.

### Бронирование и сообщения

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

### Аутентификация

Клиентские, административные и операторские сессии разделены. Email PIN
создаёт и проверяет сайт, бот является только SMTP-транспортом. SMS PIN
используется лишь для первого подтверждения телефона. Вход через мессенджер
использует одноразовый nonce и серверный claim API бота.

### Mini App

Страница `/ma` и компоненты интерфейса находятся на сайте. Публичные
`/api/ma/*` направляются Apache непосредственно боту: он проверяет Telegram
initData, связь чата и выдаёт HttpOnly-сессию. Операторская страница сайта
может получить сессию через backend API, не раскрывая `BOT_API_KEY` браузеру.

## Production-топология

```text
Интернет
  -> Apache :80/:443
     -> Next.js 127.0.0.1:3100 (все обычные страницы и API сайта)
     -> Bot 127.0.0.1:8383 (/api/ma, /o, /z, /pay, /cal, /dv, /media, /health)

kiln-app-1 -> PostgreSQL kiln-postgres-1 по сети kiln_internal
kiln-app-1 -> kiln-bot-bot-1:8383 по сети kiln_bot_api
kiln-bot-bot-1 -> MariaDB kiln-bot-db-1 по сети kiln-bot_default
```

Сайт запускается `docker-compose.production.yml` из `/var/www/html/kiln`.
PostgreSQL хранится в Docker volume `kiln_kiln_postgres`, uploads — в каталоге
хоста. Apache-конфигурации находятся в `deploy/apache`, а ежедневная retention-
задача — в `deploy/systemd`.

## Проверки и известные ограничения

- `npm run typecheck` проходит.
- `npm run lint` проходит с существующим предупреждением `no-img-element` в
  `feedback-dialog.tsx`.
- `npm test` не является автономным: требуется отдельная подготовленная БД,
  generated-файлы импорта и тестовый сайт; тесты выполняют записи в БД.
- В production-логах наблюдались предупреждения PostgreSQL о снятии чужого
  advisory lock при успешном health. Не скрывать их без отдельной диагностики.
- Запросы с некорректным Next.js Server Action ID могут быть внешним шумом;
  оценивать их вместе с частотой, источником и влиянием, а не менять обработку
  по единичной записи.
