> ## Documentation Index
> Fetch the complete documentation index at: https://omniflow-vpn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Сегменты аудитории и кампании

> Опишите проверяемую аудиторию, соберите и запланируйте рассылку и прочитайте, кого кампания действительно охватила

Кампания — это один шаблон сообщения, отправляемый одному сегменту аудитории по
расписанию, со строкой на каждого получателя, которая фиксирует, что с ним
произошло. Она живёт по адресу `/admin/marketing`, для открытия которого нужно
право `marketing.read`.

Экран построен вокруг момента перед отправкой. Кампания создаётся черновиком, и
оценка её аудитории видна сразу, планирование — отдельное второе действие, а
определение сегмента выводится словами рядом с числом, потому что ошибка, ради
предотвращения которой существует эта страница, — отправить правильное сообщение
не тем людям, и никто не заметит этого, читая код сегмента.

> [!NOTE]
>
> Несколько частей этого раздела сегодня доступны только через API. Сегменты,
> шаблоны и записи списка подавления можно прочитать в `/admin/marketing`, но
> создаются они запросами к `/v1/panel`, а не через форму. Каждый раздел ниже
> говорит, как обстоит дело у него.

## Сегмент — это фильтры, а не сохранённый SQL

Сегмент аудитории — это именованный набор фильтров, хранимый как JSON и
компилируемый в параметризованный SQL в момент чтения. Он намеренно не является
сохранённым фрагментом запроса, и на то есть две причины, каждая из которых
перевешивает гибкость, которую дал бы такой фрагмент.

Оператор должен иметь возможность прочитать, кого отбирает сегмент, до того как
что-то ему отправить, а аудитор — проверить постфактум, кого он отобрал. Ни то,
ни другое невозможно, если определение — это запрос, который кто-то однажды
написал и который теперь никто не может истолковать.

К тому же сохранённый фрагмент — это поверхность для инъекций с приложенным
журналом аудита. Здесь каждое значение попадает в SQL параметром, а словарь
закрыт, поэтому сегмент не может выразить ничего, чего словарь не допускает, — в
том числе прочитать таблицу, читать которую ему незачем.

### Словарь

| Фильтр | Допустимое значение | Что отбирает | Как читается |
| - | - | - | - |
| `status` | `active`, `suspended`, `deleted` | Статус аккаунта | «статус аккаунта — active» |
| `locale` | `en`, `ru` | Язык интерфейса | «язык интерфейса — ru» |
| `subscription` | `active` | Есть действующее право на услугу, заканчивающееся в будущем | «есть действующая подписка» |
| `subscription` | `expired` | Право на услугу было, действующего нет | «подписка была и больше её нет» |
| `subscription` | `none` | Подписки не было никогда | «подписки не было никогда» |
| `expiringWithinDays` | Целое больше нуля | Действующее право на услугу заканчивается в ближайшие N дней | «подписка заканчивается в ближайшие 7 дней» |
| `inactiveForDays` | Целое больше нуля | Нет заказов в статусе `paid` или `fulfilled` за N дней | «ничего не оплачивал 60 дней» |
| `minimumSpendMinor` | Целое, ноль или больше | Учтённые расходы не меньше суммы | «потратил не менее 500000 (в минорных единицах)» |
| `loyaltyTier` | Код уровня | Сейчас стоит на этом уровне | «стоит на уровне лояльности gold» |
| `planCode` | Код тарифа | Есть право на услугу по этому тарифу | «есть тариф pro» |

Ключ вне этого списка отклоняется при записи с `unknown audience filter`, а
значение, которое фильтр использовать не может, — с
`invalid audience filter value`; и то и другое приходит как 422. Отклонение при
записи, а не в момент отправки, сделано намеренно: молчаливо отброшенный фильтр
отправил бы сообщение более широкой аудитории, чем просил оператор, а узнать об
этом в момент начала отправки — значит узнать слишком поздно.

Числа, приходящие в JSON как дробные, должны быть целыми.
`expiringWithinDays: 7.5` — это отказ, а не округление.

