Skip to main content

Referrals System

1. Summary

Goal: Механизм привлечения новых пользователей через систему реферальных ссылок с двусторонними наградами и пассивным доходом для реферера.

User Value: Возможность получить бонус за приглашение друзей и пассивный доход (10%) от заработка приглашённых пользователей до конца текущего сезона. Путь: Поделился ссылкой → Друг зарегистрировался → Оба получили бонус → Реферер получает % от заработка друга (в течение сезона).


2. Business Logic

Reward Types

Кто получает: Пользователь, который пригласил

Одноразовая награда: Конфигурируется в Season Setup Wizard (Season.joinReferrerRewards). По умолчанию +100 XP. Может включать XP, Scrap, Streak Points, кейсы или предметы. Выдаётся через Referrer Reward модалку при заходе в TMA.

Пассивный доход: 10% от каждого заработка Scrap приглашённого друга (до конца сезона)

Сезонное ограничение

Пассивный доход работает только в том сезоне, когда был создан реферал. После смены сезона реферер перестаёт получать % от заработка приглашённого.

Referrer Reward Modal Flow

XP НЕ начисляется автоматически при активации. Вместо этого при каждом открытии TMA показывается ReferrerRewardModal со списком друзей, которые активировались. Пользователь нажимает «Забрать всё» → XP зачисляется суммарно. «Позже» → модалка закрывается до следующей сессии.

Что происходит при активации реферала (сразу):

  • +1 к счётчику friendsInvited
  • Прогресс квестов SOCIAL (socialActionType: 'referral')
  • Прогресс достижений SOCIAL
  • Feed event REFERRAL_BONUS (Live Feed)
  • Telegram пуш рефереру: «Заходи забрать награду 🎁»

Что дефёрится до клейма в модалке:

  • XP начисление (addUserXP)
  • referrerRewardClaimed = true, referrerRewardClaimedAt = now()

Invite Flow

Алгоритм приглашения (реализован в ReferralCodeService + InviteSessionService + ActivationRewardService из домена activation). Подробнее о сессиях см. Activation.

1. Генерация ссылки

  • Пользователь получает уникальный реферальный код (авто-генерация или кастомный)
  • Share URL: https://start.goloot.online/ref_CODE (красивый превью в соцсетях)
  • Redirect-service перенаправляет на: https://t.me/bot/app?startapp=ref_CODE

2. Переход по ссылке

InviteSession Flow
  1. Клик по ссылке → создаётся InviteSession (state: PENDING)
  2. Записывается ReferralClickAnalytics (для аналитики конверсий)
  3. Session живёт 24 часа, затем EXPIRED
  4. При активации записывается activationSource: REF_DEEPLINK (реферальная ссылка), UTM_DEEPLINK (UTM ссылка) или PROMO_CODE (ручной ввод кода)

3. Регистрация и активация (deeplink)

  • При регистрации проверяется наличие PENDING сессии по telegramId
  • Создаётся запись Referral (связь referrer → referred)
  • Session переходит в state: ACTIVATED, activationSource = REF_DEEPLINK
  • Referrer: handleReferrerActivation()friendsInvited++, прогресс квестов/достижений SOCIAL, Feed event; XP НЕ начисляется — откладывается до Referrer Reward модалки
  • Referred: Награды НЕ начисляются автоматически — откладываются до Welcome Bonus модалки
  • Telegram пуш рефереру: «{friendName} присоединился к goLoot! Заходи забрать награду 🎁»

3а. Активация по коду (promo-code-like)

  • Пользователь без реферальной сессии вводит код вручную → POST /api/referrals/activate-by-code
  • Создаётся InviteSession (state: ACTIVATED, activationSource = PROMO_CODE) + запись Referral
  • Валидация: код существует и активен, не самореферал, пользователь ещё не приглашён
  • Ответ содержит rewards (RewardConfig[] из сезона) и referrerName
  • Тип модалки: нейтральная («Код принят! Вот твои награды: ...»)

3.5. Welcome Bonus + Referrer Rewards (Claim по кнопке)

  • При каждом открытии приложения useReferralWelcome проверяет GET /api/referrals/welcome-bonus
  • Если referralRewardClaimed === false → показывается ReferralWelcomeModal
  • Пользователь нажимает «Забрать» → POST /api/referrals/claim-welcome-bonus → scrap зачисляется
  • «Позже» → модалка закрывается (React state), появится снова при следующем открытии

