Skip to content

Latest commit

 

History

History
1145 lines (1023 loc) · 217 KB

File metadata and controls

1145 lines (1023 loc) · 217 KB

Партнёрский кабинет Bitrix24 — Архитектура

Общее описание

Веб-приложение «Партнёрский кабинет» для управления партнёрскими ссылками, лендингами, клиентами и аналитикой с интеграцией в Bitrix24 через сервис b24-transfer-lead. Включает админ-панель для управления всеми партнёрами, систему in-app уведомлений, генерацию QR-кодов, UTM-метки, систему запросов на выплату и чат между партнёром и админом.

Стек технологий

  • Backend: Python 3.11, FastAPI 0.109, SQLAlchemy 2.0 (async), Alembic, Pydantic v2, bcrypt, fpdf2 (PDF-генерация)
  • Frontend: React 18, TypeScript, Vite 5, React Router 6, Axios, Recharts, qrcode.react
  • БД: SQLite (aiosqlite)
  • b24-transfer-lead: Python 3.12, FastAPI, SQLAlchemy (sync), SQLite, httpx
  • Max-бот: Node.js 20, TypeScript 5.3, @maxhub/max-bot-api 0.2.x, axios
  • Инфраструктура: Docker, docker-compose.dev.yml

Структура проекта

partner_bitrix24_cabinet/
├── docker-compose.dev.yml          # Docker Compose (backend:8003 + BROADCAST_API_KEY, frontend:5173, b24-service:7860, b24-frontend:3000, telegram-bot + tg-bot-users, max-bot + max-bot-users). Партнёрские боты max-bot/telegram-bot имеют DATA_DIR=/data, BROADCAST_API_KEY и именованные тома max-bot-data/telegram-bot-data для persistent known_users.db; max-bot выполняет npm rebuild better-sqlite3 при старте. Пользовательские боты max-bot-users/tg-bot-users имеют BACKEND_URL=http://backend:8003, BROADCAST_API_KEY (default dev-broadcast-key), NOTIFICATION_POLL_INTERVAL и тома max-bot-users-data/tg-bot-users-data для broadcast_state в users.db
├── .env.example                    # Шаблон переменных окружения (cp .env.example .env). Секции: b24-transfer-lead service, Workflow defaults, Backend, Broadcasts (BROADCAST_API_KEY=dev-broadcast-key — общий секрет аутентификации ботов к broadcast bot-API; NOTIFICATION_POLL_INTERVAL=60 — интервал опроса очереди рассылок ботами), токены ботов
├── architecture.md                 # Описание архитектуры проекта
├── data/                           # Директория для SQLite БД (volume)
│   └── app.db                      # Файл БД (создаётся автоматически)
├── package.json                    # Корневой package.json для E2E-runner: scripts test:e2e (playwright test), test:e2e:ui, test:e2e:headed, test:e2e:report; devDependencies @playwright/test
├── playwright.config.ts            # Конфиг Playwright: testDir=./e2e, baseURL=http://localhost:5173, без webServer (используются live docker сервисы), reporter=list+html (playwright-report/), screenshot=on, video=retain-on-failure, trace=on, single worker, locale=ru-RU
├── e2e/                            # Playwright E2E-тесты партнёрского кабинета (требуют запущенный docker stack)
│   ├── helpers/
│   │   ├── api.ts                  # API-хелперы: apiLogin, injectAuthCookies (через context.addCookies), authenticateAsAdmin/authenticateAsPartner, seedApprovedPartner (POST /api/admin/partners/register, опционально approve), submitPendingRegistration (POST /api/auth/register), waitForBackend
│   │   └── console.ts              # Console-error tracker: trackConsoleErrors(page) подписывается на page.on('console'/'pageerror'/'response') и собирает ошибки (с whitelist для шумных warning), expectNoConsoleErrors(tracker) ассертит отсутствие критичных ошибок в конце теста
│   ├── fixtures/                   # Зарезервировано под Playwright fixtures
│   ├── auth.spec.ts                # Аутентификация: рендер логина/регистрации, редирект неавторизованных на /login, успешный вход админа, неверные креды (показ ошибки), логаут
│   ├── admin-navigation.spec.ts    # Навигация по сайдбару админ-панели: 9 разделов (Обзор, Заявки, Партнёры, Отчёты, Запросы на выплату, Чат, Уведомления, B24 Transfer Lead, Настройки)
│   ├── partner-navigation.spec.ts  # Навигация по сайдбару партнёрского кабинета: 9 разделов (Обзор, Ссылки, Лиды, Лендинги, Отчеты и финансы, Чат с поддержкой, Bitrix24, Профиль, Справка); пункты Аналитика и Выплаты удалены
│   ├── admin-dashboard.spec.ts     # Админ-дашборд (Обзор): рендер всех stats-карточек, секции глобального % вознаграждения, кнопки "Все партнёры", открытие/отмена редактирования %, поиск (no-results state)
│   ├── admin-partners.spec.ts      # Управление партнёрами админом: дашборд со статистикой и поиском, список партнёров с пагинацией, переход на детальную страницу, toggle is_active с window.confirm
│   ├── admin-registrations.spec.ts # Очередь заявок: одобрение pending-партнёра (b24Mode=skip default), отклонение заявки
│   ├── admin-payment-requests.spec.ts # Админ-страница запросов на выплату: рендер списка/пустого состояния
│   ├── admin-reports.spec.ts       # Админ-отчёты: рендер заголовка, date picker, partner search dropdown, кнопка скачивания PDF, базовые stats-карточки
│   ├── admin-chat.spec.ts          # Админ-чат: рендер sidebar "Переписки", empty state "Выберите переписку слева"
│   ├── admin-notifications.spec.ts # Админ-уведомления: форма создания, список уведомлений, e2e-сценарий create→appear→delete (с window.confirm), HTML5-валидация на пустой submit
│   ├── admin-b24.spec.ts           # Админ B24 Transfer Lead: проверка наличия iframe[title="B24 Transfer Lead"]
│   ├── admin-settings.spec.ts      # Админ-настройки: 5 секций (Tracking, Sync, B24 Fields, Bot Links, Default Links), кнопки "Сохранить" в каждой секции, "+ Добавить ссылку"/"✕" — добавление и удаление дефолтной ссылки
│   ├── partner-links.spec.ts       # Партнёрские ссылки: тоггл формы создания, успешное создание ссылки и появление в списке
│   ├── partner-payments.spec.ts    # Запросы на выплату партнёра: рендер кнопки запроса, открытие/закрытие модалки
│   ├── partner-dashboard.spec.ts   # Партнёр-дашборд: рендер заголовка "Дашборд", окончание загрузки, отображение имени партнёра в sidebar
│   ├── partner-clients.spec.ts     # Партнёр-клиенты: рендер заголовка, CrmForm и ClientsTable, окончание loading
│   ├── partner-landings.spec.ts    # Партнёр-лендинги: кнопка "+ Создать лендинг", открытие/закрытие формы создания (поля Название/Заголовок/Описание)
│   ├── partner-analytics.spec.ts   # Партнёр-аналитика: рендер заголовка, секции "Статистика по ссылкам"
│   ├── partner-reports.spec.ts     # Партнёр-отчёты: рендер заголовка, кнопки "Скачать PDF" в активном (не-loading) состоянии
│   ├── partner-chat.spec.ts        # Партнёр-чат: empty state "Нет сообщений", отправка сообщения через textarea + Enter, появление в списке
│   ├── partner-b24.spec.ts         # Партнёр Bitrix24 settings: рендер заголовка, либо configured-форма либо "не настроена"-сообщение
│   ├── partner-profile.spec.ts     # Партнёр-профиль: рендер карточки, переключение редактирования email и cancel, валидация смены пароля (mismatched confirmation)
│   └── partner-help.spec.ts        # Партнёр-справка (guide): рендер заголовка, дефолтная открытая секция "Начало работы", toggle (open/close) accordion-секции
├── playwright-report/              # HTML-отчёт после прогона E2E (создаётся reporter='html'); index.html — главный файл отчёта
├── scripts/
│   └── run-all-tests.sh            # Bash-runner всех тестов: backend (pytest --cov=app), frontend (vitest --coverage), max_bot (vitest --coverage), telegram_bot (pytest --cov=bot), b24-transfer-lead/backend (pytest --cov), b24-transfer-lead/frontend (vitest --coverage). Поддерживает FAIL_FAST=1 (стоп на первой ошибке) и SKIP_COVERAGE=1 (быстрый запуск без coverage). Цветной вывод заголовков, итоговая таблица PASS/FAIL/SKIP с длительностью, exit != 0 при любом фейле
├── b24-transfer-lead/              # Сервис для создания лидов/сделок в Bitrix24
│   ├── .coveragerc                 # Coverage конфиг (source=src/backend, omit миграций/CSV-парсера, fail_under=60 проектный, services >= 70%)
│   ├── Dockerfile.backend          # Docker-образ (python:3.12-slim, uv, порт 7860)
│   └── src/backend/
│       ├── main.py                 # FastAPI app, startup: init_db, миграции, авто-создание admin
│       ├── core/
│       │   ├── config.py           # Settings: INTERNAL_API_KEY, ADMIN_USERNAME/PASSWORD и др.
│       │   └── database.py         # SQLAlchemy engine, сессии (main + per-workflow БД)
│       ├── api/v1/
│       │   ├── dependencies.py     # get_current_user (session cookie + X-Internal-API-Key bypass)
│       │   ├── workflows.py        # CRUD workflows, settings, funnels, stages, statuses, token, stats
│       │   ├── leads.py            # CRUD leads, upload/export/import CSV. _prepare_extra_fields() — пропускает UF_CRM_* поля напрямую в Bitrix24 без WorkflowFieldMapping (для tracking-полей партнёров). POST /{workflow_id}/leads/import — создание Lead локально без push в B24 (для синхронизации сделок)
│       │   ├── public.py           # Публичный API: создание лидов по api_token
│       │   ├── auth.py             # Login/logout (session-based)
│       │   ├── users.py            # Управление пользователями
│       │   ├── webhook.py          # Вебхуки из Bitrix24 (возвращает lead_update с discriminator kind='lead'|'deal' для разделения статуса лида и стадии сделки в cabinet: для событий лида — status/status_name=статус лида + deal_stage/deal_stage_name при конвертации; для событий сделки — deal_stage/deal_stage_name=стадия сделки + lead_status; плюс became_successful и opportunity; для неизвестных лидов/сделок возвращает auto_link с сырыми lead_data/deal_data из B24 для последующей привязки в cabinet)
│       │   └── b24_entities.py    # CRM-сущности B24: поиск/создание контактов и компаний, получение сделок, обновление контактов/компаний (GET contacts/search, companies/search, deals; POST contacts, companies — принимают extra_fields dict и фильтруют только UF_CRM_* ключи через _filter_uf_crm_fields; contact_type_id/company_type опциональны с default 'PARTNER'; PATCH contacts/{id}, companies/{id}). Эндпоинты: /{workflow_id}/b24/*. get_deals (crm.deal.list) пробрасывает LEAD_ID и STAGE_SEMANTIC_ID в select; DealResult содержит lead_id (int|None, безопасное приведение LEAD_ID), stage_semantic_id, stage_id, opportunity, currency, date_create
│       ├── models/                 # User, Workflow, Lead, LeadField, WorkflowFieldMapping
│       └── services/               # AuthService, Bitrix24Service, DatabaseService
├── backend/
│   ├── Dockerfile                  # Docker-образ backend (python:3.11-slim, fonts-dejavu-core для PDF)
│   ├── requirements.txt            # Python-зависимости
│   ├── alembic.ini                 # Конфигурация Alembic
│   ├── alembic/
│   │   ├── env.py                  # Настройка async-миграций с подключением всех моделей
│   │   ├── script.py.mako          # Шаблон миграций
│   │   └── versions/               # Файлы миграций
│   ├── landing_template/
│   │   └── index.html              # Jinja2 шаблон лендинга: слайдер, CRM-форма, responsive
│   └── app/
│       ├── __init__.py
│       ├── main.py                 # FastAPI app: lifespan (миграции, ensure_admin_exists, start_sync_task/stop_sync_task), CORS, статика /uploads, роутеры вкл. system_settings и system
│       ├── config.py               # Settings: DATABASE_URL, SECRET_KEY, B24_SERVICE_URL, DEFAULT_REWARD_PERCENTAGE, ADMIN_EMAIL, ADMIN_PASSWORD, B24_SERVICE_FRONTEND_URL, PUBLIC_BASE_URL (для построения публичных URL: партнёрских ссылок, deep-links ботов, landing URL), BROADCAST_API_KEY (общий секрет для аутентификации ботов к broadcast-эндпоинтам)
│       ├── database.py             # Async engine, AsyncSessionLocal, Base, get_db()
│       ├── dependencies.py         # FastAPI Depends: get_db(), get_current_user() (JWT + OAuth2), get_admin_user() (role check)
│       ├── models/
│       │   ├── __init__.py         # Реэкспорт всех моделей для Alembic
│       │   ├── partner.py          # Partner — партнёр (email (nullable, unique), password_hash, partner_code, role, reward_percentage, payment_details (JSON: saved_payment_methods), workflow_id, b24_api_token, b24_entity_type, b24_entity_id, b24_entity_name, phone (nullable, unique), approval_status, rejection_reason)
│       │   ├── link.py             # PartnerLink — партнёрская ссылка (link_type, link_code, target_url, utm_source, utm_medium, utm_campaign, utm_content, utm_term, default_link_id — uuid hex, VARCHAR(36), индексировано: ссылка на DefaultLinkConfig)
│       │   ├── click.py            # LinkClick — клик по ссылке (ip_address, user_agent, referer)
│       │   ├── client.py           # Client — клиент/лид (source, name, phone, email, webhook_sent, deal_amount, partner_reward, is_paid, paid_at, payment_comment, deal_status/deal_status_name = СТАТУС ЛИДА, deal_stage/deal_stage_name = СТАДИЯ СДЕЛКИ из воронки, b24_created_at = дата создания в Bitrix24, created_at, updated_at с onupdate=datetime.utcnow)
│       │   ├── landing.py          # LandingPage + LandingImage — лендинги с изображениями
│       │   ├── notification.py     # Notification (title, message, created_by, target_partner_id, file_path, file_name) + NotificationRead (notification_id, partner_id, read_at)
│       │   ├── payment_request.py  # PaymentRequest (partner_id, status, total_amount, client_ids, comment, payment_details, admin_comment, processed_at, processed_by)
│       │   ├── chat_message.py    # ChatMessage (partner_id, sender_id, message, file_path, file_name, is_read, created_at)
│       │   ├── system_setting.py  # SystemSetting — key-value хранилище настроек (key (unique, indexed), value (Text), description)
│       │   ├── impersonation_log.py # ImpersonationLog — audit-лог входов админа от лица партнёра (admin_id FK partners.id indexed, target_partner_id FK partners.id indexed, started_at DateTime default utcnow indexed, ended_at DateTime nullable, ip_address VARCHAR(64) nullable)
│       │   └── broadcast.py       # Broadcast (message Text, channels Text-JSON, partner_code nullable, created_by FK partners, created_at), BroadcastAttachment (broadcast_id FK CASCADE indexed, file_path, file_name, file_type image/video/file, position), BroadcastDelivery (broadcast_id FK CASCADE indexed, channel, sent, failed, updated_at; unique (broadcast_id, channel)) — модели рассылки сообщений всем ботам
│       ├── schemas/
│       │   ├── __init__.py
│       │   ├── auth.py             # PHONE_REGEX, normalize_phone() (8/7 → +7), RegisterRequest (email|phone, model_validator с нормализацией), LoginRequest (identifier с авто-нормализацией если без '@'), TokenResponse, PartnerResponse (email nullable, phone), SavedPaymentMethod, AddPaymentMethodRequest, ChangeEmailRequest, ChangePasswordRequest
│       │   ├── link.py             # LinkCreateRequest, LinkUpdateRequest, LinkResponse, LinkUtmResponse (utm_term/source/medium/campaign/content — str|None, from_attributes; ответ GET /public/links/{code}/utm), EmbedCodeResponse (с UTM-полями и redirect_url_with_utm)
│       │   ├── client.py           # ClientCreateRequest (+ source: str | None для трекинга источника из ботов), ClientResponse (с deal_amount, partner_reward, is_paid, deal_status, deal_status_name, deal_stage_name, b24_created_at, updated_at; phone/email/company скрыты от партнёра), PublicFormRequest
│       │   ├── landing.py          # LandingCreateRequest, LandingUpdateRequest, LandingImageResponse, LandingResponse
│       │   ├── analytics.py        # SummaryResponse (total_clicks, total_leads, conversion_rate), LinkStatsResponse (без link_type; leads_count вместо clients_count — статистика по источникам), ClientStatsResponse (date + total, без form_count/manual_count), DailyClicksResponse, BitrixStatsResponse
│       │   ├── admin.py            # RebindB24EntityRequest (Literal contact/company, id > 0, name, update_partner_code), ApproveRegistrationRequest (b24_entity_type, b24_entity_id, b24_entity_name), AdminRegisterPartnerRequest (name, email, password, company), ClientPaymentUpdateRequest, BulkClientPaymentUpdateRequest, PartnerPaymentSummaryResponse, AdminOverviewResponse, PartnerStatsResponse, AdminPartnerDetailResponse (+ b24_entity_type, b24_entity_id, b24_entity_name, phone), AdminConfigResponse, PartnerRewardPercentageUpdateRequest, GlobalRewardPercentageResponse, GlobalRewardPercentageUpdateRequest, RegistrationRequestResponse (+ phone), RejectRegistrationRequest, ImpersonateResponse (access_token, refresh_token, token_type='bearer', target_partner: PartnerResponse, log_id — ответ старта impersonation-сессии)
│       │   ├── system_settings.py  # BotLinksConfig (telegram_bot_url, max_bot_url — оба str | None) — конфиг публичных URL ботов для админки; PageVisibilityConfig (10 булевых флагов dashboard/links/clients/landings/analytics/reports/payment_requests/chat/bitrix_settings/guide, все default=True) — глобальная видимость страниц партнёрского кабинета
│       │   ├── notification.py     # NotificationCreateRequest, NotificationResponse (file_url, file_name), PartnerNotificationResponse (file_url, file_name), UnreadCountResponse
│       │   ├── payment_request.py  # PaymentRequestCreate (с payment_details), PaymentRequestResponse (с payment_details), PaymentRequestAdminAction, PendingCountResponse
│       │   ├── chat.py             # ChatMessageSend, ChatMessageResponse (с file_url, file_name), ChatConversationPreview, ChatUnreadCountResponse
│       │   ├── broadcast.py        # BroadcastAttachmentResponse (file_url, file_name, file_type, position), BroadcastDeliveryResponse (channel, sent, failed, updated_at), BroadcastResponse (channels: list[str], partner_code, attachments, delivery_stats), BroadcastListResponse, PendingAttachment (file_url, file_name, file_type), PendingBroadcastResponse (для ботов: id, message, partner_code, attachments), DeliveryReportRequest (channel, sent, failed)
│       │   └── report.py           # PartnerReportMetrics (с полями конверсий: total_deals, total_successful_deals, total_lost_deals, conversion_leads_to_deals, conversion_deals_to_successful), PartnerReportResponse, AllPartnersReportRow, AllPartnersReportResponse, OverviewResponse (метрики страницы «Обзор»: reward_percentage, total_reward, total_paid, available_to_withdraw, total_leads, conversion_leads_to_deals, total_deals, conversion_deals_to_successful, total_successful_deals)
│       ├── routers/
│       │   ├── __init__.py
│       │   ├── auth.py             # POST /register, /login, /refresh, /change-password, /payment-methods; PUT /email; GET /me; DELETE /payment-methods/{id}
│       │   ├── links.py            # CRUD /api/links
│       │   ├── clients.py          # CRUD /api/clients; GET /api/clients/ принимает sort_by(created|updated|amount)/order(asc|desc), фильтр date_from/date_to (по дате создания, включительно), search, пагинацию
│       │   ├── landings.py         # CRUD /api/landings
│       │   ├── analytics.py        # GET /api/analytics/summary, /overview (метрики «Обзора» через report_service.compute_partner_overview, 404 если партнёр не найден), /links, /clients/stats; POST /bitrix/fetch
│       │   ├── bitrix_settings.py  # POST /api/bitrix/setup, GET|PUT /settings, GET /funnels, /stages, /lead-statuses, /leads (перед возвратом запускает deal_sync_service.sync_deals_for_partner — ошибки логируются, но не валят запрос), /stats
│       │   ├── admin.py            # GET /api/admin/overview, /partners, /partners/{id}, /config, /partners/{id}/payments, /reward-percentage, /registrations, /registrations/count; POST /registrations/{id}/approve (опц. body: b24_entity_type, b24_entity_id, b24_entity_name), /registrations/{id}/reject, /partners/register (admin создаёт партнёра), /partners/{id}/sync-now (ручной B24-sync для партнёра → {synced: N}), /partners/{id}/impersonate (старт impersonation: возвращает ImpersonateResponse с access/refresh JWT для целевого партнёра + log_id; IP клиента берётся из request.client.host), /impersonate/{log_id}/end (завершение impersonation, 204 No Content, идемпотентен); PATCH /api/admin/partners/{id}/b24-entity (RebindB24EntityRequest → перепривязка B24-сущности); DELETE /api/admin/partners/{id} (каскадное удаление, 204); PUT /api/admin/clients/{id}/payment, /partners/{id}/reward-percentage, /partners/{id}/toggle-active, /reward-percentage, /links/{id} (админ редактирует ссылку партнёра); POST|GET|DELETE /api/admin/notifications; POST|GET|DELETE /api/admin/broadcasts (создание рассылки: multipart message/channels[]/partner_code?/files[]; список со статистикой доставки; удаление с очисткой файлов); B24-прокси: GET /b24/contacts/search, /b24/companies/search; POST /b24/contacts, /b24/companies
│       │   ├── notifications.py    # GET /api/notifications/, /unread-count; POST /notifications/{id}/read, /read-all
│       │   ├── broadcasts.py        # Bot-роутер рассылки. require_broadcast_token (заголовок X-Broadcast-Token == settings.BROADCAST_API_KEY; 503 если ключ пуст, 401 при несовпадении). GET /api/broadcasts/pending?channel=&after_id= → list[PendingBroadcastResponse] (file_url отдаётся как /uploads/..., бот сам префиксует BACKEND_URL). POST /api/broadcasts/{id}/delivery — приём агрегированной статистики доставки от бота (DeliveryReportRequest)
│       │   ├── payment_requests.py # POST|GET /api/payment-requests; GET /api/payment-requests/{id}; GET|PUT|DELETE /api/admin/payment-requests; GET /api/admin/payment-requests/pending-count
│       │   ├── chat.py             # GET|POST /api/chat/messages, POST /api/chat/messages/file, GET /api/chat/unread-count, POST /api/chat/read; GET /api/admin/chat/conversations, GET|POST /api/admin/chat/conversations/{id}/messages, POST /api/admin/chat/conversations/{id}/messages/file, GET /api/admin/chat/unread-count, POST /api/admin/chat/conversations/{id}/read
│       │   ├── reports.py          # GET /api/reports, /reports/pdf (партнёр); GET /api/admin/reports, /admin/reports/pdf (админ)
│       │   ├── public.py           # Публичные: GET /r/{code} (с UTM-параметрами), GET /links/{code}/utm (link_utm — UTM-метки активной ссылки для дозагрузки ботами по link_code, 404 для несуществующих/неактивных, клик не пишется, response_model=LinkUtmResponse), /landing/{code}, POST /form/{code}, POST /webhook/b24 (прокси + РАЗДЕЛЕНИЕ статуса лида и стадии сделки: lead_update.kind='lead'→deal_status/deal_status_name, kind='deal'→deal_stage/deal_stage_name без затирания статуса лида, эвристика-fallback для payload без kind по deal_id+':' в status; явно проставляет updated_at + авто-расчёт deal_amount/partner_reward из opportunity + уведомление с суммой и комиссией + диспатч auto_link для авто-связывания неизвестных B24-сущностей через auto_link_service.auto_link_b24_entity)
│       │   ├── system_settings.py # GET /api/admin/settings (все настройки + bot_links), PUT /api/admin/settings/tracking (UF-поля), PUT /api/admin/settings/sync (sync-конфигурация), POST /api/admin/settings/sync/run-now (ручная синхронизация), GET /api/admin/settings/default-links (стандартные ссылки; DefaultLinkConfig: id?: str | None, title, url, enabled, utm_*), PUT /api/admin/settings/default-links (обновить стандартные ссылки), POST /api/admin/settings/default-links/apply-all?force=bool (применить стандартные ссылки ко всем партнёрам; response_model=ApplyDefaultLinksResult{success, partners_updated, links_created, links_updated}), GET|PUT /api/admin/settings/bot-links (BotLinksConfig: telegram_bot_url, max_bot_url), GET|PUT /api/admin/settings/page-visibility (PageVisibilityConfig: 10 булевых флагов видимости страниц партнёра)
│       │   └── system.py          # Публичный системный роутер (для авторизованных партнёров). GET /api/system/bot-links → BotLinksConfig (telegram_bot_url, max_bot_url — null если не настроено) для отображения ссылок на ботов в партнёрском кабинете; GET /api/system/page-visibility → PageVisibilityConfig (глобальная видимость страниц; отсутствующий ключ → True) для фильтрации меню и гарда прямого доступа во фронтенде
│       ├── services/
│       │   ├── __init__.py
│       │   ├── auth_service.py     # register_partner(email|phone), login_partner(identifier — email или phone), refresh_tokens(), create_partner_workflow(), change_email(), change_password(), admin_register_partner(email|phone)
│       │   ├── link_service.py     # create_link(), get_links(), get_link(), update_link(), delete_link(), get_embed_code(), _build_url_with_utm() (для бот-deep-link URL — Telegram/Max, см. _is_bot_url: при наличии хотя бы одного utm_source/medium/campaign/content формирует start-payload <utm_term>_<link_code> или <link_code> с charset/length-fallback по _START_PAYLOAD_RE ^[A-Za-z0-9_-]{1,64}$ на голый link_code; без UTM — legacy start=<utm_term>, без utm_term возвращает base_url без изменений; всегда удаляет utm_* из query бот-URL; для обычных URL — стандартная логика подмеса непустых utm_* полей), _is_bot_url(url) (детект бот-deep-link по hostname t.me/telegram.me/max.ru/max.com и schemes tg/max)
│       │   ├── client_service.py   # create_client_manual(), create_client_from_form() (оба пробрасывают comment в лид Bitrix24 через external_api.send_client_webhook), get_clients() (search/пагинация + сортировка sort_by/order через _SORT_COLUMNS + фильтр date_from/date_to по func.date(created_at)), get_client()
│       │   ├── external_api.py     # send_client_webhook(partner, db — tracking field), fetch_bitrix_stats(), check_client_status()
│       │   ├── b24_integration_service.py # HTTP-клиент для b24-transfer-lead (httpx, X-Internal-API-Key, import_lead() для создания лидов без push в B24)
│       │   ├── b24_entity_service.py  # HTTP-прокси к b24-transfer-lead для CRM-сущностей: search_contacts(), search_companies(), create_contact(data, extra_fields?), create_company(data, extra_fields?), get_deals_by_entity() (нормализует dict: гарантирует ключи lead_id/stage_semantic_id/date_create с дефолтом None), get_leads_by_entity() (гарантирует ключ date_create), update_contact(), update_company(), get_deal_stages(workflow_id, category_id=0), get_lead_statuses(workflow_id)
│       │   ├── system_settings_service.py # get_setting(), set_setting(), get_all_settings(), get_tracking_config(), format_tracking_value(), parse_tracking_value() (обратное к format_tracking_value: 'C_<id>'/'CO_<id>' → (entity_type, entity_id)), get_default_links_config(), set_default_links_config() (back-fill стабильного uuid4 hex id для каждого link без id; существующие id сохраняются — гарантирует стабильную привязку PartnerLink ↔ DefaultLinkConfig), get_partner_code_field_config(), get_reward_percentage_field_config(), get_b24_fields_config(), set_b24_fields_config(), get_bot_links_config(), set_bot_links_config(), get_page_visibility_config() / set_page_visibility_config() (глобальная видимость 10 страниц партнёра, JSON под ключом 'partner_page_visibility'; отсутствующий ключ → True; константа PAGE_VISIBILITY_KEYS)
│       │   ├── deal_sync_service.py   # Фоновая синхронизация сделок и лидов из B24: sync_deals_for_partner(), _sync_b24_deals_for_partner(), _sync_b24_leads_for_partner(), _parse_b24_datetime(), run_sync_cycle(), sync_loop(), start_sync_task(), stop_sync_task(). ДЕДУПЛИКАЦИЯ ЛИД↔СДЕЛКА (один Client = один лид): лиды синхронизируются ПЕРВЫМИ (b24_created_at из date_create, статус лида в deal_status/deal_status_name), затем сделки; сделка с lead_id, чей лид уже синхронизирован, ОБНОВЛЯЕТ строку лида (deal_id, deal_stage/deal_stage_name через get_deal_stages, deal_amount, partner_reward) без создания дубля и без инкремента created_count; сделка без lead_id (или без лид-записи) создаёт отдельную запись source='b24_sync' со стадией в deal_stage. Commit при pending-изменениях даже без новых записей. Создаёт Client в партнёрском кабинете + Lead в b24-transfer-lead. Фильтрация по UF tracking field (приоритет) или CONTACT_ID/COMPANY_ID. Подробное логирование fetched/created/attached/skipped counts
│       │   ├── auto_link_service.py   # Авто-связывание B24 лидов/сделок с партнёрами по UF tracking field при получении webhook-событий ONCRMLEADADD/UPDATE и ONCRMDEALADD/UPDATE для сущностей, отсутствующих в workflow_db. auto_link_b24_entity(db, payload) — точка входа из proxy_b24_webhook. Внутренние: _auto_link_lead(), _auto_link_deal(), _find_partner_by_tracking_value() (parse_tracking_value + fallback итерация партнёров), _build_lead_name(), _build_deal_name(), _first_phone(), _first_email(), _calc_partner_reward(), _resolve_lead_status_name(), _resolve_deal_stage_name(), _import_lead_to_workflow() (вызов b24_service.import_lead для следующих UPDATE-евентов через стандартный путь). Идемпотентен: пропускает если Client уже есть. Для deal-евентов с LEAD_ID, если Client от lead-евента уже существует — не трогает.
│       │   ├── landing_service.py  # create_landing(), get_landings(), update_landing(), delete_landing()
│       │   ├── analytics_service.py # get_summary() (total_leads), get_links_stats() (статистика по источникам: leads_count, без link_type), get_clients_stats_by_day() (лиды по дням: только total), get_link_clicks_by_day(), get_bitrix_stats()
│       │   ├── admin_service.py    # get_admin_overview(), get_partners_stats(), get_partner_detail(), update_client_payment() (авто-расчёт partner_reward), bulk_update_client_payments(), get_partner_payment_summary(), admin_update_link(), update_partner_reward_percentage() (+ обновление % в B24), update_global_reward_percentage_in_b24(), _get_effective_reward_percentage(), _update_b24_entity_field(), toggle_partner_active(), get_pending_registrations(), get_pending_registrations_count(), approve_registration(b24_entity_type, b24_entity_id, b24_entity_name, update_partner_code) (ВСЕГДА пишет reward % в B24 UF если настроено), reject_registration(), delete_partner() (каскадное hard-delete всех связанных сущностей, не трогает B24), rebind_partner_b24_entity() (очистка у предыдущих владельцев + перепривязка + опциональная запись partner_code/% в B24), impersonate_partner(db, admin, target_id, ip) → (target, access_token, refresh_token, log_id) (валидация 404/400 self/admin/inactive, создание ImpersonationLog, генерация JWT-пары на sub=str(target.id) через create_access_token/create_refresh_token), end_impersonation(db, log_id, admin_id) (идемпотентно: SELECT по (id, admin_id), ended_at = utcnow() если найдена и активна; silently no-op на missing/foreign/closed), create_default_links_for_partner() (стампит default_link_id из конфига на каждый создаваемый PartnerLink; None при отсутствии id у legacy конфига), apply_default_links_to_all_partners(db, force: bool = False) → {partners_updated, links_created, links_updated}: (1) back-fill uuid id у enabled-конфигов через set_default_links_config (один write, идемпотентно), (2) для каждого партнёра привязка legacy PartnerLink (default_link_id IS NULL) к конфигу по совпадению target_url, (3) при force=True перезапись title/target_url/utm_source/utm_medium/utm_campaign/utm_content у matched-ссылки (защищены utm_term, link_code, is_active, default_link_id); при force=False matched пропускаются; иначе создание нового PartnerLink с default_link_id. partners_updated = уникальные партнёры с любыми изменениями. Один commit в конце
│       │   ├── notification_service.py # create_notification() (с file upload), _save_notification_upload(), get_all_notifications() (с file_url), delete_notification() (удаляет файл), get_partner_notifications() (фильтрация по target_partner_id, с file_url), get_unread_count(), mark_as_read(), mark_all_as_read()
│       │   ├── broadcast_service.py # Сервис рассылки сообщений всем ботам (pull-модель). _save_broadcast_upload() (сохранение в подпапку broadcasts/, авто-детект file_type image/video/file по расширению), create_broadcast(db, message, channels, partner_code, admin_id, files) (Broadcast + N BroadcastAttachment с position), list_broadcasts() (batch-загрузка attachments + delivery-агрегат), delete_broadcast() (удаляет файлы с диска + записи attachments/deliveries), get_pending_for_channel(db, channel, after_id) (рассылки где channel ∈ channels и id > after_id, по возрастанию id, с attachments), record_delivery(db, broadcast_id, channel, sent, failed) (upsert по (broadcast_id, channel) с накоплением sent/failed)
│       │   ├── payment_request_service.py # create_payment_request(), get_pending_count(), get_partner_requests(), get_all_requests(), get_request_detail(), process_request(), delete_request()
│       │   ├── chat_service.py    # send_message_partner(), send_message_with_file_partner(), get_partner_messages(), get_partner_unread_count(), mark_partner_messages_read(), get_conversations(), get_conversation_messages(), send_message_admin(), send_message_with_file_admin(), get_admin_total_unread_count(), mark_admin_messages_read()
│       │   ├── report_service.py  # generate_partner_report(), generate_all_partners_report(), _compute_partner_metrics(), _get_partner_clients_detail(), compute_partner_overview() (метрики «Обзора» за всё время: переиспользует _compute_partner_metrics с пустым периодом + total_paid/available_to_withdraw с учётом занятости вознаграждений в pending/approved заявках)
│       │   └── pdf_service.py     # generate_partner_report_pdf(), generate_all_partners_report_pdf() — генерация PDF через fpdf2 с DejaVu шрифтами
│       └── utils/
│           ├── __init__.py
│           ├── migrate_db.py       # migrate_partner_b24_fields(), migrate_partner_role_field(), migrate_client_payment_fields(), migrate_partner_reward_percentage(), migrate_link_utm_fields(), migrate_link_default_link_id() (default_link_id VARCHAR(36) + индекс ix_partner_links_default_link_id), migrate_notification_target_partner(), migrate_notification_file_fields(), migrate_client_deal_status_fields(), migrate_client_stage_and_dates() (ALTER clients ADD deal_stage/deal_stage_name/b24_created_at/updated_at, идемпотентно), migrate_chat_messages_table(), migrate_chat_file_fields(), migrate_partner_approval_fields(), migrate_partner_payment_details(), migrate_payment_request_details(), migrate_partner_b24_entity_fields(), migrate_partner_phone_auth() (email nullable + unique index on phone), migrate_system_settings_table(), migrate_broadcasts_tables() (CREATE TABLE IF NOT EXISTS broadcasts/broadcast_attachments/broadcast_deliveries + индексы + unique index uq_broadcast_delivery_channel на (broadcast_id, channel)), _create_impersonation_log_table() (CREATE TABLE IF NOT EXISTS impersonation_log + 3 индекса idx_impersonation_admin/target/started_at)
│           ├── create_admin.py     # ensure_admin_exists() — создание/обновление админа из env vars при старте
│           ├── merge_lead_deal_duplicates.py  # Разовая идемпотентная миграция дедупликации лид↔сделка: merge_lead_deal_duplicates(dry_run), _build_deal_lead_map(), _remap_payment_requests(), _main(). Для каждого Client source='b24_sync' определяет LEAD_ID через b24_entity_service.get_deals_by_entity, при совпадении с лид-записью (external_id='<id>' ИЛИ 'lead_<id>' — кабинетные form/manual/bot/webhook и синхронизированные лиды) переносит данные сделки в строку лида, ремапит PaymentRequest.client_ids (с дедупом) со старого id на id лида, удаляет дубль. Сделки без LEAD_ID и без существующей лид-записи не трогаются. dry_run по умолчанию (лог+rollback), --apply применяет. CLI: python -m app.utils.merge_lead_deal_duplicates [--dry-run|--apply]. Также доступна через admin POST /api/admin/maintenance/merge-lead-deal-duplicates?apply=true|false
│           └── security.py         # hash_password(), verify_password(), create_access/refresh_token()
└── frontend/
    ├── Dockerfile                  # Docker-образ frontend (node:20-alpine)
    ├── package.json                # npm-зависимости (react, axios, recharts, react-router-dom, qrcode.react)
    ├── vite.config.ts              # Vite: proxy /api → backend:8000, alias @ → src/
    ├── tsconfig.json               # TypeScript strict, paths @/* → src/*
    ├── tsconfig.node.json          # TS config для vite.config.ts
    ├── index.html                  # Точка входа HTML с div#root
    └── src/
        ├── main.tsx                # ReactDOM.createRoot, рендер App, импорт стилей
        ├── App.tsx                 # BrowserRouter + Routes (Layout, AdminLayout, ProtectedRoute, AdminProtectedRoute)
        ├── vite-env.d.ts           # Типы Vite
        ├── styles/
        │   └── index.css           # CSS reset, CSS-переменные, утилитарные классы
        ├── api/
        │   ├── client.ts           # Axios instance с JWT interceptors. baseURL='/api', добавляет Authorization: Bearer <accessToken> из cookie. Interceptor на 401: refresh -> retry, при неудаче редирект /login. Эндпоинты /auth/login, /auth/register, /auth/refresh пропускают 401-обработку, чтобы inline-error от useState не терялся при ошибке логина
        │   ├── auth.ts             # register(), login(), refresh(), getMe(), logout(), changeEmail(), changePassword(), addPaymentMethod(), deletePaymentMethod(); Partner interface (с role, saved_payment_methods), SavedPaymentMethod, ChangePasswordData
        │   ├── links.ts            # getLinks(), createLink(), updateLink(), deleteLink(); интерфейсы Link, CreateLinkData, UpdateLinkData с UTM-полями
        │   ├── clients.ts          # getClients(skip, limit, params: GetClientsParams), getClient(), createClient(); Client interface с deal_amount, partner_reward, is_paid, deal_status, deal_status_name, deal_stage_name, b24_created_at, updated_at, link_title; GetClientsParams: sort_by (ClientSortBy='created'|'updated'|'amount'), order (SortOrder='asc'|'desc'), date_from, date_to (опциональные query)
        │   ├── landings.ts         # getLandings(), createLanding(), updateLanding(), deleteLanding()
        │   ├── analytics.ts        # getSummary() (Summary: total_clicks/total_leads/conversion_rate), getOverview() (Overview: reward_percentage/total_reward/total_paid/available_to_withdraw/total_leads/conversion_leads_to_deals/total_deals/conversion_deals_to_successful/total_successful_deals — метрики «Обзора»), getLinksStats() (LinkStats без link_type, leads_count), getClientsStats() (ClientStats {date,total}), getLinkClicks(), fetchBitrixStats()
        │   ├── bitrix.ts           # setupBitrix(), getBitrixSettings(), updateBitrixSettings(), getFunnels(), getStages(), getLeads(), getStats()
        │   ├── admin.ts            # getAdminOverview(), getAdminPartners(), getAdminPartnerDetail(), getAdminConfig(), updateClientPayment(), getPartnerPaymentSummary(), updatePartnerRewardPercentage(), togglePartnerActive(), getGlobalRewardPercentage(), updateGlobalRewardPercentage(), createNotification(), getAdminNotifications(), deleteNotification(), getPendingRegistrations(), getPendingRegistrationsCount(), approveRegistration(partnerId, b24EntityData?), rejectRegistration(), searchB24Contacts(), searchB24Companies(), createB24Contact(), createB24Company(), adminRegisterPartner(), impersonatePartner(partnerId) → ImpersonateResponse {access_token, refresh_token, token_type, target_partner: Partner, log_id} (POST /admin/partners/{id}/impersonate), endImpersonation(logId) → void (POST /admin/impersonate/{logId}/end)
        │   ├── systemSettings.ts   # getSettings(), updateTrackingSettings(), updateSyncSettings(), triggerSync(), getDefaultLinksSettings(), updateDefaultLinksSettings(), applyDefaultLinksToAll(force?: boolean) → { partners_updated, links_created, links_updated }, getB24FieldsConfig(), updateB24FieldsConfig(), getBotLinks(), updateBotLinks(), getPageVisibility(), updatePageVisibility(); интерфейсы SystemSettings, TrackingConfig, SyncConfig, DefaultLinkConfig (id?, title, url, enabled, utm_*), B24FieldsConfig, BotLinksConfig (admin /admin/settings/bot-links), PageVisibilityConfig (10 булевых флагов, admin /admin/settings/page-visibility)
        │   ├── system.ts           # Публичный system API для партнёра: getBotLinks() → GET /api/system/bot-links → PublicBotLinksConfig { telegram_bot_url, max_bot_url } для блока «Используйте ботов» в GuidePage/ProfilePage; getPageVisibility() → GET /api/system/page-visibility → PageVisibilityConfig (10 булевых флагов) для фильтрации меню/гарда в Layout
        │   ├── notifications.ts    # getNotifications(), getUnreadCount(), markAsRead(), markAllAsRead()
        │   ├── paymentRequests.ts  # createPaymentRequest(), getPartnerPaymentRequests(), getPartnerPaymentRequest(), getAdminPaymentRequests(), getAdminPaymentRequest(), processPaymentRequest(), deletePaymentRequest(), getPendingCount()
        │   ├── chat.ts             # getPartnerMessages(), sendPartnerMessage(), sendPartnerFile(), getPartnerUnreadCount(), markPartnerMessagesRead(), getAdminConversations(), getAdminConversationMessages(), sendAdminMessage(), sendAdminFile(), getAdminChatUnreadCount(), markAdminMessagesRead()
        │   └── reports.ts          # getPartnerReport(), downloadPartnerReportPDF(), getAdminReport(), downloadAdminReportPDF()
        ├── context/
        │   ├── AuthContext.tsx      # AuthProvider: partner state, login/register/logout/refreshAuth, isAdmin, isImpersonating (boolean — есть ли cookie adminAccessToken), impersonationTarget ({id, name, partner_code} | null — компактный дескриптор для баннера), enterImpersonation(partnerId): копирует текущие accessToken/refreshToken в adminAccessToken/adminRefreshToken (1д/30д), записывает партнёрские токены поверх, сохраняет impersonationLogId+impersonationTarget cookies, setPartner(target_partner), window.location.href='/dashboard'. exitImpersonation(): восстанавливает admin-токены (если есть в резервных cookies), удаляет admin*+impersonation* cookies, await endImpersonation(logId) внутри try/catch (audit-close best-effort, console.warn при ошибке), window.location.href='/admin'. logout() дополнительно чистит adminAccessToken, adminRefreshToken, impersonationLogId, impersonationTarget cookies и сбрасывает isImpersonating/impersonationTarget
        │   └── ToastContext.tsx     # ToastProvider: toast-уведомления
        ├── utils/
        │   ├── cookies.ts          # setCookie(name, value, days), getCookie(name), deleteCookie(name) — хранение JWT-токенов в cookies вместо localStorage
        │   ├── formatClientSource.ts # formatClientSource(source) — маппинг machine-кодов источника клиента (manual → Вручную, form → Лендинг, telegram_bot → Telegram-бот, max_bot → Max-бот, b24_sync → Сделка B24, b24_lead_sync → Лид B24, null → —, unknown → as-is); используется в ClientsTable и AdminPartnerDetailPage
        │   ├── getPartnerIdentifier.ts # getPartnerIdentifier(partner) — возвращает email || phone || '—'; используется во всех компонентах отображения партнёра
        │   └── normalizePhone.ts    # normalizePhone(value) — приводит «8XXXXXXXXXX»/«7XXXXXXXXXX» к «+7XXXXXXXXXX», нормализует ввод с «+» (стрипает нецифры). Применяется в RegisterPage и AdminRegistrationsPage (формы createForm/registerForm)
        ├── hooks/
        │   ├── useAuth.ts          # Custom hook для доступа к AuthContext
        │   ├── useToast.ts         # Custom hook для доступа к ToastContext
        │   ├── useApi.ts           # Обобщённый hook для API-запросов
        │   └── usePageVisibility.ts # Хук загрузки глобального конфига видимости страниц (GET /system/page-visibility) с module-level promise-кэшем (один запрос). Возвращает { visibility, loading }; пока loading — не редиректить; при ошибке visibility=null (всё видимо). Используется в Layout
        ├── components/
        │   ├── Layout.tsx          # Sidebar (навигация партнёра — NAV_ITEMS: Обзор, Ссылки, «Лиды» (бывш. Клиенты), Лендинги, «Отчеты и финансы» (бывш. Отчёты; туда встроен блок Выплаты), «Чат с поддержкой» с badge, Bitrix24, Профиль, Справка; пункты Аналитика и Выплаты удалены, иконка WalletIcon удалена), Header с NotificationBell, Content. Обёрнут в outer column-flex; <ImpersonationBanner /> рендерится первым ребёнком над .layout. Через usePageVisibility() фильтрует NAV_ITEMS (скрытые visibility[key]===false не рендерятся) и гардит прямой доступ: pathToVisibilityKey(pathname) (incl /links/:id, /landings/:id; легаси /payment-requests → ключ reports) → если страница скрыта → <Navigate to="/profile" replace>; во время loading редиректа нет; /profile всегда видим. PAGE_TITLES: /clients→«Лиды», /reports и /payment-requests→«Отчеты и финансы», /chat→«Чат с поддержкой»
        │   ├── Layout.css          # Стили Layout: sidebar, header, responsive
        │   ├── AdminLayout.tsx     # Sidebar (навигация админа: Обзор, Заявки с badge, Партнёры, Отчёты, Запросы на выплату с badge, Чат с badge, Уведомления, Рассылка, B24 Transfer Lead). Обёрнут в outer column-flex; <ImpersonationBanner /> рендерится первым ребёнком над .layout
        │   ├── ImpersonationBanner.tsx # Sticky-полоса (z-1000, оранж #f59e0b) — рендерится только когда useAuth().isImpersonating === true. Показывает имя/код целевого партнёра + кнопку «Вернуться к админу» вызывающую exitImpersonation(). Disabled состояние при выполнении exit.
        │   ├── DateRangePicker.tsx # Компонент выбора периода (два input[date] + пресеты: Сегодня, Неделя, Месяц, Квартал, Год, Всё время)
        │   ├── ProtectedRoute.tsx   # Редирект на /login если не аутентифицирован
        │   ├── AdminProtectedRoute.tsx # Проверка isAdmin, редирект на /dashboard если не админ
        │   ├── NotificationBell.tsx # Колокольчик с badge, выпадающий список, поллинг каждые 30 сек
        │   ├── QRCodeBlock.tsx     # QR-код для ссылки (QRCodeCanvas + QRCodeSVG), кнопки «Скачать PNG» / «Скачать SVG»
        │   ├── LinkGenerator.tsx    # Embed-код с копированием
        │   ├── CrmForm.tsx          # Форма ручного добавления лида (поля Имя, Телефон, «Источник создания лида» — select по активным ссылкам с пустым вариантом «Вручную», Комментарий); комментарий уходит в лид Bitrix24
        │   ├── ClientsTable.tsx     # Презентационная (controlled) таблица лидов с пагинацией. Колонки: Имя, Ссылка (link_title), Статус лида (deal_status_name, цветной бейдж), Стадия сделки (deal_stage_name — показывается только если есть стадия), Сумма (deal_amount, currency RUB, сортируемая), Дата создания (b24_created_at||created_at, сортируемая), Дата обновления (updated_at, сортируемая). Колонки Источник/Webhook убраны. Кликабельные заголовки дёргают onSortChange; встроен DateRangePicker (фильтр по дате создания). Состояние sort/filter — в ClientsPage
        │   ├── StatsCard.tsx        # Карточка метрики
        │   ├── PaymentRequestsSection.tsx # Блок «Выплаты» (вынесен из бывшей PaymentRequestsPage). Список заявок партнёра на выплату (бейджи статусов) + модалка создания: выбор eligible-клиентов (partner_reward>0 && !is_paid) с подсчётом суммы, выбор/добавление реквизитов, комментарий, createPaymentRequest. Встраивается ПЕРВЫМ на ReportsPage
        │   ├── ClickChart.tsx       # Recharts AreaChart кликов
        │   └── broadcast/
        │       ├── previewUtils.ts      # Утилиты превью рассылки: sanitizePreviewHtml (escape + whitelist b,strong,i,em,u,s,a[http(s) href],code,br), fileToPreviewAttachment (File→{url:objectURL,name,type}), nowTime; тип PreviewAttachment
        │       ├── MaxPreview.tsx        # Презентационное превью рассылки в стиле Max (фиолетово-белая палитра, входящий пузырь, вложения img/video/чип файла, время)
        │       └── TelegramPreview.tsx   # Презентационное превью рассылки в стиле Telegram (бирюзово-серый узорчатый фон, белый пузырь с двойными галочками, вложения)
        └── pages/
            ├── LoginPage.tsx        # Форма входа (редирект admin → /admin, partner → /dashboard)
            ├── RegisterPage.tsx     # Форма регистрации
            ├── LinksPage.tsx        # Список ссылок + кнопка «Копировать» (в буфер обмена) + кнопка QR (модалка) + UTM-метки в форме создания
            ├── LinkDetailPage.tsx   # Детали ссылки + QR-код + UTM-метки (отображение/редактирование)
            ├── ClientsPage.tsx      # Страница «Лиды» (бывш. Клиенты): владеет состоянием sortBy/order/dateFrom/dateTo/skip, дёргает getClients с sort_by/order/date_from/date_to, рендерит CrmForm + ClientsTable
            ├── LandingsPage.tsx     # Список лендингов
            ├── LandingEditorPage.tsx # Редактор лендинга
            ├── DashboardPage.tsx    # «Обзор» (бывш. Дашборд): 9 карточек StatsCard из getOverview() (% вознаграждения, вознаграждение, выплачено, к выплате, лиды, конверсия в сделку, сделки, конверсия в успех, успешные сделки) + блок «Статистика по источникам» (таблица без колонки Тип, leads_count, клик ведёт на /links/:id) + блок «Статистика создания лидов по дням» (recharts BarChart, один Bar total). Блок BitrixStats удалён
            ├── ReportsPage.tsx      # Страница «Отчеты и финансы»: ПЕРВЫМ блоком PaymentRequestsSection («Выплаты»); заголовок «Сформировать детализированный отчет», подпись «Отчетный период» над DateRangePicker; 7 StatsCard (Текущая сумма к выплате, Выплачено, Кол-во лидов, Конверсия в сделку %, Кол-во сделок, Конверсия в успешную сделку %, Кол-во успешных сделок) из report.metrics; таблица «Лиды» (колонки Название, Сумма, Комиссия, Оплата, Статус, Дата обновления); скачать PDF
            ├── ChatPage.tsx         # Чат партнёра с поддержкой (заголовок «Чат с поддержкой» из Layout-хедера): пузыри сообщений, ввод, авто-скролл, поллинг 30с
            ├── BitrixSettingsPage.tsx # Настройки Bitrix24
            ├── ProfilePage.tsx       # Профиль партнёра: отображение данных (email, имя, компания, код), настройки отображения (тогл «Топ ссылок»), блок «Используйте ботов» (getBotLinks при монтировании, кнопки Telegram/Max только при настроенных URL) + смена пароля
            ├── GuidePage.tsx        # Справка для партнёров: блок «Используйте ботов» (кнопки Telegram/Max из getBotLinks) + аккордеон с разделами (начало работы, ссылки, клиенты, лендинги, аналитика, выплаты, чат, уведомления, Bitrix24, профиль)
            ├── NotFoundPage.tsx     # Страница 404
            └── admin/
                ├── AdminDashboardPage.tsx    # Обзорная статистика по всем партнёрам + таблица
                ├── AdminPartnersPage.tsx     # Полная таблица всех партнёров с детализацией
                ├── AdminPartnerDetailPage.tsx # Детальная информация об одном партнёре (ссылки, клиенты, QR-код для ссылок через QRCodeBlock). Содержит кнопку «Войти от лица партнёра» (disabled когда partner.role==='admin' || !partner.is_active) с модалкой подтверждения; вызывает useAuth().enterImpersonation(partner.id) — функция сама редиректит на /dashboard
                ├── AdminNotificationsPage.tsx # Создание и управление уведомлениями для партнёров
                ├── AdminBroadcastsPage.tsx   # Рассылка сообщений всем пользователям 4 ботов: двухколоночный layout — слева форма (textarea с HTML-форматированием, 4 чекбокса каналов max_users/tg_users/max_partners/tg_partners, фильтр partner_code, мультизагрузка файлов с превью-чипами) + история со статистикой доставки и удалением; справа переключатель Max/Telegram + живое превью (MaxPreview/TelegramPreview)
                ├── AdminReportsPage.tsx      # Сводный отчёт админа: DateRangePicker, выбор партнёра, StatsCard-метрики, таблица по партнёрам, скачать PDF
                ├── AdminRegistrationsPage.tsx    # Управление заявками на регистрацию: таблица pending-заявок, одобрение с привязкой B24 (создание/поиск контакта/компании), чекбокс записи partner_code в B24, отклонение с причиной, регистрация партнёра админом
                ├── AdminSettingsPage.tsx         # Настройки: отслеживание партнёров (UF поля в лидах/сделках), поля B24 (UF поля для partner_code и % вознаграждения), синхронизация сделок из B24 (вкл/выкл, интервал, ручной запуск), стандартные ссылки для новых партнёров (список с title/url/enabled, добавление/удаление, сохранение), кнопки "Применить ко всем партнёрам" (создаёт недостающие ссылки) и "Принудительно обновить у всех партнёров" (warning style, перезаписывает title/URL/UTM у существующих ссылок, сохраняет utm_term и link_code) — обе показывают тост с partners_updated/links_created/links_updated
                ├── AdminPaymentRequestsPage.tsx # Управление запросами на выплату: таблица, фильтр по статусу, одобрение/отклонение
                ├── AdminChatPage.tsx         # Двухпанельный чат админа: список переписок слева, сообщения справа, поллинг 30с
                └── AdminB24Page.tsx          # B24 Transfer Lead в iframe внутри админ-панели

