Расписания и Подписки: концепция и реализация

Документ описывает идею и реализуемые фичи подсистемы расписаний рассылок ("Подписки") для доставки контента в каналы/чаты (в первую очередь Telegram), включая модель данных, API, алгоритмы планировщика и требования к UI/UX. Содержит сводку договорённостей из текущего репозитория, CHANGELOG/TODO, UI_TODO и консолидацию идей из диалога про «Сеты категорий и расписания».

Цели

  • Гибко планировать отправку контента подписчикам по расписанию или по триггеру.
  • Отбирать релевантный контент по правилам категорий (в т.ч. предустановленные «Сеты»), избегать повторов.
  • Давать редакторам удобный UI для создания и правки подписок, с превью ближайших запусков и логами.
  • Обеспечивать предсказуемую нагрузку и прозрачные ограничения (лимиты/тихие часы/ретраи).

Ключевые сущности

  • Post — медиа‑объект с метаданными и массивом категорий source.category.
  • ContentRule — (планируется) правило отбора контента (что отправлять):
  • Дерево условий категорий (AST) с узлами AND | OR | NOT | LEAF.
  • Поддержка пресетов (избранные «Сеты»), флаг isPreset для быстрого списка.
  • Текущее состояние: используется include/exclude/all списки категорий из Subscribe.options.
  • Subscribe — подписка (кому и когда отправлять):
  • Получатель peer { provider, type, id } (например: TG пользователь/чат, в перспективе VK и др.).
  • Опции options (тип рассылки, лимиты, привязанный contentRule, и пр.).
  • Периодичность periodicity/periodicity_v2 (расписание/триггер, см. ниже).
  • Служебные поля: enabled, last_fire, next_fire, timestamps.
  • PostDelivery — история отправок (для исключения повторов по подписке).
  • SubscribeLog — логи срабатываний/ошибок (для просмотра в UI).

Правила контента (DSL)

Правила категорий хранятся в виде AST, позволяя выражать композиции AND/OR/NOT над множествами категорий. Пример JSON‑DSL:

{ "type": "AND", "children": [
  { "type": "LEAF", "categories": ["humor"] },
  { "type": "NOT", "children": [ { "type": "LEAF", "categories": ["18plus"] } ] }
]}

Трансляция AST в Mongo делается через $expr и сет‑операции: - LEAF с одной категорией → { $in: ["cat", "$source.category"] } - LEAF с несколькими → { $setIsSubset: [[...cats], "$source.category"] } - AND/OR{ $and: [...] }/{ $or: [...] } - NOT(x){ $not: [ expr(x) ] }

Базовый $match комбинирует status: 'ready' и вычисленное $expr по дереву правил.

Текущее состояние (реализовано): отбор постов по подписке на стороне API выполняется через GET /posts/for-subscription/:id по полям options.categories/categoriesExclude/categoriesAll (см. api/src/controllers/posts.js).

Модель периодичности (расписаний)

Поле periodicity описывает когда запускать подписку: - trigger: boolean — если true, запускается внешним событием; если false, по расписанию. - measure: 'minute' | 'hour' | 'day' | 'week' | 'month' | 'by_weekdays' — базовая размерность. - amount: number — шаг (каждые N минут/часов/дней и т.п.). - time: string — локальное время в формате HH:mm (для дневных/недельных/месячных режимов). - weekdays: string[] — дни недели ['mon','tue','wed','thu','fri','sat','sun'] для by_weekdays. - timezone: string — IANA TZ для корректной локализации времени и переходов. - datetime_start?: ISO / datetime_end?: ISO — окна активности.

Дополнительные опции (этап «Улучшения/UX»): - «Тихие часы» (диапазоны блокировки отправок). - Поведение при ошибках: ретраи с backoff/дефер на следующий слот. - Глобальные/пер‑подписочные лимиты: maxPerRun, perDayCap, приоритеты.

Алгоритм calculateNextFireDateTime рассчитывает next_fire на основании текущего now, last_fire и настроек расписания с учётом TZ/границ/тихих часов.

Основные потоки и взаимодействия

1) Демон/воркер расписаний: - Периодически сканирует активные Subscribe по next_fire <= now. - Для каждой подписки вызывает API получения контента и выполняет отправку. - По итогам фиксирует PostDelivery и SubscribeLog, пересчитывает next_fire.

2) Отбор контента для подписки: - GET /api/v{major}/posts/for-subscription/:id?count=N возвращает кандидаты: - $match по статусу и $expr из ContentRule; - исключение уже отправленных (PostDelivery по subscriptionId) — план; - сортировка (обычно по свежести createdAt: -1, возможен простой скоринг); - лимит по countPerTick/count.

3) Отправка ботом: - Воркеры бота берут список постов, отправляют получателю peer (Telegram и далее другие провайдеры). - При успехе пишут PostDelivery(subscriptionId, postId, sentAt); при ошибках — логируют с деталями для ретраев.