Referrer Rewards Flow (после Welcome Bonus, для реферера):

  • После закрытия Welcome Bonus useReferrerRewards проверяет GET /api/referrals/referrer-rewards
  • Если count > 0 → показывается ReferrerRewardModal со списком друзей (ответ: { count: N, items: [...] })
  • Пользователь нажимает «Забрать всё» → POST /api/referrals/claim-referrer-rewards → XP зачисляется суммарно
  • Если несколько друзей активировались → один батч-клейм, один вызов addUserXP
  • «Позже» → модалка закрывается (React state), появится снова при следующем открытии

Sequencing в App.tsx:

  1. ReferralWelcomeModal — для приглашённых (enabled: isInitialized && displayStatus === 'active')
  2. ReferrerRewardModal — для рефереров (enabled: ... && isReferralHandled, ждёт welcome bonus)
  3. PromoCodeModal — для UTM-пользователей
  4. OnboardingGuide — shouldShowPrompt ждёт все isHandled флаги

4. Passive Income (Accumulate + Claim)

Модель: Accumulate + Claim

Пассивный доход НЕ зачисляется напрямую на баланс. Он накапливается в буфере passiveIncomeBalance и пользователь забирает его вручную через кнопку в ReferralModal.

Накопление (автоматически):

  • При каждом заработке Scrap приглашённым вызывается PassiveIncomeService.processPassiveIncome()
  • Проверяется: referral.seasonId === currentSeason.id
  • Если сезоны совпадают: passiveAmount = floor(scrapAmount × 10%)
  • Минимум 1 Scrap если исходный заработок > 0
  • Если сезоны НЕ совпадают: passiveAmount = 0 (пассивный доход не начисляется)
  • Результат → user.passiveIncomeBalance += passiveAmount (буфер)

Claim (по кнопке):

  • Эндпоинт: POST /api/referrals/claim-passive-income
  • Optimistic locking: updateMany WHERE passiveIncomeBalance = exactValue — защита от double-click
  • При успехе: scrap += claimAmount, passiveIncomeBalance = 0
  • Обновляет сезонную статистику через addScrap() (scrapTotalEarned, scrapFromReferrals — только в USS)

Season Reset Auto-Claim:

  • При смене сезона невостребованный passiveIncomeBalance автоматически зачисляется в UserSeasonStats.scrapTotalEarned (старый сезон) через raw SQL
  • Буфер passiveIncomeBalance обнуляется на User
  • Пользователь не теряет накопленный доход

Referral Leaderboard

Сезонный рейтинг рефереров, мотивирующий приглашать активных друзей.

Механика:

  • Метрика ранжирования: SUM(passiveScrapEarned) от всех рефералов за текущий сезон (DESC), при равенстве — COUNT friends (DESC)
  • Топ-10: 10 мест, как в сезонном XP лидерборде
  • Reward tiers: Top 1 = legendary, Top 2-3 = mythical, Top 4-10 = epic
  • Reward items: Конфигурируются через Season Setup Wizard → Step 3 "Referral Rewards", хранятся в Season.referralRewards
  • Видимость: Секция отображается только при активном сезоне с настроенными referralRewards
  • Награды: Раздаются в конце сезона через distributeRewards() аналогично XP leaderboard
Естественный антигейминг

Для получения passive scrap — друг должен реально играть (квизы, кейсы, квесты). Фейковые аккаунты невыгодны: они не генерируют scrap, а значит реферер не накапливает passive scrap earned.

Строки лидерборда:

  • Ранг (1-10), аватар (Telegram), анонимизированное имя
  • passiveScrapEarned — основная метрика (жирный шрифт, акцент)
  • friendsCount — кол-во друзей за сезон (мелкий шрифт)
  • Reward tier badge (legendary/mythical/epic)
  • Подсветка текущего пользователя

Current user entry: Если пользователь не в топ-10 — показывается отдельно внизу с рангом и метриками.

Protection

ДействиеRate LimitAuthValidation
Create codegeneral (100/min)TelegramCreateReferralCodeSchema
Get code infogeneral (100/min)NoneReferralCodeParamsSchema
Get analyticsgeneral (100/min)TelegramReferralAnalyticsQuerySchema
Get my codesgeneral (100/min)Telegram-
Get my statsgeneral (100/min)TelegramGetMyReferralStatsSchema
Claim passive incomeachievementClaim (15/min)Telegram + Active Season-
Get welcome bonusgeneral (100/min)TelegramGetWelcomeBonusSchema
Claim welcome bonusachievementClaim (15/min)TelegramClaimWelcomeBonusSchema
Get referrer rewardsgeneral (100/min)TelegramGetReferrerRewardsSchema
Claim referrer rewardsachievementClaim (15/min)TelegramClaimReferrerRewardsSchema
Activate by codeachievementClaim (15/min)TelegramActivateByCodeBodySchema
Get leaderboardgeneral (100/min)TelegramGetReferralLeaderboardSchema
Детали реализации

