Skip to main content

Boost Pass


1. Summary

Goal: Механика стейкинга Telegram-бустов — пользователь назначает свои буст-слоты на целевой канал и получает прогресс в Boost Pass (сезонной системе milestone-наград).

User Value: Premium-юзеры получают награды (scrap, XP, предметы, кейсы, титулы, streak points) за удержание бустов на канале. Чем больше бустов и чем дольше держишь — тем быстрее прогресс.

Ключевая метафора: "Инвестиционный фонд" — припаркуй свои бусты, получай награды за удержание.

Premium Only

Фича видна только юзерам с isPremium = true и при наличии настроенного Boost Pass на текущий сезон. Не-Premium юзеры не видят кнопку/секцию.


2. Business Logic

Boost Points (BP) — прогресс

Не привязка ко дням, а XP-подобная система очков.

Начисление (snapshot-модель):

  • Cron раз в сутки (18:00 UTC / 21:00 MSK) проверяет: "буст на месте прямо сейчас?"
  • Если да → boostCount × bpPerBoost BP (по умолчанию 10 BP за буст)
  • 1 буст = 10 BP/день, 4 буста = 40 BP/день, 10 бустов = 100 BP/день
  • Без капа на количество бустов — чем больше, тем быстрее
Формула начисления BP
bpToAward = boostCount × bpPerBoost × daysMissed
ПараметрЗначениеОписание
bpPerBoost10 (default)BP за 1 буст в день, настраивается в Season
BATCH_SIZE25Количество API-запросов в батче
BATCH_DELAY_MS1100msЗадержка между батчами (Telegram ~30 req/sec)
MS_PER_DAY86400000Миллисекунд в сутках

Компенсация пропущенных дней:

  • Хранится lastBpAwardedAt для каждого стейкера
  • daysMissed = floor((now - lastBpAwardedAt) / MS_PER_DAY)
  • Если cron пропустил дни → начислить BP × daysMissed
  • Защита от двойного начисления: BP начисляется только если daysMissed >= 1

При снятии буста:

  • Начисление BP снижается пропорционально
  • Накопленные BP сохраняются — нет штрафа
  • Юзер видит что прогресс замедлился → мотивация вернуть бусты

Milestone Types

ТипОписание
REGULARСтандартный milestone: достигни порог BP → забери все награды
SHUFFLEMilestone-рулетка: достигни порог → выбери 1 из N карточек (weighted random)

Shuffle Milestone — механика

Концепт: 6 карточек (3×2 grid), результат определён сервером, выбор иллюзорный.

Правила:

  • Ровно 6 наград = 6 карточек (1:1 mapping)
  • Можно одинаковый тип с разными amount
  • Однократно за milestone (как REGULAR claim)

Animation phases:

PREVIEW (3s) → FLIP → SHUFFLE → PICK → REVEAL → REVEAL_ALL → COMPLETE
🔊 shuffle_card 🔊 card_up
🔊 prizecase (+400ms)
🎉 confetti (custom-purple, 3s)
ФазаЗвукВизуал
SHUFFLINGshuffle_card (~2.1с, синхронизирован с anime.js анимацией)Карты собираются в центр → хаотичное движение → разлетаются на новые позиции
PICKING → кликcard_up (~0.4с)Haptic feedback (heavy)
REVEALING +400msprizecase (~2.2с)Выбранная карта переворачивается (500ms CSS 3D flip) + CanvasConfetti (custom-purple, 3с)

Winner determination:

  • Weighted random с randomInt (Node.js crypto модуль) — каждая награда имеет weight
  • Race condition protection: $transaction с re-roll fallback при исчерпании лимитов
Global/Weekly Limits

Каждая награда в Shuffle milestone может иметь:

ПолеОписание
globalLimitМакс. количество выдач за всё время (null = без лимита)
weeklyLimitМакс. количество выдач за неделю (null = без лимита)

При исчерпании лимита — награда исключается из winning pool, но остаётся на карточке (иллюзия сохраняется). Если все награды исчерпаны — Shuffle недоступен.

Типы наград

Каждый milestone может содержать до 4 наград в любой комбинации:

ТипПараметрыМеханика выдачи
SCRAPamountaddScrap() — основная валюта
XPamountaddUserXP() — опыт сезона
ITEMitemIdUpsert в UserInventory (sourceType: BOOST_PASS)
TITLEtitleSlug, titleName?Create UserTitle (axis: boost_pass, tier: 0). Динамические титулы: titleName задаётся админом и денормализуется в UserTitle.name и UserTitle.tagText при grant
STREAK_POINTSamountIncrement streakPoints + streakPointsTotal
CASEcaseId, amountUpsert UserFreeCaseOpens — бесплатные открытия кейса
Atomic Claim

Claim выполняется в одной Prisma $transaction — все награды или ничего. Shuffle milestones используют отдельный endpoint.