Модели данных

Partner (partners)

Партнёр или администратор системы. Поля: email, password_hash, name, company, partner_code (uuid[:8]), role ("partner" | "admin"), is_active, approval_status ("pending" | "approved" | "rejected"), rejection_reason (nullable), reward_percentage (nullable, индивидуальный % вознаграждения), payment_details (JSON-массив сохранённых способов оплаты: [{id, label, value}], свойство saved_payment_methods), workflow_id, b24_api_token. Связи: links (1:N), clients (1:N), landings (1:N).

PartnerLink (partner_links)

Партнёрская ссылка. Типы: direct, iframe, landing. Поля: title, link_type, link_code (uuid[:10]), target_url, landing_id, utm_source, utm_medium, utm_campaign, utm_content, utm_term, default_link_id (VARCHAR(36), nullable, indexed — uuid hex DefaultLinkConfig, из которого ссылка была сгенерирована; используется для трассировки происхождения и для admin_service.apply_default_links_to_all_partners — при force=true перезаписываются title/target_url/utm_source/utm_medium/utm_campaign/utm_content у matched-ссылок; utm_term, link_code, is_active и default_link_id защищены от перезаписи). Связи: partner (N:1), clicks (1:N), clients (1:N), landing (N:1).

LinkClick (link_clicks)