См. Security Matrix для полного обзора защит.

Edge Cases

Что видит пользователь (UI):

СитуацияUI поведение
СаморефералБлокируется, ошибка "Cannot create self-referral"
Повторная регистрацияРеферал уже существует, возвращается существующий
Истекшая сессия24ч прошло — пользователь регистрируется без реферера
Нет кодовПри запросе /my автоматически создаётся код
Passive income = 0Карточка claim не показывается, вместо неё — зелёная статистика (если был доход ранее)
Passive income > 0Золотая карточка с суммой и кнопкой "Забрать"
Double-click claimOptimistic locking → ошибка CONCURRENT_CLAIM, UI показывает 0
Нет активного сезонаClaim заблокирован middleware requireActiveSeason
Welcome bonus: dismissМодалка закрывается (React state), появится снова при следующем открытии
Welcome bonus: already claimed400 "Welcome bonus already claimed"
Welcome bonus: no referral400 "No referral found for this user" — модалка не показывается
Welcome bonus + PromoCodeВзаимоисключающие: UTM-пользователь не имеет реферала, и наоборот
Welcome bonus: сезон без SCRAP-наградbonusAmount = 0 в preview (GET /welcome-bonus) — если joinReferredRewards содержит только XP/Item/Case
Referrer rewards: сезон без XP-наградxpAmountPerReferral = 0 в preview (GET /referrer-rewards) — если joinReferrerRewards содержит только Scrap/Item/Case
Join rewards не настроеныjoinReferredRewards = [] или joinReferrerRewards = []claimReferredReward / claimAllReferrerRewards всё равно выставляют referralRewardClaimed/referrerRewardClaimed = true (только один раз)
Referrer: 0 unclaimed rewardsGET /referrer-rewardscount: 0, модалка не показывается
Referrer: 1 unclaimed rewardЗаголовок "Друг присоединился!" (единственное число)
Referrer: N unclaimed rewardsЗаголовок "{N} друзей присоединились!"
Друг активировался пока модалка открытаclaimAllReferrerRewards захватит и нового (запрос в момент клейма)
Referrer: двойной клик "Забрать"isClaiming блокирует повторный клик; updateMany WHERE referrerRewardClaimed = false идемпотентен
Referrer: dismissМодалка закрывается (React state), появится снова при следующем открытии
Referrer: ошибка сети при claimПоказывается ошибка, кнопка «Забрать всё» остаётся активной
Leaderboard: нет активного сезонаКомпонент <ReferralLeaderboard /> возвращает null, секция скрыта
Leaderboard: referralRewards не настроеныКомпонент возвращает null, даже если сезон активен
Leaderboard: пользователь не в топ-10Показывается отдельной строкой в футере с его фактическим рангом
Leaderboard: нет участников (passiveScrapEarned = 0 у всех)Пустые слоты ("Свободно") для всех 10 позиций
Activate-by-code: невалидный код400 INVALID_CODE — «Invalid referral code»
Activate-by-code: самореферал400 SELF_REFERRAL — «Нельзя использовать свой код»
Activate-by-code: уже приглашён400 ALREADY_REFERRED — «You already have a referrer»
Activate-by-code: успехСоздаётся InviteSession(PROMO_CODE) + Referral, показывается нейтральная модалка с наградами
Backend Error Codes (для API/тестов)
КодHTTPСообщение
Self-referral400"Cannot create self-referral"
Referral exists200Возвращает существующий referral
Code not found404"Referral code not found"
Access denied403"You can only view analytics for your own referral codes"
Welcome bonus not found400"No referral found for this user"
Welcome bonus claimed400"Welcome bonus already claimed"
Invalid referral code400INVALID_CODE — "Invalid referral code"
Self-referral (by code)400SELF_REFERRAL — "Cannot use your own referral code"
Already referred (by code)400ALREADY_REFERRED — "You already have a referrer"

3. ADR (Architectural Decisions)

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

Проблема: Пользователь кликает по реферальной ссылке ДО регистрации в боте. Нужно сохранить информацию о реферере до момента создания аккаунта.

