Files
nunuchek c1d64f6a79 chore(deploy): адреса серверов из deploy/.env.local, а не из репозитория
DEPLOY_HOST, RELAY_HOST и TELEGRAM_BOT_ID обязательны и читаются из
неотслеживаемого deploy/.env.local (образец — .env.local.example).
2026-10-17 17:53:18 +03:00

167 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План реализации
Согласованная спецификация (интервью 2026-10-17). Термины — по `GLOSSARY.md`, крупные решения — `docs/adr/`. Дедлайн сдачи: пн 2026-10-19 18:00 МСК.
## Факты из ТЗ (ничего сверх них не выдумывать)
- Обжарка «Кварта», Минск, с 2019 года, основатели Аня и Дмитрий (бывшие бариста). Партии по 5 кг, обжарка по четвергам, развоз по Минску по пятницам, зерно у клиента на 1–3 день после обжарки.
- Эфиопия Иргачеффе — цветочный, бергамот, светлая обжарка — 250 г, 32 BYN.
- Колумбия Уила — карамель, красное яблоко — 250 г, 29 BYN.
- Бразилия Серрадо — орех, шоколад, под эспрессо — 250 г, 26 BYN.
- Дрип-пакеты ассорти — 10 шт, 18 BYN.
- Подписка — 2 пачки по 250 г раз в месяц, доставка бесплатно — 54 BYN/мес.
- Самовывоз: Минск, ул. Короля, 9. Пн–пт 8:00–20:00, сб–вс 10:00–18:00.
- Доставка по Минску в пятницу, от 50 BYN бесплатно. Цена ниже 50 BYN в ТЗ не указана → «стоимость уточнит менеджер».
- Контактов в ТЗ нет; телефон вымышленный: +375 29 000-00-00.
- Аудитория 25–45, мобильный трафик из Instagram и Яндекса. Тон тёплый, простой, без пафоса.
## Структура
```
apps/landing/ Next.js App Router, Tailwind, next/font (кириллица), next/og; output: 'standalone'
src/app/ layout.tsx, page.tsx, opengraph-image.tsx
src/components/ секции и LeadForm, StickyCta
src/content/site.ts все тексты и факты
src/lib/attribution.ts
apps/api/ Hono + @hono/node-server, better-sqlite3, сборка tsup → dist/index.js
src/app.ts createApp(deps) — без listen, для тестов через app.request()
src/index.ts запуск сервера, планировщики, polling бота
src/db/ открытие базы, миграции (PRAGMA user_version + SQL)
src/leads.ts создать заявку / уточнение / дубль — в одной транзакции
src/telegram/ клиент Bot API, очередь отправки, «Система», команды бота
src/backup.ts бэкапы и восстановление
src/rateLimit.ts
packages/shared/ @kvarta/shared, исходники TS без сборки
src/phone.ts normalizeBelarusPhone, isValidBelarusPhone, formatBelarusPhone, phoneError
src/lead.ts LeadRequest, LeadResponse, Touch, Interest, validateLead, тексты ошибок
deploy/ Dockerfile.landing, Dockerfile.api, docker-compose_vps.yml, deploy.sh, setup-nginx.sh, nginx/kvarta.unuchak.com.conf
```
Импорты: landing → shared, api → shared. Landing не импортирует api и наоборот. Тесты Vitest рядом с кодом (`*.test.ts`).
## Телефон
Оставить цифры и ведущий `+`. Принимаем: `375XXXXXXXXX` (12 цифр), `80XXXXXXXXX` (11), `0XXXXXXXXX` (10), `XXXXXXXXX` (9), где после префикса код `25|29|33|44|17` и 7 цифр. Результат `+375XXXXXXXXX`. Первая цифра абонентского номера не проверяется. Ошибки:
- пусто → «Укажите телефон — по нему перезвоним»
- другой код страны (`+7`, `+48`…) → «Нужен белорусский номер: +375…»
- неизвестный код → «Проверьте код оператора: 25, 29, 33, 44 или 17»
- не та длина → «После кода нужно 7 цифр»
В форме: `inputmode="tel"`, `autocomplete="tel"`, без маски; на blur валидный номер переформатируется в `+375 29 123-45-67`; ошибка показывается после blur или submit.
## Касания
- Первое касание: localStorage `kvarta:first-touch`, `{ ...touch, at }`, живёт 30 дней, не перезаписывается до истечения, прямой визит тоже фиксируется.
- Последнее касание: sessionStorage `kvarta:last-touch`, записывается при входе в сессию; при отправке добавляется `submitPage`.
- `Touch = { utm_source?, utm_medium?, utm_campaign?, utm_content?, utm_term?, yclid?, fbclid?, referrer?, landingPage, submitPage?, at }`. Строки ≤ 500 символов.
- Хранилища в try/catch: недоступность не ломает отправку.
## POST /api/lead
Запрос JSON ≤ 16 КБ: `{ name (1–60 после trim), phone, interest: 'subscription'|'one_time'|'question', comment? (≤1000), company? (honeypot), firstTouch?: Touch|null, lastTouch: Touch }`.
| Код | Тело | Когда |
|---|---|---|
| 201 | `{ok:true, duplicate:false}` | новая заявка; также при заполненном honeypot (ничего не сохраняем) |
| 200 | `{ok:true, duplicate:true, clarified:true}` | уточнение |
| 200 | `{ok:true, duplicate:true, clarified:false}` | повтор без изменений |
| 400 | `{ok:false, error:'validation', fieldErrors}` | ошибки полей |
| 413/415 | `{ok:false, error:'bad_request'}` | не JSON / слишком большое тело |
| 429 | `{ok:false, error:'rate_limited'}` + `Retry-After` | > 5 запросов / 10 мин с IP |
| 503 | `{ok:false, error:'unavailable'}` | идёт восстановление базы |
| 500 | `{ok:false, error:'server'}` | прочее |
Номер заявки клиенту не отдаём. IP — из `X-Real-IP`, только если сокет от 127.0.0.1. IP в базе не храним. `GET /api/health` → `{ok:true, version}`.
Клиент: кнопка «Отправляем…» и disabled на время запроса, таймаут 10 с; сеть/таймаут/5xx/429/413 → блок «Не получилось отправить. Попробуйте ещё раз или позвоните: +375 29 000-00-00».
## Уточнения (окно 10 минут от created_at заявки)
1. Тот же телефон, есть заявка моложе 10 мин, поля (name, interest, comment) равны последней версии (последнее уточнение, иначе заявка) → `clarified:false`, ничего не пишем, в Telegram ничего.
2. Поля отличаются → строка в `lead_clarifications`, в Telegram «Уточнение к заявке #N» с изменившимися полями.
3. Заявки моложе 10 мин нет → новая заявка. Уточнения окно не продлевают.
Всё в одной транзакции better-sqlite3.
## Схема
```sql
CREATE TABLE leads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
phone TEXT NOT NULL,
name TEXT NOT NULL,
interest TEXT NOT NULL CHECK (interest IN ('subscription','one_time','question')),
comment TEXT,
first_touch TEXT,
last_touch TEXT NOT NULL,
user_agent TEXT,
tg_status TEXT NOT NULL DEFAULT 'pending' CHECK (tg_status IN ('pending','sent','failed')),
tg_attempts INTEGER NOT NULL DEFAULT 0,
tg_last_error TEXT,
tg_next_at TEXT
);
CREATE INDEX leads_phone_created ON leads(phone, created_at);
CREATE TABLE lead_clarifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lead_id INTEGER NOT NULL REFERENCES leads(id),
created_at TEXT NOT NULL,
name TEXT NOT NULL,
interest TEXT NOT NULL,
comment TEXT,
last_touch TEXT NOT NULL,
tg_status TEXT NOT NULL DEFAULT 'pending' CHECK (tg_status IN ('pending','sent','failed')),
tg_attempts INTEGER NOT NULL DEFAULT 0,
tg_last_error TEXT,
tg_next_at TEXT
);
```
Время — ISO 8601 UTC. Миграции через `PRAGMA user_version`.
## Telegram
- `.env` api: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, `TELEGRAM_LEADS_THREAD_ID`, `TELEGRAM_SYSTEM_THREAD_ID`, `TELEGRAM_ADMIN_ID`. Любая может отсутствовать — api работает, отправки копятся в `pending`.
- Порядок: транзакция → ответ клиенту → попытка отправки (таймаут 5 с). Фоновая очередь каждые 60 с: `pending`/`failed`, `tg_attempts < 10`, `tg_next_at <= now`, пауза 1, 2, 4… мин; уточнение не уходит раньше своей заявки.
- Сообщение — HTML, пользовательский ввод экранируется:
```
🟥 Заявка #12 · подписка
Имя: …
Телефон: +375291234567
Комментарий: …
Источник: yandex / cpc / osen (первый визит: instagram, 9 дн. назад)
Страница: … → …
```
- Тема «Kvarta Cafe: Система» (без персональных данных, только `#N`, код, короткий stack; одинаковые ошибки не чаще раза в 10 мин с «×N за период»): 500, заявка не ушла после 10 попыток, Telegram 400/403, сбой старта/миграции (best effort), «api запущен, версия <sha>», результат бэкапа и восстановления. Не шлём: 400, 429, honeypot. Без `TELEGRAM_SYSTEM_THREAD_ID` — только stdout.
- Бот: long polling `getUpdates` в процессе api — единственный потребитель обновлений. Команды принимаются только от `from.id == TELEGRAM_ADMIN_ID`, остальным молчим:
- `/setup` в группе: проверить, что темы включены и у бота есть право управлять темами (иначе объяснить по-русски), создать «Kvarta Cafe: Заявки» и «Kvarta Cafe: Система», прислать админу в личку строки `TELEGRAM_CHAT_ID=…`, `TELEGRAM_LEADS_THREAD_ID=…`, `TELEGRAM_SYSTEM_THREAD_ID=…`. Если id уже заданы — отказ, `/setup force` — создать заново.
- `/backups` в личке — последние копии с inline-кнопками (вкл. pre-restore).
- `/backup` — копия сейчас, прислать файлом.
- кнопка копии или присланный `.sqlite.gz` → подтверждение «Восстановить базу из …? Сейчас N заявок, M новее этой копии и пропадут (#…). Текущая база будет сохранена как pre-restore.» [Восстановить] [Отмена].
## Бэкапы
- Ежедневно 03:00 Europe/Minsk и при старте, если за сегодня нет: `db.backup()` → gzip → `/data/backups/kvarta-YYYY-MM-DDTHHmm.sqlite.gz`. Храним 14 ежедневных; `pre-restore-*` — 30 дней.
- Каждую ежедневную копию — администратору в личку `sendDocument`; в «Систему» — «Бэкап 18.10 03:00: 12 заявок, 4 КБ ✅» или ошибку.
- Восстановление: gunzip → `PRAGMA integrity_check` → `user_version` не новее текущей → pre-restore копия → пауза приёма (503) → закрыть, подменить, открыть, миграции → отчёт админу и в «Систему».
## Деплой
- Сервер: root@<VPS_IP>, x86_64, `docker-compose` v2.23 (плагина `docker compose` нет), диск занят на 89%. Рядом чужие боевые сайты — трогаем только своё.
- `/root/kvarta_site/`: `docker-compose_vps.yml` (landing `127.0.0.1:13120:3000`, api `127.0.0.1:13121:3000`, volume `kvarta-data:/data` только у api, `restart: unless-stopped`), `api.env` (chmod 600, заполняет автор, скрипты не трогают).
- `deploy/deploy.sh` (локально): чистое дерево → `SHA` → buildx `linux/amd64` → `kvarta-landing:$SHA`, `kvarta-api:$SHA` (+`latest`) → проверка ≥1 ГБ свободно → `docker save | gzip | ssh 'gunzip | docker load'` → scp compose → `up -d` → ждать `/api/health` 30 с → удалить `kvarta-*` кроме текущего и предыдущего. `--rollback` — на предыдущий тег. Никакого общего `docker image prune`.
- `deploy/setup-nginx.sh` (однократно): конфиг по образцу ai-testing (`/` → 13120, `/api/` → 13121, `X-Real-IP`), `nginx -t` → reload → `certbot --nginx -d kvarta.unuchak.com`.
## Порядок коммитов (без Co-Authored-By)
0. Подготовка: .gitignore, GLOSSARY, ADR, план, ai-log.
1. pnpm workspace. 2. shared: телефон + тесты. 3. api: health и lead с SQLite. 4. landing: заглушка с формой. 5. deploy → ★ прод.
6. Уточнения + тесты. 7. Касания + тесты. 8. Rate-limit, honeypot, ошибки. 9. Telegram → ★. 10. Бэкапы и восстановление → ★.
11. DESIGN.md. 12. Секции и тексты. 13. Анимации, липкая CTA, предзаполнение. 14. OG и метаданные. 15–17. Итерации критика. 18. impeccable audit + simplify.
19. README, Lighthouse, ссылки → ★ финал.
## Если не хватает времени
Контрольная точка вс 18.10 20:00: если этап 2 (шаги 6–10) не закрыт — режем по порядку: восстановление через бота → итерации критика до одной → тема «Система» → `/setup` → анимации (оставить штамп и кнопку) → повторы Telegram. Не режем: HTTPS-деплой, все блоки с ценами, форму с телефоном/дублями/UTM/ошибками, тесты, отправку в «Заявки», OG, README и Lighthouse.