### Он объясняет себя сам

Каждый сегмент несёт сгенерированное объяснение того, кого он отбирает. Оно
генерируется из фильтров, а не пишется оператором, поэтому не может описать то,
чего запрос не делает; панель выводит его маркированным списком рядом с текущим
размером.

Фильтры компилируются в отсортированном порядке ключей независимо от того, как
был записан JSON-объект, поэтому одно и то же определение всегда даёт один и тот
же SQL и одно и то же объяснение. Сегмент, чьё отображение меняется между
проверками, — это сегмент, который никто не может утвердить.

> [!WARNING]
>
> **Пустой набор фильтров — это все клиенты.** Он компилируется в `true` и
> объясняет себя как «все клиенты». Это законный случай — уведомление об аварии
> адресовано всем, — но он назван словами, а не выглядит как пустое определение,
> чтобы никто не разослал письмо всей базе, просто забыв добавить фильтр.

И подсчёт, и отправка ограничены `users.status = 'active'` ещё до применения
любого фильтра, поэтому фильтр `status` способен лишь сузить эту выборку.
Сегмент, построенный из `status: deleted`, не отбирает никого.

### Как сохранить сегмент

Формы нет. Отправьте:

```bash theme={null}
curl -X PUT https://panel.example.com/v1/panel/marketing/segments \
  -H 'Content-Type: application/json' \
  --cookie "$PANEL_SESSION_COOKIE" -H "X-CSRF-Token: $CSRF" \
  -d '{
    "code": "lapsed",
    "nameEn": "Lapsed customers",
    "nameRu": "Ушедшие клиенты",
    "filters": {"subscription": "expired", "inactiveForDays": 60}
  }'
```

`code` соответствует `^[a-z][a-z0-9_-]{1,63}$`, названия — от 1 до 80 символов.
Для сохранения нужно `marketing.write`, и оно пишет запись аудита,
`panel.segment.saved`, несущую весь объект `filters`, — потому что «кто изменил
то, что кампания понимала под *ушедшими клиентами*» — это ровно тот вопрос, ради
которого журнал аудита и существует.

## Шаблоны объявляют свои переменные сами

Шаблон сообщения — это пара двуязычных темы и тела с объявленным списком
переменных и классом. Тело или тема, ссылающиеся на `{{ variable }}`, которой нет
в объявленном списке, отклоняются при записи. Альтернатива — клиент, получивший
`Hello {{name}}`, потому что никто не проверил, и к этому моменту письмо ушло
всем.

Оба языка обязательны по той же причине, по которой шаблоны ответов поддержки
требуют обоих: язык клиента — не язык оператора.