Решение: Промежуточная сущность InviteSession с lifecycle:

  • PENDING → ACTIVATED (при регистрации)
  • PENDING → EXPIRED (через 24ч)
  • ACTIVATED → USER_RESET (при сбросе данных пользователем / GDPR)

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

  • Cookie/localStorage — не работает в Telegram Mini App
  • Передача кода в start_param без сессии — теряется аналитика кликов

Последствия: Полная аналитика конверсий (клик → регистрация), TTL защищает от бесконечных PENDING сессий.

Почему Referrer XP дефёрится в модалку, а не начисляется немедленно?

Проблема: При автоматическом начислении XP рефереру при активации пользователь получал только Telegram пуш «Вы получили +100 XP» — без визуального фидбека в TMA. Пользователь не чувствовал награду.

Решение: Дефёрить XP в агрегированную ReferrerRewardModal (по аналогии с Welcome Bonus для приглашённого):

  • При активации реферала начисляется всё, кроме XP: friendsInvited++, квесты, достижения, Feed event
  • XP откладывается до явного клейма в модалке
  • Если активировалось несколько друзей — одна модалка, один батч-клейм
  • Telegram пуш изменён: «Заходи забрать награду 🎁» (зовёт в TMA, а не объявляет готовый XP)

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

  • Авто-начисление + toast — пользователь легко пропускает, нет осознанного действия
  • Авто-начисление + анимация в TMA при следующем открытии — сложнее реализовать без гонки состояний

Последствия:

  • Симметричный паттерн с Welcome Bonus (приглашённый получает Scrap через модалку, реферер — XP через модалку)
  • Агрегация: несколько друзей = одна модалка (не N всплывающих)
  • referrerRewardClaimed поле в Referral используется для обоих целей: трекинг XP (для реферера) + идемпотентность клейма

Почему Passive Income в % а не фиксированный бонус?

Проблема: Как мотивировать рефереров после одноразового бонуса?

Решение: 10% от заработка реферала. Это создаёт:

  • Мотивацию приглашать активных друзей
  • Интерес к успеху приглашённых (помогать им)

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

  • Фиксированный бонус за каждое действие реферала — сложно балансировать

Последствия: Простая понятная механика.

Почему Passive Income Accumulate + Claim, а не Auto-Credit?

Проблема: При автоматическом зачислении пассивного дохода пользователь не видит, сколько именно приносят рефералы. Это снижает ощущение ценности реферальной программы.

Решение: Доход копится в буфере passiveIncomeBalance. Пользователь видит сумму и забирает вручную:

  • Золотая карточка в ReferralModal показывает накопленную сумму
  • Кнопка "Забрать" с haptic feedback и TopFloatingReward анимацией
  • Optimistic locking защищает от race conditions

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

  • Auto-credit при каждом заработке реферала — пользователь не замечает начислений
  • Ежедневная автовыплата — сложнее, не даёт контроль пользователю

Последствия:

  • Пользователь видит конкретную пользу от рефералов
  • Геймификация: приятный момент "забрать" награду
  • При смене сезона — auto-claim в scrapTotalEarned (ничего не теряется)

Почему Welcome Bonus модалка, а не авто-начисление?

Проблема: При автоматическом начислении scrap при активации реферальной сессии пользователь не замечал награду. Toast-уведомление легко пропустить, и новичок не понимал, что получил бонус за приглашение.

Решение: Явный UI-флоу через ReferralWelcomeModal:

  • При активации начисляется только XP рефереру (немедленно)
  • Scrap приглашённому откладывается до нажатия кнопки «Забрать» в модалке (referralRewardClaimed = true, referralRewardClaimedAt = now())
  • Модалка появляется при каждом открытии приложения, пока бонус не забран

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

  • Авто-начисление + toast — пользователь не замечает, нет осознанного действия
  • Авто-начисление + splash screen — сложнее, не даёт контроль пользователю

Последствия:

  • Пользователь осознанно получает бонус (геймификация)
  • Симметричный паттерн с PromoCodeModal (UTM-пользователи получают награду через промокод)
  • Dismiss = React state (модалка пропадает, при следующем открытии — снова)

Почему метрика лидерборда — passiveScrapEarned, а не количество друзей?

Проблема: Простой рейтинг по количеству рефералов мотивирует спам-инвайтинг неактивных пользователей (фейков).

