Boost Pass
1. Summary
Goal: Механика стейкинга Telegram-бустов — пользователь назначает свои буст-слоты на целевой канал и получает прогресс в Boost Pass (сезонной системе milestone-наград).
User Value: Premium-юзеры получают награды (scrap, XP, предметы, кейсы, титулы, streak points) за удержание бустов на канале. Чем больше бустов и чем дольше держишь — тем быстрее прогресс.
Ключевая метафора: "Инвестиционный фонд" — припаркуй свои бусты, получай награды за удержание.
Фича видна только юзерам с isPremium = true и при наличии настроенного Boost Pass на текущий сезон. Не-Premium юзеры не видят кнопку/секцию.
2. Business Logic
Boost Points (BP) — прогресс
Не привязка ко дням, а XP-подобная система очков.
Начисление (snapshot-модель):
- Cron раз в сутки (18:00 UTC / 21:00 MSK) проверяет: "буст на месте прямо сейчас?"
- Если да →
boostCount × bpPerBoostBP (по умолчанию 10 BP за буст) - 1 буст = 10 BP/день, 4 буста = 40 BP/день, 10 бустов = 100 BP/день
- Без капа на количество бустов — чем больше, тем быстрее
Формула начисления BP
bpToAward = boostCount × bpPerBoost × daysMissed
| Параметр | Значение | Описание |
|---|---|---|
bpPerBoost | 10 (default) | BP за 1 буст в день, настраивается в Season |
BATCH_SIZE | 25 | Количество API-запросов в батче |
BATCH_DELAY_MS | 1100ms | Задержка между батчами (Telegram ~30 req/sec) |
MS_PER_DAY | 86400000 | Миллисекунд в сутках |
Компенсация пропущенных дней:
- Хранится
lastBpAwardedAtдля каждого стейкера daysMissed = floor((now - lastBpAwardedAt) / MS_PER_DAY)- Если cron пропустил дни → начислить BP × daysMissed
- Защита от двойного начисления: BP начисляется только если
daysMissed >= 1
При снятии буста:
- Начисление BP снижается пропорционально
- Накопленные BP сохраняются — нет штрафа
- Юзер видит что прогресс замедлился → мотивация вернуть бусты
Milestone Types
| Тип | Описание |
|---|---|
| REGULAR | Стандартный milestone: достигни порог BP → забери все награды |
| SHUFFLE | Milestone-рулетка: достигни порог → выбери 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)
| Фаза | Звук | Визуал |
|---|---|---|
| SHUFFLING | shuffle_card (~2.1с, синхронизирован с anime.js анимацией) | Карты собираются в центр → хаотичное движение → разлетаются на новые позиции |
| PICKING → клик | card_up (~0.4с) | Haptic feedback (heavy) |
| REVEALING +400ms | prizecase (~2.2с) | Выбранная карта переворачивается (500ms CSS 3D flip) + CanvasConfetti (custom-purple, 3с) |
Winner determination:
- Weighted random с
randomInt(Node.jscryptoмодуль) — каждая награда имеетweight - Race condition protection:
$transactionс re-roll fallback при исчерпании лимитов
Global/Weekly Limits
Каждая награда в Shuffle milestone может иметь:
| Поле | Описание |
|---|---|
globalLimit | Макс. количество выдач за всё время (null = без лимита) |
weeklyLimit | Макс. количество выдач за неделю (null = без лимита) |
При исчерпании лимита — награда исключается из winning pool, но остаётся на карточке (иллюзия сохраняется). Если все награды исчерпаны — Shuffle недоступен.
Типы наград
Каждый milestone может содержать до 4 наград в любой комбинации:
| Тип | Параметры | Механика выдачи |
|---|---|---|
| SCRAP | amount | addScrap() — основная валюта |
| XP | amount | addUserXP() — опыт сезона |
| ITEM | itemId | Upsert в UserInventory (sourceType: BOOST_PASS) |
| TITLE | titleSlug, titleName? | Create UserTitle (axis: boost_pass, tier: 0). Динамические титулы: titleName задаётся админом и денормализуется в UserTitle.name и UserTitle.tagText при grant |
| STREAK_POINTS | amount | Increment streakPoints + streakPointsTotal |
| CASE | caseId, amount | Upsert UserFreeCaseOpens — бесплатные открытия кейса |
Claim выполняется в одной Prisma $transaction — все награды или ничего. Shuffle milestones используют отдельный endpoint.
Milestone Statuses
| Статус | Условие | UI |
|---|---|---|
| LOCKED | currentBp < bpThreshold | Серый, заблокирован |
| UNLOCKED | currentBp >= bpThreshold && не claimed | Подсвечена кнопка "Забрать" |
| CLAIMED | currentBp >= bpThreshold && claimed | Галочка |
Milestone Auto-generation (Admin)
Админ может автоматически расставить milestones по timeline с progressive difficulty curves:
maxBP = bpPerBoost × daysInSeason
threshold[i] = maxBP × (i / count) ^ exponent
| Curve | Exponent | Описание |
|---|---|---|
| Linear | 1.0 | Равномерное распределение |
| Soft | 1.5 | Лёгкое начало, сложный конец |
| Hard | 2.0 | Очень лёгкое начало, крутой конец |
maxBP рассчитывается автоматически из настроек сезона (дата начала/конца + bpPerBoost).
Верификация бустов — двойная система
| Источник | Роль | Когда |
|---|---|---|
Webhook (chat_boost / removed_chat_boost) | Быстрое обновление UI + instant BP | Real-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— весь прогресс BPBoostStake— все стейки бустовBoostSnapshot— все daily snapshotsBoostMilestoneClaim— все claims
BoostMilestone и BoostMilestoneReward каскадно удаляются вместе с Season.
Edge Cases
| Ситуация | Поведение |
|---|---|
| Юзер забустил канал до начала сезона | Cron при initial sync обнаружит бусты через getUserChatBoosts, начнёт начисление с первого дня |
| Юзер присоединяется mid-season | Без надбавок. Прогресс с момента первого буста, на общих условиях |
| Юзер купил Premium mid-season | isPremium обновляется при следующем входе в TMA → иконка появится → может бустить |
| Юзер потерял Premium mid-season | Telegram снимает все бусты → webhook removed_chat_boost → BP перестаёт капать, накопленное сохраняется |
| Буст от незарегистрированного юзера | Webhook игнорирует (source.user.id не в БД). Cron обнаружит при регистрации |
| Boost Pass не настроен на сезон | Иконка скрыта у всех, cron не запускается |
| Бот удалён из админов канала mid-season | getUserChatBoosts() в 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 title | tagText берётся из 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
| Компонент | Путь | Описание |
|---|---|---|
| BoostPassService | backend/src/domains/boosts/services/boost-pass.service.ts | Page data, leaderboard (+ rewards), milestone statuses, getTopBoosters() |
| BoostPassRewardService | backend/src/domains/boosts/services/boost-pass-reward.service.ts | Atomic claim, reward granting |
| BoostPassAdminService | backend/src/domains/boosts/services/boost-pass-admin.service.ts | Channel config, milestone CRUD |
| BoostWebhookService | backend/src/domains/boosts/services/boost-webhook.service.ts | Telegram webhook handlers |
| ShuffleService | backend/src/domains/boosts/services/shuffle.service.ts | Shuffle milestone logic |
| BpAwardJob | backend/src/domains/boosts/jobs/bp-award.job.ts | Daily cron: BP award + stake sync |
| Shared Types | shared/src/types/boost-pass.types.ts | DTOs, enums, BoostLeaderboardRewards, BoostLeaderboardRewardConfig |
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| Season (boost fields) | Конфиг Boost Pass привязан к сезону | boostChannelId, boostChannelUsername, boostChannelTitle, boostPassEnabled, boostBpPerBoost |
| BoostMilestone | Milestone-пороги BP | seasonId, bpThreshold, sortOrder, type (REGULAR/SHUFFLE) |
| BoostMilestoneReward | Награды milestone (до 4 штук) | milestoneId, rewardType, amount, itemId, titleSlug, titleName?, caseId, weight, globalLimit, weeklyLimit |
| UserBoostProgress | BP-прогресс пользователя | userId+seasonId (unique), currentBp, activeBoostCount, peakBoostCount, lastBpAwardedAt |
| BoostStake | Отдельные boost_id из webhook | boostId+seasonId (unique), userId, addDate, expirationDate, source |
| BoostMilestoneClaim | Забранные milestone | userId+milestoneId (unique), rewardSnapshot (JSON), wonRewardId |
| BoostSnapshot | Ежедневные snapshots cron | userId+seasonId+snapshotDate (unique), boostCount, bpAwarded |
Relationships
6. API Endpoints
- User API
- Admin API
| Метод | Эндпоинт | Описание | Auth |
|---|---|---|---|
| GET | /api/boost-pass | Данные Boost Pass (прогресс, milestones, deep link) | telegramAuth |
| POST | /api/boost-pass/claim | Забрать награду REGULAR milestone | telegramAuth |
| GET | /api/boost-pass/shuffle/:milestoneId | Получить данные shuffle (карточки) | telegramAuth |
| POST | /api/boost-pass/shuffle/:milestoneId/pick | Выбрать карточку в shuffle | telegramAuth |
| GET | /api/boost-pass/leaderboard | Лидерборд по BP (+ boostLeaderboardRewards если настроены) | telegramAuth |
| Метод | Эндпоинт | Описание | Auth |
|---|---|---|---|
| POST | /admin/boost-pass/validate-channel | Проверить канал (getChat + getChatMember) | adminJWT |
| GET | /admin/boost-pass/:seasonId | Получить конфиг Boost Pass | adminJWT |
| PUT | /admin/boost-pass/:seasonId | Сохранить конфиг (channel + milestones) | adminJWT |
| PATCH | /admin/boost-pass/:seasonId/toggle | Включить/выключить Boost Pass | adminJWT |
| GET | /admin/boost-pass/:seasonId/milestone-stats | Статистика claim по milestones | adminJWT |
| POST | /admin/boost-pass/:seasonId/milestones | Создать milestone | adminJWT |
| PUT | /admin/boost-pass/:seasonId/milestones/:milestoneId | Обновить milestone | adminJWT |
| DELETE | /admin/boost-pass/:seasonId/milestones/:milestoneId | Удалить milestone | adminJWT |
Leaderboard Rewards (через Season Setup Wizard):
| Метод | Эндпоинт | Описание | Auth |
|---|---|---|---|
| GET | /admin/season-setup/:seasonId/boost-leaderboard-rewards | Получить награды лидерборда бустеров | adminJWT |
| PUT | /admin/season-setup/:seasonId/boost-leaderboard-rewards | Настроить награды (top1/3/10) | adminJWT |
| DELETE | /admin/season-setup/:seasonId/boost-leaderboard-rewards | Убрать награды лидерборда | adminJWT |
| POST | /admin/season-setup/:seasonId/boost-leaderboard-rewards/copy-from-last | Скопировать из предыдущего сезона | adminJWT |
Error Codes
| Код | Описание |
|---|---|
MILESTONE_NOT_FOUND | Milestone не найден или используется shuffle endpoint для REGULAR |
SEASON_NOT_ACTIVE | Сезон не активен |
BOOST_PASS_DISABLED | Boost Pass отключён |
NOT_ENOUGH_BP | Недостаточно BP для milestone |
ALREADY_CLAIMED | Milestone уже забран |
SEASON_ACTIVE | Сезон активен — редактирование milestones запрещено (только DRAFT/SCHEDULED) |
Protection
| Защита | Реализация |
|---|---|
| Premium-only visibility | isPremium check (обновляется при каждом входе в TMA) |
| Rate limiting | rateLimitConfigs.general (GET), rateLimitConfigs.mutations (POST) |
| Atomic rewards | Prisma $transaction — all or nothing |
| Double claim | Unique constraint userId+milestoneId |
| Instant BP abuse | peakBoostCount — защита от фарма через перекидывание бустов |
| Channel validation | getChatMember(channelId, botId) при сохранении в админке |
| Season lock | Канал нельзя менять во время активного сезона |
| Admin editing constraint | Config editable только в статусах DRAFT/SCHEDULED. При ACTIVE — только emergency disable |
| Premium guard | GET /boost-pass возвращает null для non-Premium; POST/leaderboard → 403 |
| Leaderboard reward idempotency | SeasonRewardClaim с type: BOOST_LEADERBOARD + @@unique([userId, seasonId, type]) |