Тела отправляются в режиме разметки HTML Telegram, поэтому к ним применяется то
же правило разметки, что и к [переформулированным уведомлениям](https://omniflow-vpn.mintlify.site/ru/operations/message-wording.md):
только теги, которые принимает Telegram (`b`, `strong`, `i`, `em`, `u`, `s`,
`strike`, `del`, `code`, `pre` и `a` со ссылкой `https`), каждый тег закрыт, а
буквальный `<` записывается как `&lt;`. Всё остальное отклоняется при записи с
кодом 422. Раньше `Скидка <50%` принималось, а затем Telegram отклонял его для
каждого получателя кампании. Темы экранируются перед отправкой и могут
содержать что угодно.

Если Telegram всё же отклоняет сообщение, отказ записывается один раз как
`failed` и не повторяется — тот же текст получит тот же ответ — и не
засчитывается в состояние доставки клиента, потому что с его чатом всё в
порядке.

Создание доступно только через API: `PUT /v1/panel/marketing/templates` с
`{code, class, subjectEn, subjectRu, bodyEn, bodyRu, variables}`, требует
`marketing.write`. Тела — от 1 до 3500 символов, темы могут быть пустыми и
ограничены 200 символами, `class` по умолчанию `marketing`.

> [!WARNING]
>
> **Объявленная переменная превращается в ничто.** Доставка кампании подставляет
> пустую карту значений, а затем вырезает любой уцелевший плейсхолдер, и ни одна
> часть модели кампании не хранит значения по получателям. Тело, написанное как
> `Hello {{name}}`, уходит как `Hello ` и ничего вместо плейсхолдера. Пишите
> шаблоны без переменных, пока не появятся значения, которыми их заполнять.
>
> **Шаблон класса `transactional` доставить нельзя.** При доставке сообщение
> классифицируется передачей строки класса шаблона в классификатор уведомлений,
> который знает *виды* уведомлений; `marketing` оказывается одним из них, а
> `transactional` — нет. Транзакционный шаблон приводит к тому, что нотификатор
> пишет в лог «campaign classification failed» и оставляет каждого получателя в
> `queued`, что заодно не даёт кампании когда-либо завершиться. Используйте
> `marketing` — значение по умолчанию.

## Сборка и планирование рассылки

**1. Откройте карточку кампаний**

`/admin/marketing`. С правом `marketing.write` над таблицей кампаний
появляется пунктирный конструктор.

**2. Назовите её, выберите сегмент, выберите шаблон**

В выпадающем списке сегментов показано английское название каждого сегмента
и его текущий размер. При выборе появляется блок с предпросмотром того, кого
это охватит, — собственными сгенерированными словами сегмента, ещё до того
как что-либо создано.

**3. Создайте черновик**

`POST /v1/panel/marketing/campaigns` с `{name, segmentId, templateId}`.
Аудитория считается сейчас и сохраняется как `estimatedAudience`. Ничего не
ставится в очередь — черновик, уже поставивший в очередь своих получателей,
превратил бы проверку в формальность. Оценка считается тем же скомпилированным
фрагментом, который обойдёт отправка, поэтому то, что утверждает оператор, и
то, что получит сообщение, не могут происходить из разных определений.

**4. Отправьте себе предпросмотр**

**Preview EN** и **Preview RU** ставят в очередь по одной копии сообщения для
операторской группы, отрисованной ровно так, как её получит клиент. Больше её
никто не получает, и в кампании ничего не меняется. См.
[Предпросмотр кампании](#предпросмотр-кампании).

**5. Запланируйте её**

Нажмите **Schedule** — откроется выбор даты и времени: календарь для дня,
затем часы и минуты двумя списками. Прошедшие дни не предлагаются, а кнопка
остаётся заблокированной, пока момент не выбран. Это отправляет
`{"state": "scheduled", "scheduledFor": "<ISO 8601>"}` на
`/v1/panel/marketing/campaigns/{campaignID}/state`. Планирование без даты —
это 422: «отправить немедленно» и «кто-то забыл дату» не должны быть одним и
тем же запросом.

**6. Наблюдайте и остановите, если что-то не так**

В назначенное время воркер переводит кампанию в `running` и разворачивает
аудиторию. **Pause** или **Cancel** — из того же элемента управления.

## Предпросмотр кампании

`POST /v1/panel/marketing/campaigns/{campaignID}/test` с `{"locale": "en"}` или
`{"locale": "ru"}` ставит в очередь одну копию сообщения кампании для
операторской группы. Требуется `marketing.write`: чтение кампании не должно
уметь что-либо отправлять в чат.

Приходит собственное сообщение кампании, отрисованное тем же кодом, что и
настоящая отправка: переменные шаблона превращаются в ничто, потому что именно
это получает клиент, — предпросмотр с подставленными примерами был бы
предпросмотром текста, которого никто не увидит. Он попадает в отдельную тему
форума, **📣 Campaign previews**, под заголовком, называющим кампанию, язык и то,
что больше его никто не получил.

Две вещи он намеренно не трогает:

* **Получателей.** Запись живёт в отдельной таблице `campaign_test_sends`. Все
  счётчики рядом с кампанией — в очереди, отправлено, ошибок, подавлено —
  выводятся из `campaign_recipients`, а разворачивание аудитории пропускает
  всех, у кого там уже есть строка. Предпросмотр, записанный как получатель,
  сдвинул бы числа, по которым оператор вот-вот будет судить о кампании, и убрал
  бы настоящего клиента из отправки. Ни то, ни другое не откатить после того, как
  кампания прошла.
* **Саму кампанию.** Состояние не меняется, счётчики не двигаются, а запись
  аудита — `panel.campaign.tested`, а не что-то похожее на отправку.

Для кампании в состоянии `completed` или `cancelled` запрос отклоняется.
Предпросмотр нужен, пока решение ещё открыто, а текст в операторской группе для
кампании, которая больше не запустится, читается так, будто она может запуститься.

Доставку выполняет бот, поэтому панель сообщает, что предпросмотр **поставлен в
очередь**. Он приходит на следующем проходе уведомителя, примерно в течение
десяти секунд. Предпросмотр, который не удалось доставить — не настроена
операторская группа, у бота нет права создавать темы, — записывается как `failed`
с категорией и не повторяется: попросить ещё один — это один клик, а цикл
повторов против чата, который отказывает, наполняет очередь, а не чат.

> [!NOTE]
>
> Нужна операторская группа. Если она не настроена, уведомитель бота не
> запускается и предпросмотры остаются в состоянии `queued`. О привязке группы —
> в [Telegram](https://omniflow-vpn.mintlify.site/ru/integrations/telegram.md).

### Разрешённые переходы

Конечный автомат реализован в самом запросе, а не в вызывающем коде: обновление
несёт `AND status = ANY(allowed_from)`, поэтому запрещённый переход не находит ни
одной строки и API отвечает **404**, а не ошибкой валидации.

| Целевое состояние | Достижимо из |
| - | - |
| `scheduled` | `draft`, `paused` |
| `running` | `draft`, `scheduled`, `paused` |
| `paused` | `running` |
| `cancelled` | `draft`, `scheduled`, `paused` |
| `completed` | `running` |

Завершённая кампания не может вернуться в работу, а отменённую нельзя воскресить
с другой аудиторией. Работающую кампанию нельзя отменить напрямую — сначала
поставьте её на паузу.

> [!NOTE]
>
> Панель предлагает **Pause** для запланированной кампании, но `paused` достижим
> только из `running`, поэтому такая кнопка отвечает 404. Вместо этого отмените
> кампанию или перепланируйте её на более позднее время. Учтите также, что любой
> переход, кроме планирования, очищает `scheduled_for`, поэтому кампании, которую
> поставили на паузу и возобновили, дату нужно задать заново.

### Что делает воркер

Обработчик кампаний в процессе воркера срабатывает раз в минуту и берёт до 20
подошедших кампаний — тех, что в `scheduled` со временем в прошлом, плюс всё, что
уже `running`.

* Кампания в `scheduled` переводится в `running`, что один раз проставляет
  `started_at`.
* Сегмент перекомпилируется на каждом проходе. **Сегмент, который больше не
  компилируется, ставит кампанию на паузу**, вместо того чтобы запускать её по
  аудитории, которую никто не может прочитать.
* За проход вставляется до 5000 строк получателей. Дедупликацией служит составной
  первичный ключ строки получателя, поэтому кампания, поставленная на паузу и
  возобновлённая, продолжается, а не начинается заново, и клиент не может попасть
  в очередь дважды.
* Клиент из списка подавления или без Telegram-идентичности, на которую бот
  мог бы написать, сразу при постановке в очередь получает исход `suppressed` с
  причиной `suppressed` или `no_telegram`. Раньше клиент без Telegram-идентичности
  ставился в очередь для бота, который его никогда не читал, поэтому строка
  оставалась в `queued`, и кампания не завершалась.
* Счётчики после каждого прохода пересчитываются из строк получателей, а не
  инкрементируются, поэтому они не могут разойтись с реальностью.
* Когда проход не ставит в очередь никого нового и ничего не осталось в `queued`,
  кампания помечается как `completed`.

> [!NOTE]
>
> Разворачивание аудитории раньше ломалось для любого сегмента, несущего
> связанное значение, — то есть почти для всех. Запрос передавал идентификатор
> кампании как `$1` и добавлял собственные аргументы сегмента после него, тогда
> как скомпилированный фильтр тоже нумерует плейсхолдеры с `$1`; они
> сталкивались, PostgreSQL отклонял привязку, проход писал в лог «campaign
> expansion failed» и не ставил в очередь никого — из панели это неотличимо от
> сегмента, которому никто не соответствует. Теперь аргументы сегмента идут
> первыми, а идентификатор кампании — последним. Интеграционный тест
> разворачивает сегмент по `locale` и проверяет, что в очередь попал именно тот
> клиент, которого сегмент называет.

## Доставка и кого пропускают

Воркер владеет аудиторией. Доставкой владеет нотификатор бота, потому что
согласие, тихие часы, ограничение частоты и состояние доставки в Telegram уже
живут там — дублирование означало бы два места, которые могут разойтись во
мнении, можно ли писать клиенту. Он вычерпывает получателей в `queued` у
работающих кампаний по 200 за раз, читая следующую пачку сразу после отправки
предыдущей и заглядывая снова каждые 30 секунд, когда очередь пуста, и
записывает исход по каждому получателю. Темп задаёт общий для бота
`APP_TELEGRAM_DELIVERY_RATE`, по умолчанию 25 сообщений в секунду; раньше это
было 200 за пятнадцатиминутный проход, около 800 в час. Флуд-ожидание Telegram
оставляет остаток пачки в очереди до следующей, а не помечает его неудачным.

Эти 200 делятся между работающими кампаниями: каждая отдаёт своих самых старых
получателей, и проход берёт первого из каждой кампании, затем второго и так
далее. Кампания с большим хвостом не может задержать маленькую, как это делала
одна очередь, упорядоченная по времени постановки. Читается каждый получатель в
`queued`, доступен он или нет, поэтому ни один не может застрять в `queued`
навсегда: клиент без Telegram-идентичности, клиент, чей аккаунт приостановили
или удалили после разворачивания аудитории, и клиент, чей чат заведомо не
принимает сообщения, получают исход с причиной, а шаблон с классом, которого не
знает политика доставки, проваливает получателя с `classification_failed`
вместо того, чтобы оставить его в очереди.

Получатель, которому политика отказала, записывается как подавленный с причиной,
а не отбрасывается молча:

| Статус | Причина | Значение |
| - | - | - |
| `sent` | — | Telegram принял сообщение |
| `failed` | — | Telegram отклонил его; `error_code` равен `send_failed` либо `classification_failed` для класса шаблона, которого не знает политика |
| `suppressed` | `no_telegram` | Нет доступной Telegram-идентичности; `error_code` равен `account_inactive`, если аккаунт приостановили или удалили после разворачивания |
| `suppressed` | `suppressed` | На момент разворачивания аудитории был в списке подавления |
| `suppressed` | `no_consent` | Нет маркетингового согласия либо выключена настройка новостей |
| `suppressed` | `frequency_cap` | Уже израсходовал свою маркетинговую квоту в этом окне |
| `suppressed` | `quiet_hours` | Находится внутри своего тихого окна |
| `suppressed` | `delivery_blocked` | Заблокировал бота, ни разу его не запускал либо аккаунт деактивирован |

Кампания **записывает** отказ по тихим часам, а не придерживает сообщение до
утра. Маркетинговая рассылка недостаточно срочна, чтобы приходить с опозданием в
пачке, которая уже не соответствует тому, ради чего оператор её писал.

Тема, если она у шаблона есть, добавляется к телу сверху жирным шрифтом.
Отрендеренные тела никогда не хранятся: историей служат шаблон плюс запись о
доставке, а копирование тела в таблицу кампаний продублировало бы обращённое к
клиенту содержимое во второй режим хранения без всякой операционной пользы.

Каждый получатель проверяется по маркетинговому переключателю клиента независимо
от класса шаблона, а запрос, фиксирующий исход, находит только строку, всё ещё
находящуюся в `queued`, поэтому повторная доставка не может перезаписать уже
записанный исход.

Правила классификации, арифметику тихих часов и сами настройки ограничения
частоты см. в разделе
[Новости и общение с клиентами](https://omniflow-vpn.mintlify.site/ru/commerce/referrals-communication.md).

## Как читать результат

В таблице кампаний по каждой кампании показаны: название, под ним
`segmentCode · templateCode`, бейдж статуса со временем запланированной отправки,
оценка аудитории и тройка результата — **отправлено / не удалось / подавлено**.

Подавленные отчитываются рядом с отправленными, а не сворачиваются в них, потому
что «мы намеренно не написали этим людям» — факт иного рода, нежели сбой.
Оператору, оценивающему охват, нужно видеть, что четыреста человек были в списке
и не получили сообщения, и почему. Кампания, которая просто показала бы меньшую
аудиторию, скрыла бы это решение, а меньшая аудитория без объяснения неотличима
от сломанной отправки.

Поэтому разрыв между `estimatedAudience` и `queued + sent + failed + suppressed`
— это честная мера того, насколько далеко продвинулось разворачивание, а не
расхождение.

## Список подавления

`communication_suppressions` хранит по одной строке на клиента с причиной из
закрытого набора — `customer_request`, `bounced`, `complaint`, `operator`, —
необязательной заметкой и тем, кто её добавил. Любая другая причина — 422.

Это намеренно не флаг маркетингового согласия. Согласие — это настройка, которую
клиент переключает; подавление — это постоянное указание, которое переживает
случайное обратное переключение настройки.

`/admin/marketing` → **Suppressions** показывает идентификатор клиента, причину,
заметку и кнопку **Remove** для всех, у кого есть `marketing.write`. Добавление
возможно только через API: `PUT /v1/panel/marketing/suppressions` с
`{customerId, reason, note}`, с upsert по клиенту, поэтому повторное подавление
перезаписывает причину и заметку. Обе операции пишут записи аудита,
`panel.suppression.added` и `panel.suppression.removed`.

Клиент, отписавшийся на `/account/preferences`, пишет собственную строку с
причиной `customer_request`, и повторное согласие позже удаляет **только** эту
причину. Подавление, записанное из-за возврата письма или жалобы, — это чужой
вывод о доставляемости, и галочка, поставленная клиентом, его не отменяет. Экран
настроек клиента сообщает о действующем подавлении с его причиной и датой, но не
даёт никакого элемента управления, чтобы его снять.

Колонки срока действия нет, поэтому подавление стоит до тех пор, пока его не
снимет оператор.

## Права доступа

| Действие | Право доступа |
| - | - |
| Читать сегменты, шаблоны, кампании и подавления | `marketing.read` |
| Сохранить сегмент или шаблон, создать или перевести кампанию, добавить или снять подавление | `marketing.write` |
| Изменить то, сколько платит реферальная программа | `settings.write` |

Каждая запись здесь аудируется: `panel.segment.saved`, `panel.template.saved`,
`panel.campaign.created`, `panel.campaign.<state>`, `panel.suppression.added` и
`panel.suppression.removed`.

## Смежные страницы

> **[Новости и общение с клиентами](https://omniflow-vpn.mintlify.site/ru/commerce/referrals-communication.md)**
>
> Входящие с объявлениями, классы уведомлений, согласие, тихие часы и
> ограничение частоты.

> **[Тексты сообщений](https://omniflow-vpn.mintlify.site/ru/operations/message-wording.md)**
>
> Транзакционные уведомления: они переписываются в другом месте и по другим причинам.

> **[Уведомления для клиента](https://omniflow-vpn.mintlify.site/ru/customer/notifications.md)**
>
> Что клиент видит и чем управляет на своей стороне.

> **[Панель администратора](https://omniflow-vpn.mintlify.site/ru/operations/admin-panel.md)**
>
> Сессии, роли и всё остальное в `/admin`.

> **[Задания и очереди](https://omniflow-vpn.mintlify.site/ru/operations/jobs-and-queues.md)**
>
> Циклы воркера, которые разворачивают кампании и публикуют запланированные
> посты.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.