CRM-мессенджер: пагинация, сделки и unread tracking

Когда заказчик говорит «у меня должно быть 400 диалогов, а я вижу только 50» — это не баг пользователя, это хардкод разработчика. Именно с такой ситуации началась серия доработок нашего CRM-мессенджера, которая в итоге переросла в полноценный рефакторинг работы с контактами, сделками и системой непрочитанных сообщений.
В этой статье разберу весь путь: от банального limit=50 в коде фронтенда до двусторонней синхронизации с Twenty CRM и дизайна unread tracking по образцу Telegram.
Проблема первая: куда делись 437 контактов
Система загружала диалоги корректно — в базе данных через Supabase хранилось 487 контактов. В панели Twenty CRM было видно сотни лидов. Но в интерфейсе мессенджера отображалось ровно 50. Всегда 50, независимо от количества реальных контактов.
Причина оказалась классической: в файле contact-list.tsx на строке 68 был захардкожен лимит без какой-либо пагинации:
// БЫЛО — хардкод без пагинации
params.set("limit", "50");Запрос отправлялся один раз при загрузке компонента, подгружал первые 50 записей и на этом останавливался. Никакого infinite scroll, никакого offset, никакого счётчика общего количества.
Просто убрать лимит было бы плохим решением — грузить 487 контактов одним запросом тяжело для клиента и для базы. Правильный путь — infinite scroll с подгрузкой порциями по 50 при прокрутке вниз.
Что потребовалось изменить
Первым делом нужно было добавить поддержку total на стороне API. Бэкенд принимал offset, но не возвращал общее количество записей — фронтенд не знал, когда останавливать подгрузку.
Supabase поддерживает подсчёт через параметр count="exact" в методе .select():
# БЫЛО
result = supabase.table("contacts").select("*").range(offset, offset + limit - 1).execute()
# СТАЛО
result = supabase.table("contacts").select("*", count="exact").range(offset, offset + limit - 1).execute()
total = result.countТеперь API возвращал { contacts: [...], total: 487 }, и фронтенд мог корректно отображать бейдж с общим числом и останавливать подгрузку когда все контакты загружены.
На стороне React добавился стандартный паттерн infinite scroll: отслеживание скролла контейнера, инкремент offset при достижении нижней границы, объединение порций через setContacts(prev => [...prev, ...newContacts]):
// СТАЛО — infinite scroll
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const loadMore = useCallback(async () => {
if (!hasMore || isLoading) return;
const data = await fetchContacts({ limit: 50, offset });
setContacts(prev => [...prev, ...data.contacts]);
setHasMore(offset + 50 < data.total);
setOffset(prev => prev + 50);
}, [offset, hasMore, isLoading]);Результат: все 487 контактов теперь доступны, загружаются порциями при скролле, бейдж показывает реальное число.
Проблема вторая: контакты без сделок
После того как контакты стали видны, выяснилась следующая проблема. Импорт диалогов из Telegram создавал записи в локальной базе данных, но не создавал соответствующие Opportunity в Twenty CRM. А по бизнес-логике каждый контакт должен иметь хотя бы одну сделку — иначе менеджеры не видят его в своём пайплайне.
При этом ситуация была запутанной:
- В локальной БД: 487 контактов, у большинства
twenty_person_id = null - В Twenty CRM: 396 People с
leadSource=TELEGRAM - Связи между ними: нет
- Сделок (Opportunities): тоже нет
То есть импорт когда-то создал людей в Twenty, но не сохранил их ID обратно в локальную БД. Нужно было решить три задачи последовательно:
- Сопоставить существующих People в Twenty с контактами в локальной БД
- Обновить
twenty_person_idдля найденных совпадений - Создать Opportunity для каждого контакта
Массовая синхронизация и создание сделок
Добавил эндпоинт /api/contacts/sync-and-create-opportunities, который запускается как background task:
@router.post("/contacts/sync-and-create-opportunities")
async def sync_and_create_opportunities(background_tasks: BackgroundTasks):
background_tasks.add_task(_sync_people_and_create_opps)
return {"status": "started"}
async def _sync_people_and_create_opps():
# 1. Загружаем всех People из Twenty CRM
people = await twenty_crm.get_all_people()
# 2. Матчим по Telegram username / имени
for person in people:
contact = await db.find_contact_by_name(person["name"])
if contact:
await db.update_contact(contact["id"], {"twenty_person_id": person["id"]})
# 3. Создаём Opportunity для контактов без сделок
contacts_without_opps = await db.get_contacts_without_opportunities()
for contact in contacts_without_opps:
await twenty_crm.create_opportunity({
"name": contact["name"],
"stage": "NEW",
"personId": contact["twenty_person_id"]
})Важный момент — стадии сделок. В API Twenty CRM доступны стадии NEW, SCREENING, MEETING, PROPOSAL, CUSTOMER, LOST. В исходном коде была прописана стадия SCREENING как дефолтная — исправили на NEW.
Распределение сделок по менеджерам
Когда 439 сделок были созданы, встал вопрос распределения. Менеджеры — Анна и Тамиля — должны были получить примерно поровну. Мария работала со своими контактами отдельно.
Для автоматического распределения новых сделок реализовали round-robin логику прямо в хуке создания контакта:
async def assign_manager_round_robin(opportunity_id: str):
managers = await db.get_active_managers() # только Анна и Тамиля
counts = await twenty_crm.get_opportunity_counts_by_manager(managers)
# Назначаем менеджера с наименьшим числом сделок
target_manager = min(counts, key=lambda m: m["count"])
await twenty_crm.update_opportunity(opportunity_id, {
"assigneeId": target_manager["twenty_member_id"]
})Итог распределения: Тамиля — 217 сделок, Анна — 215, Мария — 7 (свои контакты).
Проблема третья: нет трекинга непрочитанных
Третья крупная задача пришла с вопросом: как менеджер понимает, что клиент написал и ждёт ответа? В текущей реализации — никак. unread_count на контактах брался из Telegram при импорте и больше не обновлялся. Никакого is_read, никаких галочек.
После обсуждения требований сформировался дизайн из трёх уровней:
Уровень 1 — счётчик на контакте. Стандартный unread_count в таблице contacts. Инкрементируется при входящем сообщении, сбрасывается при открытии чата менеджером.
Уровень 2 — per-message read status. Каждое входящее сообщение получает is_read = false до момента когда менеджер открывает чат. Это позволяет визуально выделить непрочитанные сообщения в интерфейсе — как в самом Telegram, где непрочитанные сообщения отображаются с другим фоном или разделителем «Новые сообщения».
Уровень 3 — read receipts от клиента. Галочки для исходящих сообщений — прочитал ли клиент сообщение менеджера. Это позволяет менеджеру выбирать тактику: если клиент читает, но не отвечает — можно написать более настойчиво.
Для получения статуса прочтения из Telegram используется Telethon — при открытии чата проверяем диалог через client.get_entity() и смотрим dialog.message.out + статус прочтения. Real-time события не нужны — достаточно проверки при открытии.
Схема БД для unread tracking
-- Добавляем в contacts
ALTER TABLE crm_messenger.contacts
ADD COLUMN unread_count INTEGER NOT NULL DEFAULT 0;
-- Добавляем в messages
ALTER TABLE crm_messenger.messages
ADD COLUMN is_read BOOLEAN NOT NULL DEFAULT false, -- входящие: прочитал ли менеджер
ADD COLUMN read_by_contact BOOLEAN NOT NULL DEFAULT false; -- исходящие: прочитал ли клиентОтдельная таблица прочтений (как в полноценных чат-системах) была признана избыточной для текущего кейса — счётчик на контакте + флаг на сообщении покрывают все реальные сценарии использования.
Логика обновления статусов
# При входящем сообщении
async def _handle_incoming(message):
await db.insert_message({
"contact_id": contact_id,
"text": message.text,
"direction": "incoming",
"is_read": False, # менеджер ещё не прочитал
"read_by_contact": None # неприменимо для входящих
})
await db.increment_unread(contact_id)
# При открытии чата менеджером
async def mark_as_read(contact_id: str):
await db.execute("""
UPDATE crm_messenger.messages
SET is_read = true
WHERE contact_id = $1 AND direction = 'incoming' AND is_read = false
""")
await db.execute("""
UPDATE crm_messenger.contacts
SET unread_count = 0
WHERE id = $1
""")
# При открытии чата — проверяем read receipt от клиента
async def check_client_read_receipts(contact_id: str, telegram_dialog_id: int):
dialog = await telegram_client.get_dialog(telegram_dialog_id)
if dialog.is_read: # клиент прочитал последнее исходящее
await db.execute("""
UPDATE crm_messenger.messages
SET read_by_contact = true
WHERE contact_id = $1 AND direction = 'outgoing' AND read_by_contact = false
""")Двусторонняя синхронизация с Twenty CRM
Параллельно выяснилось, что синхронизация между мессенджером и Twenty CRM была практически односторонней:
| Действие | Направление | Статус до |
|----------|-------------|-----------|
| Назначение менеджера | чат → CRM | ❌ Только локально |
| Смена стадии | чат → CRM | ⚠️ Только при создании |
| Изменения в CRM | CRM → чат | ❌ Отсутствует |
| Автораспределение | — | ❌ Отсутствует |
Исправление синхронизации назначения менеджера потребовало добавить вызов Twenty CRM API при каждом изменении owner в локальной БД:
async def assign_manager_to_contact(contact_id: str, manager_id: str):
manager = await db.get_manager(manager_id)
# Обновляем локально
await db.update_contact(contact_id, {"manager_id": manager_id})
# Синхронизируем в Twenty CRM
contact = await db.get_contact(contact_id)
if contact["twenty_opportunity_id"]:
await twenty_crm.update_opportunity(contact["twenty_opportunity_id"], {
"assigneeId": manager["twenty_member_id"]
})Результаты и выводы
В итоге за несколько сессий разработки были решены три принципиально разные проблемы, которые объединяло одно: каждая из них незаметна при разработке, но критична в реальной эксплуатации.
Хардкод limit=50 без пагинации — классическая ловушка, в которую попадают когда пишут MVP с тестовыми данными. Пока контактов 10-20, всё работает. Когда их становится несколько сотен — интерфейс просто перестаёт показывать данные. Решение в виде infinite scroll — правильный паттерн для списков произвольного размера, и его стоит закладывать с самого начала.
Отсутствие twenty_person_id в локальной БД — типичная проблема не-атомарных операций. Импорт создавал запись в Twenty, но не сохранял ID обратно. Решение через bulk-синхронизацию с матчингом по имени — рабочее, но хрупкое. Правильный подход — транзакционное создание с сохранением ID на той же операции.
Unread tracking кажется простой задачей ровно до момента, когда начинаешь разбирать детали. Счётчик на контакте, флаг на сообщении, read receipts из Telegram — три разных механизма, каждый со своей логикой обновления. Правильное решение здесь — начать с минимального (счётчик), убедиться что он работает, и только потом добавлять per-message статусы и галочки.
Главный урок всего этого цикла разработки: интеграция двух систем (мессенджер + CRM) требует явного контракта о том, кто является источником правды для каждого поля. Пока этот контракт не зафиксирован, каждое изменение в одной системе рискует не отразиться в другой. Мы зафиксировали это правило в документации: локальная БД — источник правды для состояния диалогов, Twenty CRM — для стадий сделок и назначений менеджеров, синхронизация идёт в обе стороны явными вызовами API.

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.