Решение: Ранжировать по сумме passiveScrapEarned (сколько passive scrap принесли все друзья реферера за сезон):

  • Passive income начисляется только при реальной активности друга (scrap earning через квизы, кейсы, квесты)
  • Фейковые/неактивные аккаунты не генерируют scrap → 0 passive income → не влияют на ранг
  • При равенстве passiveScrapEarned — второй критерий: количество друзей (friendsCount)

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

  • COUNT friends — мотивирует спам, фейки влияют напрямую
  • SUM(scrap earned by friends) напрямую — сложнее получить (нужна агрегация транзакций), passiveScrapEarned уже хранится в Referral.passiveScrapEarned

Последствия: Антигейминг встроен в механику, без дополнительных проверок. Топ отражает рефереров с действительно активными друзьями.

Почему Passive Income ограничен сезоном?

Баланс экономики

Бесконечный пассивный доход создаёт неконтролируемую инфляцию и снижает ценность активной игры.

Проблема: Пользователь, пригласивший 100 друзей год назад, получает огромный пассивный доход, не прилагая усилий. Это создаёт:

  • Перекос в экономике (ранние игроки слишком богаты)
  • Снижение мотивации к активной игре
  • Неконтролируемую инфляцию

Решение: Пассивный доход работает только в сезоне создания реферала.

  • При создании реферала сохраняется seasonId
  • PassiveIncomeService проверяет: referral.seasonId === currentSeason.id
  • Связь реферер → приглашённый остаётся навсегда (для статистики)
  • Referral.passiveScrapEarned хранит lifetime total (не сбрасывается)
  • UserSeasonStats.scrapFromReferrals сбрасывается каждый сезон (новая запись USS)

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

  • Passive income навсегда — создаёт инфляцию и перекос
  • Ограничение по времени (30 дней) — не привязано к игровым циклам

Последствия:

  • Чёткая привязка к игровым сезонам
  • Мотивация приглашать друзей каждый сезон
  • Контролируемая экономика
  • UI показывает isPassiveIncomeActive флаг для каждого реферала

Почему per-season конфигурация наград, а не глобальная?

Проблема: Награды за приглашение (join rewards) были хардкоднуты в REFERRAL_CONFIG. Нужно сделать их настраиваемыми.

Решение: Per-season подход — Season.joinReferrerRewards / Season.joinReferredRewards:

  • Referral.seasonId уже существует — рефералы уже привязаны к сезону
  • Season record иммутабелен после закрытия → referral.seasonId → season.joinReferrerRewards — иммутабельная ссылка
  • Snapshot не нужен — сезон хранит конфигурацию, referral ссылается на сезон
  • Copy-from-last season (уже реализован в wizard) решает проблему ручного ввода каждый раз
  • Approval gate в wizard предотвращает запуск без настроенных наград

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

  • GlobalSettings — нет привязки к сезону, сложнее версионировать
  • Snapshot полей на Referral (referrerRewardSnapshot, referredRewardSnapshot) — избыточно, сезон уже хранит данные иммутабельно

Последствия: Семантически точнее: «какие награды за приглашение в этом сезоне» — это сезонная политика. Разные рефералы из разных сезонов получат награды по конфигурации своего сезона.

Почему activationSource на InviteSession?

Проблема: С добавлением ручного ввода реферального кода (promo-code-like) нужно различать как пользователь активировался: по ссылке (deeplink) или ввёл код вручную.

Решение: Поле InviteSession.activationSource (REF_DEEPLINK | UTM_DEEPLINK | PROMO_CODE):

  • REF_DEEPLINK — пользователь перешёл по реферальной ссылке
  • UTM_DEEPLINK — пользователь перешёл по UTM ссылке
  • PROMO_CODE — пользователь вручную ввёл реферальный код

Последствия:

  • Тип модалки зависит от activationSource: REF_DEEPLINK → персонализированная («{name} пригласил тебя!»), PROMO_CODE → нейтральная («Код принят!»)
  • Аналитика конверсии по каналам привлечения

Почему timestamps в Referral, а не в FeedEvent?

Проблема: Нужно знать «когда именно» реферер и приглашённый забрали свои награды. Казалось бы, для этого можно использовать уже существующий FeedEvent.REFERRAL_BONUS.

Решение: Хранить timestamps прямо в Referral (referrerRewardClaimedAt, referralRewardClaimedAt):

  • FeedEvent.REFERRAL_BONUS создаётся при активации реферала (когда друг регистрируется)
  • Фактический claim происходит позже — в модалках ReferrerRewardModal / ReferralWelcomeModal
  • Это два разных момента времени

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

  • Поиск в FeedEvent — неверное время (момент регистрации, а не клейма)
  • Отдельная таблица событий claim — избыточно, лишняя сущность

