П/ВИН

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

·8 мин чтения
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 обратно в локальную БД. Нужно было решить три задачи последовательно:

  1. Сопоставить существующих People в Twenty с контактами в локальной БД
  2. Обновить twenty_person_id для найденных совпадений
  3. Создать 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 в бизнес.