Files
kvarta_project/docs/plan.md
T
nunuchek 4b880e7d9a docs: план реализации, глоссарий, ADR, журнал ошибок ИИ
План по итогам интервью (docs/plan.md), GLOSSARY.md, ADR о двух
сервисах и о боте как канале администрирования, docs/ai-log.md,
.gitignore для секретов, базы и артефактов.
2026-10-17 15:30:30 +03:00

14 KiB
Raw Blame History

План реализации

Согласованная спецификация (интервью 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.

Схема

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 запущен, версия », результат бэкапа и восстановления. Не шлём: 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)

  1. Подготовка: .gitignore, GLOSSARY, ADR, план, ai-log.
  2. pnpm workspace. 2. shared: телефон + тесты. 3. api: health и lead с SQLite. 4. landing: заглушка с формой. 5. deploy → ★ прод.
  3. Уточнения + тесты. 7. Касания + тесты. 8. Rate-limit, honeypot, ошибки. 9. Telegram → ★. 10. Бэкапы и восстановление → ★.
  4. DESIGN.md. 12. Секции и тексты. 13. Анимации, липкая CTA, предзаполнение. 14. OG и метаданные. 15–17. Итерации критика. 18. impeccable audit + simplify.
  5. README, Lighthouse, ссылки → ★ финал.

Если не хватает времени

Контрольная точка вс 18.10 20:00: если этап 2 (шаги 6–10) не закрыт — режем по порядку: восстановление через бота → итерации критика до одной → тема «Система» → /setup → анимации (оставить штамп и кнопку) → повторы Telegram. Не режем: HTTPS-деплой, все блоки с ценами, форму с телефоном/дублями/UTM/ошибками, тесты, отправку в «Заявки», OG, README и Lighthouse.