Последствия: Минимальные изменения схемы (2 nullable поля), данные привязаны к записи Referral, нет N+1 при чтении.

Почему referrals вынесены в parallel query в getUserDetailed?

Проблема: Исходный prisma.user.findUnique({ include: { referrals: ... } }) возвращает User[] — поля модели User, а не модели Referral. Нужны поля именно Referral-модели: claim flags, timestamps, passiveScrapEarned, seasonId.

Решение: Выделить в отдельный parallel запрос prisma.referral.findMany({ where: { referrerId } }), по аналогии с уже существующим seasonReferralsRaw паттерном.

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

  • Nested include глубже — Prisma не позволяет получить поля junction-записи через include.referrals
  • Добавить поля в User.referrals relation — нарушило бы существующий контракт

Последствия: Запросы батчируются через Promise.all, нет N+1. Структура getUserDetailed явно разделяет запросы по смыслу.

Почему reward images резолвятся на backend?

Проблема: Сезонные награды хранятся как RewardConfig[] в JSON-поле (тип CASE/ITEM с caseId/itemId). Чтобы показать иконку, нужно знать imageUrl конкретного кейса или предмета.

Решение: Backend резолвит imageUrl единым batch-запросом при построении getUserDetailed:

  1. Собрать все уникальные caseId и itemId из всех наград всех рефералов
  2. Один findMany для Case, один для Item
  3. Синхронно смаппить buildRewardsFromJson() без доп. запросов в DB

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

  • Frontend сам резолвит по caseId → N дополнительных API-запросов
  • Frontend хранит маппинг caseId → imageUrl — frontend не должен знать про Static URL структуру

Последствия: Zero N+1, frontend получает готовый RewardConfigWithImage[] с полями imageUrl и name.


4. Architecture

Services Overview

Key Components

КомпонентПутьОписание
ReferralServicebackend/src/domains/referrals/services/referral.service.tsCRUD для Referral связей
ReferralCodeServicebackend/src/domains/referrals/services/referral-code.service.tsУправление кодами, генерация URL
ReferralRewardServicebackend/src/domains/referrals/services/referral-reward.service.tsНачисление наград при активации
PassiveIncomeServicebackend/src/domains/referrals/services/passive-income.service.tsРасчёт и начисление пассивного дохода
ReferralCodesControllerbackend/src/domains/referrals/controllers/referral-codes.controller.tsUser API для кодов
ReferralStatsControllerbackend/src/domains/referrals/controllers/referral-stats.controller.tsAdmin API: статистика, конверсия, топ-рефереры, тренды
UserReferrals Routesbackend/src/domains/referrals/routes/user-referrals.routes.tsUser API для статистики
ReferralAnalyticsServicebackend/src/domains/referrals/services/referral-analytics.service.tsАналитика рефералов (графики, статистика по периодам)
ReferralAdminServicebackend/src/domains/referrals/services/referral-admin.service.tsAdmin CRUD: поиск, пагинация, управление статусами рефералов
ReferralShortLinkServicebackend/src/domains/referrals/services/referral-short-link.service.tsГенерация коротких ссылок для рефералов
ReferralCodeValidatorbackend/src/domains/referrals/validators/referral-code.validator.tsВалидация формата и уникальности реферальных кодов
BatchLoadingUtilsbackend/src/domains/referrals/utils/batch-loading.utils.tsBatch loading для решения N+1 проблемы (загрузка referrer info)
ReferralUrlUtilsbackend/src/domains/referrals/utils/referral-url.utils.tsГенерация реферальных URL
Messages Constantsbackend/src/domains/referrals/constants/messages.tsКонстанты сообщений об ошибках домена
Referral Typesbackend/src/domains/referrals/types/referral.types.tsТипы: аналитика, создание кодов, отслеживание кликов
Admin Routesbackend/src/domains/referrals/routes/admin-referrals.routes.tsAdmin API
useReferralWelcomefrontend/src/hooks/useReferralWelcome.tsХук для Welcome Bonus: проверка и claim
ReferralWelcomeModalfrontend/src/components/ui/ReferralWelcomeModal.tsxМодалка Welcome Bonus для реферальных пользователей
useReferrerRewardsfrontend/src/hooks/useReferrerRewards.tsХук для Referrer Rewards: проверка незабранного XP и batch claim
ReferrerRewardModalfrontend/src/components/ui/ReferrerRewardModal.tsxМодалка для рефереров: список друзей + «Забрать всё»
Referral API Clientfrontend/src/services/referral.api.tsAPI client для welcome-bonus и referrer-rewards endpoints
Referral Types (FE)frontend/src/types/referral.types.tsFrontend типы для Welcome Bonus, Referrer Rewards и Leaderboard API
ReferralLeaderboardServicebackend/src/domains/referrals/services/referral-leaderboard.service.tsАгрегация топ-рефереров по passiveScrapEarned; getLeaderboard(), getTopReferrers() для раздачи наград
Referral Leaderboard Typesbackend/src/domains/referrals/types/referral-leaderboard.types.tsReferralLeaderboardEntry, ReferralLeaderboardResponse
ReferralLeaderboardfrontend/src/components/referrals/ReferralLeaderboard.tsxUI компонент лидерборда (pixel-perfect по SeasonModal); скрыт если нет season.referralRewards
useReferralLeaderboardfrontend/src/hooks/useReferralLeaderboard.tsReact Query hook для GET /api/referrals/leaderboard, staleTime: 60s
Неактивные компоненты

