{promo_code}\n🎯 Bonus: +{bonus_days}d\n🕐 Time: {timestamp}",
@@ -685,7 +685,7 @@
"email_support_row_remaining": "Remaining",
"email_support_row_panel_status": "Panel status",
"email_support_cta_open_ticket": "Open ticket",
- "email_support_cta_open_mini_app": "Open in Mini App",
+ "email_support_cta_open_mini_app": "Open ticket",
"wa_loading": "Loading...",
"wa_back": "Back",
"wa_next": "Next",
@@ -1268,11 +1268,11 @@
"user_premium_override_card_title": "Premium traffic",
"user_premium_override_card_hint": "Unlimited access and extra volume for premium squads on top of the tariff.",
"user_regular_override_card_title": "Main traffic",
- "user_regular_override_card_hint": "Unlimited-style ceiling and a persistent bonus on the main traffic limit.",
+ "user_regular_override_card_hint": "Unlimited access and a persistent bonus on the main traffic limit.",
"user_regular_override_status_unlimited": "Current: unlimited",
"regular_override_saved": "Main traffic override saved",
"user_traffic_override_title": "Traffic overrides",
- "user_traffic_override_hint": "Extra main or premium traffic limits on top of the tariff; unlimited applies to premium squads only.",
+ "user_traffic_override_hint": "Extra main or premium traffic limits on top of the tariff; unlimited can be applied to main traffic or premium squads.",
"user_regular_override_bonus": "Extra main traffic, GB",
"user_regular_override_bonus_hint": "Persistent bonus bytes added to the main traffic limit (not a balance top-up).",
"user_regular_override_status_bonus": "Main traffic now: +{gb} GB",
diff --git a/locales/ru.json b/locales/ru.json
index 674e4eb..650f6e9 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -374,8 +374,8 @@
"traffic_warning_premium_depleted": "⛔️ Лимит премиум-серверов тарифа {tariff_name} израсходован.\n\nТекущий период:\nизрасходовано — {used}\nдоступно — {remaining}\nлимит — {limit_total}\n\nЛимит действует на:\n{servers}\n\nДоступ может быть ограничен до сброса периода или докупки премиум-трафика.\n\nДокупить можно через кнопку ниже.",
"traffic_warning_premium_generic_servers": "• серверы с премиум-доступом по вашему тарифу",
"traffic_warning_premium_servers_more": "… и ещё серверов: {count}",
- "traffic_warn_btn_topup_webapp_regular": "Докупить трафик (мини-приложение)",
- "traffic_warn_btn_topup_webapp_premium": "Докупить премиум-трафик (мини-приложение)",
+ "traffic_warn_btn_topup_webapp_regular": "Докупить трафик",
+ "traffic_warn_btn_topup_webapp_premium": "Докупить премиум-трафик",
"traffic_warn_btn_topup_regular": "Докупить трафик",
"traffic_warn_btn_topup_premium": "Докупить премиум-трафик",
"log_promo_activation": "🎁 Активирован промокод\n\n👤 Пользователь: {user_display}\n🏷 Код: {promo_code}\n🎯 Бонус: +{bonus_days} дн.\n🕐 Время: {timestamp}",
@@ -685,7 +685,7 @@
"email_support_row_remaining": "Осталось",
"email_support_row_panel_status": "Статус в панели",
"email_support_cta_open_ticket": "Открыть тикет",
- "email_support_cta_open_mini_app": "Открыть в Mini App",
+ "email_support_cta_open_mini_app": "Открыть обращение",
"wa_loading": "Загрузка...",
"wa_back": "Назад",
"wa_next": "Далее",
@@ -1272,7 +1272,7 @@
"user_regular_override_status_unlimited": "Сейчас: безлимит",
"regular_override_saved": "Оверрайд основного трафика сохранён",
"user_traffic_override_title": "Оверрайд трафика",
- "user_traffic_override_hint": "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
+ "user_traffic_override_hint": "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.",
"user_regular_override_bonus": "Доп. основной трафик, GB",
"user_regular_override_bonus_hint": "Постоянный бонус к лимиту основного трафика (не то же самое, что докупка ГБ).",
"user_regular_override_status_bonus": "Основной сейчас: +{gb} GB",
diff --git a/tests/test_admin_traffic_grants.py b/tests/test_admin_traffic_grants.py
index 1bf3934..7b01022 100644
--- a/tests/test_admin_traffic_grants.py
+++ b/tests/test_admin_traffic_grants.py
@@ -133,6 +133,59 @@ class AdminGrantTopupTests(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(await service.admin_grant_topup(AsyncMock(), 1, 0))
self.assertIsNone(await service.admin_grant_topup(AsyncMock(), 1, -10))
+ async def test_regular_unlimited_override_syncs_zero_panel_limit(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ settings = _make_settings(_tariffs_config_payload(), tmpdir)
+ panel_service = AsyncMock(spec=PanelApiService)
+ panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}})
+ service = SubscriptionService(settings, panel_service)
+
+ db_user = SimpleNamespace(
+ user_id=42,
+ first_name="Tester",
+ last_name=None,
+ username="tester",
+ language_code="ru",
+ panel_user_uuid="panel-uuid",
+ email=None,
+ telegram_id=42,
+ )
+ sub = SimpleNamespace(
+ subscription_id=7,
+ user_id=42,
+ panel_user_uuid="panel-uuid",
+ end_date=datetime.now(timezone.utc) + timedelta(days=10),
+ tariff_key="standard",
+ tier_baseline_bytes=100 * (1024**3),
+ topup_balance_bytes=0,
+ traffic_limit_bytes=105 * (1024**3),
+ traffic_used_bytes=2 * (1024**5),
+ regular_bonus_bytes=0,
+ regular_unlimited_override=True,
+ is_throttled=True,
+ hwid_device_limit=3,
+ extra_hwid_devices=0,
+ premium_is_limited=False,
+ )
+
+ with (
+ patch(
+ "bot.services.subscription_service.user_dal.get_user_by_id",
+ new=AsyncMock(return_value=db_user),
+ ),
+ patch(
+ "bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id",
+ new=AsyncMock(return_value=sub),
+ ),
+ ):
+ await service.sync_main_traffic_limit_to_panel(AsyncMock(), 42)
+
+ self.assertEqual(sub.traffic_limit_bytes, 0)
+ self.assertFalse(sub.is_throttled)
+ panel_service.update_user_details_on_panel.assert_awaited_once()
+ panel_payload = panel_service.update_user_details_on_panel.await_args.args[1]
+ self.assertEqual(panel_payload["trafficLimitBytes"], 0)
+
async def test_premium_grant_clears_limited_state_when_balance_covers_overuse(self):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(_tariffs_config_payload(premium=True), tmpdir)
diff --git a/tests/test_mini_app_url.py b/tests/test_mini_app_url.py
index b137fdf..312b8d4 100644
--- a/tests/test_mini_app_url.py
+++ b/tests/test_mini_app_url.py
@@ -46,6 +46,23 @@ class MiniAppUrlTests(unittest.TestCase):
subscription_mini_app_topup_url(s, "regular"),
"https://app.example.com/webapp?topup=regular",
)
+ self.assertEqual(
+ subscription_mini_app_topup_url(s, "premium"),
+ "https://app.example.com/webapp?topup=premium",
+ )
+
+ def test_subscription_mini_app_topup_url_preserves_existing_query(self):
+ s = Settings(
+ _env_file=None,
+ BOT_TOKEN="x",
+ POSTGRES_USER="u",
+ POSTGRES_PASSWORD="p",
+ SUBSCRIPTION_MINI_APP_URL="https://app.example.com/webapp?lang=ru",
+ )
+ self.assertEqual(
+ subscription_mini_app_topup_url(s, "premium"),
+ "https://app.example.com/webapp?lang=ru&topup=premium",
+ )
def test_subscription_mini_app_path_url(self):
s = Settings(
diff --git a/tests/test_subscription_service_behavior.py b/tests/test_subscription_service_behavior.py
index 7ac0e16..9453289 100644
--- a/tests/test_subscription_service_behavior.py
+++ b/tests/test_subscription_service_behavior.py
@@ -96,7 +96,7 @@ class SubscriptionServiceCalculationTests(unittest.TestCase):
["fallback-a", "fallback-b"],
)
- def test_main_traffic_limit_includes_topup_bonus_and_unlimited_floor(self):
+ def test_main_traffic_limit_includes_topup_bonus_and_unlimited_zero(self):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(_tariffs_config_payload(), tmpdir)
service = _make_service(settings)
@@ -117,7 +117,7 @@ class SubscriptionServiceCalculationTests(unittest.TestCase):
regular_unlimited_override=True,
traffic_used_bytes=2 * (1024**5),
)
- self.assertEqual(unlimited_limit, 2 * (1024**5) + 512 * GIB)
+ self.assertEqual(unlimited_limit, 0)
def test_premium_effective_limit_ignores_negative_balances(self):
self.assertEqual(
diff --git a/tests/test_support_notifications.py b/tests/test_support_notifications.py
index 2068380..65b9e4d 100644
--- a/tests/test_support_notifications.py
+++ b/tests/test_support_notifications.py
@@ -41,7 +41,7 @@ def test_support_ticket_closed_email_uses_user_language():
assert ' dict:
class TariffWorkerTests(unittest.IsolatedAsyncioTestCase):
+ def test_topup_webapp_button_labels_do_not_mention_mini_app(self):
+ class I18n:
+ def gettext(self, _lang, key, **_kwargs):
+ return {
+ "traffic_warn_btn_topup_webapp_regular": "Top up traffic",
+ "traffic_warn_btn_topup_webapp_premium": "Top up premium traffic",
+ }.get(key, key)
+
+ worker = TariffTrafficWorker(
+ settings=SimpleNamespace(SUBSCRIPTION_MINI_APP_URL="https://app.example.com"),
+ session_factory=SimpleNamespace(),
+ panel_service=SimpleNamespace(),
+ subscription_service=SimpleNamespace(),
+ bot=SimpleNamespace(),
+ i18n=I18n(),
+ )
+
+ regular = worker._traffic_topup_markup("en", "regular").inline_keyboard[0][0]
+ premium = worker._traffic_topup_markup("en", "premium").inline_keyboard[0][0]
+
+ self.assertEqual(regular.text, "Top up traffic")
+ self.assertEqual(regular.web_app.url, "https://app.example.com?topup=regular")
+ self.assertEqual(premium.text, "Top up premium traffic")
+ self.assertEqual(premium.web_app.url, "https://app.example.com?topup=premium")
+
async def test_db_tick_retries_deadlock_once(self):
class FakeSession:
def __init__(self):
From ecf779763c580fa6debff46807c9d9a1a64eebd5 Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Sun, 31 May 2026 23:05:40 +0300
Subject: [PATCH 2/4] feat: audit outbound user notifications
---
backend/bot/services/email_auth_service.py | 25 +++++++-
backend/bot/services/message_audit.py | 57 +++++++++++++++++++
.../subscription_lifecycle_notifications.py | 25 ++++++++
.../subscription_notification_worker.py | 18 ++++++
backend/bot/services/tariff_worker.py | 55 ++++++++++++++++++
.../bot/services/user_email_notifications.py | 15 +++++
tests/test_message_audit.py | 47 +++++++++++++++
7 files changed, 241 insertions(+), 1 deletion(-)
create mode 100644 backend/bot/services/message_audit.py
create mode 100644 tests/test_message_audit.py
diff --git a/backend/bot/services/email_auth_service.py b/backend/bot/services/email_auth_service.py
index 8d9b90b..9391932 100644
--- a/backend/bot/services/email_auth_service.py
+++ b/backend/bot/services/email_auth_service.py
@@ -17,8 +17,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.email_templates import EmailContent, render_login_code
+from bot.services.message_audit import log_user_message_delivery
from config.settings import Settings
-from db.dal import security_dal
+from db.dal import security_dal, user_dal
from db.models import EmailVerificationCode
logger = logging.getLogger(__name__)
@@ -222,6 +223,28 @@ class EmailAuthService:
magic_link=magic_link,
purpose=purpose,
)
+ resolved_target_user_id = target_user_id
+ if resolved_target_user_id is None:
+ try:
+ existing_user = await user_dal.get_user_by_email(session, normalized_email)
+ resolved_target_user_id = (
+ int(existing_user.user_id) if existing_user is not None else None
+ )
+ except Exception:
+ logger.exception(
+ "Failed to resolve email auth target user for audit log: %s",
+ normalized_email,
+ )
+ await log_user_message_delivery(
+ session,
+ target_user_id=resolved_target_user_id,
+ event_type="email_login_code_sent"
+ if purpose == "login"
+ else "email_verification_code_sent",
+ channel="email",
+ recipient=normalized_email,
+ content=f"purpose={purpose} magic_link={bool(magic_link)}",
+ )
return EmailCodeRequestResult(ok=True)
async def verify_code(
diff --git a/backend/bot/services/message_audit.py b/backend/bot/services/message_audit.py
new file mode 100644
index 0000000..0c3594c
--- /dev/null
+++ b/backend/bot/services/message_audit.py
@@ -0,0 +1,57 @@
+import logging
+from datetime import datetime, timezone
+from typing import Optional
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from db.dal import message_log_dal
+
+logger = logging.getLogger(__name__)
+
+
+def _clean_piece(value: Optional[object]) -> str:
+ return str(value or "").strip()
+
+
+async def log_user_message_delivery(
+ session: AsyncSession,
+ *,
+ target_user_id: Optional[int],
+ event_type: str,
+ channel: str,
+ content: str,
+ recipient: Optional[str] = None,
+ timestamp: Optional[datetime] = None,
+) -> None:
+ """Add a best-effort user log entry for important outbound messages."""
+ clean_event = _clean_piece(event_type)
+ clean_channel = _clean_piece(channel)
+ if not clean_event or not clean_channel:
+ return
+
+ parts = [f"channel={clean_channel}"]
+ clean_recipient = _clean_piece(recipient)
+ if clean_recipient:
+ parts.append(f"recipient={clean_recipient}")
+ clean_content = _clean_piece(content)
+ if clean_content:
+ parts.append(clean_content)
+
+ try:
+ await message_log_dal.create_message_log_no_commit(
+ session,
+ {
+ "user_id": None,
+ "event_type": clean_event,
+ "content": " | ".join(parts)[:4000],
+ "is_admin_event": False,
+ "target_user_id": int(target_user_id) if target_user_id is not None else None,
+ "timestamp": timestamp or datetime.now(timezone.utc),
+ },
+ )
+ except Exception:
+ logger.exception(
+ "Failed to add outbound message audit log for user %s event %s",
+ target_user_id,
+ clean_event,
+ )
diff --git a/backend/bot/services/subscription_lifecycle_notifications.py b/backend/bot/services/subscription_lifecycle_notifications.py
index 8fedd60..7cb2510 100644
--- a/backend/bot/services/subscription_lifecycle_notifications.py
+++ b/backend/bot/services/subscription_lifecycle_notifications.py
@@ -13,6 +13,7 @@ from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
from bot.middlewares.i18n import JsonI18n
from bot.services.email_auth_service import EmailAuthService
from bot.services.email_templates import render_subscription_lifecycle_notification
+from bot.services.message_audit import log_user_message_delivery
from bot.services.telegram_notifications import (
TELEGRAM_NOTIFICATIONS_BLOCKED,
TELEGRAM_NOTIFICATIONS_ENABLED,
@@ -188,6 +189,18 @@ class SubscriptionLifecycleNotificationService:
self._channel_key(stage.key, "telegram"),
sent_at=sent_at,
)
+ await log_user_message_delivery(
+ session,
+ target_user_id=getattr(sub, "user_id", None),
+ event_type="telegram_subscription_notification_sent",
+ channel="telegram",
+ recipient=str(chat_id),
+ content=(
+ f"stage={stage.key} message_key={stage.message_key} "
+ f"subscription_id={getattr(sub, 'subscription_id', '')}"
+ ),
+ timestamp=sent_at,
+ )
if user:
status = normalize_telegram_notification_status(
getattr(user, "telegram_notifications_status", None)
@@ -253,6 +266,18 @@ class SubscriptionLifecycleNotificationService:
self._channel_key(stage.key, "email"),
sent_at=sent_at,
)
+ await log_user_message_delivery(
+ session,
+ target_user_id=getattr(sub, "user_id", None),
+ event_type="email_subscription_notification_sent",
+ channel="email",
+ recipient=recipient,
+ content=(
+ f"stage={stage.key} message_key={stage.message_key} "
+ f"subscription_id={getattr(sub, 'subscription_id', '')}"
+ ),
+ timestamp=sent_at,
+ )
return True
async def _already_sent(
diff --git a/backend/bot/services/subscription_notification_worker.py b/backend/bot/services/subscription_notification_worker.py
index 5cde438..674a5d9 100644
--- a/backend/bot/services/subscription_notification_worker.py
+++ b/backend/bot/services/subscription_notification_worker.py
@@ -13,6 +13,7 @@ from sqlalchemy.orm import selectinload, sessionmaker
from bot.infra.redis import redis_lock
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
from bot.middlewares.i18n import JsonI18n
+from bot.services.message_audit import log_user_message_delivery
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_lifecycle_notifications import (
SubscriptionLifecycleNotificationService,
@@ -331,6 +332,17 @@ class SubscriptionNotificationWorker:
parse_mode="HTML",
)
telegram_sent = True
+ await log_user_message_delivery(
+ session,
+ target_user_id=user_id,
+ event_type="telegram_traffic_warning_sent",
+ channel="telegram",
+ recipient=str(telegram_chat_id),
+ content=(
+ "kind=trial warning_key=trial_traffic_depleted "
+ f"used_bytes={used} limit_bytes={limit}"
+ ),
+ )
except Exception as exc:
status = telegram_notification_status_from_error(exc)
if status and user and user_id:
@@ -355,6 +367,12 @@ class SubscriptionNotificationWorker:
subject_key="email_trial_traffic_depleted_subject",
message_text=message_text,
dashboard_url=(getattr(self.settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
+ session=session,
+ audit_event_type="email_traffic_warning_sent",
+ audit_content=(
+ "kind=trial warning_key=trial_traffic_depleted "
+ f"used_bytes={used} limit_bytes={limit}"
+ ),
)
return {"telegram": telegram_sent, "email": email_sent}
diff --git a/backend/bot/services/tariff_worker.py b/backend/bot/services/tariff_worker.py
index 49c2dad..3e19047 100644
--- a/backend/bot/services/tariff_worker.py
+++ b/backend/bot/services/tariff_worker.py
@@ -13,6 +13,7 @@ from sqlalchemy.orm import sessionmaker
from bot.infra.redis import redis_lock
from bot.middlewares.i18n import JsonI18n
+from bot.services.message_audit import log_user_message_delivery
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.services.user_email_notifications import send_user_notification_email
@@ -111,6 +112,8 @@ class TariffTrafficWorker:
subject_key: str,
message_text: str,
kind: str,
+ warning_key: str,
+ audit_content: str,
) -> None:
try:
user = await user_dal.get_user_by_id(session, user_id)
@@ -131,6 +134,9 @@ class TariffTrafficWorker:
if kind == "premium"
else "email_traffic_warning_regular_cta"
),
+ session=session,
+ audit_event_type="email_traffic_warning_sent",
+ audit_content=f"{audit_content} subject_key={subject_key} warning_key={warning_key}",
)
async def run(self) -> None:
@@ -602,6 +608,15 @@ class TariffTrafficWorker:
**usage,
)
subject_key = "email_traffic_warning_regular_depleted_subject"
+ warning_key = (
+ "traffic_warning_regular_almost"
+ if level < 100
+ else "traffic_warning_regular_depleted"
+ )
+ audit_content = (
+ f"kind=regular warning_key={warning_key} level={level} "
+ f"used_bytes={used_val} limit_bytes={limit_val}"
+ )
if self.bot:
try:
markup = self._traffic_topup_markup(user_lang, "regular")
@@ -611,6 +626,14 @@ class TariffTrafficWorker:
reply_markup=markup,
parse_mode="HTML",
)
+ await log_user_message_delivery(
+ session,
+ target_user_id=sub.user_id,
+ event_type="telegram_traffic_warning_sent",
+ channel="telegram",
+ recipient=str(sub.user_id),
+ content=audit_content,
+ )
except Exception:
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
await self._send_traffic_warning_email(
@@ -619,6 +642,8 @@ class TariffTrafficWorker:
subject_key=subject_key,
message_text=text,
kind="regular",
+ warning_key=warning_key,
+ audit_content=audit_content,
)
if ratio >= 1.0 and not sub.is_throttled:
logging.info(
@@ -1066,6 +1091,11 @@ class TariffTrafficWorker:
servers=servers,
**usage,
)
+ warning_key = "traffic_warning_premium_depleted"
+ audit_content = (
+ f"kind=premium warning_key={warning_key} "
+ f"used_bytes={used_val} limit_bytes={limit_val}"
+ )
if self.bot:
try:
markup = self._traffic_topup_markup(user_lang, "premium")
@@ -1075,6 +1105,14 @@ class TariffTrafficWorker:
reply_markup=markup,
parse_mode="HTML",
)
+ await log_user_message_delivery(
+ session,
+ target_user_id=sub.user_id,
+ event_type="telegram_traffic_warning_sent",
+ channel="telegram",
+ recipient=str(sub.user_id),
+ content=audit_content,
+ )
except Exception:
logging.exception(
"Failed to send premium traffic depleted warning to user %s", sub.user_id
@@ -1085,6 +1123,8 @@ class TariffTrafficWorker:
subject_key="email_traffic_warning_premium_depleted_subject",
message_text=text,
kind="premium",
+ warning_key=warning_key,
+ audit_content=audit_content,
)
return
@@ -1134,6 +1174,11 @@ class TariffTrafficWorker:
servers=servers,
**usage,
)
+ warning_key = "traffic_warning_premium_almost"
+ audit_content = (
+ f"kind=premium warning_key={warning_key} level={int(level)} "
+ f"used_bytes={used_val} limit_bytes={limit_val}"
+ )
if self.bot:
try:
markup = self._traffic_topup_markup(user_lang, "premium")
@@ -1143,6 +1188,14 @@ class TariffTrafficWorker:
reply_markup=markup,
parse_mode="HTML",
)
+ await log_user_message_delivery(
+ session,
+ target_user_id=sub.user_id,
+ event_type="telegram_traffic_warning_sent",
+ channel="telegram",
+ recipient=str(sub.user_id),
+ content=audit_content,
+ )
except Exception:
logging.exception(
"Failed to send premium traffic warning to user %s", sub.user_id
@@ -1153,6 +1206,8 @@ class TariffTrafficWorker:
subject_key="email_traffic_warning_premium_almost_subject",
message_text=text,
kind="premium",
+ warning_key=warning_key,
+ audit_content=audit_content,
)
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
diff --git a/backend/bot/services/user_email_notifications.py b/backend/bot/services/user_email_notifications.py
index 6dc86be..e889a4e 100644
--- a/backend/bot/services/user_email_notifications.py
+++ b/backend/bot/services/user_email_notifications.py
@@ -1,9 +1,12 @@
import logging
from typing import Any, Optional
+from sqlalchemy.ext.asyncio import AsyncSession
+
from bot.middlewares.i18n import JsonI18n
from bot.services.email_auth_service import EmailAuthService
from bot.services.email_templates import render_user_notification
+from bot.services.message_audit import log_user_message_delivery
from config.settings import Settings
@@ -34,6 +37,9 @@ async def send_user_notification_email(
subject_kwargs: Optional[dict[str, Any]] = None,
heading_key: Optional[str] = None,
intro_key: Optional[str] = None,
+ session: Optional[AsyncSession] = None,
+ audit_event_type: Optional[str] = None,
+ audit_content: Optional[str] = None,
) -> bool:
if not getattr(settings, "email_auth_configured", False):
return False
@@ -78,6 +84,15 @@ async def send_user_notification_email(
email=recipient,
content=content,
)
+ if session is not None and audit_event_type:
+ await log_user_message_delivery(
+ session,
+ target_user_id=getattr(user, "user_id", None),
+ event_type=audit_event_type,
+ channel="email",
+ recipient=recipient,
+ content=audit_content or f"subject_key={subject_key}",
+ )
return True
except Exception:
logging.exception("Failed to send user notification email to %s.", recipient)
diff --git a/tests/test_message_audit.py b/tests/test_message_audit.py
new file mode 100644
index 0000000..95dd9fb
--- /dev/null
+++ b/tests/test_message_audit.py
@@ -0,0 +1,47 @@
+import unittest
+from datetime import datetime, timezone
+
+from bot.services import message_audit
+
+
+class MessageAuditTests(unittest.IsolatedAsyncioTestCase):
+ async def test_log_user_message_delivery_adds_targeted_log(self):
+ calls = []
+ sent_at = datetime(2026, 5, 31, tzinfo=timezone.utc)
+
+ async def fake_create(_session, payload):
+ calls.append(payload)
+
+ original = message_audit.message_log_dal.create_message_log_no_commit
+ message_audit.message_log_dal.create_message_log_no_commit = fake_create
+ self.addCleanup(
+ self._restore_create_message_log,
+ original,
+ )
+
+ await message_audit.log_user_message_delivery(
+ object(),
+ target_user_id=42,
+ event_type="telegram_traffic_warning_sent",
+ channel="telegram",
+ recipient="100500",
+ content="kind=regular level=90",
+ timestamp=sent_at,
+ )
+
+ self.assertEqual(
+ calls,
+ [
+ {
+ "user_id": None,
+ "event_type": "telegram_traffic_warning_sent",
+ "content": "channel=telegram | recipient=100500 | kind=regular level=90",
+ "is_admin_event": False,
+ "target_user_id": 42,
+ "timestamp": sent_at,
+ }
+ ],
+ )
+
+ def _restore_create_message_log(self, original):
+ message_audit.message_log_dal.create_message_log_no_commit = original
From 7cf1d577f33127de69ee6f5415d54b2c22720006 Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Sun, 31 May 2026 23:27:38 +0300
Subject: [PATCH 3/4] fix: restore pending email code screens
---
frontend/src/App.svelte | 4 +
.../src/lib/webapp/stores/accountStore.js | 14 ++-
frontend/src/lib/webapp/stores/authStore.js | 94 ++++++++++++++++++-
3 files changed, 109 insertions(+), 3 deletions(-)
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index cfc7d79..c71616c 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -1219,6 +1219,7 @@
const emailHint = readEmailCodeLoginDeeplink();
if (!emailHint) return;
emailLoginDeeplinkConsumed = true;
+ authStore.clearPendingEmailCode();
authStore.update((s) => ({
...s,
email: emailHint,
@@ -1670,6 +1671,9 @@
screen = "login";
activeTab = "home";
setPasswordLoginMode(isPasswordLoginPath(), true);
+ authStore.restorePendingEmailCode((nextScreen) => {
+ screen = nextScreen;
+ });
void startEmailCodeLoginFromDeeplink();
}
diff --git a/frontend/src/lib/webapp/stores/accountStore.js b/frontend/src/lib/webapp/stores/accountStore.js
index 2771f45..f911dbd 100644
--- a/frontend/src/lib/webapp/stores/accountStore.js
+++ b/frontend/src/lib/webapp/stores/accountStore.js
@@ -188,10 +188,17 @@ export function createAccountStore({
async function requestLinkEmailCode() {
const s = get(state);
- if (s.linkEmailPending && s.linkEmailResendCooldown > 0) return;
const normalized = String(s.linkEmailValue || "")
.trim()
.toLowerCase();
+ if (
+ s.linkEmailPending &&
+ s.linkEmailResendCooldown > 0 &&
+ (!normalized || normalized === s.linkEmailPending)
+ ) {
+ state.update((s) => ({ ...s, linkEmailOpen: true }));
+ return;
+ }
if (!normalized || !normalized.includes("@")) {
state.update((s) => ({ ...s, linkEmailFieldError: t("wa_auth_invalid_email") }));
return;
@@ -251,7 +258,10 @@ export function createAccountStore({
async function requestSetPasswordCode() {
const s = get(state);
- if (s.setPasswordPending && s.setPasswordResendCooldown > 0) return;
+ if (s.setPasswordPending && s.setPasswordResendCooldown > 0) {
+ state.update((s) => ({ ...s, setPasswordOpen: true }));
+ return;
+ }
if (!validatePasswordDraft()) return;
state.update((s) => ({ ...s, setPasswordBusy: true }));
setPasswordStatus(t("wa_auth_sending_code"));
diff --git a/frontend/src/lib/webapp/stores/authStore.js b/frontend/src/lib/webapp/stores/authStore.js
index 9d34fdb..e7d5613 100644
--- a/frontend/src/lib/webapp/stores/authStore.js
+++ b/frontend/src/lib/webapp/stores/authStore.js
@@ -6,6 +6,10 @@ import {
emailError,
} from "../authHelpers.js";
+const EMAIL_CODE_PENDING_STORAGE_KEY = "rw_email_code_login_pending_v1";
+const EMAIL_CODE_PENDING_TTL_MS = 10 * 60 * 1000;
+const EMAIL_CODE_RESEND_MS = 60 * 1000;
+
export function createAuthStore({
publicApi,
setToken,
@@ -35,6 +39,80 @@ export function createAuthStore({
let authResendTimer = null;
let telegramLoginWatchdogTimer = null;
+ function readPendingEmailCodeSession() {
+ if (typeof window === "undefined" || !window.sessionStorage) return null;
+ try {
+ const raw = window.sessionStorage.getItem(EMAIL_CODE_PENDING_STORAGE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ const email = String(parsed?.email || "")
+ .trim()
+ .toLowerCase();
+ const expiresAt = Number(parsed?.expiresAt || 0);
+ const cooldownUntil = Number(parsed?.cooldownUntil || 0);
+ if (!email || !email.includes("@") || !expiresAt || expiresAt <= Date.now()) {
+ window.sessionStorage.removeItem(EMAIL_CODE_PENDING_STORAGE_KEY);
+ return null;
+ }
+ return { email, expiresAt, cooldownUntil };
+ } catch (_error) {
+ window.sessionStorage.removeItem(EMAIL_CODE_PENDING_STORAGE_KEY);
+ return null;
+ }
+ }
+
+ function writePendingEmailCodeSession(email) {
+ if (typeof window === "undefined" || !window.sessionStorage) return;
+ try {
+ window.sessionStorage.setItem(
+ EMAIL_CODE_PENDING_STORAGE_KEY,
+ JSON.stringify({
+ email,
+ expiresAt: Date.now() + EMAIL_CODE_PENDING_TTL_MS,
+ cooldownUntil: Date.now() + EMAIL_CODE_RESEND_MS,
+ })
+ );
+ } catch (_error) {
+ void _error;
+ }
+ }
+
+ function clearPendingEmailCode() {
+ if (typeof window === "undefined" || !window.sessionStorage) return;
+ try {
+ window.sessionStorage.removeItem(EMAIL_CODE_PENDING_STORAGE_KEY);
+ } catch (_error) {
+ void _error;
+ }
+ }
+
+ function restorePendingEmailCode(changeScreen) {
+ const pending = readPendingEmailCodeSession();
+ if (!pending) return false;
+ state.update((s) => ({
+ ...s,
+ email: pending.email,
+ pendingEmail: pending.email,
+ emailCode: "",
+ authStatus: "",
+ authIsError: false,
+ authBusy: false,
+ passwordLoginMode: false,
+ passwordLoginFallback: false,
+ loginEmailFieldError: "",
+ loginEmailTooltipOpen: false,
+ }));
+ const cooldownSeconds = Math.ceil((pending.cooldownUntil - Date.now()) / 1000);
+ if (cooldownSeconds > 0) {
+ startCooldownTimer(cooldownSeconds);
+ } else {
+ clearCooldownTimer();
+ state.update((s) => ({ ...s, authResendCooldown: 0 }));
+ }
+ if (typeof changeScreen === "function") changeScreen("code");
+ return true;
+ }
+
function setAuthStatus(message, isError = false) {
state.update((s) => ({ ...s, authStatus: message, authIsError: isError }));
}
@@ -101,6 +179,7 @@ export function createAuthStore({
const response = await publicApi("/auth/email/magic", payload);
if (response.ok && response.csrf_token) {
setToken("", response.csrf_token);
+ clearPendingEmailCode();
clearAuthQuery();
await loadData();
return true;
@@ -131,6 +210,7 @@ export function createAuthStore({
const response = await publicApi("/auth/token", payload, { signal: options.signal });
if (response.ok && response.csrf_token) {
setToken("", response.csrf_token);
+ clearPendingEmailCode();
clearAuthQuery();
setAuthStatus("");
await loadData();
@@ -157,8 +237,15 @@ export function createAuthStore({
async function requestEmailCode(changeScreen) {
const s = get(state);
- if (s.authResendCooldown > 0 && s.pendingEmail) return;
const normalized = s.email.trim().toLowerCase();
+ if (
+ s.authResendCooldown > 0 &&
+ s.pendingEmail &&
+ (!normalized || normalized === s.pendingEmail)
+ ) {
+ if (typeof changeScreen === "function") changeScreen("code");
+ return;
+ }
if (!normalized || !normalized.includes("@")) {
state.update((s) => ({
...s,
@@ -185,6 +272,7 @@ export function createAuthStore({
.replace(/\D/g, "")
.slice(0, 6);
state.update((s) => ({ ...s, pendingEmail: normalized, emailCode: presetCode }));
+ writePendingEmailCodeSession(normalized);
changeScreen("code");
setAuthStatus("");
startCooldownTimer(60);
@@ -226,6 +314,7 @@ export function createAuthStore({
});
if (!response.ok || !response.csrf_token) throw response;
setToken("", response.csrf_token);
+ clearPendingEmailCode();
await loadData();
setAuthStatus("");
} catch (error) {
@@ -258,6 +347,7 @@ export function createAuthStore({
const response = await publicApi("/auth/email/verify", payload);
if (!response.ok || !response.csrf_token) throw response;
setToken("", response.csrf_token);
+ clearPendingEmailCode();
await loadData();
setAuthStatus("");
} catch (error) {
@@ -335,6 +425,8 @@ export function createAuthStore({
loginWithEmailPassword,
verifyEmailCode,
openTelegramLogin,
+ restorePendingEmailCode,
+ clearPendingEmailCode,
clearCooldownTimer,
stopTelegramLoginWatchdog,
setAuthStatus,
From c2344824dc961d2194ad809e345821c80a711a96 Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Mon, 1 Jun 2026 00:36:05 +0300
Subject: [PATCH 4/4] feat: show referral relationships in admin user cards
---
backend/bot/app/web/admin_api_impl/routes.py | 1 +
backend/bot/app/web/admin_api_impl/users.py | 69 +++++-
backend/bot/handlers/admin/user_management.py | 165 +++++++++++++-
backend/db/dal/user_dal.py | 33 +++
.../src/admin/sections/UserDetailModal.svelte | 204 ++++++++++++++++++
frontend/src/lib/admin/stores/usersStore.js | 74 +++++++
frontend/src/lib/webapp/mockApi.js | 41 +++-
locales/en.json | 18 ++
locales/ru.json | 18 ++
tests/test_user_dal.py | 50 +++++
tests/test_webapp_route_contract.py | 1 +
11 files changed, 662 insertions(+), 12 deletions(-)
diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py
index 5b20c35..6b0a10c 100644
--- a/backend/bot/app/web/admin_api_impl/routes.py
+++ b/backend/bot/app/web/admin_api_impl/routes.py
@@ -9,6 +9,7 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/users", admin_users_list_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
+ router.add_get("/api/admin/users/{user_id:-?\\d+}/referrals", admin_user_referrals_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py
index b4ea1df..29ee2e2 100644
--- a/backend/bot/app/web/admin_api_impl/users.py
+++ b/backend/bot/app/web/admin_api_impl/users.py
@@ -255,6 +255,17 @@ async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) ->
return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows}
+def _serialize_admin_user_with_avatar(user: User, avatar_keys: Dict[int, str]) -> Dict[str, Any]:
+ payload = _serialize_user(user)
+ user_id = int(user.user_id)
+ payload["avatar_url"] = (
+ f"/api/admin/users/{user_id}/avatar?v={avatar_keys[user_id]}"
+ if user_id in avatar_keys
+ else None
+ )
+ return payload
+
+
async def admin_user_avatar_route(request: web.Request) -> web.Response:
"""Serve the cached Telegram avatar for any user (admin-only).
@@ -587,7 +598,12 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
)
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
log_count = await message_log_dal.count_user_message_logs(session, target_id)
- avatar_keys = await _bulk_user_avatar_keys(session, [target_id])
+ inviter = await user_dal.get_referrer_for_user(session, user)
+ invitees_total = await user_dal.count_users_referred_by(session, target_id)
+ avatar_user_ids = [target_id]
+ if inviter is not None:
+ avatar_user_ids.append(int(inviter.user_id))
+ avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
# Referral links — both the bot deep-link and the webapp deep-link.
referral_code: Optional[str] = None
@@ -635,11 +651,9 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
exc_panel,
)
- serialized_user = _serialize_user(user)
- serialized_user["avatar_url"] = (
- f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
- if target_id in avatar_keys
- else None
+ serialized_user = _serialize_admin_user_with_avatar(user, avatar_keys)
+ serialized_inviter = (
+ _serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
)
return _ok(
@@ -655,11 +669,54 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
"code": referral_code,
"bot_link": referral_bot_link,
"webapp_link": referral_webapp_link,
+ "inviter": serialized_inviter,
+ "invitees_total": int(invitees_total or 0),
},
}
)
+async def admin_user_referrals_route(request: web.Request) -> web.Response:
+ _require_admin_user_id(request)
+ target_id = int(request.match_info["user_id"])
+ page = max(0, int(request.query.get("page", 0) or 0))
+ page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
+ async_session_factory: sessionmaker = request.app["async_session_factory"]
+
+ async with async_session_factory() as session:
+ user = await user_dal.get_user_by_id(session, target_id)
+ if not user:
+ return _error(404, "not_found", "User not found")
+
+ inviter = await user_dal.get_referrer_for_user(session, user)
+ invitees_total = await user_dal.count_users_referred_by(session, target_id)
+ invitees = await user_dal.get_users_referred_by(
+ session,
+ target_id,
+ limit=page_size,
+ offset=page * page_size,
+ )
+ avatar_user_ids = [target_id, *(int(u.user_id) for u in invitees)]
+ if inviter is not None:
+ avatar_user_ids.append(int(inviter.user_id))
+ avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
+
+ return _ok(
+ {
+ "user": _serialize_admin_user_with_avatar(user, avatar_keys),
+ "inviter": _serialize_admin_user_with_avatar(inviter, avatar_keys)
+ if inviter is not None
+ else None,
+ "invitees": [
+ _serialize_admin_user_with_avatar(invitee, avatar_keys) for invitee in invitees
+ ],
+ "total": int(invitees_total or 0),
+ "page": page,
+ "page_size": page_size,
+ }
+ )
+
+
async def admin_user_ban_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
diff --git a/backend/bot/handlers/admin/user_management.py b/backend/bot/handlers/admin/user_management.py
index 786fbd2..2476e0e 100644
--- a/backend/bot/handlers/admin/user_management.py
+++ b/backend/bot/handlers/admin/user_management.py
@@ -88,6 +88,31 @@ async def _find_user_by_admin_input(
return None
+def _admin_user_reference_label(
+ user: Optional[User], fallback_user_id: Optional[int] = None
+) -> str:
+ if user is None:
+ return f"ID {fallback_user_id}" if fallback_user_id is not None else "N/A"
+
+ first_name = sanitize_display_name(user.first_name) if user.first_name else ""
+ last_name = sanitize_display_name(user.last_name) if user.last_name else ""
+ full_name = f"{first_name} {last_name}".strip()
+ if full_name:
+ label = full_name
+ elif user.username:
+ label = username_for_display(user.username, with_at=True)
+ elif user.email:
+ label = user.email
+ else:
+ label = f"ID {user.user_id}"
+ return f"{label} · ID {user.user_id}"
+
+
+def _admin_user_button_label(user: User) -> str:
+ label = _admin_user_reference_label(user)
+ return label[:64]
+
+
async def users_list_handler(
callback: types.CallbackQuery,
i18n_data: dict,
@@ -195,7 +220,13 @@ def get_user_card_keyboard(
text=_(key="admin_user_refresh_button"), callback_data=f"user_action:refresh:{user_id}"
)
- # Row 3b: Premium override + traffic grant
+ # Row 3b: Referral details
+ builder.button(
+ text=_(key="admin_user_invitees_button"),
+ callback_data=f"user_action:invitees:{user_id}:0",
+ )
+
+ # Row 4: Premium override + traffic grant
builder.button(
text=_(key="admin_user_premium_override_button"),
callback_data=f"user_action:premium_override:{user_id}",
@@ -229,9 +260,9 @@ def get_user_card_keyboard(
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
if quick_links_count == 0:
- builder.adjust(2, 2, 2, 2, 1, 2)
+ builder.adjust(2, 2, 2, 1, 2, 1, 2)
else:
- builder.adjust(2, 2, 2, 2, quick_links_count, 1, 2)
+ builder.adjust(2, 2, 2, 1, 2, quick_links_count, 1, 2)
return builder
@@ -314,7 +345,11 @@ async def format_user_card(
# Referral info
if user.referred_by_id:
- card_parts.append(f"{_('admin_user_referral_label')} {hcode(str(user.referred_by_id))}")
+ referrer = await user_dal.get_referrer_for_user(session, user)
+ card_parts.append(
+ f"{_('admin_user_invited_by_label')} "
+ f"{hcode(_admin_user_reference_label(referrer, user.referred_by_id))}"
+ )
# Panel info
if user.panel_user_uuid:
@@ -619,6 +654,12 @@ async def user_action_handler(
await handle_send_message_prompt(callback, state, user, i18n, current_lang)
elif action == "view_logs":
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
+ elif action == "invitees":
+ try:
+ page = max(0, int(parts[3])) if len(parts) > 3 else 0
+ except (TypeError, ValueError):
+ page = 0
+ await handle_view_user_invitees(callback, user, session, i18n, current_lang, page=page)
elif action == "refresh":
await handle_refresh_user_card(
callback, user, subscription_service, session, settings, i18n, current_lang
@@ -1056,6 +1097,120 @@ async def handle_view_user_logs(
await callback.answer(_("admin_user_logs_error"), show_alert=True)
+async def handle_view_user_invitees(
+ callback: types.CallbackQuery,
+ user: User,
+ session: AsyncSession,
+ i18n_instance,
+ lang: str,
+ *,
+ page: int = 0,
+):
+ """Show users invited by the selected account."""
+ _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
+ page_size = 10
+ safe_page = max(0, int(page or 0))
+
+ try:
+ total = await user_dal.count_users_referred_by(session, user.user_id)
+ total_pages = max(1, (total + page_size - 1) // page_size)
+ if safe_page >= total_pages:
+ safe_page = total_pages - 1
+ invitees = await user_dal.get_users_referred_by(
+ session,
+ user.user_id,
+ limit=page_size,
+ offset=safe_page * page_size,
+ )
+
+ header = _(
+ "admin_user_invitees_message_title",
+ user=hcode(_admin_user_reference_label(user)),
+ total=total,
+ current=safe_page + 1,
+ total_pages=total_pages,
+ )
+ if total <= 0:
+ invitees_text = f"{header}\n\n{_('admin_user_invitees_empty')}"
+ else:
+ lines = []
+ for index, invitee in enumerate(invitees, start=safe_page * page_size + 1):
+ registered = (
+ invitee.registration_date.strftime("%Y-%m-%d")
+ if invitee.registration_date
+ else ""
+ )
+ suffix = (
+ _("admin_user_invitee_registered_suffix", date=registered) if registered else ""
+ )
+ lines.append(
+ _(
+ "admin_user_invitee_item",
+ index=index,
+ user=hcode(_admin_user_reference_label(invitee)),
+ suffix=suffix,
+ )
+ )
+ invitees_text = "\n".join([header, "", *lines])
+
+ builder = InlineKeyboardBuilder()
+ for invitee in invitees:
+ builder.row(
+ types.InlineKeyboardButton(
+ text=_admin_user_button_label(invitee),
+ callback_data=f"user_action:refresh:{invitee.user_id}",
+ )
+ )
+
+ pagination_buttons = []
+ if safe_page > 0:
+ pagination_buttons.append(
+ types.InlineKeyboardButton(
+ text=_("prev_page_button"),
+ callback_data=f"user_action:invitees:{user.user_id}:{safe_page - 1}",
+ )
+ )
+ if safe_page < total_pages - 1:
+ pagination_buttons.append(
+ types.InlineKeyboardButton(
+ text=_("next_page_button"),
+ callback_data=f"user_action:invitees:{user.user_id}:{safe_page + 1}",
+ )
+ )
+ if pagination_buttons:
+ builder.row(*pagination_buttons)
+ builder.row(
+ types.InlineKeyboardButton(
+ text=_("admin_user_back_to_card_button"),
+ callback_data=f"user_action:refresh:{user.user_id}",
+ )
+ )
+ builder.row(
+ types.InlineKeyboardButton(
+ text=_("back_to_admin_panel_button"), callback_data="admin_action:main"
+ )
+ )
+
+ try:
+ await callback.message.edit_text(
+ invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
+ )
+ except Exception:
+ await callback.message.answer(
+ invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
+ )
+
+ await callback.answer()
+ except Exception as exc:
+ logging.error(
+ "Error viewing invitees for user %s: %s",
+ user.user_id,
+ exc,
+ exc_info=True,
+ )
+ await callback.answer(_("admin_user_invitees_error"), show_alert=True)
+
+
async def handle_refresh_user_card(
callback: types.CallbackQuery,
user: User,
@@ -1964,7 +2119,7 @@ async def user_card_from_list_handler(
text=_("admin_user_back_to_list_button"), callback_data=f"admin_action:users_list:{page}"
)
quick_links_width = 2 if user.referred_by_id else 1
- keyboard.adjust(2, 2, 2, 2, quick_links_width, 1, 2, 1)
+ keyboard.adjust(2, 2, 2, 1, 2, quick_links_width, 1, 2, 1)
# Format user card
try:
diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py
index 8b506e4..cc2102e 100644
--- a/backend/db/dal/user_dal.py
+++ b/backend/db/dal/user_dal.py
@@ -95,6 +95,39 @@ async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
return result.scalar_one_or_none()
+async def get_referrer_for_user(session: AsyncSession, user: User) -> Optional[User]:
+ referred_by_id = getattr(user, "referred_by_id", None)
+ if referred_by_id is None:
+ return None
+ return await get_user_by_id(session, int(referred_by_id))
+
+
+async def get_users_referred_by(
+ session: AsyncSession,
+ user_id: int,
+ *,
+ limit: int = 50,
+ offset: int = 0,
+) -> List[User]:
+ safe_limit = max(1, min(500, int(limit or 50)))
+ safe_offset = max(0, int(offset or 0))
+ stmt = (
+ select(User)
+ .where(User.referred_by_id == user_id)
+ .order_by(User.registration_date.desc().nullslast(), User.user_id.desc())
+ .offset(safe_offset)
+ .limit(safe_limit)
+ )
+ result = await session.execute(stmt)
+ return result.scalars().all()
+
+
+async def count_users_referred_by(session: AsyncSession, user_id: int) -> int:
+ stmt = select(func.count(User.user_id)).where(User.referred_by_id == user_id)
+ result = await session.execute(stmt)
+ return int(result.scalar_one() or 0)
+
+
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
clean_username = username.lstrip("@").lower()
stmt = select(User).where(func.lower(User.username) == clean_username)
diff --git a/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte
index a424b25..f0ecc1d 100644
--- a/frontend/src/admin/sections/UserDetailModal.svelte
+++ b/frontend/src/admin/sections/UserDetailModal.svelte
@@ -23,6 +23,7 @@
Trash2,
UserMinus,
UserPlus,
+ UsersRound,
} from "$components/ui/icons.js";
import { getContext } from "svelte";
@@ -63,6 +64,12 @@
userDeleteOpen,
userBanConfirmOpen,
userMessageConfirmOpen,
+ userReferralsOpen,
+ userReferralsLoading,
+ userReferrals,
+ userReferralsTotal,
+ userReferralsPage,
+ userReferralsPageSize,
premiumUnlimitedDraft,
userDetailTab,
userLogs,
@@ -75,8 +82,13 @@
$: userLogsHasMore =
Number(userLogsTotal || 0) > (Number(userLogsPage || 0) + 1) * Number(userLogsPageSize || 20);
+ $: userReferralsHasMore =
+ Number(userReferralsTotal || 0) >
+ (Number(userReferralsPage || 0) + 1) * Number(userReferralsPageSize || 25);
$: openedUserAvatarUrl = openedUser ? resolvedAvatarUrl(openedUser) : "";
+ $: referralInviter = openedUserDetail?.referral?.inviter || null;
+ $: referralInviteesTotal = Number(openedUserDetail?.referral?.invitees_total || 0);
$: openedUserTelegramProfileLink = openedUser ? userTelegramProfileLink(openedUser) : "";
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
$: openedUserTelegramProfileHint =
@@ -119,6 +131,12 @@
}
openTelegramProfileLink(openedUserTelegramProfileLink);
}
+
+ function openRelatedUser(user) {
+ if (!user?.user_id) return;
+ usersStore.closeUserReferrals();
+ usersStore.openUser(user);
+ }
+
+