Milestone Statuses

СтатусУсловиеUI
LOCKEDcurrentBp < bpThresholdСерый, заблокирован
UNLOCKEDcurrentBp >= bpThreshold && не claimedПодсвечена кнопка "Забрать"
CLAIMEDcurrentBp >= bpThreshold && claimedГалочка

Milestone Auto-generation (Admin)

Админ может автоматически расставить milestones по timeline с progressive difficulty curves:

maxBP = bpPerBoost × daysInSeason
threshold[i] = maxBP × (i / count) ^ exponent
CurveExponentОписание
Linear1.0Равномерное распределение
Soft1.5Лёгкое начало, сложный конец
Hard2.0Очень лёгкое начало, крутой конец
tip

maxBP рассчитывается автоматически из настроек сезона (дата начала/конца + bpPerBoost).

Верификация бустов — двойная система

ИсточникРольКогда
Webhook (chat_boost / removed_chat_boost)Быстрое обновление UI + instant BPReal-time
Cron (getUserChatBoosts API)Source of truth для BP + синхронизацияЕжедневно 18:00 UTC / 21:00 MSK

Webhook: записывает boost_id в БД (upsert по boost_id), удаляет при снятии.

Cron: для каждого стейкера вызывает API → начисляет BP → синхронизирует BoostStakes (добавляет/удаляет расхождения).

Push Notifications

При начислении BP (cron) проверяется: пересёк ли currentBp порог (bpThreshold) любого незабранного milestone.

  • Триггер: wasBelow && isAbove — одноразовая гарантия (1 push на milestone)
  • Проверки: canReceivePush + botStatus юзера
  • Delivery: bot.api.sendMessage(), non-blocking (try/catch — ошибка не блокирует cron)
  • Batch: несколько milestone за раз → одно сообщение

Leaderboard Rewards (опционально)

Опциональные награды (скины) за топ-позиции в Boost Pass лидерборде. Точная копия системы наград реферального лидерборда.

ПараметрЗначение
Тиры наградTop 1 (Legendary), Top 2-3 (Mythical), Top 4-10 (Epic)
НастройкаВнутри BoostPassStep в админке
Момент раздачиПри завершении сезона (season-lifecycle job)
КлонированиеКнопка "Из прошлого сезона"
ОбязательностьОпционально — без наград лидерборд работает как раньше

Хранение: Season.boostLeaderboardRewards — JSON поле формата SeasonRewards (top1?, top3?, top10?).

Раздача: SeasonRewardService.distributeBoostLeaderboardRewards() — вызывается при завершении сезона, аналог distributeReferralRewards(). Идемпотентность через SeasonRewardClaim с type: BOOST_LEADERBOARD.

Frontend: Если награды настроены — скины отображаются в секциях top1/top3/top10 лидерборда. Без наград — лидерборд отображается как раньше (только позиции).

Сезонность

  • Начало сезона: BP = 0, cron обнаруживает существующие бусты у Premium-юзеров (initial sync)
  • Во время сезона: BP копится, milestones разблокируются, claim по инициативе юзера
  • Конец сезона: сброс BP, unclaimed награды сгорают, раздача leaderboard rewards (если настроены)
  • Межсезонье: админ может сменить целевой канал и настроить новый Pass
Season Reset — что удаляется

В SeasonResetService.resetAllUsers() в одной $transaction:

  • UserBoostProgress — весь прогресс BP
  • BoostStake — все стейки бустов
  • BoostSnapshot — все daily snapshots
  • BoostMilestoneClaim — все claims

BoostMilestone и BoostMilestoneReward каскадно удаляются вместе с Season.

Edge Cases

СитуацияПоведение
Юзер забустил канал до начала сезонаCron при initial sync обнаружит бусты через getUserChatBoosts, начнёт начисление с первого дня
Юзер присоединяется mid-seasonБез надбавок. Прогресс с момента первого буста, на общих условиях
Юзер купил Premium mid-seasonisPremium обновляется при следующем входе в TMA → иконка появится → может бустить
Юзер потерял Premium mid-seasonTelegram снимает все бусты → webhook removed_chat_boost → BP перестаёт капать, накопленное сохраняется
Буст от незарегистрированного юзераWebhook игнорирует (source.user.id не в БД). Cron обнаружит при регистрации
Boost Pass не настроен на сезонИконка скрыта у всех, cron не запускается
Бот удалён из админов канала mid-seasongetUserChatBoosts() в cron вернёт ошибку → alert в мониторинг
Boost Pass отключён экстренноUI в серых тонах, "Временно недоступен". Unlocked награды можно забрать. Обратно включить нельзя до нового сезона
Bootstrap (initial load)Отдельный GET /boost-pass (не в bootstrap response). Возвращает null если нет Boost Pass
User потерял Premium после получения титулаТитул остаётся в earned, можно выбирать как active до конца сезона
Cron job + boost_pass active titletagText берётся из UserTitle.tagText (fallback, т.к. динамические титулы не в TITLE_BY_SLUG)
Нет earned boost_pass titles у юзераСекция "Boost Pass" не показывается в списке званий
Leaderboard без настроенных наградОтображается как раньше — только позиции, без скинов
Season end → boost leaderboard rewards не настроеныПропускается, логируется "No boost leaderboard rewards configured, skipping"
Юзер в топ-10 бустеров и топ-10 XP/рефереровСоздаются отдельные SeasonRewardClaim для каждого типа (XP, REFERRAL, BOOST_LEADERBOARD) — все независимы