В домене также присутствуют файлы для alerting и metrics (referral-alerting.service.ts, referral-metrics.service.ts и соответствующие routes/controllers), но они НЕ зарегистрированы в server.ts и не активны. Эти компоненты предназначены для мониторинга аномалий (низкая конверсия, высокий error rate) и интеграции с Prometheus/OpenTelemetry, но пока не подключены.

Bootstrap Integration

Performance Optimization

Referral data is preloaded via /api/bootstrap endpoint during splash screen to eliminate loading states in UI.

Included in Bootstrap:

  • Referral Config — static configuration (passiveIncomePercent)
  • User Stats — totalReferrals, activeReferrals, totalPassiveScrapEarned
  • My Code — user's referral code and URL (null if not created yet)
  • My Referrer — information about who invited the user (null if no referrer)
  • User ProfilepassiveIncomeBalance (для отображения кнопки claim в ReferralModal)

Cache Strategy:

  • staleTime: 5-10 minutes (data rarely changes)
  • React Query cache keys: ['referralStats'], ['referralConfig'], ['myReferralCode'], ['myReferrer']
  • Hydrated via hydrateReferralData() in bootstrapHydration.ts

Backend Optimization:

  • getUserReferralStats() uses Prisma aggregation instead of findMany()
  • Performance: 50KB → 12 bytes, 20ms → 3ms
  • Method: Promise.all([count(), count(), aggregate()])

User Experience:

  • Before: ReferralModal showed loading skeleton (200-400ms)
  • After: Instant modal opening with 0ms delay (native app effect)

Implementation:

// Backend: BootstrapService.getReferralBootstrapData()
const [stats, userCodes, referralRelation] = await Promise.all([
this.referralService.getUserReferralStats(userId),
this.referralCodeService.getUserReferralCodes(userId),
this.referralService.getReferralByReferredId(userId),
]);

// Frontend: bootstrapHydration.ts
queryClient.setQueryData(['referralStats'], data.stats);
queryClient.setQueryData(['referralConfig'], data.config);
queryClient.setQueryData(['myReferralCode'], data.myCode);
queryClient.setQueryData(['myReferrer'], data.myReferrer);

Admin Panel: getUserDetailed Enrichment

GET /admin/users/:id (user-management) возвращает расширенные реферальные данные:

Воронка конверсии (referralCode):

ПолеИсточникОписание
clicksCountReferralCode.clicksCountКликнуло по ссылке
appOpensCountReferralCode.appOpensCountОткрыло TMA (authenticated)
activatedViaLinkInviteSession.count (REF_DEEPLINK + ACTIVATED)Зарегистрировалось через реферальную ссылку
activatedViaCodeInviteSession.count (PROMO_CODE + ACTIVATED)Ввело реферальный код вручную
Разные пути InviteSession

activatedViaLink считает через InviteSession ← ReferralClickAnalytics.referralCodeId. activatedViaCode считает через InviteSession.metadata.referrerUserId = userId (PROMO_CODE не создаёт ReferralClickAnalytics).

Данные по каждому рефералу (referrals[]):

  • referrerRewardClaimed / referrerRewardClaimedAt — забрал ли реферер и когда
  • referralRewardClaimed / referralRewardClaimedAt — забрал ли приглашённый и когда
  • passiveScrapEarned — накопленный пассивный доход с этого реферала
  • joinRewardsForReferrer: RewardConfigWithImage[] — награды реферера с резолвнутыми imageUrl