Клик по ссылке. Поля: link_id, ip_address, user_agent, referer. Связи: link (N:1).

Client (clients)

Клиент/лид партнёра. Источник: form или manual. Поля: name, phone, email, company, comment, external_id, webhook_sent, deal_amount, partner_reward, is_paid, paid_at, payment_comment, deal_id (ID сделки в Bitrix24), deal_status/deal_status_name (СТАТУС ЛИДА, crm.lead STATUS_ID), deal_stage/deal_stage_name (СТАДИЯ СДЕЛКИ из воронки, crm.deal STAGE_ID), b24_created_at (дата создания лида в Bitrix24, DATE_CREATE, с fallback на created_at), created_at, updated_at (onupdate=datetime.utcnow — автообновление при изменении записи). Связи: partner (N:1), link (N:1).

LandingPage (landing_pages)

Лендинг партнёра. Поля: title, description, header_text, button_text, theme_color, is_active. Связи: partner (N:1), images (1:N cascade), links (1:N).

LandingImage (landing_images)

Изображение лендинга. Поля: landing_id, file_path, sort_order. Связи: landing (N:1, ondelete CASCADE).

Notification (notifications)

Уведомление от администратора. Поля: title, message, created_by (FK partners.id), target_partner_id (FK partners.id, nullable — если NULL, broadcast всем; если задан, только конкретному партнёру), file_path (String(500), nullable — относительный путь к файлу в uploads/notifications/), file_name (String(255), nullable — оригинальное имя файла), created_at. Допустимые форматы файлов: jpg, jpeg, png, gif, webp, mp4, mov, avi, pdf, doc, docx, xls, xlsx, csv, txt. Макс. размер: 50 МБ.

NotificationRead (notification_reads)

Запись о прочтении уведомления. Поля: notification_id (FK notifications.id, CASCADE), partner_id (FK partners.id), read_at.

PaymentRequest (payment_requests)

Запрос партнёра на выплату вознаграждения. Поля: partner_id (FK partners.id), status ("pending" | "approved" | "rejected" | "paid"), total_amount, client_ids (JSON-массив ID клиентов), comment (партнёра), payment_details (реквизиты для выплаты), admin_comment, created_at, processed_at, processed_by (FK partners.id, nullable). Жизненный цикл: pending → approved → paid (или pending → rejected). При approved клиенты НЕ помечаются оплаченными; при paid — is_paid=True, paid_at=now.

ChatMessage (chat_messages)

Сообщение в чате между партнёром и админом. Поля: partner_id (FK partners.id — к какому партнёру относится переписка), sender_id (FK partners.id — кто отправил), message (Text), file_path (String(500), nullable — относительный путь к файлу в uploads/), file_name (String(255), nullable — оригинальное имя файла), is_read (Boolean, default False), created_at. Индекс по partner_id. Группировка по partner_id даёт одну беседу на партнёра. Файлы сохраняются в uploads/chat/{partner_id}/{uuid}.{ext}.

ImpersonationLog (impersonation_log)

Audit-лог сессий "вход админа от лица партнёра". Поля: admin_id (FK partners.id, NOT NULL, indexed — кто из админов запустил сессию), target_partner_id (FK partners.id, NOT NULL, indexed — от лица какого партнёра), started_at (DateTime, default datetime.utcnow, NOT NULL, indexed — начало сессии), ended_at (DateTime, nullable — конец сессии, NULL пока активна), ip_address (VARCHAR(64), nullable — IP админа на момент входа). Используется для безопасности и для определения активных сессий в UI. Файл: backend/app/models/impersonation_log.py.

Broadcast / BroadcastAttachment / BroadcastDelivery (broadcasts, broadcast_attachments, broadcast_deliveries)

Рассылка сообщений всем пользователям всех ботов (пользовательских max_bot_users/tg_bot_users и партнёрских max_bot/telegram_bot). Pull-модель: backend хранит рассылки, боты периодически опрашивают bot-эндпоинт и шлют своим пользователям. Broadcast: message (Text), channels (Text — JSON-массив каналов max_users/tg_users/max_partners/tg_partners), partner_code (String, nullable — опциональный фильтр по партнёру), created_by (FK partners.id — админ), created_at. BroadcastAttachment: broadcast_id (FK CASCADE, indexed), file_path/file_name, file_type (image/video/file), position (порядок) — несколько вложений на пост. BroadcastDelivery: broadcast_id (FK CASCADE, indexed), channel, sent/failed (счётчики), updated_at; уникальность по (broadcast_id, channel) — агрегированная статистика, боты делают upsert. Файл: backend/app/models/broadcast.py. Аутентификация ботов к backend — общий секрет BROADCAST_API_KEY (заголовок X-Broadcast-Token), т.к. пользовательские боты не имеют JWT.

API эндпоинты

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

Метод URL Описание Auth
POST /api/auth/register Регистрация партнёра Нет
POST /api/auth/login Логин, получение JWT-токенов Нет
POST /api/auth/refresh Обновление access token Нет
GET /api/auth/me Текущий партнёр (с полем role, saved_payment_methods) Да
POST /api/auth/payment-methods Добавить сохранённый способ оплаты Да
DELETE /api/auth/payment-methods/{id} Удалить сохранённый способ оплаты Да

