docs: план реализации, глоссарий, ADR, журнал ошибок ИИ
План по итогам интервью (docs/plan.md), GLOSSARY.md, ADR о двух сервисах и о боте как канале администрирования, docs/ai-log.md, .gitignore для секретов, базы и артефактов.
This commit is contained in:
+30
@@ -0,0 +1,30 @@
|
|||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# секреты и данные
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.env
|
||||||
|
!*.env.example
|
||||||
|
data/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-*
|
||||||
|
*.sqlite.gz
|
||||||
|
|
||||||
|
# артефакты проверки дизайна и производительности
|
||||||
|
.screenshots/
|
||||||
|
.lighthouse/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
|
# worktree'ы агентов
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
# Кварта
|
||||||
|
|
||||||
|
Лендинг минской обжарки «Кварта»: посетитель оставляет заявку, менеджер получает её в Telegram.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
**Заявка**:
|
||||||
|
Обращение посетителя через форму: имя, телефон, интерес, комментарий. Имеет номер #N.
|
||||||
|
_Avoid_: лид, заказ, обращение
|
||||||
|
|
||||||
|
**Уточнение**:
|
||||||
|
Повторная отправка с того же телефона в течение 10 минут после первой отправки заявки, которая что-то меняет. Дописывается к заявке, новой заявки не создаёт.
|
||||||
|
_Avoid_: дубль, обновление заявки
|
||||||
|
|
||||||
|
**Окно уточнений**:
|
||||||
|
10 минут от создания заявки; уточнения его не продлевают. Отправка после окна — новая заявка.
|
||||||
|
_Avoid_: таймаут дублей
|
||||||
|
|
||||||
|
**Интерес**:
|
||||||
|
Что хочет посетитель: подписка, разовый заказ или вопрос.
|
||||||
|
_Avoid_: тип заявки, категория
|
||||||
|
|
||||||
|
## Люди
|
||||||
|
|
||||||
|
**Посетитель**:
|
||||||
|
Человек на лендинге; после отправки формы — автор заявки.
|
||||||
|
_Avoid_: пользователь, клиент, лид
|
||||||
|
|
||||||
|
**Менеджер**:
|
||||||
|
Сотрудник «Кварты», который читает заявки в Telegram-группе и перезванивает.
|
||||||
|
_Avoid_: оператор, админ
|
||||||
|
|
||||||
|
**Администратор**:
|
||||||
|
Единственный человек, которому бот подчиняется в личке: бэкапы, восстановление, настройка тем.
|
||||||
|
_Avoid_: менеджер, владелец
|
||||||
|
|
||||||
|
## Источник
|
||||||
|
|
||||||
|
**Касание**:
|
||||||
|
Визит посетителя на лендинг с его метками (utm, yclid, fbclid), referrer и страницей входа.
|
||||||
|
_Avoid_: сессия, хит
|
||||||
|
|
||||||
|
**Первое касание**:
|
||||||
|
Самый ранний визит посетителя за последние 30 дней, в том числе прямой без меток.
|
||||||
|
_Avoid_: first touch, первоисточник
|
||||||
|
|
||||||
|
**Последнее касание**:
|
||||||
|
Визит, во время которого отправлена заявка; включает ещё и страницу отправки.
|
||||||
|
_Avoid_: last touch, текущий источник
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Лендинг и API — два отдельных приложения
|
||||||
|
|
||||||
|
Лендинг (`apps/landing`, Next.js) и приём заявок (`apps/api`, Hono + better-sqlite3) живут в pnpm workspace как два приложения с общим пакетом `packages/shared` (телефон, контракт заявки, валидация) и деплоятся двумя контейнерами за одним nginx: `/` → 13120, `/api/` → 13121. Так бэкенд с базой, очередью Telegram и бэкапами перезапускается и развивается независимо от вёрстки, а граница между ними проверяется сборкой, а не договорённостью.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- Одно Next.js-приложение с `route.ts` и границей модулей через ESLint — проще деплой (один образ), но API, планировщик бэкапов и long polling бота жили бы в процессе фронта.
|
||||||
|
- Статический экспорт лендинга, отдаваемый nginx с хоста — отказались: теряем `next/image` и меняем привычную на сервере схему «контейнер за nginx».
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Один домен, поэтому CORS не нужен; форма шлёт `fetch('/api/lead')`.
|
||||||
|
- `packages/shared` поставляется исходниками: Next — через `transpilePackages`, api — вшивается tsup.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Telegram-бот как канал администрирования
|
||||||
|
|
||||||
|
Менеджер видит заявки только в Telegram, а на сервере соседствуют чужие боевые сайты, поэтому всё администрирование — настройка тем (`/setup`), ежедневные бэкапы с копией администратору в личку, восстановление базы — делает процесс api через бота, который читает обновления long polling'ом. Ни cron на хосте, ни входящего webhook'а, ни админки: на сервере меняется только наш каталог и один конфиг nginx.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- Cron на хосте + ручное восстановление по ssh — трогает общий crontab сервера, и восстановление требует доступа по ssh.
|
||||||
|
- Webhook вместо polling — нужен публичный эндпоинт и секрет, а выигрыша при нашем трафике нет.
|
||||||
|
- Отдельный скрипт `tg:setup` через `getUpdates` — конфликтует с polling работающего api, заменён командой `/setup`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- У бота один поток обновлений: никакой другой процесс не должен вызывать `getUpdates` с этим токеном.
|
||||||
|
- Если Telegram недоступен целиком, о сбоях узнаём только из `docker-compose logs api`.
|
||||||
|
- Восстановление из копии удаляет заявки, пришедшие после неё; бот перечисляет их в подтверждении и сохраняет текущую базу как `pre-restore`.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Ошибки ИИ
|
||||||
|
|
||||||
|
Записи по ходу работы: что ИИ сделал не так, чем поймали, что исправили. Источник для раздела README «где ИИ ошибся».
|
||||||
|
|
||||||
|
| # | Шаг | Ошибка ИИ | Чем поймали | Исправление |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | План, деплой | Предложил `docker compose run …`, не проверив сервер; там нет плагина compose, только `docker-compose` v2.23 | Осмотр сервера по ssh перед планом деплоя | Везде `docker-compose` |
|
||||||
|
| 2 | План, Telegram | Предложил скрипт `tg:setup` через `getUpdates`, а позже — long polling в api; два потребителя одного потока обновлений конфликтуют | Сам заметил при проектировании восстановления через бота | Скрипт заменён командой бота `/setup` (ADR-0002) |
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
# План реализации
|
||||||
|
|
||||||
|
Согласованная спецификация (интервью 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@185.5.249.52, 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.
|
||||||
Reference in New Issue
Block a user