API контракты (текущее + MVP)

  • GET /api/v{major}/subscribes/list — список (page/count_on_page), серверная пагинация.
  • GET /api/v{major}/subscribes/search — поиск с фильтрами (bot/provider/enabled/peer.*/mode).
  • GET /api/v{major}/subscribes/ontime — подписки, актуальные к рассылке для бота (учёт next_fire, интервала, опций).
  • GET /api/v{major}/subscribes/get_content — текст/контент подписки по типу (используется ботом для отправки/превью).
  • POST /api/v{major}/subscribes — создание подписки.
  • GET /api/v{major}/subscribes/:subscribe_id — детали подписки.
  • PUT /api/v{major}/subscribes/:subscribe_id — обновление подписки (синхронизирует periodicity/periodicity_v2).
  • DELETE /api/v{major}/subscribes/:subscribe_id — удаление подписки.
  • GET /api/v{major}/subscribes/logs/stat — статистика логов за период.
  • GET /api/v{major}/subscribes/logs/by_uuid/:uuid — детали лога по UUID.
  • POST /api/v{major}/subscribes/logs — создать лог.
  • PUT /api/v{major}/subscribes/logs/:log_id — обновить лог.
  • GET /api/v{major}/posts/for-subscription/:id — подбор постов по include/exclude/all из Subscribe.options.
  • GET /api/v{major}/posts/for-chat/:chat_id — универсальный подбор по категориям (параметры в query).
  • (Доп.) GET /api/v{major}/bot/subscriptions/stat — сводная статистика для дашборда.

UI/UX админки (страница «Подписки»)

Роут /subscribes включает: - Таблица (lazy, server‑side): - Колонки: Получатель (иконка провайдера + id), Тип, Активна, Периодичность (кратко), Следующий запуск, Последний запуск, Обновлено. - Фильтры: provider/type/enabled, текстовый поиск по peer.id/alias, даты по обновлению/следующему запуску. - Действия: Редактировать · Дублировать · Вкл/Выкл · Логи · Удалить; массовые — Вкл/Выкл · Удалить.

  • Форма редактирования (Drawer/SidePanel) с вкладками: 1) Получатель — провайдер (readonly после создания), тип/ID, проверка существования, enabled. 2) Тип/Контент — тип подписки (options.type), include/exclude категорий (MultiSelect), опциональные фильтры/теги, лимиты. 3) Периодичность — trigger, measure, amount, time, weekdays[], datetime_start/end, timezone; виджет превью ближайших запусков. 4) Расширенные — «тихие часы», ретраи/дефер, fallback формата, редактор options (JSON) для power‑users. 5) Привязка получателя — сервис‑специфичные проверки (TG: QR/ссылка для /start; VK: токен/чат).

  • Логи подписки — быстрый просмотр SubscribeLog: дата, статус/ошибка, количество отправок, фильтры и пагинация.

UI замечания (из UI_TODO и текущего стека PrimeVue): - Единый стиль заголовков/диалогов (PrimeBlocks), доступность раздела только для авторизованных. - Поведенческие улучшения: инлайн enable/disable, индикатор ближайшего запуска, консистентная тёмная тема.

Схемы и индексы (Mongo)

  • Post: индексы по status, source.category, createdAt (композитный { status: 1, "source.category": 1, createdAt: -1 }).
  • PostDelivery: уникальный композит { subscriptionId: 1, postId: 1 }, индексы по внешним ключам.
  • ContentRule: индекс isPreset для списка «Сетов».
  • Subscribe: индексы по enabled, next_fire, peer.id, updatedAt (см. модель и фактические индексы).
  • Subscription (новая модель для валидации/next_fire/summary): индексы по peer.*, next_fire, enabled.

Синхронизация periodicity полей: - API синхронизирует periodicity (legacy) и periodicity_v2 через controllers/utils/periodicity.{legacyToV2,v2ToLegacy,syncPeriodicityFields}.

Валидации и безопасность

  • Доступ к разделу «Подписки» и API — только авторизованным, аналогично «Загрузчику» (JWT, проверка на фронте и в API).
  • Серверная валидация расписаний (корректность TZ, диапазонов, measure/amount) и ссылочной целостности (contentRule, существование peer).
  • Защита от повторов (идемпотентность через PostDelivery), лимиты на подписку/канал.

Этапы реализации (сводно)

1) UI‑скелет /subscribes (роут, пустая таблица, Drawer). 2) Таблица: серверная пагинация/сортировки/фильтры + базовые действия. 3) Форма: MVP (получатель, активность, тип, базовая периодичность, include/exclude категорий). 4) Превью расписания: API POST /subscribes/preview + виджет в форме. 5) Логи: GET /subscribes/:id/logs и просмотр в UI. 6) Улучшения: «тихие часы», ретраи/дефер, расширенный options, инлайн enable/disable, индикаторы. 7) Документация: Swagger/Redoc обновление, пользовательский мини‑гайд для редакторов.

Открытые вопросы

  • Полный перечень options.type и их специфичных параметров.
  • Нужны ли глобальные анти‑спам лимиты по чатам/провайдерам.
  • Где резолвить category id → name (бэк с кэшем vs фронт рантайм).

Приложение: вспомогательные заготовки

Псевдокод построения $match из AST правил категорий (упрощённо):

type Node = { type: 'AND'|'OR'|'NOT'|'LEAF'; categories?: string[]; children?: Node[] };
function nodeToExpr(n: Node): any { /* см. реализацию в проектных заметках */ }
export function buildRuleMatch(root: Node) {
  return { $match: { status: 'ready', $expr: nodeToExpr(root) } };
}

Контур эндпоинта подбора постов:

GET /api/v{major}/posts/for-subscription/:id?count=10
// 1) найти подписку, получить include/exclude/all из options (позже — ContentRule)
// 2) собрать pipeline: match по правилу → исключить уже отправленное → сортировка → лимит
// 3) вернуть список постов