Ссылки

Метод URL Описание Auth
GET /api/links/ Список ссылок партнёра Да
POST /api/links/ Создание ссылки (с UTM-полями) Да
GET /api/links/{id} Детали ссылки (с UTM-полями) Да
PUT /api/links/{id} Обновление ссылки (с UTM-полями) Да
DELETE /api/links/{id} Деактивация ссылки Да
GET /api/links/{id}/embed-code Embed-код ссылки (с redirect_url_with_utm) Да

Лендинги

Метод URL Описание Auth
GET /api/landings/ Список лендингов партнёра Да
POST /api/landings/ Создание лендинга Да
GET /api/landings/{id} Детали лендинга Да
PUT /api/landings/{id} Обновление лендинга Да
DELETE /api/landings/{id} Деактивация лендинга Да
POST /api/landings/{id}/images Загрузка изображения Да
DELETE /api/landings/{id}/images/{image_id} Удаление изображения Да

Клиенты

Метод URL Описание Auth
GET /api/clients/ Список лидов партнёра (пагинация, search, sort_by/order, date_from/date_to) Да
POST /api/clients/ Ручное создание лида + webhook (comment пробрасывается в лид B24) Да
GET /api/clients/{id} Детали лида Да

Запросы на выплату (партнёр)

Метод URL Описание Auth
POST /api/payment-requests/ Создать запрос на выплату Partner
GET /api/payment-requests/ Список своих запросов Partner
GET /api/payment-requests/{id} Детали запроса Partner

Аналитика

Метод URL Описание Auth
GET /api/analytics/summary Общая статистика (total_leads) Да
GET /api/analytics/overview Метрики «Обзора» (вознаграждение, выплаты, воронка лиды→сделки) Да
GET /api/analytics/links Статистика по источникам (без типа) Да
GET /api/analytics/links/{id}/clicks Клики по дням для ссылки Да
GET /api/analytics/clients/stats Лиды по дням (только total) Да
POST /api/analytics/bitrix/fetch Запрос данных из Bitrix24 Да

Bitrix24 настройки

Метод URL Описание Auth
POST /api/bitrix/setup Первоначальная настройка Bitrix24 Да
GET /api/bitrix/settings Получение настроек workflow Да
PUT /api/bitrix/settings Обновление настроек Да
GET /api/bitrix/funnels Список воронок из Bitrix24 Да
GET /api/bitrix/stages Список этапов воронки Да
GET /api/bitrix/lead-statuses Список статусов лидов Да
GET /api/bitrix/leads Список лидов из b24-transfer-lead Да
GET /api/bitrix/stats Статистика конверсии Да

Отчёты (партнёр)

Метод URL Описание Auth
GET /api/reports JSON-отчёт партнёра за период Partner
GET /api/reports/pdf Скачать PDF-отчёт партнёра Partner

Публичные

Метод URL Описание Auth
GET /api/public/r/{code} Публичный редирект + запись клика (с UTM-параметрами) Нет
GET /api/public/links/{code}/utm UTM-метки активной ссылки для ботов (404 если нет/неактивна, клик не пишется) Нет
GET /api/public/landing/{code} Публичная страница лендинга (Jinja2) Нет
POST /api/public/form/{code} Приём формы лендинга, создание клиента Нет
POST /api/public/webhook/b24 Прокси webhook из Bitrix24 в b24-transfer-lead + разделение статуса лида (deal_status) и стадии сделки (deal_stage) + updated_at + авто-расчёт deal_amount/partner_reward + уведомление с суммой и комиссией Нет

Админ-панель (требуют role=admin)

Метод URL Описание Auth
GET /api/admin/registrations Список pending-заявок на регистрацию Admin
GET /api/admin/registrations/count Количество pending-заявок (для badge) Admin
POST /api/admin/registrations/{id}/approve Одобрить заявку на регистрацию Admin
POST /api/admin/registrations/{id}/reject Отклонить заявку (с причиной) Admin
GET /api/admin/overview Агрегированная статистика Admin
GET /api/admin/partners Статистика по каждому партнёру Admin
GET /api/admin/partners/{id} Детали одного партнёра Admin
PUT /api/admin/clients/{id}/payment Обновить данные оплаты клиента Admin
PUT /api/admin/clients/bulk-payment Массовое обновление оплаты клиентов Admin
GET /api/admin/partners/{id}/payments Итоги выплат по партнёру Admin
PUT /api/admin/partners/{id}/toggle-active Переключить активность партнёра Admin
PUT /api/admin/partners/{id}/reward-percentage Установить индивидуальный % вознаграждения Admin
GET /api/admin/reward-percentage Получить глобальный % вознаграждения Admin
PUT /api/admin/reward-percentage Изменить глобальный % вознаграждения Admin
GET /api/admin/config Конфиг (URL b24, default_reward_percentage) Admin
POST /api/admin/notifications Создать уведомление (multipart: Form title, message + File) Admin
GET /api/admin/notifications Все уведомления Admin
DELETE /api/admin/notifications/{id} Удалить уведомление Admin
GET /api/admin/payment-requests Все запросы на выплату Admin
GET /api/admin/payment-requests/{id} Детали запроса на выплату Admin
PUT /api/admin/payment-requests/{id} Одобрить / Отклонить запрос Admin
GET /api/admin/payment-requests/pending-count Количество pending-запросов (для badge) Admin
GET /api/admin/reports JSON сводный отчёт по всем/одному партнёру Admin
GET /api/admin/reports/pdf Скачать PDF сводный отчёт Admin

Чат (партнёр)

Метод URL Описание Auth
GET /api/chat/messages Сообщения переписки партнёра Partner
POST /api/chat/messages Отправить сообщение админу Partner
POST /api/chat/messages/file Отправить файл (multipart: file + message) Partner
GET /api/chat/unread-count Непрочитанные (для badge) Partner
POST /api/chat/read Пометить прочитанными Partner

Чат (админ)

Метод URL Описание Auth
GET /api/admin/chat/conversations Список переписок Admin
GET /api/admin/chat/conversations/{id}/messages Сообщения переписки Admin
POST /api/admin/chat/conversations/{id}/messages Ответить партнёру Admin
POST /api/admin/chat/conversations/{id}/messages/file Отправить файл партнёру (multipart) Admin
GET /api/admin/chat/unread-count Всего непрочитанных Admin
POST /api/admin/chat/conversations/{id}/read Пометить прочитанными Admin

Уведомления партнёра

Метод URL Описание Auth
GET /api/notifications/ Уведомления с is_read (фильтрация по target_partner_id) Да
GET /api/notifications/unread-count Кол-во непрочитанных Да
POST /api/notifications/{id}/read Прочитать одно Да
POST /api/notifications/read-all Прочитать все Да

Маршруты фронтенда

URL Компонент Layout Auth
/ → /dashboard
/login LoginPage Нет Нет
/register RegisterPage Нет Нет
/dashboard DashboardPage Layout Partner
/links LinksPage Layout Partner
/links/:id LinkDetailPage Layout Partner
/clients ClientsPage Layout Partner
/landings LandingsPage Layout Partner
/landings/:id/edit LandingEditorPage Layout Partner
/analytics → /dashboard (редирект) Layout Partner
/reports ReportsPage (+ блок Выплаты) Layout Partner
/payment-requests → /reports (редирект) Layout Partner
/chat ChatPage Layout Partner
/bitrix-settings BitrixSettingsPage Layout Partner
/profile ProfilePage Layout Partner
/guide GuidePage Layout Partner
/admin AdminDashboardPage AdminLayout Admin
/admin/registrations AdminRegistrationsPage AdminLayout Admin
/admin/settings AdminSettingsPage AdminLayout Admin
/admin/partners AdminPartnersPage AdminLayout Admin
/admin/partners/:id AdminPartnerDetailPage AdminLayout Admin
/admin/reports AdminReportsPage AdminLayout Admin
/admin/payment-requests AdminPaymentRequestsPage AdminLayout Admin
/admin/chat AdminChatPage AdminLayout Admin
/admin/notifications AdminNotificationsPage AdminLayout Admin
/admin/b24 AdminB24Page AdminLayout Admin
* NotFoundPage Нет Нет

Система ролей

  • partner (по умолчанию) — доступ к партнёрскому кабинету
  • admin — доступ к админ-панели, создаётся автоматически при старте из ADMIN_EMAIL/ADMIN_PASSWORD (env vars)

Проверка роли — через get_admin_user() dependency (403 если role != "admin").

Система уведомлений

  • In-app уведомления от админа для всех партнёров (broadcast) или для конкретного партнёра (targeted через target_partner_id)
  • Таблица notifications — уведомления с опциональным target_partner_id, notification_reads — записи прочтения
  • Если target_partner_id = NULL — broadcast всем партнёрам (текущее поведение)
  • Если target_partner_id задан — уведомление видно только этому партнёру
  • Партнёрский UI: колокольчик с badge (непрочитанные), поллинг каждые 30 сек
  • Поддержка файлов: изображения, видео, документы (хранение в uploads/notifications/{uuid}.{ext})
  • Админский UI: форма создания с file input + превью + список с удалением (📎 индикатор)
  • Партнёрский UI (bell): изображения inline (thumbnail), видео/документы как ссылки
  • Telegram-бот: скачивание файла через get_raw_bytes() + отправка send_photo/send_video/send_document

Система запросов на выплату

  • Партнёр выбирает клиентов с рассчитанным вознаграждением (partner_reward != NULL) и создаёт запрос на выплату
  • Валидация: клиенты принадлежат партнёру, вознаграждение рассчитано, клиенты не в другом pending-запросе
  • Сумма автоматически рассчитывается как сумма partner_reward выбранных клиентов
  • Админ видит badge с количеством pending-запросов на пункте «Запросы на выплату» в сайдбаре (поллинг каждые 30 сек)
  • Админ может одобрить или отклонить запрос с комментарием
  • Двухэтапная выплата: при одобрении партнёр получает уведомление "одобрен, ожидайте 1-3 дня"; при переводе в статус "paid" клиенты помечаются оплаченными (is_paid=True), партнёр получает уведомление "выплата выполнена"
  • При обработке запроса создаётся адресное уведомление для партнёра (через target_partner_id)