Admin Edge Cases

СитуацияUI поведение
Переключение milestone с несохранёнными изменениямиWarning dialog "Unsaved Changes" + чекбокс "Don't show again" (localStorage)
Drag точки на timelineСвободное перемещение без snap, BP обновляется в реальном времени в sidebar
Dot colors: пустой milestoneСиний (нет наград)
Dot colors: настроенный milestoneЗелёный (есть награды, тип REGULAR)
Dot colors: SHUFFLE milestoneГрадиент purple→amber
Escape при открытом sidebarЗакрывает sidebar (не modal). Повторный Escape — закрывает modal

3. ADR (Architectural Decisions)

Почему Snapshot-модель, а не полные сутки?

Проблема: Как начислять BP — трекать точное время удержания или проверять наличие на момент snapshot?

Решение: Snapshot-модель. Cron раз в сутки проверяет: буст есть → BP.

Альтернативы (отклонены):

  • Полные сутки (24ч непрерывного удержания) — сложный трекинг, edge cases с timezone
  • Почасовая проверка — избыточная нагрузка на Telegram API

Последствия: Telegram cooldown 24ч на перенос буста защищает от абьюза. Простая и надёжная модель.

Почему Premium-only?

Проблема: Показывать ли фичу не-Premium юзерам?

Решение: Полностью скрыта для не-Premium. Нет FOMO для тех, кто не может участвовать.

Последствия: isPremium актуализируется при каждом входе в TMA (через telegram-auth.middleware).

Почему Webhook + Cron (двойная верификация)?

Проблема: Webhook может быть пропущен, а ежедневный cron создаёт задержку в UI.