Данные о рефере (referredBy):

  • referralRewardClaimed / referralRewardClaimedAt — статус claim текущего пользователя
  • joinRewardsForReferred: RewardConfigWithImage[] — что получил приглашённый за вступление

5. Database Schema

Models

МодельОписаниеКлючевые поля
ReferralCodeУникальный код пользователя для приглашенийuserId (unique), code (unique), clicksCount, appOpensCount, isActive
InviteSessionСессия от клика до регистрацииtelegramId, state (PENDING/ACTIVATED/EXPIRED/USER_RESET), metadata (JSON с referralCode), expiresAt, activationSource (REF_DEEPLINK / UTM_DEEPLINK / PROMO_CODE)
ReferralClickAnalyticsДетальная аналитика кликовreferralCodeId, telegramHash, inviteSessionId, clickedAt
ReferralСвязь реферер → приглашённыйreferrerId, referredId (unique), seasonId, referrerRewardClaimed, referralRewardClaimed, passiveScrapEarned, referrerRewardClaimedAt (DateTime? — когда реферер забрал), referralRewardClaimedAt (DateTime? — когда приглашённый забрал)
Season (поля)Конфигурация наград при вступленииjoinReferrerRewards (JSON: RewardConfig[]) — награды рефереру, joinReferredRewards (JSON: RewardConfig[]) — награды приглашённому
User (поля)Буфер пассивного доходаpassiveIncomeBalance (Int, default 0) — накапливается автоматически, обнуляется при claim

Relationships

Key Constraints

  • Referral.referredId — UNIQUE (один пользователь = один реферер навсегда)
  • ReferralCode.userId — UNIQUE (один пользователь = один код)
  • InviteSession — UNIQUE по [telegramId, type] (один PENDING на type)

6. API Endpoints

МетодЭндпоинтОписаниеDocs
POST/api/referral-codes/createСоздать реферальный код
GET/api/referral-codes/:codeПолучить информацию о коде
GET/api/referral-codes/myМои реферальные коды
GET/api/referral-codes/:code/analyticsАналитика кода (владелец)
GET/api/referral-codes/user/codesКоды пользователя (алиас /my)

7. Configuration

Пассивный доход (config.ts)

Конфигурация из backend/src/domains/referrals/config.ts:

ПараметрДефолтENV VariableОписание
PASSIVE_INCOME_PERCENT10REFERRAL_PASSIVE_INCOME_PERCENTПроцент пассивного дохода

Изменения применяются при рестарте backend без перекомпиляции.

Join Rewards (Season Setup)

Награды при вступлении реферала настраиваются через Season Setup Wizard → Step "Referral Join Rewards" и хранятся в полях Season:

ПолеТипОписание
Season.joinReferrerRewardsRewardConfig[] (JSON)Награды рефереру (тому, кто пригласил)
Season.joinReferredRewardsRewardConfig[] (JSON)Награды приглашённому пользователю

RewardConfig поддерживает типы: SCRAP, XP, STREAK_POINTS, CASEcaseId), ITEMitemId).

RewardConfigWithImage

При отображении наград в Admin Panel backend резолвит RewardConfig в расширенный тип RewardConfigWithImage:

ПолеТипОписание
type'SCRAP' | 'XP' | 'STREAK_POINTS' | 'CASE' | 'ITEM'Тип награды
amountnumberКоличество
itemIdstring | nullID предмета (для ITEM)
caseIdstring | nullID кейса (для CASE)
imageUrlstring | nullРезолвнутый полный URL изображения
namestring | nullНазвание кейса или предмета

Резолв происходит единым batch-запросом без N+1 (см. ADR выше).

Полная замена legacy констант

Старые хардкоды REFERRAL_CONFIG.BONUS_XP и REFERRAL_CONFIG.BONUS_SCRAP_REFERRED полностью удалены из join reward логики. Все начисления читаются из Season.joinReferrerRewards / Season.joinReferredRewards. Если поля пустые ([]) — награды не выдаются, но флаги referrerRewardClaimed/referralRewardClaimed всё равно выставляются (идемпотентность). REFERRAL_CONFIG.PASSIVE_INCOME_PERCENT не затронут — это отдельный механизм.


  • Activation — домен InviteSession (создание и активация сессий)
  • Quests — SOCIAL квесты на приглашение друзей
  • Achievements — SOCIAL достижения за рефералов
  • Live Feed — события REFERRAL_BONUS в ленте
  • Users — поле friendsInvited в профиле
  • UTM Tracking — альтернативная система отслеживания (маркетинг)