Чат партнёр-админ

  • У каждого партнёра — одна переписка с админом, группировка по partner_id в chat_messages
  • Поддержка отправки файлов (изображения, документы) — допустимые форматы: jpg, jpeg, png, gif, webp, pdf, doc, docx, xls, xlsx, csv, txt; макс. 10 МБ
  • Файлы сохраняются в uploads/chat/{partner_id}/{uuid}.{ext}, отдаются через /uploads/
  • Партнёр: полноэкранный чат с пузырями (партнёр справа #1a73e8, админ слева #f1f3f4), ввод + отправка текста/файлов, авто-скролл, поллинг 30 сек
  • Админ: двухпанельный layout — слева список переписок (имя, превью, badge), справа выбранная переписка с отправкой текста/файлов, поллинг 30 сек
  • Telegram-бот: обработка фото и документов в ChatStates.active — скачивание из Telegram API, загрузка на backend через multipart
  • Фронтенд: изображения показываются inline (max-width: 300px, кликабельные), документы — ссылкой на скачивание
  • Badge в сайдбаре партнёра (Layout) — непрочитанные сообщения от админа
  • Badge в сайдбаре админа (AdminLayout) — всего непрочитанных сообщений от партнёров
  • Mark-read при открытии чата (партнёр) и при выборе переписки (админ)
  • is_read отслеживается раздельно: для партнёра — сообщения от админа (sender_id != partner_id), для админа — сообщения от партнёра (sender_id == partner_id)

Система отчётов (PDF)

  • Генерация отчётов с ключевыми бизнес-метриками: лиды, сделки, конверсии (лиды→сделки, сделки→успешные), продажи, комиссия, выплаты, клики, запросы на выплату
  • Партнёр: отчёт по себе за выбранный период (JSON + PDF)
  • Админ: сводный отчёт по всем партнёрам или по конкретному (JSON + PDF)
  • PDF-генерация через fpdf2 с DejaVu шрифтами (Unicode/русский текст)
  • Dockerfile устанавливает fonts-dejavu-core для доступа к шрифтам в контейнере
  • Структура PDF: заголовок, период, блок метрик (таблица 2 колонки), таблица клиентов/партнёров
  • report_service.py — агрегация данных из Client, LinkClick, PaymentRequest
  • pdf_service.py — генерация PDF с таблицами через table() context manager fpdf2
  • Лиды в работе: deal_status NOT IN ('WON', 'LOSE', 'C:WON', 'C:LOSE') OR deal_status IS NULL
  • Конверсия лиды → сделки: клиенты с deal_status != NULL или deal_amount > 0 / total_leads
  • Конверсия сделки → успешные: клиенты с deal_status IN ('WON', 'C:WON') / total_deals
  • Метрики конверсии: total_deals, total_successful_deals, total_lost_deals, conversion_leads_to_deals (%), conversion_deals_to_successful (%)
  • Компонент DateRangePicker: два input[date] + пресеты (Сегодня, Неделя, Месяц, Квартал, Год, Всё время)

QR-коды

  • Генерация QR-кодов полностью на фронтенде (библиотека qrcode.react)
  • QR-код кодирует URL /api/public/r/{link_code} (полный URL с origin)
  • Компонент QRCodeBlock: QRCodeCanvas для отображения, QRCodeSVG для экспорта
  • Кнопки «Скачать PNG» и «Скачать SVG»
  • Доступен на странице деталей ссылки (LinkDetailPage) и через кнопку «QR» в таблице ссылок (LinksPage, модалка)

UTM-метки

  • 5 опциональных UTM-полей на PartnerLink: utm_source, utm_medium, utm_campaign, utm_content, utm_term
  • Задаются при создании/редактировании ссылки
  • При redirect через /api/public/r/{code} UTM-параметры автоматически добавляются к target_url
  • EmbedCodeResponse включает redirect_url_with_utm с полным URL включая UTM-параметры
  • Хелпер _build_url_with_utm() корректно мержит UTM с существующими query-параметрами URL

Инфраструктура

  • docker-compose.dev.yml — четыре сервиса (b24-service, b24-frontend, backend, frontend) в одной сети
  • b24-service: b24-transfer-lead API, порт 7860 (только внутри docker-сети), volume b24-data для SQLite и workflows
  • b24-frontend: b24-transfer-lead UI (Vite dev server), порт 3000 (только внутри docker-сети), base=/b24/, проксируется через frontend Vite
  • Backend: порт 8003, volume ./backend:/app и ./data:/app/data, depends_on b24-service
    • Env: DATABASE_URL, SECRET_KEY, CORS_ORIGINS, B24_SERVICE_URL, B24_INTERNAL_API_KEY, B24_WEBHOOK_URL, B24_ENTITY_TYPE, B24_DEAL_CATEGORY_ID, B24_DEAL_STAGE_ID, B24_LEAD_STATUS_ID, B24_FIELD_MAPPINGS, DEFAULT_REWARD_PERCENTAGE, ADMIN_EMAIL, ADMIN_PASSWORD, B24_SERVICE_FRONTEND_URL, PUBLIC_BASE_URL
  • Frontend: порт 5173, proxy /api → backend:8003, depends_on backend
  • SQLite: файл data/app.db, персистентность через Docker volume
  • Uploads: директория backend/uploads для загруженных изображений лендингов

Система одобрения регистрации

  • При регистрации партнёр создаётся с is_active=False, approval_status="pending" — без доступа к системе
  • Админ видит заявки в разделе «Заявки» в сайдбаре с badge (количество pending), поллинг каждые 30 сек
  • Одобрение: approval_status="approved", is_active=True, создание B24 workflow
  • Отклонение: approval_status="rejected", сохранение причины в rejection_reason
  • При попытке входа: pending → сообщение "ожидает рассмотрения", rejected → сообщение с причиной, is_active=False → "деактивирован"
  • Существующие партнёры при миграции получают approval_status="approved" автоматически

Интеграция с b24-transfer-lead

Partner Cabinet (backend:8000)  --HTTP-->  b24-transfer-lead (b24-service:7860)  --API-->  Bitrix24
       │                                           │
   Partner model                            Workflow per partner
   (workflow_id, b24_api_token)             (leads DB, webhooks, stats)
  • Сервис-к-сервису: заголовок X-Internal-API-Key (bypass сессионной auth в b24-transfer-lead)
  • При одобрении регистрации партнёра автоматически создаётся workflow с настройками из env:
    • B24_WEBHOOK_URL — единый webhook URL для всех партнёров
    • B24_ENTITY_TYPE — тип сущности (lead/deal)
    • B24_DEAL_CATEGORY_ID, B24_DEAL_STAGE_ID, B24_LEAD_STATUS_ID — параметры создания
    • B24_FIELD_MAPPINGS — JSON-массив маппинга полей (field_name → bitrix24_field_id)
  • Клиенты передаются как лиды/сделки через API b24-transfer-lead
  • Статистика и конверсия получаются через API b24-transfer-lead
  • B24IntegrationService (b24_integration_service.py) — HTTP-клиент (httpx, таймаут 120s для создания лидов) для всех операций
  • Ссылка на UI b24-transfer-lead доступна в админ-панели (B24_SERVICE_FRONTEND_URL)

Telegram-бот для партнёров

Telegram-бот — чистый API-клиент на aiogram 3.x, вызывает те же REST-эндпоинты, что и React-фронтенд. Отдельный Docker-сервис, без прямого доступа к БД.

Telegram API  <-->  telegram-bot (aiogram 3.x)  --HTTP/JWT-->  backend:8003

Стек: Python 3.11, aiogram 3.25, httpx, pydantic-settings, qrcode[pil]

Структура telegram_bot/

telegram_bot/
├── Dockerfile                     # python:3.11-slim, pip install, CMD python -m bot.main
├── requirements.txt               # aiogram, httpx, pydantic, pydantic-settings
├── bot/
│   ├── __init__.py
│   ├── main.py                    # Entry point: Bot + Dispatcher + polling + фоновые pollers (уведомлений + рассылок). KnownUsersMiddleware регистрируется на ВСЕ роутеры (public start/auth + protected) для message и callback_query; poll_broadcasts(bot) запускается через asyncio.create_task рядом с poll_notifications, оба отменяются в finally
│   ├── config.py                  # Settings (TELEGRAM_BOT_TOKEN, BACKEND_URL, NOTIFICATION_POLL_INTERVAL, DATA_DIR — каталог для known_users.db, BROADCAST_API_KEY — общий секрет для broadcast-эндпоинтов, пусто → broadcast-поллер выключен)
│   ├── api_client/
│   │   ├── __init__.py
│   │   ├── base.py                # httpx AsyncClient с JWT auth + auto-refresh на 401, get_bytes() для PDF, post_file() для multipart upload, get_raw_bytes() для загрузки файлов с root URL
│   │   ├── auth.py                # login(), get_me()
│   │   ├── analytics.py           # get_summary(), get_links_stats()
│   │   ├── links.py               # get_links(), get_link(), create_link()
│   │   ├── clients.py             # get_clients(), get_client(), create_client()
│   │   ├── reports.py             # get_report(), get_report_pdf() (bytes)
│   │   ├── payment_requests.py    # get_payment_requests(), get_payment_request(), create_payment_request()
│   │   ├── chat.py                # get_messages(), send_message(), send_file(), get_unread_count(), mark_read()
│   │   └── notifications.py       # get_notifications(), get_unread_count(), mark_as_read(), mark_all_as_read()
│   ├── handlers/
│   │   ├── __init__.py
│   │   ├── start.py               # /start, /help, /cancel
│   │   ├── auth.py                # /login (FSM: email → password), /logout
│   │   ├── dashboard.py           # Кнопка «Дашборд» — метрики из /api/analytics/summary
│   │   ├── analytics.py           # Кнопка «Аналитика» — расширенная аналитика
│   │   ├── links.py               # Кнопка «Ссылки» — список, детали, QR-код (генерация PNG через qrcode), создание (FSM: title → type → url → utm → confirm)
│   │   ├── clients.py             # Кнопка «Клиенты» — список, детали, создание (FSM: name → phone → email → company → comment → confirm)
│   │   ├── reports.py             # Кнопка «Отчёты» — пресеты периодов, кастомные даты FSM, метрики, PDF-скачивание
│   │   ├── payment_requests.py    # Кнопка «Выплаты» — список, создание (FSM: выбор клиентов → реквизиты → комментарий → confirm)
│   │   ├── chat.py                # Кнопка «Чат» — просмотр, отправка текста и файлов (фото/документ), mark_read
│   │   ├── notifications.py       # Кнопка «Уведомления» — список, детали, mark_read, mark_all
│   │   └── profile.py             # Кнопка «Профиль» — инфо, добавление/удаление способов оплаты (FSM)
│   ├── keyboards/
│   │   ├── __init__.py
│   │   ├── main_menu.py           # ReplyKeyboard: Дашборд, Ссылки, Клиенты, Аналитика, Отчёты, Выплаты, Чат, Уведомления, Профиль
│   │   ├── inline.py              # InlineKeyboard builders: списки с пагинацией, link_detail_keyboard (QR-код), выбор клиентов, способы оплаты, подтверждение, пропуск
│   │   └── callbacks.py           # CallbackData-классы: MenuCB, PaginationCB, LinkCB, ClientCB, PaymentCB, ReportCB, ChatCB, NotifCB, ProfileCB, ClientSelectCB, PayMethodCB, ConfirmCB
│   ├── states/
│   │   ├── __init__.py
│   │   ├── auth.py                # LoginStates (email, password)
│   │   ├── link.py                # CreateLinkStates (title, link_type, target_url, utm_source, utm_medium, utm_campaign, confirm)
│   │   ├── client.py              # CreateClientStates (name, phone, email, company, comment, link_id, confirm)
│   │   ├── payment_request.py     # CreatePaymentStates (select_clients, select_payment_method, new_payment_label, new_payment_value, comment, confirm)
│   │   ├── report.py              # ReportStates (date_from, date_to)
│   │   └── chat.py                # ChatStates (composing)
│   ├── middlewares/
│   │   ├── __init__.py
│   │   ├── auth.py                # AuthMiddleware: проверка сессии, инъекция api_client + session в data хендлера
│   │   └── known_users.py         # KnownUsersMiddleware (BaseMiddleware): неблокирующая, на каждом апдейте читает event_from_user, берёт partner_code из session_manager (если есть сессия) и вызывает known_users_store.upsert(user_id, partner_code), затем всегда пропускает дальше. Исключения логируются, не ломают обработку
│   ├── services/
│   │   ├── __init__.py
│   │   ├── session_manager.py     # In-memory dict[telegram_user_id → UserSession] (access_token, refresh_token, partner_id, partner_name, partner_email, partner_code)
│   │   ├── known_users_store.py   # KnownUsersStore (stdlib sqlite3, ${DATA_DIR}/known_users.db, check_same_thread=False + Lock): таблицы known_users(user_id PK, partner_code TEXT NULL, first_seen) и broadcast_state(id=1, last_broadcast_id). upsert (COALESCE partner_code — не сбрасывает known код в NULL), get_all_users, get_users_by_partner_code, get_last_broadcast_id/set_last_broadcast_id. Singleton known_users_store
│   │   ├── notification_poller.py # Фоновый asyncio task: поллинг unread notifications + chat, push в Telegram при увеличении счётчика
│   │   └── broadcast_poller.py    # Фоновый asyncio task poll_broadcasts(bot), канал "tg_partners". Выключен если BROADCAST_API_KEY пуст. GET /api/broadcasts/pending?channel=&after_id= (X-Broadcast-Token); получатели из known_users_store (get_users_by_partner_code при фильтре, иначе get_all_users); вложения скачиваются с BACKEND_URL+file_url и шлются через send_photo/send_video/send_document (BufferedInputFile), текст — caption первого вложения (≤1024) или отдельным сообщением; агрегат sent/failed → POST /api/broadcasts/{id}/delivery; идемпотентность через set_last_broadcast_id
│   └── utils/
│       ├── __init__.py
│       ├── formatters.py          # Форматирование API-данных в HTML-сообщения: dashboard, link, client, analytics, report, payment_request, notification, chat, profile (показывает email и/или телефон); get_notification_file_type() (image/video/document)
│       ├── pagination.py          # Хелпер пагинации
│       └── phone.py               # normalize_phone() (8/7 → +7), is_valid_phone() — валидация по PHONE_REGEX. Используется в /login для приёма email или телефона

Docker-интеграция

  • Сервис telegram-bot в docker-compose.dev.yml
  • Env: TELEGRAM_BOT_TOKEN, BACKEND_URL=http://backend:8003, NOTIFICATION_POLL_INTERVAL (default 60)
  • depends_on: backend, restart: unless-stopped
  • Volume: ./telegram_bot:/app (hot-reload при разработке)

Архитектурные решения

  • Чистый API-клиент: вся бизнес-логика в backend, бот только вызывает REST API
  • JWT авторизация: при /login бот получает access+refresh токены, хранит in-memory по telegram_user_id
  • Auto-refresh: при 401 автоматически обновляет токен через POST /api/auth/refresh
  • FSM (Finite State Machine): aiogram StatesGroup для многошаговых потоков (создание ссылки, клиента, запроса на выплату, отчёта)
  • CallbackData: type-safe маршрутизация inline-кнопок через aiogram CallbackData классы с prefix
  • Auth middleware: на всех protected роутерах проверяет сессию, инъектирует api_client + session
  • Фоновый поллинг: asyncio.create_task для периодической проверки новых уведомлений и сообщений чата
  • Пагинация: inline-клавиатуры с навигацией ⬅️/➡️ для всех списков

Max-бот для партнёров

Max-бот — аналог Telegram-бота для мессенджера Max. Чистый API-клиент на Node.js/TypeScript, вызывает те же REST-эндпоинты backend. Отдельный Docker-сервис.

Max API  <-->  max-bot (@maxhub/max-bot-api)  --HTTP/JWT-->  backend:8003

Стек: Node.js 20, TypeScript 5.3 (strict), @maxhub/max-bot-api, axios, dotenv

Структура max_bot/

max_bot/
├── Dockerfile                     # node:20-alpine, npm install, npm run build, CMD node dist/main.js
├── .dockerignore                  # node_modules, dist, .env
├── package.json                   # @maxhub/max-bot-api, axios, better-sqlite3, dotenv, qrcode, typescript, ts-node, @types/better-sqlite3, @types/qrcode
├── tsconfig.json                  # strict, ES2022, NodeNext, outDir dist/
└── src/
    ├── main.ts                    # Entry point: Bot init, bot.use middleware персистинга known-users (на каждом апдейте getUserId → knownUsersStore.upsert(userId, session.partnerCode||null)), registerHandlers, startPoller(bot), startBroadcastPoller(bot) только если config.BROADCAST_API_KEY задан, polling, graceful shutdown (stopPoller + stopBroadcastPoller + bot.stop)
    ├── config.ts                  # Config: MAX_BOT_TOKEN (required), BACKEND_URL, NOTIFICATION_POLL_INTERVAL, DATA_DIR (каталог known_users.db, default /data), BROADCAST_API_KEY (общий секрет, пусто → broadcast-поллер выключен), LOG_LEVEL
    ├── api-client/
    │   ├── base.ts                # ApiClient class: axios с JWT auth, interceptors для auto-refresh на 401, AuthError, get/post/put/delete, getBytes, getRawBytes, postFile
    │   ├── auth.ts                # login(), getMe(), isLoginError(); интерфейсы LoginResponse, PartnerResponse
    │   ├── analytics.ts           # getSummary(), getLinksStats(); интерфейсы SummaryResponse, LinkStatsItem
    │   ├── notifications.ts       # getNotifications(), getUnreadCount(), markAsRead(), markAllAsRead(); интерфейсы NotificationItem, NotificationsResponse, UnreadCountResponse
    │   ├── links.ts               # getLinks(), getLink(), createLink(); интерфейсы LinkResponse, CreateLinkData, CreateLinkResult
    │   ├── clients.ts             # getClients(skip, limit, search), getAllClients(), getClient(), createClient(); интерфейсы ClientResponse, CreateClientData, CreateClientResult
    │   ├── payment-requests.ts    # getPaymentRequests(), getPaymentRequest(), createPaymentRequest(); интерфейсы PaymentRequestResponse, CreatePaymentRequestData, CreatePaymentRequestResult
    │   ├── chat.ts                # getMessages(), sendMessage(), sendFile(), getUnreadCount(), markRead(); интерфейсы ChatMessageResponse, ChatUnreadCountResponse
    │   └── reports.ts             # getReport(), getReportPdf() → Buffer; интерфейс PartnerReportResponse
    ├── handlers/
    │   ├── index.ts               # Центральный роутинг: registerHandlers(bot) — bot_started, message_created, message_callback. Приоритет: FSM > chat mode > commands > menu callbacks. MENU:dashboard/analytics/profile/notifications/links/clients/payments/reports/chat. NOTIF:*, PROFILE:*, PAGE:(notif/link/client/payment/chat). LINK:*(detail/qr/list/create), CLIENT:*(detail/list/create/search), PAYMENT:*(detail/list/create/cl_select/cl_deselect/method_select/method_new), CONFIRM:*(link/client/payment). REPORT:*(period/pdf/new). CHAT:*(exit). FSM: login, profile, create_link, create_client, search_client, create_payment, report, chat. Chat mode: chatTracker + FSM state chat:active. MENU:back clears FSM + chatTracker. File attachments в chat mode
    │   ├── start.ts               # handleBotStarted (приветствие + меню/логин), handleHelp (/help), handleCancel (/cancel + clear FSM)
    │   ├── auth.ts                # handleLoginCommand (/login FSM start), handleLoginEmail, handleLoginPassword (FSM steps), handleLoginFSM (dispatch), handleLogoutCommand (/logout). FSM states: login:email, login:password
    │   ├── dashboard.ts           # handleDashboard — метрики из /api/analytics/summary, formatDashboard, кнопка назад
    │   ├── analytics.ts           # handleAnalytics — summary + per-link stats, formatAnalytics, кнопка назад
    │   ├── profile.ts             # handleProfile, handleProfileAdd, handleProfileDelete, handleProfileFSM. FSM states: profile:add_label, profile:add_value. Показ профиля, добавление/удаление способов оплаты
    │   ├── notifications.ts       # handleNotifications, handleNotifPage, handleNotifDetail, handleNotifList, handleNotifReadAll. In-memory cache, пагинация, файлы через Max uploads API (image/video/document)
    │   ├── links.ts               # handleLinksList (пагинированный список), handleLinksPage, handleLinkDetail (formatLink + QR/list/menu кнопки), handleLinkQR (QR через npm qrcode, URL из config.PUBLIC_BASE_URL, upload как image через Max API с детальным логированием ошибок в console.error и fallback на текст), handleLinkCreate (старт FSM), handleLinkFSM (title/url/utm_source/utm_medium/utm_campaign), handleLinkConfirmCallback (type select/skip/confirm). FSM: create_link:title→type→url→utm_source→utm_medium→utm_campaign→confirm. UTM skip через buildSkipKeyboard
    │   ├── clients.ts             # handleClientsList (пагинированный список с search/create), handleClientsPage, handleClientDetail, handleClientSearch (FSM search_client:query), handleClientSearchFSM, handleClientCreate (старт FSM), handleClientFSM (name/phone/comment), handleClientConfirmCallback (skip/confirm). FSM: create_client:name→phone→comment→confirm. Email и company не запрашиваются. В payload createClient всегда source='max_bot'
    │   ├── payment-requests.ts    # handlePaymentsList (пагинированный список со статусами), handlePaymentsPage, handlePaymentDetail, handlePaymentCreate (загрузка eligible клиентов, multi-select), handlePaymentCallback (cl_select/cl_deselect toggle, method_select/method_new), handlePaymentConfirmCallback (clients_done, skip comment, confirm), handlePaymentFSM (new_label/new_value/comment). FSM: create_payment:select_clients→select_method→(new_label→new_value)→comment→confirm. Toggle-выбор клиентов с running total
    │   ├── chat.ts                # handleChatEnter (вход в chat mode: getMessages, markRead, chatTracker.enterChat, FSM chat:active), handleChatExit (выход: chatTracker.exitChat, clearState, меню), handleChatPage (пагинация), handleChatText (отправка текста в chat mode), handleChatFile (скачивание файлов из Max API, пересылка через postFile). formatChatMessage для отображения. Inline кнопки: exit chat, pagination
    │   └── reports.ts             # handleReports (меню пресетов периодов: today/week/month/quarter/year/all/custom), handleReportPeriod (вычисление дат, показ отчёта), handleReportPdf (скачивание PDF, upload через Max API как файл), handleReportNew (сброс к выбору периода), handleReportFSM (кастомные даты FSM: report:date_from → report:date_to). Парсинг дат ДД.ММ.ГГГГ → YYYY-MM-DD
    ├── keyboards/
    │   ├── callbacks.ts           # encodeCallback(prefix, action, ...params), decodeCallback(payload) → {prefix, action, params}. Константы: MENU, AUTH, LINK, CLIENT, ANALYTICS, REPORT, PAYMENT, CHAT, NOTIF, PROFILE, PAGE, CONFIRM
    │   ├── main-menu.ts           # getMainMenuKeyboard() — inline keyboard 3x3 (9 кнопок: Дашборд, Ссылки, Клиенты, Аналитика, Отчёты, Выплаты, Чат, Уведомления, Профиль), getLoginKeyboard() — кнопка входа
    │   └── inline.ts              # buildPaginatedList(items, page, prefix, pageSize, extraButtons) — paginated inline keyboard. buildConfirmKeyboard(prefix, itemId?) — Да/Нет. buildSkipKeyboard(step) — кодирует callback как 'confirm:skip:<step>'. buildBackToMenuKeyboard()
    ├── middleware/
    │   └── auth.ts                # requireAuth(handler) HOF — проверка сессии, инъекция session+apiClient в AuthContext. getUserId(ctx) — извлечение userId из любого типа update
    ├── utils/
    │   ├── formatters.ts          # Форматирование API-данных в HTML-сообщения: formatDashboard, formatLink, formatLinkList, formatClient, formatClientList, formatAnalytics, formatReport, formatPaymentRequest, formatNotification, formatNotificationPush, formatChatMessage, formatProfile (показывает email и/или телефон), getNotificationFileType. Числа через Intl.NumberFormat('ru-RU'), даты DD.MM.YYYY HH:MM
    │   ├── pagination.ts          # paginate<T>(items, page, pageSize) → PaginationResult<T> {items, page, totalPages, hasNext, hasPrev}
    │   └── phone.ts               # normalizePhone() (8/7 → +7), isValidPhone() — валидация по PHONE_REGEX. Используется в /login для приёма email или телефона
    └── services/
        ├── session-manager.ts     # SessionManager class (singleton): Map<maxUserId, UserSession> (теперь включает partnerCode из /auth/me partner_code), createSession, getSession, removeSession, hasSession, getAllSessions
        ├── known-users-store.ts   # KnownUsersStore (better-sqlite3, синхронный, ${DATA_DIR}/known_users.db, WAL): таблицы known_users(user_id PK, partner_code TEXT NULL, first_seen) и broadcast_state(id=1, last_broadcast_id). upsert (COALESCE partner_code — не сбрасывает известный код в NULL), getAllUsers, getUsersByPartnerCode, getLastBroadcastId/setLastBroadcastId. Singleton knownUsersStore
        ├── state-machine.ts       # StateMachine class (singleton): Map<userId, StateData>, setState, getState, updateData, clearState, isInState (с prefix matching)
        ├── chat-tracker.ts        # ChatTracker class (singleton): Set<userId>, enterChat, exitChat, isInChat. O(1) lookup для определения chat mode
        ├── notification-poller.ts # Фоновый setInterval-поллер: startPoller(bot), stopPoller(). Для каждой активной сессии: проверка новых уведомлений (knownNotifIds Set для дедупликации), проверка chat unread count. Push через bot.api.sendMessageToUser. Файлы через Max uploads API. clearUserState() для очистки при logout. try/catch per session
        └── broadcast-poller.ts    # Фоновый setInterval-поллер startBroadcastPoller(bot)/stopBroadcastPoller(), канал "max_partners". GET /api/broadcasts/pending?channel=&after_id= (X-Broadcast-Token); получатели из knownUsersStore (getUsersByPartnerCode при фильтре, иначе getAllUsers); вложения скачиваются с BACKEND_URL+file_url, загружаются в Max через bot.api.raw.post('uploads')+multipart (как в notification-poller), отправка через sendMessageToUser(format:'html', attachments); агрегат sent/failed → POST /api/broadcasts/{id}/delivery; идемпотентность через setLastBroadcastId

Docker-интеграция

  • Сервис max-bot в docker-compose.dev.yml
  • Env: MAX_BOT_TOKEN, BACKEND_URL=http://backend:8003 (internal, container-to-container), PUBLIC_BASE_URL (optional, fallback на BACKEND_URL; используется для user-facing URL в QR-кодах), NOTIFICATION_POLL_INTERVAL (default 60), LOG_LEVEL (default info)
  • depends_on: backend, restart: unless-stopped
  • Volumes: ./max_bot:/app, /app/node_modules (анонимный volume)
  • Dev command: npx ts-node src/main.ts

Архитектурные решения

  • Чистый API-клиент: вся бизнес-логика в backend, бот только вызывает REST API
  • JWT авторизация: при /login бот получает access+refresh токены, хранит in-memory по maxUserId
  • Auto-refresh: axios interceptor при 401 автоматически обновляет токен через POST /api/auth/refresh
  • Кастомный FSM: Map<userId, {state, data}> с паттерном состояний 'group:step' (login:email, login:password)
  • Session Manager: Map<maxUserId, UserSession> с ApiClient instance per session, auto-refresh callback синхронизирует токены
  • Callback encoding: строковое кодирование payload через ':' разделитель (prefix:action:param1:param2) — замена aiogram CallbackData классов
  • requireAuth HOF: вместо middleware class, оборачивает handler и инъектирует session/apiClient в контекст
  • Центральный роутинг: единый registerHandlers() регистрирует три типа событий (bot_started, message_created, message_callback) и маршрутизирует по приоритету: FSM state > chat mode > commands > callback prefix
  • Inline меню: в Max нет Reply Keyboards — главное меню реализовано как InlineKeyboard (3x3 сетка), пересылается после каждого действия

max-bot-users — Бот Max для конечных пользователей

Отдельный Max-бот для приёма заявок (lead capture) от конечных пользователей. Создаёт лиды в Bitrix24 напрямую через REST webhook (crm.lead.add). Не зависит от backend (backend:8003) и от b24-transfer-lead (b24-service:7860) — самодостаточный сервис.

Архитектура

Max API  <-->  max-bot-users (@maxhub/max-bot-api)  --HTTP webhook-->  Bitrix24 (crm.lead.add)

Стек: Node.js 20, TypeScript 5.3 (strict), @maxhub/max-bot-api, axios, dotenv

Структура max_bot_users/

max_bot_users/
├── README.md                    # Quick start (dev), инструкция получения токенов (MAX_BOT_USERS_TOKEN через @MasterBot, B24_USERS_WORKFLOW_TOKEN через b24-transfer-lead UI), команды локальной сборки (npm install/build/start/test), чек-лист ручной верификации Phase 6 (happy-path 8 шагов + проверка лида в B24 + edge-кейсы: невалидный телефон, будущая дата, /cancel, без фото, b24-service down + retry), список того что проверено автоматически (npm build, docker build, smoke-start с заглушкой)
├── Dockerfile                    # node:20-alpine, npm install, npm run build, CMD node dist/main.js (скопирован из max_bot/)
├── package.json                  # @maxhub/max-bot-api ^0.2.2, axios ^1.7.0, better-sqlite3 ^12.10.0, dotenv ^16.4.0, form-data ^4.0.0 (multipart upload вложений в Max); devDeps: typescript, tsx, vitest, @vitest/coverage-v8, nock, @types/node, @types/better-sqlite3. Без qrcode и партнёрских deps
├── tsconfig.json                 # strict, ES2022, NodeNext, outDir dist/ (скопирован из max_bot/)
├── vitest.config.ts              # node env, setup ./src/test/setup.ts, coverage v8 (lines/functions/statements 70, branches 60) — скопирован из max_bot/
└── src/
    ├── main.ts                   # Entry point: инициализация Bot(config.MAX_BOT_TOKEN), bot.catch (глобальный обработчик ошибок), registerHandlers(bot), запуск startBroadcastPoller(bot) только если config.BROADCAST_API_KEY задан (иначе warn-лог), bot.use middleware для liveness watchdog (lastUpdateAt), graceful shutdown (SIGINT/SIGTERM → bot.stop + process.exit(0)), watchdog setInterval (каждые 2 мин; если 15 мин без апдейтов — process.exit(1) для рестарта Docker), цикл while(true) с try/catch вокруг bot.start() и backoff 5 сек. БЕЗ notification-poller, session-manager, chat-tracker
    ├── config.ts                 # dotenv.config(); requireEnv/optionalEnv/optionalIntEnv хелперы; normalizeWebhookUrl (гарантирует завершающий /); экспорт config: MAX_BOT_TOKEN (required), B24_WEBHOOK_URL (required, нормализованный URL Bitrix24 webhook), LOG_LEVEL (default 'info'), DATA_DIR (каталог SQLite, default ./data), BACKEND_URL (default http://backend:8003), API_BASE_URL getter (`${BACKEND_URL}/api`), BROADCAST_API_KEY (общий секрет для broadcast bot-API, пусто → поллер выключен), NOTIFICATION_POLL_INTERVAL (default 60). Бросает Error при отсутствии required env
    ├── api-client/
    │   ├── leads.ts              # Прямой клиент Bitrix24 REST через webhook. export const UF_PHOTOS_FIELD='UF_CRM_1774973838656' (file UF «Фото с места ДТП», единый источник правды для file-полей). createLead(fields: BitrixLeadFields): Promise<{id: number}> — POST {config.B24_WEBHOOK_URL}crm.item.add.json (entityTypeId=1, useOriginalUfNames=Y) с body {fields} (timeout 60с, maxBodyLength 50MB для base64-фото). Парсит ответ Bitrix: {result:{item:{id}}} → success; {error, error_description} → throw. На сетевую ошибку — formatAxiosError с URL/статусом/телом. BitrixLeadFields: title?, name?, comments?, stageId?, utmTerm?, utmSource?, utmMedium?, utmCampaign?, utmContent?, fm? [{typeId,valueType,value}], [key: string]: unknown (для UF_CRM_*). Интерфейс LeadUtmRecord (partner_code/utm_source/utm_medium/utm_campaign/utm_content) и applyLeadUtm(fields, rec, defaultSource): мутирует fields — utmSource = rec.utm_source || defaultSource, utmMedium/utmCampaign/utmContent только если непусты, utmTerm = rec.partner_code если непусто, rec может быть null. build{Lead,Overload,Consult}Comment — резервный текст для COMMENTS
    │   ├── links.ts             # HTTP-клиент к публичному контракту бэкенда для UTM партнёрских ссылок. fetchLinkUtm(linkCode): Promise<FetchLinkUtmResult> — GET ${config.API_BASE_URL}/public/links/{linkCode}/utm (timeout 5000), никогда не бросает: 200 → {ok:true, utm:LinkUtm{utm_term/utm_source/utm_medium/utm_campaign/utm_content, каждое string|null}}; 404 → {ok:false, notFound:true} (дизамбигуация legacy partner_code); таймаут/5xx/сеть → {ok:false, notFound:false} (возможна ленивая повторная попытка)
    │   └── status.ts            # Read-клиент Bitrix24 REST для определения статуса заявки по телефону (только чтение, лид не создаётся). callB24<T>(method, body) — низкоуровневый POST на {B24_WEBHOOK_URL}<method>.json. findLatestLeadIdByPhone(phone) → number|null (crm.duplicate.findbycomm type=PHONE/entity=LEAD, берёт макс. ID; fallback на телефон без '+'). findContactIdByPhone(phone) → number|null (findbycomm PHONE/CONTACT, result.CONTACT[], макс. ID, fallback на телефон без '+'). getApplicationStatus(phone): Promise<{found:false} | {found:true; kind:'lead'|'deal'; statusName:string}> — оркестратор: нет лида → not found; есть сделка по LEAD_ID (crm.deal.list) → kind='deal' + резолв стадии; иначе crm.lead.get → kind='lead' + резолв статуса. Названия статусов/стадий резолвятся через crm.status.list (ENTITY_ID STATUS/DEAL_STAGE[_<cat>]) с module-level кэшем; clearStatusCache() для тестов
    ├── handlers/
    │   ├── index.ts              # Центральная регистрация хендлеров. Экспортирует registerHandlers(bot: Bot): void, который вешает 3 события: bot_started → handleBotStarted; message_created → routeMessage (команды /start /help /cancel ДО state, затем диспетч по FSM-state включая STATE_STATUS_PHONE → handleStatusPhone, иначе мягкий fallback); message_callback → routeCallback (decodeCallback → switch по prefix: ACCIDENT / OVERLOAD / CONSULT / STATUS (start|retry → handleStatusStart) / DATE / MENU (restart → handleAccidentStart, cancel → handleCancelDraft) / CONFIRM/PHOTOS warn-only). Каждое событие обёрнуто в try/catch с error reply. Хелпер getUserId извлекает user_id из ctx.user / ctx.message.sender / ctx.callback.user. Логгер на console.log/warn/error
    │   ├── start.ts              # Базовые хендлеры приветствия и сервисных команд. handleBotStarted читает update.payload (deep-link `https://max.ru/<bot>?start=<payload>`) и вызывает resolveAndStore(userId, payload) (разбор legacy partner_code | <partnerCode>_<linkCode> | <linkCode> + дозагрузка UTM с бэкенда). handleStart дополнительно парсит аргумент команды `/start <arg>` и так же вызывает resolveAndStore. handleHelp — справка (HELP_TEXT упоминает кнопки всех 4 сценариев, включая «🔍 Статус заявки»). handleCancel — clearState + сообщение. handleCancelDraft (CB.CANCEL: clearState + «Заявка отменена.» + getStartKeyboard через attachments). Локальный getUserId; константы WELCOME_TEXT/HELP_TEXT/CANCEL_TEXT
    │   ├── status.ts             # FSM-сценарий «🔍 Статус заявки» (read-only, лид не создаётся). Константа STATE_STATUS_PHONE='status:phone'. handleStatusStart(ctx) — на callback status:start/status:retry переводит FSM в STATE_STATUS_PHONE и просит телефон с getPhoneKeyboard(). handleStatusPhone(ctx) — извлекает телефон (text или contact-vcf через parseVcfPhone), normalizePhone+isValidPhone, вызывает getApplicationStatus из api-client/status.ts. Ветки: невалидный/пустой телефон → повтор (FSM сохраняется); не найдено → сообщение + getStartKeyboard, FSM очищается; lead → «Статус заявки: <статус>»; deal → «Ваша заявка в работе: <стадия>» (FSM очищается); ошибка B24 → сообщение + getStatusRetryKeyboard, FSM сохраняется
    │   └── accident.ts           # FSM-сценарий «Сообщить о ДТП» из 6 шагов. Константы STATE_NAME/PHONE/DATE/TYPE/PHOTOS/CONFIRM. Bitrix UF-маппинг: UF_CRM_1774973703195 (Дата ДТП, ISO), UF_CRM_1774973775036 (Тип, enum: gibdd→2962, europrotokol→2964, none→2966), UF_CRM_1774973838656 (Фото, file UF). Хендлеры: handleAccidentStart, handleAccidentName, handleAccidentPhone (text/contact), handleAccidentDate, handleAccidentDateToday, handleAccidentType (сохраняет human label + typeKey), handleAccidentPhotos (копит URL), handleAccidentPhotosDone, handleAccidentConfirmShow, handleAccidentConfirm (собирает Bitrix-fields {title, name, stageId:'NEW', comments: buildLeadComment, fm:[{typeId:'PHONE',...}], UF_CRM_1774973703195: ISO date, UF_CRM_1774973775036: enum}, затем await getAttribution(userId) + applyLeadUtm(fields, attribution, 'max_bot_users') проставляет UTM, вызывает createLead напрямую через webhook), handleAccidentConfirmCancel, handleAccidentRetry. overload.ts и consultation.ts аналогично используют getAttribution + applyLeadUtm. Все хендлеры логируют userId+action
    ├── keyboards/
    │   ├── callbacks.ts          # Префиксы callback_data (MENU/ACCIDENT/OVERLOAD/CONSULT/STATUS/CONFIRM/DATE/PHOTOS, DELIMITER ':'); объект CB со всеми callback-значениями сценариев (ACCIDENT_*, OVERLOAD_*, CONSULT_*, плюс STATUS_START='status:start' и STATUS_RETRY='status:retry', DATE_TODAY, MENU_RESTART, CANCEL='menu:cancel' — универсальная отмена черновика); тип CallbackValue (union string-литералов из CB); хелперы encodeCallback(prefix, action, ...params)/decodeCallback(payload) → DecodedCallback {prefix, action, params, value}, parseCallback(data) → {prefix, action, value}
    │   └── inline.ts             # Билдеры inline-клавиатур через Keyboard.inlineKeyboard из @maxhub/max-bot-api. getStartKeyboard — стартовое меню из 4 кнопок: '🚗 Сообщить о ДТП' (ACCIDENT_START), '⚖️ Штраф за перегруз' (OVERLOAD_START), '💬 Бесплатная консультация' (CONSULT_START), '🔍 Статус заявки' (STATUS_START). Прочие: getDateKeyboard, getAccidentTypeKeyboard, getPhotoKeyboard, getPhoneKeyboard (requestContact), getDocsKeyboard (overload) — каждая step-клавиатура содержит нижнюю строку [Keyboard.button.callback('❌ Отменить заявку', CB.CANCEL)] для отмены на любом шаге; getConfirmKeyboard, getRetryKeyboard, getOverloadConfirmKeyboard/getOverloadRetryKeyboard (overload), getConsultConfirmKeyboard/getConsultRetryKeyboard (consult), getStatusRetryKeyboard ('🔁 Попробовать ещё раз' → CB.STATUS_RETRY) — без cancel-строки (там своя отмена/retry); getCancelInline (одна кнопка '❌ Отменить заявку' → CB.CANCEL, прикрепляется к prompt-ам шагов свободного ввода name/fine/question). Все используют CB.* из callbacks.ts
    ├── services/
    │   ├── state-machine.ts      # In-memory FSM-стор: интерфейс StateData {state, data}, класс StateMachine (setState/getState/updateData/clearState/isInState). Singleton fsm. Идентичен max_bot/src/services/state-machine.ts
    │   ├── partner-store.ts      # SQLite-хранилище (better-sqlite3, WAL). Таблицы: user_partner(user_id PK, partner_code, first_seen + UTM-колонки link_code/utm_source/utm_medium/utm_campaign/utm_content TEXT NULL, добавляются идемпотентной миграцией migrate() через PRAGMA table_info + ALTER TABLE ADD COLUMN — старые БД дополняются без потери данных) семантика first-wins (firstSet/firstSetRecord INSERT OR IGNORE) и broadcast_state(id=1 CHECK, last_broadcast_id) для идемпотентности рассылок. Методы: legacy firstSet/get/clear; firstSetRecord(полная запись, допускает partner_code='')/getRecord (StoredUserRecord)/fillUtm (UPDATE только если все UTM NULL, COALESCE — не перетирает уже заполненные); getAllUsers(): StoredUser[], getUsersByPartnerCode(code): number[], getLastBroadcastId()/setLastBroadcastId(n), close
    │   ├── partner-context.ts    # Фасад над PartnerStore (singleton partnerContext, БД ${DATA_DIR}/users.db). set (first-wins)/get/clear для legacy partner_code; setRecord/getRecord/fillUtm для UTM-атрибуции; pass-through getAllUsers/getUsersByPartnerCode/getLastBroadcastId/setLastBroadcastId для broadcast-поллера. Атрибуция сохраняется при bot_started/payload или /start <arg> через start-attribution.resolveAndStore, читается в хендлерах лидов через getAttribution
    │   ├── start-attribution.ts  # Резолвинг атрибуции из deep-link payload. resolveAndStore(userId, rawPayload) — parseStartPayload; нет linkCode→partnerContext.set(partnerCode); есть linkCode→fetchLinkUtm: ok→setRecord(partner_code = префикс payload ?? utm_term ?? '', linkCode, UTM); 404→legacy set(rawPayload); сеть→setRecord без UTM (ленивая дозагрузка). Никогда не бросает. getAttribution(userId): StoredUserRecord|null — getRecord; если link_code есть и UTM все NULL→ровно одна попытка fetchLinkUtm+fillUtm (ошибки глотаются), вернуть свежую запись
    │   ├── broadcast-client.ts   # HTTP-клиент к backend bot-API рассылок (axios, X-Broadcast-Token). fetchPending(channel, afterId) — GET /api/broadcasts/pending; reportDelivery(id, channel, sent, failed) — POST /api/broadcasts/{id}/delivery; downloadAttachment(fileUrl): Buffer — GET /uploads/... с префиксом BACKEND_URL. Экспортирует типы PendingBroadcast/PendingAttachment
    │   └── broadcast-poller.ts   # Фоновый setInterval-поллер startBroadcastPoller(bot)/stopBroadcastPoller(), канал "max_users". fetchPending(getLastBroadcastId); получатели через partnerContext (getUsersByPartnerCode при фильтре partner_code, иначе getAllUsers); вложения скачиваются и загружаются в Max через bot.api.raw.post('uploads')+multipart (как в notification-poller), отправка sendMessageToUser(format:'html', attachments); агрегат sent/failed → reportDelivery; идемпотентность через setLastBroadcastId
    └── utils/
        ├── phone.ts              # Регекс PHONE_REGEX = /^\+[1-9]\d{9,14}$/; normalizePhone (8XXX→+7XXX, 7XXX→+7XXX); isValidPhone. Идентичен max_bot/src/utils/phone.ts
        ├── vcf.ts                # parseVcfPhone(vcf): string|null — извлекает RAW-телефон из vCard регексом /^TEL[^:]*:(.+)$/im. Нормализация отдельно через normalizePhone
        ├── start-payload.ts      # Чистая функция parseStartPayload(raw): {partnerCode, linkCode} — разбор deep-link payload. Дизамбигуация по форме хвоста: '<partnerCode>_<10hex>' (^(.+)_([0-9a-f]{10})$) → оба заданы; '<10hex>' (^[0-9a-f]{10}$) → только linkCode; пустая строка → оба null; всё остальное → legacy partnerCode целиком. Без побочных эффектов
        └── photo-fetch.ts        # Загрузка фото в base64 для file UF Bitrix24. fetchPhotoAsBase64(url, fallbackName?) → {name, base64} | null (timeout 60с, max 10MB, axios responseType arraybuffer, fallbackName из URL pathname). fetchPhotosAsBase64(urls[]) → PhotoFile[] (Promise.all + filter null). Извлечение имени из URL: последний segment pathname + .jpg если нет расширения

Файлы

Env-переменные

  • MAX_BOT_USERS_TOKEN — токен Max-бота (получить в @MasterBot в Max). Маппится на MAX_BOT_TOKEN внутри контейнера. Required.
  • B24_USERS_WEBHOOK_URL — Bitrix24 webhook URL для прямого создания лидов через crm.lead.add. Если не задан — fallback на общий B24_WEBHOOK_URL из .env. Маппится на B24_WEBHOOK_URL внутри контейнера. Required (через сам или fallback).
  • MAX_BOT_USERS_LOG_LEVEL — уровень логирования (default info). Маппится на LOG_LEVEL. Optional.

Docker-интеграция

  • Сервис max-bot-users в docker-compose.dev.yml
  • Build: context ./max_bot_users, dockerfile Dockerfile
  • Env: MAX_BOT_TOKEN=${MAX_BOT_USERS_TOKEN}, B24_WEBHOOK_URL=${B24_USERS_WEBHOOK_URL:-${B24_WEBHOOK_URL}}, LOG_LEVEL=${MAX_BOT_USERS_LOG_LEVEL:-info}
  • depends_on: нет (полностью изолирован, ходит только в Max API и Bitrix24 webhook)
  • Volumes: ./max_bot_users:/app, /app/node_modules (анонимный volume)
  • Dev command: npx tsx src/main.ts
  • restart: unless-stopped

Архитектурные решения

  • Полная изоляция: бот не обращается ни к backend, ни к b24-transfer-lead. Лиды создаются прямым POST на Bitrix24 webhook (crm.lead.add.json). Нет промежуточных сервисов — меньше точек отказа
  • Отсутствие notification-poller: бот реактивный (отвечает только на апдейты пользователя), фоновых задач нет
  • Liveness watchdog: middleware фиксирует время последнего апдейта; если 15 мин тишины — process.exit(1) для рестарта Docker
  • Auto-restart polling: цикл while(true) с 5-секундным backoff между перезапусками bot.start()
  • FSM на in-memory singleton: services/state-machine.ts хранит состояние пользователей в Map<userId, StateData> — переживает только до перезапуска контейнера; для lead-capture бота этого достаточно (в случае рестарта пользователь начнёт заявку заново)
  • Роутер по приоритету: команды (/start//help//cancel) обрабатываются ДО проверки FSM-state, чтобы пользователь всегда мог сбросить заявку. Внутри callback-роутера префикс accident обслуживает несколько action (start/type/photos/confirm) — резервные префиксы CONFIRM/PHOTOS залогированы как warn для будущего раскола, но сейчас не используются

Структура tg_bot_users/

Telegram-бот для конечных пользователей (зеркало max_bot_users на стеке grammy вместо @maxhub/max-bot-api). Захват лидов в Bitrix24 по тем же сценариям (ДТП / штраф за перегруз / консультация / статус заявки). Созданы каркас, неизменяемые файлы (api-client, services, utils, keyboards/callbacks), core-адаптеры под grammy/Telegram (config.ts, utils/photo-fetch.ts, keyboards/inline.ts) и все handler-файлы (start/accident/overload/consultation/status/index). Точка входа main.ts добавляется в следующей фазе.

tg_bot_users/
├── package.json                  # deps: grammy, axios, better-sqlite3, dotenv; devDeps: @types/better-sqlite3, @types/node, tsx, typescript, vitest, @vitest/coverage-v8, nock. scripts build (tsc) / start / dev / test. Без @maxhub/max-bot-api
├── tsconfig.json                 # strict, ES2022, NodeNext, outDir dist/ — идентичен max_bot_users/tsconfig.json
└── src/
    ├── main.ts                  # Entry point: new Bot(config.TG_BOT_TOKEN) (grammy), registerHandlers(bot), запуск startBroadcastPoller(bot) только если config.BROADCAST_API_KEY задан (иначе warn-лог), глобальный bot.catch, graceful shutdown по SIGINT/SIGTERM, liveness watchdog (нет апдейтов 15 мин → process.exit(1) для рестарта в Docker), polling-цикл while(true) с авто-рестартом через 5s при тихом выходе/краше. Аналог max_bot_users/src/main.ts с TG_BOT_TOKEN
    ├── config.ts                # Конфиг из .env (dotenv): TG_BOT_TOKEN + B24_WEBHOOK_URL (обязательные, норм. слэш), LOG_LEVEL/DATA_DIR, BACKEND_URL (default http://backend:8003), API_BASE_URL getter, BROADCAST_API_KEY (пусто → поллер выключен), NOTIFICATION_POLL_INTERVAL (default 60). Аналог max_bot_users/config.ts с TG_BOT_TOKEN
    ├── api-client/
    │   ├── leads.ts              # Прямой клиент Bitrix24 webhook. export const UF_PHOTOS_FIELD='UF_CRM_1774973838656' (file UF «Фото с места ДТП», единый источник правды). createLead(fields) → POST crm.item.add.json (entityTypeId:1, useOriginalUfNames:Y). buildLeadComment/buildOverloadComment/buildConsultComment. BitrixLeadFields расширен полями utmMedium/utmCampaign/utmContent. applyLeadUtm(fields, rec: LeadUtmRecord|null, defaultSource) — проставляет UTM лида: utmSource=rec?.utm_source||defaultSource; utmMedium/Campaign/Content если непусты; utmTerm=rec?.partner_code если непусто
    │   ├── links.ts             # fetchLinkUtm(linkCode): Promise<{ok:true,utm:LinkUtm}|{ok:false,notFound:boolean}>. axios GET ${API_BASE_URL}/public/links/{linkCode}/utm, timeout 5000. 200→{ok:true,utm}; 404→{ok:false,notFound:true} (дизамбигуация: payload=legacy partner_code); таймаут/сеть/прочее→{ok:false,notFound:false}. Никогда не бросает (стиль broadcast-client)
    │   └── status.ts            # Read-клиент Bitrix24: getApplicationStatus(phone) → findbycomm(PHONE/LEAD) → deal.list/lead.get → имя статуса (кэш statusNameCache, clearStatusCache). findLatestLeadIdByPhone(phone)→number|null. findContactIdByPhone(phone)→number|null (findbycomm PHONE/CONTACT, result.CONTACT[], max id, fallback на телефон без '+'). Точная копия max_bot_users/src/api-client/status.ts
    ├── services/
    │   ├── state-machine.ts      # In-memory FSM: StateMachine (setState/getState/updateData/clearState/isInState), singleton fsm. Точная копия max_bot_users
    │   ├── partner-store.ts      # SQLite-хранилище (better-sqlite3, WAL). Таблица user_partner(user_id PK, partner_code, first_seen, link_code, utm_source, utm_medium, utm_campaign, utm_content) — first-wins; broadcast_state(id=1, last_broadcast_id) для идемпотентности рассылок. Идемпотентная миграция migrate() через PRAGMA table_info добавляет недостающие UTM-колонки (ALTER TABLE ADD COLUMN TEXT). Методы: firstSet/get/clear (legacy); firstSetRecord(userId, NewUserRecord) — INSERT OR IGNORE полной записи, допускает partner_code=''; getRecord(userId): StoredUserRecord|null; fillUtm(userId, UtmFields) — UPDATE с COALESCE, заполняет UTM только если все utm_* сейчас NULL (ленивая дозагрузка, не перетирает); getAllUsers/getUsersByPartnerCode/getLastBroadcastId/setLastBroadcastId
    │   ├── partner-context.ts    # Фасад partnerContext поверх PartnerStore (config.DATA_DIR/users.db); set/get/clear (legacy) + setRecord/getRecord/fillUtm (проброс к firstSetRecord/getRecord/fillUtm) + pass-through getAllUsers/getUsersByPartnerCode/getLastBroadcastId/setLastBroadcastId
    │   ├── start-attribution.ts  # Резолвинг атрибуции из deep-link payload. resolveAndStore(userId, rawPayload) — parseStartPayload; нет linkCode→partnerContext.set(partnerCode); есть linkCode→fetchLinkUtm: ok→setRecord(partnerCode??utm_term??'', linkCode, UTM); 404→legacy set(rawPayload); сеть→setRecord без UTM (ленивая дозагрузка). Никогда не бросает. getAttribution(userId): StoredUserRecord|null — getRecord; если link_code есть и UTM все NULL→ровно одна попытка fetchLinkUtm+fillUtm (ошибки глотаются), вернуть свежую запись
    │   ├── broadcast-client.ts   # HTTP-клиент к backend bot-API рассылок (axios, X-Broadcast-Token): fetchPending/reportDelivery/downloadAttachment. Точная копия max_bot_users
    │   └── broadcast-poller.ts   # Фоновый setInterval-поллер startBroadcastPoller(bot)/stopBroadcastPoller(), канал "tg_users". fetchPending(getLastBroadcastId); получатели через partnerContext; вложения скачиваются (InputFile из Buffer) и шлются: одно медиа = sendPhoto/sendVideo/sendDocument с caption; несколько фото/видео = sendMediaGroup (InputMediaBuilder, caption на первом); документы отдельными sendDocument; текст без caption = sendMessage; parse_mode HTML; агрегат sent/failed → reportDelivery; идемпотентность через setLastBroadcastId
    ├── utils/
    │   ├── start-payload.ts      # parseStartPayload(raw): ParsedStartPayload{partnerCode,linkCode}. Чистая функция, разбор deep-link payload /start. /^(.+)_([0-9a-f]{10})$/→оба; /^[0-9a-f]{10}$/→только linkCode; иначе legacy→весь payload в partnerCode; пустая (trim)→оба null. linkCode (10-hex lowercase) для дозагрузки UTM
    │   ├── phone.ts              # PHONE_REGEX, normalizePhone, isValidPhone. Точная копия max_bot_users
    │   ├── vcf.ts                # parseVcfPhone(vcf) — извлечение телефона из vCard. Точная копия max_bot_users
    │   └── photo-fetch.ts        # fetchPhotosAsBase64(fileIds): для каждого Telegram file_id GET getFile→file_path, затем download api.telegram.org/file/bot<TOKEN>/<path> (arraybuffer)→base64. Интерфейс PhotoFile{name,base64} совместим с createLead. Битые id пропускаются. Лимиты 15s/60s/20MB
    ├── keyboards/
    │   ├── callbacks.ts          # Константы callback_data CB (вкл. CANCEL='menu:cancel' — универсальная отмена черновика) + encodeCallback/decodeCallback/parseCallback, тип CallbackValue. Точная копия max_bot_users
    │   └── inline.ts             # Билдеры клавиатур grammy. Inline через new InlineKeyboard().text(label, CB.*): getStartKeyboard, getDateKeyboard/getAccidentTypeKeyboard/getPhotoKeyboard/getDocsKeyboard — каждая step-клавиатура содержит нижнюю строку .row().text('❌ Отменить заявку', CB.CANCEL); getConfirmKeyboard/getRetryKeyboard/getOverloadConfirmKeyboard/getOverloadRetryKeyboard/getConsultConfirmKeyboard/getConsultRetryKeyboard/getStatusRetryKeyboard — без cancel-строки; getCancelInline (одна кнопка '❌ Отменить заявку' → CB.CANCEL, прикрепляется к prompt-ам name/fine/question). Контакт — reply Keyboard: getPhoneKeyboard() = new Keyboard().requestContact().row().text('❌ Отменить заявку').resized().oneTime() (reply-кнопка отмены, текст перехватывается в routeMessage); getRemoveKeyboard()={remove_keyboard:true}
    └── handlers/
        ├── start.ts             # handleStart (deep-link payload из ctx.match → await resolveAndStore(userId, startArg): парсинг payload, дозагрузка UTM по link_code, сохранение first-wins; сброс FSM, WELCOME+getStartKeyboard), handleHelp, handleCancel, handleCancelDraft (CB.CANCEL: clearState + «Заявка отменена.» + getStartKeyboard через reply_markup). getUserId=ctx.from?.id. Нет handleBotStarted (в Telegram нет события bot_started — первый контакт это /start)
        ├── accident.ts          # 6-шаговый FSM ДТП (name→phone→date→type→photos→confirm). Телефон из ctx.message.text/contact.phone_number. Фото/документы — file_id из ctx.message.photo (последний PhotoSize)/document, хранятся в data.photos. handleAccidentConfirm: getAttribution(userId)→fetchPhotosAsBase64(file_ids)→crm.item.add с UF_CRM даты/типа/фото, applyLeadUtm(fields, attribution, 'tg_bot_users'). STATE_* константы. reply_markup, getRemoveKeyboard после контакта
        ├── overload.ts          # 5-шаговый FSM «Штраф за перегруз» (name→phone→fine→docs→confirm). Размер штрафа + file_id документов в COMMENTS (buildOverloadComment), без UF. getAttribution+applyLeadUtm. crm.item.add. STATE_OVERLOAD_*
        ├── consultation.ts      # 4-шаговый FSM «Бесплатная консультация» (name→phone→question→confirm). Вопрос в COMMENTS (buildConsultComment). getAttribution+applyLeadUtm. crm.item.add. STATE_CONSULT_*
        ├── status.ts            # Сценарий «Статус заявки» (один шаг телефона, лид НЕ создаётся). handleStatusStart/handleStatusPhone → getApplicationStatus → лид/сделка/не найдено/ошибка(retry). STATE_STATUS_PHONE
        └── index.ts             # registerHandlers(bot): bot.command(start/help/cancel), bot.on('message')→routeMessage (перехват reply-текста CANCEL_BUTTON_TEXT='❌ Отменить заявку' → handleCancelDraft ДО FSM-роутинга, т.к. шаг телефона на reply-keyboard; затем диспетч по FSM-state), bot.on('callback_query:data')→routeCallback (decodeCallback→prefix; MENU restart→handleAccidentStart, MENU cancel→handleCancelDraft). answerCallbackQuery в finally. Каждый обработчик в try/catch

Ссылки:

Тестовая инфраструктура

Каждый подпроект имеет автономный тестовый стек: pytest для Python, vitest для TypeScript/React. Все тестовые фикстуры/конфиги изолированы (in-memory БД, моки внешних сервисов).

backend/tests/ — pytest + pytest-asyncio + httpx ASGITransport + aiosqlite + respx

  • backend/pytest.iniasyncio_mode = auto, testpaths = tests, --strict-markers, маркеры slow/integration/unit
  • backend/.coveragercsource=app, omit миграций (alembic/, utils/migrate_db.py, utils/create_admin.py) и __init__-ов; [report] fail_under=70, show_missing=True; [html] directory=htmlcov
  • backend/requirements-test.txt — pytest 8.3, pytest-asyncio 0.24, pytest-cov, httpx, respx, freezegun, Faker, aiosqlite
  • backend/tests/init.py — пустой пакет
  • backend/tests/conftest.py — фикстуры: event_loop (session), engine (aiosqlite :memory: через session-scope с Base.metadata.create_all), db_session (function-scope с table-truncation на teardown), client (httpx.AsyncClient(transport=ASGITransport(app)) с overrides get_db/get_current_user/get_admin_user), authed_client/admin_client (auto-login через factory), partner_factory/admin_factory/link_factory/client_factory/landing_factory/notification_factory/payment_request_factory/chat_message_factory/click_factory/system_setting_factory, auth_headers (Bearer JWT через app.utils.security.create_access_token), mock_b24_service (respx, base_url из settings.B24_SERVICE_URL), respx_mock (generic). Env (SECRET_KEY, DATABASE_URL, B24_SERVICE_URL, B24_INTERNAL_API_KEY) выставляется в начале файла до импорта app-модулей.
  • backend/tests/factories.py — async-фабрики моделей: create_partner/create_admin/create_link/create_client/create_landing/create_notification/create_notification_read/create_payment_request/create_chat_message/create_click/create_system_setting. Используют Faker(ru_RU), генерируют валидные password_hash через app.utils.security.hash_password.
  • backend/tests/helpers.pyassert_response_ok(response, expected=200), make_login_payload, make_register_payload, fake_jwt_for(partner), fake_refresh_jwt_for(partner), auth_header_for(partner).
  • backend/tests/test_infra_smoke.py — 7 smoke-тестов: db_session создание partner, admin role, link+client связки, public root endpoint, auth_headers, /api/auth/me через authed_client, mock_b24_service interception.
  • backend/tests/test_edge_cases.py — 18 пограничных сценариев: SQL-injection в search (clients/admin), XSS persistence в notification, 4-байтные UTF-8 emoji в name, длинный Text comment, JWT-варианты (no Bearer, wrong scheme, empty, garbled, no sub claim), невалидный JSON в payment_details, нормализация телефона (8/+7), pagination boundary 422, sequential clicks recording, concurrent payment_method appends.
  • backend/tests/utils/ — тесты утилит:
    • test_security.py — 17 тестов: hash_password (bcrypt, unicode, empty, long), verify_password, create_access_token / create_refresh_token (payload, expiry, settings), decode_token (invalid signature, garbled, expired через freezegun, wrong type).
    • test_create_admin.py — 5 тестов через sqlite3 + tmp_path: создание нового admin, idempotent на повтор, повышение существующего user до admin, skip при отсутствии ADMIN_EMAIL/ADMIN_PASSWORD.
    • test_merge_lead_deal_duplicates.py — 8 тестов миграции дедупликации лид↔сделка (AsyncSessionLocal монкипатчится на тестовый engine, get_deals_by_entity застаблен): dry-run без изменений, apply (перенос данных сделки в лид + ремап PaymentRequest.client_ids + удаление дубля), слияние в кабинетный лид (external_id без префикса 'lead_'), идемпотентность (2-й прогон 0 изменений), standalone-сделка без lead_id не трогается, сделка с lead_id без лид-записи не удаляется, _remap_payment_requests дедуп old→new и dry-run без записи.
  • backend/tests/models/ — тесты моделей (36 тестов):
    • test_partner.py — 13 тестов: дефолты, unique email/phone/partner_code, payment_details JSON round-trip, saved_payment_methods (валидный/невалидный JSON, dict вместо list), b24_entity_* поля.
    • test_link.py — 6 тестов: типы (direct/iframe/landing), unique link_code, UTM-поля, partner relationship.
    • test_landing.py — 6 тестов: дефолты (button_text, theme_color), images attached, cascade delete (через ORM), partner relationship.
    • test_notification.py — 5 тестов: broadcast vs targeted, NotificationRead запись, FK ondelete=CASCADE schema check, file metadata.
    • test_payment_request.py — 6 тестов: дефолты, client_ids JSON, переходы status, payment_details JSON, total_amount float, processed_by FK.
  • backend/tests/services/ — тесты сервисов (264 теста):
    • test_auth_service.py — 24 теста: register (новый/дубликат/phone-only/reset rejected), login (success/wrong/unknown/pending/rejected/inactive/by phone), refresh_tokens (success/access-token-not-allowed/unknown/inactive/expired-freezegun), change_password, change_email (success/conflict), admin_register_partner.
    • test_link_service.py — 40 тестов: create_link (direct/iframe/landing, ownership 404, unique link_code retry loop), get_links/get_link/get_link_with_counts, update_link (ownership), delete_link (soft), get_embed_code (3 типа), _is_bot_url, build_url_with_utm (no params/full/cyrillic encoded/preserves existing query; бот-ветка: UTM→start=<utm_term><link_code>, пустой utm_term→<link_code>, underscore в term, невалидный charset→fallback, >64→fallback, без UTM→legacy start=<utm_term>, без UTM и term→base_url; не-бот регрессия).
    • test_client_service.py — create_client_manual (no workflow, b24 success, b24 failure, link 404 для чужого, default source, comment доходит до лида B24), create_client_from_form (+ comment в лиде), get_clients (search, pagination, sort_by amount/created, невалидный sort → fallback, date filter), get_client (404 / другой партнёр), edge cases (long comment, unicode).
    • test_external_api.py — send_client_webhook (no workflow / success / payload extras incl. comment / comment в extra_fields / None comment → null / 5xx / connect error / tracking field), fetch_bitrix_stats (no workflow / success с status map / fallback / b24 error), check_client_status (3 кейса).
    • test_b24_integration_service.py — 15 тестов: все методы B24IntegrationService (workflows, settings, token, field mapping, funnels/stages/lead-statuses, leads create/import/get, conversion stats, X-Internal-API-Key header, 5xx propagation, timeouts).
    • test_b24_entity_service.py — 15 тестов: search/create/update contacts+companies (с/без extra_fields), get_deals_by_entity (entity vs UF field, 5xx, timeout, нормализация ключей lead_id/stage_semantic_id/date_create), get_leads_by_entity (+ нормализация date_create).
    • test_system_settings_service.py — 24 теста: get/set_setting (upsert), get_all_settings, tracking config (default/set), format_tracking_value (template/crm_entity contact/company/no entity/cyrillic), default_links / bot_links / b24_fields конфиги (round-trip, invalid JSON fallback).
    • test_deal_sync_service.py — 19 тестов: sync_deals_for_partner (skip без workflow / без entity / happy path standalone-сделка стадия в deal_stage / dedup deal_id / UF field filter / lead sync / dedup external_id / b24 failure / invalid opportunity), ДЕДУПЛИКАЦИЯ лид↔сделка (сделка с lead_id обновляет лид-запись без дубля, lead_id без лид-записи → standalone fallback, сделка без lead_id → standalone, commit при update без новых записей), _parse_b24_datetime (offset→UTC / None+invalid), _build_lead_name (3 варианта), start_sync_task / stop_sync_task.
    • test_landing_service.py — 12 тестов: create/get/update/delete (soft) landing, ownership 404, upload_image (invalid ext / too large / no filename / success), delete_image (success удаляет файл с диска / 404).
    • test_analytics_service.py — get_summary (empty/with data total_leads, без ZeroDivisionError), get_links_stats (leads_count, без link_type), get_link_clicks_by_day (link 404, другой партнёр), get_clients_stats_by_day (только total, без form/manual), get_bitrix_stats (partner not found / no workflow / success / b24 failure).
    • test_admin_service.py — 45 тестов: update_client_payment (auto-calc individual/global/explicit override/zero amount/marks paid/unpaid clears paid_at/404), bulk_update_client_payments, get_admin_overview, get_partner_payment_summary, get_partner_detail, get_partners_stats (paginated/search), update_partner_reward_percentage, toggle_partner_active (self/admin block), pending_registrations, approve/reject_registration, admin_update_link, default_links (create/apply_all/skip existing/no config), rebind_partner_b24_entity (clears other partner / 404), delete_partner cascade (self/admin block / 404).
    • test_notification_service.py — 17 тестов: create (no file / pdf / invalid ext / too large / targeted), delete (file removed from disk / 404 / no file_path), get_partner_notifications (broadcast / targeted / is_read flag), unread_count, mark_as_read (idempotent / 404), mark_all_as_read (no dupes).
    • test_payment_request_service.py — 22 теста: create (success / другой партнёр / no reward / overlapping client), get (partner/all/filter/detail), process (pending→approved, →rejected, approved→paid marks clients, paid без approved fails, already processed, 404), delete (resets clients / 404), parse helpers, _build_deal_url (no external/no webhook/with webhook host/with deal_id).
    • test_chat_service.py — 15 тестов: send_message_partner / get_partner_messages (только своё), unread counts (partner / admin total), mark read (partner / admin не трогает чужие), send_message_admin, get_conversations (empty / with data / truncates long message), get_conversation_messages, file upload validation (invalid ext / too large / partner+admin success).
    • test_report_service.py — generate_partner_report (empty / not found / metrics / date filter / leads_in_progress), generate_all_partners_report (empty / with partners / filter by ids), _apply_date_filter, _resolve_client_status (4 fallback paths), _build_lead_status_maps (B24 success), compute_partner_overview (not found / default % / partner % / total_reward=metrics / total_paid только paid / available_to_withdraw исключает paid+locked / rejected не блокирует / изоляция по партнёрам).
    • test_pdf_service.py — 11 тестов: helpers (_format_money, _format_period × 4 кейса), generate_partner_report_pdf (basic / cyrillic / clients table), generate_all_partners_report_pdf (empty / with partners). Использует matplotlib bundled DejaVu fonts (cross-platform).
  • backend/tests/routers/ — тесты роутеров (167 тестов):
    • test_router_auth.py — 22 теста: /register (201/dup/short pwd 422/no contact 422), /login (200/401/403 pending|inactive), /refresh (success/access-token/garbled), /me (auth/no-auth), /change-password (3 кейса), /email (200/409/422), /payment-methods (add/delete/404).
    • test_router_links.py — 11 тестов: list (unauth/empty/pagination), create (direct/iframe/invalid type 422/no target 422), get (other partner 404), update, delete, embed-code.
    • test_router_clients.py — list (unauth/empty/data/pagination/search), create (success/invalid phone 422), get (404/success), pagination limit 422, новые поля ClientResponse (b24_created_at/updated_at/deal_stage_name/deal_amount), сортировка (amount asc/desc, невалидный sort_by/order → 422), фильтр по дате (date_from/date_to, диапазон, включительность).
    • test_router_landings.py — 12 тестов: list, create (success/bad theme color 422), get (404), update, delete, image upload (success/invalid ext 400), image delete (success/404).
    • test_router_analytics.py — /summary (total_leads вместо total_clients), /overview (unauth/empty/with data: total_reward/total_paid/available_to_withdraw/воронка), /links (без link_type, leads_count), /links/{id}/clicks (validation), /clients/stats (только date+total), /bitrix/fetch (no workflow).
    • test_router_bitrix_settings.py — 9 тестов: GET/PUT settings (configured/with workflow), funnels/stages/lead-statuses/leads/stats без workflow → 400.
    • test_router_admin.py — 45 тестов: auth/forbidden, overview, partners (list/search/detail/404), delete (self/admin block/404), rebind_b24_entity, sync-now, admin link/payment updates, registrations (list/count/approve/reject), config, reward%, toggle, notifications (admin), partner register, B24 proxy endpoints (contacts/companies search+create).
    • test_router_notifications.py — 7 тестов: list (unauth/empty/broadcast included), unread-count, mark as read (success/404), mark-all-as-read.
    • test_router_payment_requests.py — 15 тестов: partner endpoints (list/empty/create/invalid/get/forbidden/404), admin endpoints (pending-count/list/filter/get 404/process approved/invalid status 422/delete).
    • test_router_chat.py — 15 тестов: partner messages (get/send/too long 422/file invalid ext), unread-count, mark read; admin conversations / messages / send / unread-count / mark / file upload.
    • test_router_reports.py — 8 тестов: partner /reports (unauth/success/PDF), admin /reports (success/filter by id/PDF single/PDF all/PDF 404). Использует bundled DejaVu fonts.
    • test_router_system_settings.py — 12 тестов: unauth, all-settings, bot-links (get empty/update), tracking config update, sync config update, b24-fields (get empty/update), default-links (get empty/update/apply-all), sync/run-now (с monkeypatched run_sync_cycle).
    • test_router_public.py — 25 тестов: redirect (3 link types: direct/iframe/landing), redirect 404, landing page (unknown/iframe), form submit (success/unknown link/invalid 422), upload serving (404/success), B24 webhook proxy (502 при connect error / pass-through), РАЗДЕЛЕНИЕ статуса лида и стадии сделки в webhook (kind='lead'→deal_status + updated_at, kind='deal'→deal_stage без затирания статуса лида, эвристика без kind), redirect records click, redirect бот-URL с UTM (start=<utm_term>_<link_code>), max-бот с UTM, бот без UTM (legacy start), не-бот UTM-регрессия, link_utm-эндпоинт (200 все поля / 404 несуществующий / 404 неактивный / не пишет клик).

frontend/src/test/ — vitest + jsdom + @testing-library/react + MSW 2.x

  • frontend/vitest.config.tsenvironment: 'jsdom', setupFiles ./src/test/setup.ts, coverage v8 (text/html/lcov), exclude src/main.tsx, src/test/**. coverage.thresholds: lines/statements 70, functions 45, branches 60.
  • frontend/src/test/setup.ts — импорт @testing-library/jest-dom/vitest, MSW lifecycle (server.listenresetHandlers/cleanupclose), полифилы TextEncoder/TextDecoder, моки window.matchMedia (для recharts) и navigator.clipboard.writeText.
  • frontend/src/test/server.tssetupServer(...handlers) с дефолтными хэндлерами (auth/me, login, refresh, register, links/clients/landings/analytics summary/notifications/payment-requests/chat). Экспортирует defaultPartner и defaultAdmin.
  • frontend/src/test/render.tsxrenderWithProviders(ui, options) с обёртками MemoryRouter + AuthProvider + ToastProvider + опциональные Routes/Route. Реэкспортирует @testing-library/react.
  • frontend/src/test/infra-smoke.test.tsx — 4 smoke-теста.
  • frontend/src/test/edge-cases.test.tsx — edge-кейсы: 401 во время поллинга, 1000/5000-rows rendering, поведение Toast при rapid push, 403 не приводит к редиректу.
  • frontend/package.json — devDeps: vitest 2.1, @testing-library/react 16, @testing-library/user-event, @testing-library/jest-dom, msw 2.7, jsdom 25, @vitest/coverage-v8. Скрипты: test, test:watch, test:coverage.

frontend test files (рядом с исходниками, *.test.ts(x))

max_bot/src/test/ — vitest + nock + vi.mock(@maxhub/max-bot-api)

  • max_bot/vitest.config.tsenvironment: 'node', setupFiles ./src/test/setup.ts, coverage v8. coverage.thresholds: lines/statements/functions 70, branches 60.
  • max_bot/src/test/setup.ts — env-stubs (MAX_BOT_TOKEN, BACKEND_URL), nock.disableNetConnect() (allow localhost), vi.mock('@maxhub/max-bot-api') — заменяет Bot/Composer/Api/Keyboard стабом со spy-методами (start/stop/command/hears/action/use/catch/on, api: sendMessage/editMessage/deleteMessage/answerOnCallback/sendFile и т.д., Keyboard.inlineKeyboard, Keyboard.button.callback/link).
  • max_bot/src/test/factories.tsdefaultUser/defaultChat, buildMessage/buildCallback, buildMessageCreatedUpdate/buildMessageCallbackUpdate/buildBotStartedUpdate, buildContext({update, user, chatId, text, callbackPayload}) возвращает Context-like объект со spy-методами reply/editMessage/deleteMessage/answerOnCallback/sendAction/getChat/leaveChat.
  • max_bot/src/test/infra-smoke.test.ts — 6 smoke-тестов.
  • max_bot/package.json — добавлены devDeps: vitest 2.1, @vitest/coverage-v8, nock 13. Скрипты: test, test:watch, test:coverage.

max_bot/src/**/*.test.ts — Phase 4 тесты (368 тестов в 30 файлах)

utils:

  • max_bot/src/utils/formatters.test.ts — 50 тестов, покрывает все 13 форматтеров (formatDashboard/Link/LinkList/Client/ClientList/Analytics/Report/PaymentRequest/Notification/NotificationPush/ChatMessage/Profile + getNotificationFileType): пустые поля, кириллица, статус-варианты, очень большие числа (>1млрд), пагинация чата.
  • max_bot/src/utils/pagination.test.ts — 6 тестов: первая/последняя страницы, отрицательная страница, превышение, пустой массив, кастомный pageSize.
  • max_bot/src/utils/phone.test.ts — 13 тестов: normalizePhone (8XX→+7XX, 7XX→+7XX, очистка не-цифр, null/undefined), isValidPhone (E.164, отказ для коротких/длинных/без +), PHONE_REGEX.

keyboards:

middleware:

  • max_bot/src/middleware/auth.test.ts — 8 тестов: getUserId (3 источника update), requireAuth (передаёт сессию, блокирует анонимных, отказывается без user_id).

api-client (с nock и refresh-token логикой):

handlers:

edge-cases:

  • max_bot/src/edge-cases.test.ts — 13 тестов: размер callback payload (типичный <64 байт, длинный ID >64 байт, кириллица 2 байта/символ), bot-originated message (defaultUser is_bot:true), параллельные FSM (Alice/Bob не пересекаются через Promise.all), sessionManager изоляция, fsm.isInState префикс-матч.

Coverage (v8): 75.58% statements, 90.74% functions overall. api-client: 98.24%, keyboards: 100%, middleware: 100%, utils: 99.29%. handlers: 70.47% (index.ts роутер 5.54% — тестируется через registerHandlers wiring, не отдельный роутинг код).

telegram_bot/tests/ — pytest + pytest-asyncio + aiogram MockedBot + respx

  • telegram_bot/pytest.ini — asyncio mode=auto, default loop scope=function.
  • telegram_bot/.coveragercsource=bot, omit bot/main.py и __init__-ов; [report] fail_under=70, show_missing=True; [html] directory=htmlcov.
  • telegram_bot/requirements-test.txt — pytest 8.3, pytest-asyncio, pytest-cov, respx, freezegun, Faker, qrcode[pil]==8.0.
  • telegram_bot/tests/conftest.pyMockedSession(BaseSession) (no-op session, make_request записывает все TelegramMethod в self.calls, дефолтные ответы для GetMe/SendMessage/EditMessageText/AnswerCallbackQuery/DeleteMessage/SendChatAction; ответы можно перекрыть через responses[method_name]); MockedBot(Bot) (token + MockedSession); фикстуры bot, storage (MemoryStorage), dispatcher, fsm_context_factory(user_id, chat_id) -> FSMContext, make_user/make_chat/make_message/make_callback_query/make_update_message/make_update_callback_query, respx_mock_backend (привязан к settings.api_base_url), respx_mock_any. Env (TELEGRAM_BOT_TOKEN, BACKEND_URL) выставляется до импорта app-модулей.
  • telegram_bot/tests/factories.pymake_user/make_chat/make_message/make_callback_query/make_update_message/make_update_callback_query (реальные aiogram-модели, т.е. фильтры/FSM работают как в проде).
  • telegram_bot/tests/test_infra_smoke.py — 6 smoke-тестов.

services/ — 31 тест services (purge _sessions/_states/_known_notif_ids/_prev_chat_counts per test):

  • telegram_bot/tests/services/test_session_manager.py — 11 тестов: save_session/get_session/delete_session round-trip, перезапись, идемпотентный delete, изоляция между user_id, get_all_sessions возвращает копию, get_api_client без сессии → None, callback _on_tokens_refreshed обновляет токены в сессии, конкурентные save_session через asyncio.gather.
  • telegram_bot/tests/services/test_chat_tracker.py — 9 тестов: enter_chat/exit_chat помечают/удаляют состояние, track_message no-op без enter_chat, get_and_clear_messages сбрасывает только message_ids (chat_id остаётся), изоляция users, re-enter перезаписывает chat_id.
  • telegram_bot/tests/services/test_notification_poller.py — 11 тестов: clear_user_state идемпотентен, первый poll seed-ит known IDs без push, второй push-ит новые unread + mark_as_read, skip is_read=True, chat_count рост → notification (если не в чате), unchanged → ничего, CancelledError через mock asyncio.sleep корректно завершает loop, per-user exception не валит цикл, _push_chat_update шлёт сообщения и трекает.

api_client/ — 68 тестов всех 9 модулей через respx_mock_backend:

handlers/ — 128 тестов 12 хендлеров. conftest.py — фикстуры _clean_module_state (autouse сбрасывает singletons), state_for(user_id, chat_id) (FSMContext по storage), attach_bot(obj, bot) (через obj.as_(bot) + model_copy для вложенного callback.message).

  • telegram_bot/tests/handlers/test_auth.py — 10: cmd_login (no session FSM start, with session — message), process_identifier (email/phone/invalid stays), process_password (success creates session, login error clears, get_me failure), cmd_logout с/без session.
  • telegram_bot/tests/handlers/test_start.py — 7: /start без/с session, очистка chat-mode + state, /help, /cancel (без state, со state, чистит chat-mode).
  • telegram_bot/tests/handlers/test_dashboard.py — 3: рендеринг summary, отсутствие данных, callback analytics.
  • telegram_bot/tests/handlers/test_clients.py — 13: список/empty/error, paginate uses cache vs fetch, detail/not-found, full create FSM (name→phone→comment→confirm), skip comment, cancel/error, search.
  • telegram_bot/tests/handlers/test_links.py — 13: список/empty/error, paginate cache, detail с QR keyboard, qr generation (qrcode/PIL), full create FSM (title→type→target_url→utm_source/medium/campaign→confirm) + skip пути для UTM, cancel/error.
  • telegram_bot/tests/handlers/test_payment_requests.py — 19: список/empty/error, detail, start_create без eligible/с eligible, toggle_client, clients_done (требует selection / saved methods / no methods → new), select_payment_method, new_payment_method, label/value, skip_pay_comment / pay_comment text, confirm yes/no/api-error.
  • telegram_bot/tests/handlers/test_chat.py — 9: enter_chat (state ChatStates.active + chat_tracker), exit_chat, refresh, paginate via ChatCB, noop, send_message text/empty/failure.
  • telegram_bot/tests/handlers/test_reports.py — 13: show_reports period buttons, today/week/month с freeze_time("2024-06-15"), custom → FSM, date_from/to валидация формата, get_report failure clears state, download_pdf (BufferedInputFile), new_report.
  • telegram_bot/tests/handlers/test_notifications.py — 9: список/empty/error, paginate cache, detail marks read и рендерит, image (SendPhoto) / document (SendDocument) flow через get_raw_bytes, mark_all_as_read.
  • telegram_bot/tests/handlers/test_profile.py — 8: show_profile success/failure, FSM AddPaymentMethodStates label→value, POST/DELETE /auth/payment-methods через respx.
  • telegram_bot/tests/handlers/test_analytics.py — 3: summary + links_stats, без summary → ошибка, без links_stats работает.
  • telegram_bot/tests/handlers/test_filter_edge_cases.py — 9: уникальность префиксов 12 CallbackData классов, pack/unpack round-trip ≤64 байт, CallbackData.filter() принимает один magic + комбинацию через &, слишком длинный payload → ValueError, дефолтные значения, invalid unpack raises.

utils/ — 54 теста чистых функций:

  • telegram_bot/tests/utils/test_phone.py — 12: normalize +79.../8.../7.../с пробелами/скобками/дефисами parameterized, None/empty, неузнанный формат, is_valid_phone parameterized.
  • telegram_bot/tests/utils/test_pagination.py — 6: empty list, first/last page, clamp high/negative, per_page=1.
  • telegram_bot/tests/utils/test_formatters.py — 36: _format_dt (Z/+offset/None/empty/invalid), format_dashboard (минимум, conversion %), format_link_detail (active/UTM/inactive), format_client_detail (form/manual, paid/unpaid), format_analytics с/без links_stats, format_report полная карта метрик, format_payment_request_detail (dict pd / string pd), format_notification (read/unread/file_name), format_notification_push, get_notification_file_type parameterized (image/video/document), format_chat_page (empty / pagination 20→3 pages / sender Вы vs Админ / file_name), format_profile (с methods + email + Cyrillic / без methods).

keyboards/ — 22 теста структуры кнопок:

  • telegram_bot/tests/keyboards/test_main_menu.py — 4: ReplyKeyboardMarkup, 9 кнопок в 5 рядах [2,2,2,2,1], remove_keyboard.
  • telegram_bot/tests/keyboards/test_inline.py — 18: link_detail (QR), links_list (active/inactive icons + pagination 1/3 + ➡), clients_list (📝/✋ + Поиск + Добавить), payment_requests (4 status icons), report_period (7 кнопок), notifications_list (truncate 30+..., 📬/📭), chat_pagination (без ◀ на page 0, без ▶ на last), client_select (✅/⬜ + total selected only), payment_method (Card + Ввести новый), confirm (Подтвердить + Отмена), skip, profile (methods + Добавить), paginated_list_keyboard generic (label_fn + default).

middlewares/ — 4 теста AuthMiddleware:

  • telegram_bot/tests/middlewares/test_auth.py — без user проходит без session, без session блокирует Message (отвечает /login) / CallbackQuery (answer show_alert=True), с session инжектит data["session"] и data["api_client"].

Coverage (pytest-cov): 90% по bot/ (1804 stmt). 100% по api_client/auth/clients/links/notifications/payment_requests/reports/analytics, services/session_manager/chat_tracker, keyboards/callbacks/main_menu, middlewares, states, handlers/auth/dashboard/profile/analytics, utils/pagination/phone. 96-98% formatters/clients/inline keyboards/links handler/api_client.base. 89-94% notifications/reports/start handlers. 67-69% handlers/chat и services/notification_poller (часть file-upload/error-recovery веток). bot/main.py 0% (entry-point не вызывается в юнит-тестах).

b24-transfer-lead/src/backend/tests/ — pytest + sync sqlalchemy + FastAPI TestClient + respx

  • b24-transfer-lead/src/backend/pytest.ini
  • b24-transfer-lead/.coveragerc — coverage конфиг для b24-transfer-lead/backend (source=src/backend, omit main.py, migrate_db.py, csv_parser.py, __init__-ов; [report] fail_under=60 — service-layer 70%+ при изолированном запуске, общий проект ниже из-за неохваченных API-роутов).
  • b24-transfer-lead/src/backend/requirements-test.txt
  • b24-transfer-lead/src/backend/tests/conftest.py — sync sqlite :memory: через StaticPool, engine session-scope, db_session function-scope (truncate на teardown), client (TestClient с override get_main_db и очисткой in-memory auth.sessions), internal_api_client (auto-добавляет X-Internal-API-Key), authed_client/admin_client (cookie session_id через AuthService.create_session), фабрики user_factory/admin_factory/workflow_factory/field_mapping_factory, respx_mock_b24 (для outbound calls). Импорты tests.factories сделаны относительными from . import factories (избегает коллизии с глобальным пакетом tests в site-packages).
  • b24-transfer-lead/src/backend/tests/factories.pycreate_user/create_admin/create_workflow/create_field_mapping. Хеширует пароли через AuthService.hash_password. Поля WorkflowFieldMapping соответствуют схеме (field_name/display_name/bitrix24_field_id/bitrix24_field_name/entity_type/update_on_event).
  • b24-transfer-lead/src/backend/tests/test_infra_smoke.py — 9 smoke-тестов: /health, db_session, admin/workflow factories, internal_api_key auth, session cookie auth, 401 без авторизации, respx_mock_b24, password roundtrip.
  • b24-transfer-lead/src/backend/tests/test_models.py — 21 теста моделей: User (unique username, required password_hash, default role/created_at, admin role), Workflow (defaults entity_type/lead_status_id, user_id required, unique api_token, owner relationship), WorkflowFieldMapping (default update_on_event=False, required fields, cascade delete-orphan от Workflow), user_workflow_access (composite PK uniqueness, accessible_workflows relationship), Lead/LeadField (defaults, required fields, cascade delete от Lead). Локальный lead_session фикстура поднимает отдельный engine для LeadBase.
  • b24-transfer-lead/src/backend/tests/test_workflow.py — 17 тестов привязки workflow_id: TestWorkflowOwnerBinding (user_id NOT NULL, owner relationship round-trip, owner.workflows back_populates, settings PUT не меняет user_id), TestFieldMappingBinding (workflow_id required, cascade при удалении workflow, multiple mappings per workflow с одним field_name в разных entity_type), TestSharedAccess (grantee видит/получает shared workflow через accessible_workflows), TestPublicTokenResolution (public endpoint 404 на unknown token, happy path, unique constraint api_token, generate/regenerate persistence), TestMissingWorkflow (404 для get/settings/field-mappings).
  • b24-transfer-lead/src/backend/tests/test_api/ — 84 API теста через FastAPI TestClient с переопределённым get_main_db.
    • test_auth.py — TestLogin (success cookie, 401 invalid/unknown, 422 missing field), TestMe (401, session, X-Internal-API-Key, неправильный ключ → 401, invalid session → 401), TestLogout (clears session, без session — ok).
    • test_users.py — TestListUsers (401, 403 non-admin, admin OK, X-Internal-API-Key), TestCreateUser (401/403, success, duplicate 400, invalid workflow_id 400, valid workflow grants access, missing password 422).
    • test_workflows.py — list (admin/owner views), create (success/422), get (404/admin/403 outsider), delete (admin/404), settings (404, defaults, invalid entity_type 400, update entity_type, generate-token), field mappings (empty/404, delete admin-only).
    • test_b24_entities.py_make_b24_mock factory с patch на BitrixAsync (через unittest.mock). TestAuthBoundary (401 без auth, X-Internal-API-Key OK, неправильный ключ 401), TestSearchContacts (нормализация PHONE/EMAIL, B24 5xx → 502, empty → [], query пустой 422, workflow без webhook 400), TestCreateContact (UF_CRM_* фильтрация, B24 fail → 502, 422), TestUpdateContact (success, B24 fail → 502 включая 429), Search/Create/Update Companies, TestGetDeals (требует filter, by contact/company/UF field, B24 fail → 502), TestGetB24Leads (требует filter, parsing PHONE/EMAIL/CONTACT_ID, skips invalid ID).
    • test_leads.py — TestListLeads, TestImportLead (creates локально без B24 call, dedup by deal_id и bitrix24_lead_id, 404, 422), TestCreateLead (creates + B24 call, B24 fail swallowed, UF_CRM_TRACKING передаётся в crm.lead.add).
  • b24-transfer-lead/src/backend/tests/test_services/ — 45 тестов сервисного слоя.
    • test_auth_service.py — 14 тестов: TestPasswordHashing (bcrypt round-trip, разные salt), TestSessions (create/get/delete, expiry удаляет запись, unknown silent), TestAuthenticateUser (success/wrong/unknown), TestCreateUser (default user role, admin, duplicate ValueError), TestIsAdmin.
    • test_bitrix24_service.py — 31 тест Bitrix24Service: TestInit (webhook URL trailing slash), TestCreateLead (creates контакт + lead, extra_fields merged), TestUpdateContact/TestUpdateCompany (success + 5xx + 429), TestGetEntities (get_lead/get_deal unwrap order0000000000, get_user возвращает None при ошибке), TestGetDealsByLead (list/empty/swallowed exception), TestGetLeadFields/TestGetDealFields (UF_CRM_* listLabel приоритет, propagates 5xx), TestGetLeadStatuses (TTL cache hit), TestGetDealCategories (extracts из dict/list/fallback на call), TestGetDealStages (DEAL_STAGE/DEAL_STAGE_N entity ID), TestCreateContact/TestCreateDeal (split name, contact link), TestUpdateLeadStatus, TestAddContactRelations.

b24-transfer-lead/src/frontend/src/test/ — vitest + jsdom + RTL + MSW

b24-transfer-lead frontend тесты рядом с исходниками (Phase 6)

Скрипты автоматизации

  • scripts/run-all-tests.sh — bash-runner всего тестового стека. Последовательно запускает 6 подпроектов: backend (pytest --cov=app), frontend (npm run test:coverage), max_bot (npm run test:coverage), telegram_bot (pytest --cov=bot), b24-transfer-lead/backend (pytest src/backend/tests --cov), b24-transfer-lead/frontend (npm run test:coverage). Автоопределение project root по BASH_SOURCE. Переменные окружения: FAIL_FAST=1 (стоп на первой ошибке), SKIP_COVERAGE=1 (быстрый прогон без coverage). Цветной вывод заголовков, отслеживание PASS/FAIL/SKIP с длительностью, итоговая сводная таблица, exit-код 0 при полном успехе и 1 при любом падении.