Решение: Разделение ответственности:

  • Webhook → быстрый UI + instant BP (real-time)
  • Cron → source of truth для ежедневного BP (устойчивый к пропущенным webhook'ам)

Последствия: При расхождении webhook-данных с API — cron корректирует.

Почему канал привязан к сезону?

Проблема: Может ли админ менять целевой канал в любой момент?

Решение: Канал locked во время активного сезона. Менять только в межсезонье.

Последствия: Стабильность для юзеров — не теряют прогресс из-за смены канала.

Почему Sidebar вместо Popup для milestone editor?

Проблема: Popup-editor (420×520px) позиционировался рядом с точкой на timeline canvas. Мало места для SHUFFLE milestones (6 карт + weights + limits), модалки выбора кейсов/итемов ломают фокус, тесно при большом количестве наград.

Решение: Левый сайдбар 400px, полная высота контентной области. Timeline canvas сдвигается вправо (flex-1). Закрытие: X / повторный клик по точке / Escape.

Альтернативы (отклонены):

  • Увеличить popup — по-прежнему конфликт с zoom/pan canvas
  • Правый сайдбар — timeline привычнее читать слева

Последствия: Те же внутренние компоненты (MilestoneRewardEditor, ShuffleRewardEditor) переиспользуются без изменений. Inline TypeSelectionView заменяет отдельный portal MilestoneTypeSelector.

Почему inline columns для титулов, а не отдельная таблица?

Проблема: Boost Pass может давать кастомные титулы (TITLE reward). Где хранить titleName — отдельная таблица BoostTitleDefinition или inline в существующих моделях?

Решение: Inline column в двух таблицах:

  • BoostMilestoneReward + titleName — source of truth для метаданных
  • UserTitle + name, tagText — денормализация при grant (name = tagText, immutable)

Альтернативы (отклонены):

  • Отдельная таблица BoostTitleDefinition — лишний cross-domain join, усложнение для 1-5 титулов за сезон
  • Только в BoostMilestoneReward (без денормализации) — title service зависит от boosts domain

Последствия: При grant награды name/tagText копируются в UserTitle → title domain работает автономно без join в boosts. Hardcoded titles (TITLE_BY_SLUG) имеют name/tagText = null — fallback на константы.

Почему Per-Milestone Save?

Проблема: Bulk save (PUT /:seasonId) удаляет ВСЕ milestones и создаёт заново в транзакции. При настройке 30+ milestones — рискованно и медленно.

Решение: Параллельный per-milestone CRUD (POST/PUT/DELETE) + bulk save в header bar остаётся для полного пересохранения. Per-milestone dirty tracking через JSON snapshot comparison.

Альтернативы (отклонены):

  • Только bulk save — неудобно при точечных правках
  • Заменить bulk save на per-milestone only — bulk нужен для auto-generate milestones

Последствия: id?: string в AdminBoostMilestoneInput отличает сохранённые milestones (PUT) от новых (POST). Unsaved changes warning при переключении между milestones.


4. Architecture

Services Overview

Key Components

КомпонентПутьОписание
BoostPassServicebackend/src/domains/boosts/services/boost-pass.service.tsPage data, leaderboard (+ rewards), milestone statuses, getTopBoosters()
BoostPassRewardServicebackend/src/domains/boosts/services/boost-pass-reward.service.tsAtomic claim, reward granting
BoostPassAdminServicebackend/src/domains/boosts/services/boost-pass-admin.service.tsChannel config, milestone CRUD
BoostWebhookServicebackend/src/domains/boosts/services/boost-webhook.service.tsTelegram webhook handlers
ShuffleServicebackend/src/domains/boosts/services/shuffle.service.tsShuffle milestone logic
BpAwardJobbackend/src/domains/boosts/jobs/bp-award.job.tsDaily cron: BP award + stake sync
Shared Typesshared/src/types/boost-pass.types.tsDTOs, enums, BoostLeaderboardRewards, BoostLeaderboardRewardConfig

5. Database Schema

Models

МодельОписаниеКлючевые поля
Season (boost fields)Конфиг Boost Pass привязан к сезонуboostChannelId, boostChannelUsername, boostChannelTitle, boostPassEnabled, boostBpPerBoost
BoostMilestoneMilestone-пороги BPseasonId, bpThreshold, sortOrder, type (REGULAR/SHUFFLE)
BoostMilestoneRewardНаграды milestone (до 4 штук)milestoneId, rewardType, amount, itemId, titleSlug, titleName?, caseId, weight, globalLimit, weeklyLimit
UserBoostProgressBP-прогресс пользователяuserId+seasonId (unique), currentBp, activeBoostCount, peakBoostCount, lastBpAwardedAt
BoostStakeОтдельные boost_id из webhookboostId+seasonId (unique), userId, addDate, expirationDate, source
BoostMilestoneClaimЗабранные milestoneuserId+milestoneId (unique), rewardSnapshot (JSON), wonRewardId
BoostSnapshotЕжедневные snapshots cronuserId+seasonId+snapshotDate (unique), boostCount, bpAwarded

Relationships


6. API Endpoints

МетодЭндпоинтОписаниеAuth
GET/api/boost-passДанные Boost Pass (прогресс, milestones, deep link)telegramAuth
POST/api/boost-pass/claimЗабрать награду REGULAR milestonetelegramAuth
GET/api/boost-pass/shuffle/:milestoneIdПолучить данные shuffle (карточки)telegramAuth
POST/api/boost-pass/shuffle/:milestoneId/pickВыбрать карточку в shuffletelegramAuth
GET/api/boost-pass/leaderboardЛидерборд по BP (+ boostLeaderboardRewards если настроены)telegramAuth

Error Codes

КодОписание
MILESTONE_NOT_FOUNDMilestone не найден или используется shuffle endpoint для REGULAR
SEASON_NOT_ACTIVEСезон не активен
BOOST_PASS_DISABLEDBoost Pass отключён
NOT_ENOUGH_BPНедостаточно BP для milestone
ALREADY_CLAIMEDMilestone уже забран
SEASON_ACTIVEСезон активен — редактирование milestones запрещено (только DRAFT/SCHEDULED)

Protection

ЗащитаРеализация
Premium-only visibilityisPremium check (обновляется при каждом входе в TMA)
Rate limitingrateLimitConfigs.general (GET), rateLimitConfigs.mutations (POST)
Atomic rewardsPrisma $transaction — all or nothing
Double claimUnique constraint userId+milestoneId
Instant BP abusepeakBoostCount — защита от фарма через перекидывание бустов
Channel validationgetChatMember(channelId, botId) при сохранении в админке
Season lockКанал нельзя менять во время активного сезона
Admin editing constraintConfig editable только в статусах DRAFT/SCHEDULED. При ACTIVE — только emergency disable
Premium guardGET /boost-pass возвращает null для non-Premium; POST/leaderboard → 403
Leaderboard reward idempotencySeasonRewardClaim с type: BOOST_LEADERBOARD + @@unique([userId, seasonId, type])

  • Seasons — Boost Pass привязан к сезонному lifecycle
  • Titles — TITLE награда создаёт UserTitle (axis: boost_pass)
  • Streaks — STREAK_POINTS награда начисляет streak points