Merge branch 'dev' into feature/multicurrency
This commit is contained in:
@@ -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", 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+}", 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_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+}/ban", admin_user_ban_route)
|
||||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
|
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
|
||||||
|
|||||||
@@ -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}
|
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:
|
async def admin_user_avatar_route(request: web.Request) -> web.Response:
|
||||||
"""Serve the cached Telegram avatar for any user (admin-only).
|
"""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()
|
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
|
||||||
log_count = await message_log_dal.count_user_message_logs(session, target_id)
|
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 links — both the bot deep-link and the webapp deep-link.
|
||||||
referral_code: Optional[str] = None
|
referral_code: Optional[str] = None
|
||||||
@@ -635,11 +651,9 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
|||||||
exc_panel,
|
exc_panel,
|
||||||
)
|
)
|
||||||
|
|
||||||
serialized_user = _serialize_user(user)
|
serialized_user = _serialize_admin_user_with_avatar(user, avatar_keys)
|
||||||
serialized_user["avatar_url"] = (
|
serialized_inviter = (
|
||||||
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
|
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
|
||||||
if target_id in avatar_keys
|
|
||||||
else None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return _ok(
|
return _ok(
|
||||||
@@ -655,11 +669,54 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
|||||||
"code": referral_code,
|
"code": referral_code,
|
||||||
"bot_link": referral_bot_link,
|
"bot_link": referral_bot_link,
|
||||||
"webapp_link": referral_webapp_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:
|
async def admin_user_ban_route(request: web.Request) -> web.Response:
|
||||||
_require_admin_user_id(request)
|
_require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
@@ -1031,7 +1088,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
|
|||||||
|
|
||||||
|
|
||||||
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
|
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
|
||||||
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
|
"""Main (regular) traffic: native unlimited panel limit + admin bonus GB."""
|
||||||
actor_id = _require_admin_user_id(request)
|
actor_id = _require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
|
|||||||
@@ -89,6 +89,31 @@ async def _find_user_by_admin_input(
|
|||||||
return None
|
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(
|
async def users_list_handler(
|
||||||
callback: types.CallbackQuery,
|
callback: types.CallbackQuery,
|
||||||
i18n_data: dict,
|
i18n_data: dict,
|
||||||
@@ -196,7 +221,13 @@ def get_user_card_keyboard(
|
|||||||
text=_(key="admin_user_refresh_button"), callback_data=f"user_action:refresh:{user_id}"
|
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(
|
builder.button(
|
||||||
text=_(key="admin_user_premium_override_button"),
|
text=_(key="admin_user_premium_override_button"),
|
||||||
callback_data=f"user_action:premium_override:{user_id}",
|
callback_data=f"user_action:premium_override:{user_id}",
|
||||||
@@ -230,9 +261,9 @@ def get_user_card_keyboard(
|
|||||||
|
|
||||||
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
|
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
|
||||||
if quick_links_count == 0:
|
if quick_links_count == 0:
|
||||||
builder.adjust(2, 2, 2, 2, 1, 2)
|
builder.adjust(2, 2, 2, 1, 2, 1, 2)
|
||||||
else:
|
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
|
return builder
|
||||||
|
|
||||||
|
|
||||||
@@ -315,7 +346,11 @@ async def format_user_card(
|
|||||||
|
|
||||||
# Referral info
|
# Referral info
|
||||||
if user.referred_by_id:
|
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
|
# Panel info
|
||||||
if user.panel_user_uuid:
|
if user.panel_user_uuid:
|
||||||
@@ -622,6 +657,12 @@ async def user_action_handler(
|
|||||||
await handle_send_message_prompt(callback, state, user, i18n, current_lang)
|
await handle_send_message_prompt(callback, state, user, i18n, current_lang)
|
||||||
elif action == "view_logs":
|
elif action == "view_logs":
|
||||||
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
|
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":
|
elif action == "refresh":
|
||||||
await handle_refresh_user_card(
|
await handle_refresh_user_card(
|
||||||
callback, user, subscription_service, session, settings, i18n, current_lang
|
callback, user, subscription_service, session, settings, i18n, current_lang
|
||||||
@@ -1059,6 +1100,120 @@ async def handle_view_user_logs(
|
|||||||
await callback.answer(_("admin_user_logs_error"), show_alert=True)
|
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(
|
async def handle_refresh_user_card(
|
||||||
callback: types.CallbackQuery,
|
callback: types.CallbackQuery,
|
||||||
user: User,
|
user: User,
|
||||||
@@ -1967,7 +2122,7 @@ async def user_card_from_list_handler(
|
|||||||
text=_("admin_user_back_to_list_button"), callback_data=f"admin_action:users_list:{page}"
|
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
|
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
|
# Format user card
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.services.email_templates import EmailContent, render_login_code
|
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 config.settings import Settings
|
||||||
from db.dal import security_dal
|
from db.dal import security_dal, user_dal
|
||||||
from db.models import EmailVerificationCode
|
from db.models import EmailVerificationCode
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -222,6 +223,28 @@ class EmailAuthService:
|
|||||||
magic_link=magic_link,
|
magic_link=magic_link,
|
||||||
purpose=purpose,
|
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)
|
return EmailCodeRequestResult(ok=True)
|
||||||
|
|
||||||
async def verify_code(
|
async def verify_code(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -13,6 +13,7 @@ from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
|||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.services.email_auth_service import EmailAuthService
|
from bot.services.email_auth_service import EmailAuthService
|
||||||
from bot.services.email_templates import render_subscription_lifecycle_notification
|
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 (
|
from bot.services.telegram_notifications import (
|
||||||
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
@@ -188,6 +189,18 @@ class SubscriptionLifecycleNotificationService:
|
|||||||
self._channel_key(stage.key, "telegram"),
|
self._channel_key(stage.key, "telegram"),
|
||||||
sent_at=sent_at,
|
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:
|
if user:
|
||||||
status = normalize_telegram_notification_status(
|
status = normalize_telegram_notification_status(
|
||||||
getattr(user, "telegram_notifications_status", None)
|
getattr(user, "telegram_notifications_status", None)
|
||||||
@@ -253,6 +266,18 @@ class SubscriptionLifecycleNotificationService:
|
|||||||
self._channel_key(stage.key, "email"),
|
self._channel_key(stage.key, "email"),
|
||||||
sent_at=sent_at,
|
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
|
return True
|
||||||
|
|
||||||
async def _already_sent(
|
async def _already_sent(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import selectinload, sessionmaker
|
|||||||
from bot.infra.redis import redis_lock
|
from bot.infra.redis import redis_lock
|
||||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||||
from bot.middlewares.i18n import JsonI18n
|
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.panel_api_service import PanelApiService
|
||||||
from bot.services.subscription_lifecycle_notifications import (
|
from bot.services.subscription_lifecycle_notifications import (
|
||||||
SubscriptionLifecycleNotificationService,
|
SubscriptionLifecycleNotificationService,
|
||||||
@@ -331,6 +332,17 @@ class SubscriptionNotificationWorker:
|
|||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
telegram_sent = True
|
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:
|
except Exception as exc:
|
||||||
status = telegram_notification_status_from_error(exc)
|
status = telegram_notification_status_from_error(exc)
|
||||||
if status and user and user_id:
|
if status and user and user_id:
|
||||||
@@ -355,6 +367,12 @@ class SubscriptionNotificationWorker:
|
|||||||
subject_key="email_trial_traffic_depleted_subject",
|
subject_key="email_trial_traffic_depleted_subject",
|
||||||
message_text=message_text,
|
message_text=message_text,
|
||||||
dashboard_url=(getattr(self.settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
|
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}
|
return {"telegram": telegram_sent, "email": email_sent}
|
||||||
|
|
||||||
|
|||||||
@@ -112,15 +112,14 @@ class TariffMixin:
|
|||||||
regular_unlimited_override: bool,
|
regular_unlimited_override: bool,
|
||||||
traffic_used_bytes: int,
|
traffic_used_bytes: int,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Numeric cap sent to the panel; ``regular_unlimited_override`` uses a large practical ceiling.""" # noqa: E501
|
"""Numeric cap sent to the panel; Remnawave treats ``0`` as unlimited."""
|
||||||
floor = (
|
floor = (
|
||||||
int(tier_baseline_bytes or 0)
|
int(tier_baseline_bytes or 0)
|
||||||
+ max(0, int(topup_balance_bytes or 0))
|
+ max(0, int(topup_balance_bytes or 0))
|
||||||
+ max(0, int(regular_bonus_bytes or 0))
|
+ max(0, int(regular_bonus_bytes or 0))
|
||||||
)
|
)
|
||||||
if regular_unlimited_override:
|
if regular_unlimited_override:
|
||||||
used = max(0, int(traffic_used_bytes or 0))
|
return 0
|
||||||
return max(floor, used + 512 * (1024**3), 1024**5)
|
|
||||||
return floor
|
return floor
|
||||||
|
|
||||||
async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]:
|
async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from bot.infra.redis import redis_lock
|
from bot.infra.redis import redis_lock
|
||||||
from bot.middlewares.i18n import JsonI18n
|
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.panel_api_service import PanelApiService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.services.user_email_notifications import send_user_notification_email
|
from bot.services.user_email_notifications import send_user_notification_email
|
||||||
@@ -111,6 +112,8 @@ class TariffTrafficWorker:
|
|||||||
subject_key: str,
|
subject_key: str,
|
||||||
message_text: str,
|
message_text: str,
|
||||||
kind: str,
|
kind: str,
|
||||||
|
warning_key: str,
|
||||||
|
audit_content: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
user = await user_dal.get_user_by_id(session, user_id)
|
user = await user_dal.get_user_by_id(session, user_id)
|
||||||
@@ -131,6 +134,9 @@ class TariffTrafficWorker:
|
|||||||
if kind == "premium"
|
if kind == "premium"
|
||||||
else "email_traffic_warning_regular_cta"
|
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:
|
async def run(self) -> None:
|
||||||
@@ -602,6 +608,15 @@ class TariffTrafficWorker:
|
|||||||
**usage,
|
**usage,
|
||||||
)
|
)
|
||||||
subject_key = "email_traffic_warning_regular_depleted_subject"
|
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:
|
if self.bot:
|
||||||
try:
|
try:
|
||||||
markup = self._traffic_topup_markup(user_lang, "regular")
|
markup = self._traffic_topup_markup(user_lang, "regular")
|
||||||
@@ -611,6 +626,14 @@ class TariffTrafficWorker:
|
|||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
parse_mode="HTML",
|
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:
|
except Exception:
|
||||||
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
|
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
|
||||||
await self._send_traffic_warning_email(
|
await self._send_traffic_warning_email(
|
||||||
@@ -619,6 +642,8 @@ class TariffTrafficWorker:
|
|||||||
subject_key=subject_key,
|
subject_key=subject_key,
|
||||||
message_text=text,
|
message_text=text,
|
||||||
kind="regular",
|
kind="regular",
|
||||||
|
warning_key=warning_key,
|
||||||
|
audit_content=audit_content,
|
||||||
)
|
)
|
||||||
if ratio >= 1.0 and not sub.is_throttled:
|
if ratio >= 1.0 and not sub.is_throttled:
|
||||||
logging.info(
|
logging.info(
|
||||||
@@ -1066,6 +1091,11 @@ class TariffTrafficWorker:
|
|||||||
servers=servers,
|
servers=servers,
|
||||||
**usage,
|
**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:
|
if self.bot:
|
||||||
try:
|
try:
|
||||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||||
@@ -1075,6 +1105,14 @@ class TariffTrafficWorker:
|
|||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
parse_mode="HTML",
|
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:
|
except Exception:
|
||||||
logging.exception(
|
logging.exception(
|
||||||
"Failed to send premium traffic depleted warning to user %s", sub.user_id
|
"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",
|
subject_key="email_traffic_warning_premium_depleted_subject",
|
||||||
message_text=text,
|
message_text=text,
|
||||||
kind="premium",
|
kind="premium",
|
||||||
|
warning_key=warning_key,
|
||||||
|
audit_content=audit_content,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1134,6 +1174,11 @@ class TariffTrafficWorker:
|
|||||||
servers=servers,
|
servers=servers,
|
||||||
**usage,
|
**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:
|
if self.bot:
|
||||||
try:
|
try:
|
||||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||||
@@ -1143,6 +1188,14 @@ class TariffTrafficWorker:
|
|||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
parse_mode="HTML",
|
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:
|
except Exception:
|
||||||
logging.exception(
|
logging.exception(
|
||||||
"Failed to send premium traffic warning to user %s", sub.user_id
|
"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",
|
subject_key="email_traffic_warning_premium_almost_subject",
|
||||||
message_text=text,
|
message_text=text,
|
||||||
kind="premium",
|
kind="premium",
|
||||||
|
warning_key=warning_key,
|
||||||
|
audit_content=audit_content,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
|
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.services.email_auth_service import EmailAuthService
|
from bot.services.email_auth_service import EmailAuthService
|
||||||
from bot.services.email_templates import render_user_notification
|
from bot.services.email_templates import render_user_notification
|
||||||
|
from bot.services.message_audit import log_user_message_delivery
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +37,9 @@ async def send_user_notification_email(
|
|||||||
subject_kwargs: Optional[dict[str, Any]] = None,
|
subject_kwargs: Optional[dict[str, Any]] = None,
|
||||||
heading_key: Optional[str] = None,
|
heading_key: Optional[str] = None,
|
||||||
intro_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:
|
) -> bool:
|
||||||
if not getattr(settings, "email_auth_configured", False):
|
if not getattr(settings, "email_auth_configured", False):
|
||||||
return False
|
return False
|
||||||
@@ -78,6 +84,15 @@ async def send_user_notification_email(
|
|||||||
email=recipient,
|
email=recipient,
|
||||||
content=content,
|
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
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Failed to send user notification email to %s.", recipient)
|
logging.exception("Failed to send user notification email to %s.", recipient)
|
||||||
|
|||||||
@@ -95,6 +95,39 @@ async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
|||||||
return result.scalar_one_or_none()
|
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]:
|
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
|
||||||
clean_username = username.lstrip("@").lower()
|
clean_username = username.lstrip("@").lower()
|
||||||
stmt = select(User).where(func.lower(User.username) == clean_username)
|
stmt = select(User).where(func.lower(User.username) == clean_username)
|
||||||
|
|||||||
@@ -1219,6 +1219,7 @@
|
|||||||
const emailHint = readEmailCodeLoginDeeplink();
|
const emailHint = readEmailCodeLoginDeeplink();
|
||||||
if (!emailHint) return;
|
if (!emailHint) return;
|
||||||
emailLoginDeeplinkConsumed = true;
|
emailLoginDeeplinkConsumed = true;
|
||||||
|
authStore.clearPendingEmailCode();
|
||||||
authStore.update((s) => ({
|
authStore.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
email: emailHint,
|
email: emailHint,
|
||||||
@@ -1670,6 +1671,9 @@
|
|||||||
screen = "login";
|
screen = "login";
|
||||||
activeTab = "home";
|
activeTab = "home";
|
||||||
setPasswordLoginMode(isPasswordLoginPath(), true);
|
setPasswordLoginMode(isPasswordLoginPath(), true);
|
||||||
|
authStore.restorePendingEmailCode((nextScreen) => {
|
||||||
|
screen = nextScreen;
|
||||||
|
});
|
||||||
void startEmailCodeLoginFromDeeplink();
|
void startEmailCodeLoginFromDeeplink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
Trash2,
|
Trash2,
|
||||||
UserMinus,
|
UserMinus,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
UsersRound,
|
||||||
} from "$components/ui/icons.js";
|
} from "$components/ui/icons.js";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
@@ -63,6 +64,12 @@
|
|||||||
userDeleteOpen,
|
userDeleteOpen,
|
||||||
userBanConfirmOpen,
|
userBanConfirmOpen,
|
||||||
userMessageConfirmOpen,
|
userMessageConfirmOpen,
|
||||||
|
userReferralsOpen,
|
||||||
|
userReferralsLoading,
|
||||||
|
userReferrals,
|
||||||
|
userReferralsTotal,
|
||||||
|
userReferralsPage,
|
||||||
|
userReferralsPageSize,
|
||||||
premiumUnlimitedDraft,
|
premiumUnlimitedDraft,
|
||||||
userDetailTab,
|
userDetailTab,
|
||||||
userLogs,
|
userLogs,
|
||||||
@@ -75,8 +82,13 @@
|
|||||||
|
|
||||||
$: userLogsHasMore =
|
$: userLogsHasMore =
|
||||||
Number(userLogsTotal || 0) > (Number(userLogsPage || 0) + 1) * Number(userLogsPageSize || 20);
|
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) : "";
|
$: openedUserAvatarUrl = openedUser ? resolvedAvatarUrl(openedUser) : "";
|
||||||
|
$: referralInviter = openedUserDetail?.referral?.inviter || null;
|
||||||
|
$: referralInviteesTotal = Number(openedUserDetail?.referral?.invitees_total || 0);
|
||||||
$: openedUserTelegramProfileLink = openedUser ? userTelegramProfileLink(openedUser) : "";
|
$: openedUserTelegramProfileLink = openedUser ? userTelegramProfileLink(openedUser) : "";
|
||||||
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
|
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
|
||||||
$: openedUserTelegramProfileHint =
|
$: openedUserTelegramProfileHint =
|
||||||
@@ -119,6 +131,12 @@
|
|||||||
}
|
}
|
||||||
openTelegramProfileLink(openedUserTelegramProfileLink);
|
openTelegramProfileLink(openedUserTelegramProfileLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openRelatedUser(user) {
|
||||||
|
if (!user?.user_id) return;
|
||||||
|
usersStore.closeUserReferrals();
|
||||||
|
usersStore.openUser(user);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -225,6 +243,41 @@
|
|||||||
"—"}</strong
|
"—"}</strong
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="admin-user-ref-row">
|
||||||
|
<span>{at("user_label_invited_by", {}, "Пригласил")}</span>
|
||||||
|
<strong class="admin-user-ref-value">
|
||||||
|
{#if referralInviter}
|
||||||
|
<span>{userDisplayName(referralInviter)}</span>
|
||||||
|
<small>ID {referralInviter.user_id}</small>
|
||||||
|
{:else}
|
||||||
|
<span>{at("user_invited_by_none", {}, "—")}</span>
|
||||||
|
{/if}
|
||||||
|
</strong>
|
||||||
|
{#if referralInviter}
|
||||||
|
<AdminButton
|
||||||
|
size="icon"
|
||||||
|
variant="icon"
|
||||||
|
title={at("user_open_related", {}, "Открыть карточку")}
|
||||||
|
aria-label={at("user_open_related", {}, "Открыть карточку")}
|
||||||
|
onclick={() => openRelatedUser(referralInviter)}
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
</AdminButton>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
<li class="admin-user-ref-row">
|
||||||
|
<span>{at("user_label_invited_users", {}, "Приглашённые")}</span>
|
||||||
|
<strong>{referralInviteesTotal}</strong>
|
||||||
|
<AdminButton
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={referralInviteesTotal <= 0}
|
||||||
|
onclick={() => usersStore.openUserReferrals(0)}
|
||||||
|
>
|
||||||
|
<UsersRound size={14} />
|
||||||
|
{at("user_invitees_open", {}, "Показать")}
|
||||||
|
</AdminButton>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link}
|
{#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link}
|
||||||
@@ -934,6 +987,101 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={userReferralsOpen}
|
||||||
|
title={at("user_invitees_title", {}, "Приглашённые пользователи")}
|
||||||
|
description={openedUser
|
||||||
|
? at(
|
||||||
|
"user_invitees_description",
|
||||||
|
{ name: userDisplayName(openedUser), count: userReferralsTotal },
|
||||||
|
`${userDisplayName(openedUser)} · ${userReferralsTotal}`
|
||||||
|
)
|
||||||
|
: ""}
|
||||||
|
closeLabel={at("close", {}, "Закрыть")}
|
||||||
|
onclose={usersStore.closeUserReferrals}
|
||||||
|
class="admin-dialog admin-user-referrals-dialog"
|
||||||
|
>
|
||||||
|
<div class="admin-user-referrals-body">
|
||||||
|
{#if userReferralsLoading}
|
||||||
|
<AdminTableSkeleton
|
||||||
|
headers={[
|
||||||
|
at("user_col_user", {}, "Пользователь"),
|
||||||
|
"ID",
|
||||||
|
at("user_label_registration", {}, "Регистрация"),
|
||||||
|
"",
|
||||||
|
]}
|
||||||
|
rows={5}
|
||||||
|
widths={["42%", "18%", "26%", "14%"]}
|
||||||
|
/>
|
||||||
|
{:else if !userReferrals.length}
|
||||||
|
<AdminEmptyState tone="card">
|
||||||
|
<span class="admin-muted"
|
||||||
|
>{at("user_invitees_empty", {}, "Пользователь пока никого не пригласил")}</span
|
||||||
|
>
|
||||||
|
</AdminEmptyState>
|
||||||
|
{:else}
|
||||||
|
<ScrollArea class="admin-user-referrals-table-wrap" maxHeight="min(55vh, 460px)">
|
||||||
|
<AdminTable class="admin-user-referrals-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{at("user_col_user", {}, "Пользователь")}</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>{at("user_label_registration", {}, "Регистрация")}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each userReferrals as invitee (invitee.user_id)}
|
||||||
|
<tr>
|
||||||
|
<td data-label={at("user_col_user", {}, "Пользователь")}>
|
||||||
|
<span class="admin-referral-user-cell">
|
||||||
|
<strong>{userDisplayName(invitee)}</strong>
|
||||||
|
<small>{userSecondaryName(invitee)}</small>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="admin-cell-mono" data-label="ID">{invitee.user_id}</td>
|
||||||
|
<td data-label={at("user_label_registration", {}, "Регистрация")}>
|
||||||
|
{fmtDateShort(invitee.registration_date)}
|
||||||
|
</td>
|
||||||
|
<td class="admin-referral-user-actions">
|
||||||
|
<AdminButton
|
||||||
|
size="icon"
|
||||||
|
variant="icon"
|
||||||
|
title={at("user_open_related", {}, "Открыть карточку")}
|
||||||
|
aria-label={at("user_open_related", {}, "Открыть карточку")}
|
||||||
|
onclick={() => openRelatedUser(invitee)}
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
</AdminButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</AdminTable>
|
||||||
|
</ScrollArea>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if userReferralsTotal > userReferralsPageSize}
|
||||||
|
<AdminPagination
|
||||||
|
meta={at(
|
||||||
|
"pagination_meta",
|
||||||
|
{
|
||||||
|
current: userReferralsPage + 1,
|
||||||
|
total: Math.max(1, Math.ceil(userReferralsTotal / userReferralsPageSize)),
|
||||||
|
},
|
||||||
|
`${userReferralsPage + 1}/${Math.max(1, Math.ceil(userReferralsTotal / userReferralsPageSize))}`
|
||||||
|
)}
|
||||||
|
prevLabel={at("prev_page", {}, "Назад")}
|
||||||
|
nextLabel={at("next_page", {}, "Вперёд")}
|
||||||
|
prevDisabled={userReferralsLoading || userReferralsPage <= 0}
|
||||||
|
nextDisabled={userReferralsLoading || !userReferralsHasMore}
|
||||||
|
onPrev={() => usersStore.setUserReferralsPage(userReferralsPage - 1)}
|
||||||
|
onNext={() => usersStore.setUserReferralsPage(userReferralsPage + 1)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={avatarPreviewOpen}
|
open={avatarPreviewOpen}
|
||||||
title={avatarPreviewName || at("user_avatar_title", {}, "Аватар")}
|
title={avatarPreviewName || at("user_avatar_title", {}, "Аватар")}
|
||||||
@@ -1159,6 +1307,62 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
.admin-user-ref-row {
|
||||||
|
grid-template-columns: 130px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.admin-user-ref-row :global(.admin-btn) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.admin-user-ref-value {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.admin-user-ref-value small {
|
||||||
|
color: var(--admin-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
:global(.admin-user-referrals-dialog) {
|
||||||
|
width: min(760px, calc(100vw - 28px));
|
||||||
|
max-height: min(760px, calc(100dvh - 28px));
|
||||||
|
}
|
||||||
|
.admin-user-referrals-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
:global(.admin-user-referrals-table-wrap) {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
.admin-referral-user-cell {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.admin-referral-user-cell strong {
|
||||||
|
color: var(--admin-text);
|
||||||
|
font-weight: 650;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.admin-referral-user-cell small {
|
||||||
|
color: var(--admin-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.admin-referral-user-actions {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.admin-user-ref-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 4px 8px;
|
||||||
|
}
|
||||||
|
.admin-user-ref-row > span {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
:global(.admin-avatar-dialog) {
|
:global(.admin-avatar-dialog) {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
userDeleteOpen: false,
|
userDeleteOpen: false,
|
||||||
userBanConfirmOpen: false,
|
userBanConfirmOpen: false,
|
||||||
userMessageConfirmOpen: false,
|
userMessageConfirmOpen: false,
|
||||||
|
userReferralsOpen: false,
|
||||||
|
userReferralsLoading: false,
|
||||||
|
userReferrals: [],
|
||||||
|
userReferralsTotal: 0,
|
||||||
|
userReferralsPage: 0,
|
||||||
|
userReferralsPageSize: USERS_PAGE_SIZE,
|
||||||
|
userReferralsInviter: null,
|
||||||
userDetailTab: "profile",
|
userDetailTab: "profile",
|
||||||
premiumUnlimitedDraft: false,
|
premiumUnlimitedDraft: false,
|
||||||
premiumBonusGbDraft: "",
|
premiumBonusGbDraft: "",
|
||||||
@@ -126,6 +133,12 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
userExtendDays: 30,
|
userExtendDays: 30,
|
||||||
userDetailLoading: true,
|
userDetailLoading: true,
|
||||||
userDetailTab: "subscription",
|
userDetailTab: "subscription",
|
||||||
|
userReferralsOpen: false,
|
||||||
|
userReferralsLoading: false,
|
||||||
|
userReferrals: [],
|
||||||
|
userReferralsTotal: 0,
|
||||||
|
userReferralsPage: 0,
|
||||||
|
userReferralsInviter: null,
|
||||||
userLogs: [],
|
userLogs: [],
|
||||||
userLogsTotal: 0,
|
userLogsTotal: 0,
|
||||||
userLogsPage: 0,
|
userLogsPage: 0,
|
||||||
@@ -175,6 +188,12 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
userDeleteOpen: false,
|
userDeleteOpen: false,
|
||||||
userBanConfirmOpen: false,
|
userBanConfirmOpen: false,
|
||||||
userMessageConfirmOpen: false,
|
userMessageConfirmOpen: false,
|
||||||
|
userReferralsOpen: false,
|
||||||
|
userReferralsLoading: false,
|
||||||
|
userReferrals: [],
|
||||||
|
userReferralsTotal: 0,
|
||||||
|
userReferralsPage: 0,
|
||||||
|
userReferralsInviter: null,
|
||||||
userLogs: [],
|
userLogs: [],
|
||||||
userLogsTotal: 0,
|
userLogsTotal: 0,
|
||||||
userLogsPage: 0,
|
userLogsPage: 0,
|
||||||
@@ -231,6 +250,58 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
loadUserLogs(page);
|
loadUserLogs(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openUserReferrals(page = 0) {
|
||||||
|
let s;
|
||||||
|
state.update((st) => {
|
||||||
|
s = st;
|
||||||
|
return st;
|
||||||
|
});
|
||||||
|
if (!s.openedUser) return;
|
||||||
|
const userId = s.openedUser.user_id;
|
||||||
|
const targetPage = Number.isFinite(page) ? Math.max(0, Math.floor(page)) : 0;
|
||||||
|
state.update((st) => ({
|
||||||
|
...st,
|
||||||
|
userReferralsOpen: true,
|
||||||
|
userReferralsLoading: true,
|
||||||
|
userReferralsPage: targetPage,
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(targetPage),
|
||||||
|
page_size: String(s.userReferralsPageSize || USERS_PAGE_SIZE),
|
||||||
|
});
|
||||||
|
const data = await api(`/admin/users/${userId}/referrals?${params.toString()}`);
|
||||||
|
if (data?.ok) {
|
||||||
|
state.update((st) => {
|
||||||
|
if (!st.openedUser || st.openedUser.user_id !== userId) return st;
|
||||||
|
return {
|
||||||
|
...st,
|
||||||
|
userReferrals: data.invitees || [],
|
||||||
|
userReferralsTotal: Number(data.total || 0),
|
||||||
|
userReferralsPage: Number(data.page || 0),
|
||||||
|
userReferralsPageSize: Number(data.page_size || st.userReferralsPageSize),
|
||||||
|
userReferralsInviter: data.inviter || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else if (data?.error) {
|
||||||
|
onToast(data.error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
state.update((st) => ({ ...st, userReferralsLoading: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUserReferrals() {
|
||||||
|
state.update((s) => ({
|
||||||
|
...s,
|
||||||
|
userReferralsOpen: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUserReferralsPage(page) {
|
||||||
|
openUserReferrals(page);
|
||||||
|
}
|
||||||
|
|
||||||
function copyToClipboard(text, successMessage = at("link_copied", {}, "Скопировано")) {
|
function copyToClipboard(text, successMessage = at("link_copied", {}, "Скопировано")) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
|
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
|
||||||
@@ -564,5 +635,8 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
grantTraffic,
|
grantTraffic,
|
||||||
loadUserLogs,
|
loadUserLogs,
|
||||||
setUserLogsPage,
|
setUserLogsPage,
|
||||||
|
openUserReferrals,
|
||||||
|
closeUserReferrals,
|
||||||
|
setUserReferralsPage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
<X size={18} />
|
<X size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea class="dialog-body-scroll" maxHeight="none">
|
<ScrollArea class="dialog-body-scroll scroll-area--dialog" maxHeight="none">
|
||||||
<slot />
|
<slot />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -36,6 +36,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.scroll-area--dialog) {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
:global(.scroll-area__viewport) {
|
:global(.scroll-area__viewport) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -56,6 +60,10 @@
|
|||||||
width: 10px;
|
width: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.scroll-area--dialog .scroll-area__scrollbar[data-orientation="vertical"]) {
|
||||||
|
transform: translateX(12px);
|
||||||
|
}
|
||||||
|
|
||||||
:global(.scroll-area__scrollbar[data-orientation="horizontal"]) {
|
:global(.scroll-area__scrollbar[data-orientation="horizontal"]) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
|
|||||||
@@ -104957,10 +104957,9 @@ export const DEMO_DATASET = {
|
|||||||
updated_by: null,
|
updated_by: null,
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
base: "Unlimited-style ceiling and a persistent bonus on the main traffic limit.",
|
base: "Unlimited access and a persistent bonus on the main traffic limit.",
|
||||||
fallback: "Режим безлимита и постоянный бонус к лимиту основного трафика.",
|
fallback: "Режим безлимита и постоянный бонус к лимиту основного трафика.",
|
||||||
effective:
|
effective: "Unlimited access and a persistent bonus on the main traffic limit.",
|
||||||
"Unlimited-style ceiling and a persistent bonus on the main traffic limit.",
|
|
||||||
override: "",
|
override: "",
|
||||||
overridden: false,
|
overridden: false,
|
||||||
updated_at: null,
|
updated_at: null,
|
||||||
@@ -105241,22 +105240,22 @@ export const DEMO_DATASET = {
|
|||||||
audience: "internal",
|
audience: "internal",
|
||||||
values: {
|
values: {
|
||||||
ru: {
|
ru: {
|
||||||
base: "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
|
base: "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.",
|
||||||
fallback:
|
fallback:
|
||||||
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
|
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.",
|
||||||
effective:
|
effective:
|
||||||
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
|
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.",
|
||||||
override: "",
|
override: "",
|
||||||
overridden: false,
|
overridden: false,
|
||||||
updated_at: null,
|
updated_at: null,
|
||||||
updated_by: null,
|
updated_by: null,
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
base: "Extra main or premium traffic limits on top of the tariff; unlimited applies to premium squads only.",
|
base: "Extra main or premium traffic limits on top of the tariff; unlimited can be applied to main traffic or premium squads.",
|
||||||
fallback:
|
fallback:
|
||||||
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
|
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.",
|
||||||
effective:
|
effective:
|
||||||
"Extra main or premium traffic limits on top of the tariff; unlimited applies to premium squads only.",
|
"Extra main or premium traffic limits on top of the tariff; unlimited can be applied to main traffic or premium squads.",
|
||||||
override: "",
|
override: "",
|
||||||
overridden: false,
|
overridden: false,
|
||||||
updated_at: null,
|
updated_at: null,
|
||||||
@@ -134112,18 +134111,18 @@ export const DEMO_DATASET = {
|
|||||||
audience: "user",
|
audience: "user",
|
||||||
values: {
|
values: {
|
||||||
ru: {
|
ru: {
|
||||||
base: "Докупить премиум-трафик (мини-приложение)",
|
base: "Докупить премиум-трафик",
|
||||||
fallback: "Докупить премиум-трафик (мини-приложение)",
|
fallback: "Докупить премиум-трафик",
|
||||||
effective: "Докупить премиум-трафик (мини-приложение)",
|
effective: "Докупить премиум-трафик",
|
||||||
override: "",
|
override: "",
|
||||||
overridden: false,
|
overridden: false,
|
||||||
updated_at: null,
|
updated_at: null,
|
||||||
updated_by: null,
|
updated_by: null,
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
base: "Top up premium traffic (mini app)",
|
base: "Top up premium traffic",
|
||||||
fallback: "Докупить премиум-трафик (мини-приложение)",
|
fallback: "Докупить премиум-трафик",
|
||||||
effective: "Top up premium traffic (mini app)",
|
effective: "Top up premium traffic",
|
||||||
override: "",
|
override: "",
|
||||||
overridden: false,
|
overridden: false,
|
||||||
updated_at: null,
|
updated_at: null,
|
||||||
@@ -134136,18 +134135,18 @@ export const DEMO_DATASET = {
|
|||||||
audience: "user",
|
audience: "user",
|
||||||
values: {
|
values: {
|
||||||
ru: {
|
ru: {
|
||||||
base: "Докупить трафик (мини-приложение)",
|
base: "Докупить трафик",
|
||||||
fallback: "Докупить трафик (мини-приложение)",
|
fallback: "Докупить трафик",
|
||||||
effective: "Докупить трафик (мини-приложение)",
|
effective: "Докупить трафик",
|
||||||
override: "",
|
override: "",
|
||||||
overridden: false,
|
overridden: false,
|
||||||
updated_at: null,
|
updated_at: null,
|
||||||
updated_by: null,
|
updated_by: null,
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
base: "Top up traffic (mini app)",
|
base: "Top up traffic",
|
||||||
fallback: "Докупить трафик (мини-приложение)",
|
fallback: "Докупить трафик",
|
||||||
effective: "Top up traffic (mini app)",
|
effective: "Top up traffic",
|
||||||
override: "",
|
override: "",
|
||||||
overridden: false,
|
overridden: false,
|
||||||
updated_at: null,
|
updated_at: null,
|
||||||
|
|||||||
@@ -405,6 +405,32 @@ function withDemoAvatars(users, size = 96) {
|
|||||||
return (users || []).map((user) => withDemoAvatar(user, size));
|
return (users || []).map((user) => withDemoAvatar(user, size));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function demoAdminUserById(userId) {
|
||||||
|
return (DEMO_DATASET.adminUsers || []).find((user) => Number(user.user_id) === Number(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function demoInviteesForUser(userId) {
|
||||||
|
return (DEMO_DATASET.adminUsers || [])
|
||||||
|
.filter((user) => Number(user.referred_by_id) === Number(userId))
|
||||||
|
.sort((a, b) => stringDate(b.registration_date) - stringDate(a.registration_date));
|
||||||
|
}
|
||||||
|
|
||||||
|
function withDemoReferralSummary(detail) {
|
||||||
|
if (!detail || typeof detail !== "object") return detail;
|
||||||
|
const decorated = withDemoAvatarDetail(detail);
|
||||||
|
const user = decorated.user || {};
|
||||||
|
const inviter = user.referred_by_id ? demoAdminUserById(user.referred_by_id) : null;
|
||||||
|
const invitees = demoInviteesForUser(user.user_id);
|
||||||
|
return {
|
||||||
|
...decorated,
|
||||||
|
referral: {
|
||||||
|
...(decorated.referral || {}),
|
||||||
|
inviter: inviter ? withDemoAvatar(inviter) : null,
|
||||||
|
invitees_total: invitees.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function withDemoAvatarTickets(tickets, size = 96) {
|
function withDemoAvatarTickets(tickets, size = 96) {
|
||||||
return (tickets || []).map((ticket) => withDemoAvatarTicket(ticket, size));
|
return (tickets || []).map((ticket) => withDemoAvatarTicket(ticket, size));
|
||||||
}
|
}
|
||||||
@@ -610,8 +636,21 @@ function demoApiResponse(path, cleanPath, options, context) {
|
|||||||
const id = Number(parts[3]);
|
const id = Number(parts[3]);
|
||||||
const detail = DEMO_DATASET.adminUserDetails?.[String(id)];
|
const detail = DEMO_DATASET.adminUserDetails?.[String(id)];
|
||||||
if (!detail) return { ok: false, error: "not_found" };
|
if (!detail) return { ok: false, error: "not_found" };
|
||||||
const decoratedDetail = withDemoAvatarDetail(detail);
|
const decoratedDetail = withDemoReferralSummary(detail);
|
||||||
if (parts[4]) {
|
if (parts[4]) {
|
||||||
|
if (parts[4] === "referrals") {
|
||||||
|
const invitees = demoInviteesForUser(id);
|
||||||
|
const page = paged(invitees, params, 25);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
user: clone(decoratedDetail.user),
|
||||||
|
inviter: clone(decoratedDetail.referral?.inviter || null),
|
||||||
|
invitees: clone(withDemoAvatars(page.items)),
|
||||||
|
total: page.total,
|
||||||
|
page: page.page,
|
||||||
|
page_size: page.pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (parts[4] === "telegram-profile-link") {
|
if (parts[4] === "telegram-profile-link") {
|
||||||
return { ok: true, url: `https://t.me/${detail.user?.username || "demo_user"}` };
|
return { ok: true, url: `https://t.me/${detail.user?.username || "demo_user"}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,10 +188,17 @@ export function createAccountStore({
|
|||||||
|
|
||||||
async function requestLinkEmailCode() {
|
async function requestLinkEmailCode() {
|
||||||
const s = get(state);
|
const s = get(state);
|
||||||
if (s.linkEmailPending && s.linkEmailResendCooldown > 0) return;
|
|
||||||
const normalized = String(s.linkEmailValue || "")
|
const normalized = String(s.linkEmailValue || "")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
if (
|
||||||
|
s.linkEmailPending &&
|
||||||
|
s.linkEmailResendCooldown > 0 &&
|
||||||
|
(!normalized || normalized === s.linkEmailPending)
|
||||||
|
) {
|
||||||
|
state.update((s) => ({ ...s, linkEmailOpen: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!normalized || !normalized.includes("@")) {
|
if (!normalized || !normalized.includes("@")) {
|
||||||
state.update((s) => ({ ...s, linkEmailFieldError: t("wa_auth_invalid_email") }));
|
state.update((s) => ({ ...s, linkEmailFieldError: t("wa_auth_invalid_email") }));
|
||||||
return;
|
return;
|
||||||
@@ -251,7 +258,10 @@ export function createAccountStore({
|
|||||||
|
|
||||||
async function requestSetPasswordCode() {
|
async function requestSetPasswordCode() {
|
||||||
const s = get(state);
|
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;
|
if (!validatePasswordDraft()) return;
|
||||||
state.update((s) => ({ ...s, setPasswordBusy: true }));
|
state.update((s) => ({ ...s, setPasswordBusy: true }));
|
||||||
setPasswordStatus(t("wa_auth_sending_code"));
|
setPasswordStatus(t("wa_auth_sending_code"));
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
emailError,
|
emailError,
|
||||||
} from "../authHelpers.js";
|
} 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({
|
export function createAuthStore({
|
||||||
publicApi,
|
publicApi,
|
||||||
setToken,
|
setToken,
|
||||||
@@ -35,6 +39,80 @@ export function createAuthStore({
|
|||||||
let authResendTimer = null;
|
let authResendTimer = null;
|
||||||
let telegramLoginWatchdogTimer = 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) {
|
function setAuthStatus(message, isError = false) {
|
||||||
state.update((s) => ({ ...s, authStatus: message, authIsError: isError }));
|
state.update((s) => ({ ...s, authStatus: message, authIsError: isError }));
|
||||||
}
|
}
|
||||||
@@ -101,6 +179,7 @@ export function createAuthStore({
|
|||||||
const response = await publicApi("/auth/email/magic", payload);
|
const response = await publicApi("/auth/email/magic", payload);
|
||||||
if (response.ok && response.csrf_token) {
|
if (response.ok && response.csrf_token) {
|
||||||
setToken("", response.csrf_token);
|
setToken("", response.csrf_token);
|
||||||
|
clearPendingEmailCode();
|
||||||
clearAuthQuery();
|
clearAuthQuery();
|
||||||
await loadData();
|
await loadData();
|
||||||
return true;
|
return true;
|
||||||
@@ -131,6 +210,7 @@ export function createAuthStore({
|
|||||||
const response = await publicApi("/auth/token", payload, { signal: options.signal });
|
const response = await publicApi("/auth/token", payload, { signal: options.signal });
|
||||||
if (response.ok && response.csrf_token) {
|
if (response.ok && response.csrf_token) {
|
||||||
setToken("", response.csrf_token);
|
setToken("", response.csrf_token);
|
||||||
|
clearPendingEmailCode();
|
||||||
clearAuthQuery();
|
clearAuthQuery();
|
||||||
setAuthStatus("");
|
setAuthStatus("");
|
||||||
await loadData();
|
await loadData();
|
||||||
@@ -157,8 +237,15 @@ export function createAuthStore({
|
|||||||
|
|
||||||
async function requestEmailCode(changeScreen) {
|
async function requestEmailCode(changeScreen) {
|
||||||
const s = get(state);
|
const s = get(state);
|
||||||
if (s.authResendCooldown > 0 && s.pendingEmail) return;
|
|
||||||
const normalized = s.email.trim().toLowerCase();
|
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("@")) {
|
if (!normalized || !normalized.includes("@")) {
|
||||||
state.update((s) => ({
|
state.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
@@ -185,6 +272,7 @@ export function createAuthStore({
|
|||||||
.replace(/\D/g, "")
|
.replace(/\D/g, "")
|
||||||
.slice(0, 6);
|
.slice(0, 6);
|
||||||
state.update((s) => ({ ...s, pendingEmail: normalized, emailCode: presetCode }));
|
state.update((s) => ({ ...s, pendingEmail: normalized, emailCode: presetCode }));
|
||||||
|
writePendingEmailCodeSession(normalized);
|
||||||
changeScreen("code");
|
changeScreen("code");
|
||||||
setAuthStatus("");
|
setAuthStatus("");
|
||||||
startCooldownTimer(60);
|
startCooldownTimer(60);
|
||||||
@@ -226,6 +314,7 @@ export function createAuthStore({
|
|||||||
});
|
});
|
||||||
if (!response.ok || !response.csrf_token) throw response;
|
if (!response.ok || !response.csrf_token) throw response;
|
||||||
setToken("", response.csrf_token);
|
setToken("", response.csrf_token);
|
||||||
|
clearPendingEmailCode();
|
||||||
await loadData();
|
await loadData();
|
||||||
setAuthStatus("");
|
setAuthStatus("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -258,6 +347,7 @@ export function createAuthStore({
|
|||||||
const response = await publicApi("/auth/email/verify", payload);
|
const response = await publicApi("/auth/email/verify", payload);
|
||||||
if (!response.ok || !response.csrf_token) throw response;
|
if (!response.ok || !response.csrf_token) throw response;
|
||||||
setToken("", response.csrf_token);
|
setToken("", response.csrf_token);
|
||||||
|
clearPendingEmailCode();
|
||||||
await loadData();
|
await loadData();
|
||||||
setAuthStatus("");
|
setAuthStatus("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -335,6 +425,8 @@ export function createAuthStore({
|
|||||||
loginWithEmailPassword,
|
loginWithEmailPassword,
|
||||||
verifyEmailCode,
|
verifyEmailCode,
|
||||||
openTelegramLogin,
|
openTelegramLogin,
|
||||||
|
restorePendingEmailCode,
|
||||||
|
clearPendingEmailCode,
|
||||||
clearCooldownTimer,
|
clearCooldownTimer,
|
||||||
stopTelegramLoginWatchdog,
|
stopTelegramLoginWatchdog,
|
||||||
setAuthStatus,
|
setAuthStatus,
|
||||||
|
|||||||
@@ -2760,15 +2760,6 @@
|
|||||||
max-height: min(100%, 760px);
|
max-height: min(100%, 760px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-user-dialog .dialog-body-scroll {
|
|
||||||
margin-right: -10px;
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-dialog .dialog-body-scroll > .scroll-area__viewport {
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-dialog-body {
|
.admin-user-dialog-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
|||||||
+23
-5
@@ -374,8 +374,8 @@
|
|||||||
"traffic_warning_premium_depleted": "⛔️ The <b>premium</b> server quota for plan <b>{tariff_name}</b> is used up.\n\n<b>Current period:</b>\nused — <b>{used}</b>\navailable — <b>{remaining}</b>\nlimit — <b>{limit_total}</b>\n\nThis limit applies to:\n{servers}\n\nAccess may be limited until the period resets or you buy more premium traffic.\n\nUse the button below to top up.",
|
"traffic_warning_premium_depleted": "⛔️ The <b>premium</b> server quota for plan <b>{tariff_name}</b> is used up.\n\n<b>Current period:</b>\nused — <b>{used}</b>\navailable — <b>{remaining}</b>\nlimit — <b>{limit_total}</b>\n\nThis limit applies to:\n{servers}\n\nAccess may be limited until the period resets or you buy more premium traffic.\n\nUse the button below to top up.",
|
||||||
"traffic_warning_premium_generic_servers": "• servers with premium access under your plan",
|
"traffic_warning_premium_generic_servers": "• servers with premium access under your plan",
|
||||||
"traffic_warning_premium_servers_more": "… and {count} more servers",
|
"traffic_warning_premium_servers_more": "… and {count} more servers",
|
||||||
"traffic_warn_btn_topup_webapp_regular": "Top up traffic (mini app)",
|
"traffic_warn_btn_topup_webapp_regular": "Top up traffic",
|
||||||
"traffic_warn_btn_topup_webapp_premium": "Top up premium traffic (mini app)",
|
"traffic_warn_btn_topup_webapp_premium": "Top up premium traffic",
|
||||||
"traffic_warn_btn_topup_regular": "Top up traffic",
|
"traffic_warn_btn_topup_regular": "Top up traffic",
|
||||||
"traffic_warn_btn_topup_premium": "Top up premium traffic",
|
"traffic_warn_btn_topup_premium": "Top up premium traffic",
|
||||||
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
|
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
|
||||||
@@ -440,6 +440,13 @@
|
|||||||
"admin_user_referral_revenue_label": "💸 <b>Referral Revenue:</b>",
|
"admin_user_referral_revenue_label": "💸 <b>Referral Revenue:</b>",
|
||||||
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
|
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
|
||||||
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
|
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
|
||||||
|
"admin_user_invited_by_label": "🎁 <b>Invited by:</b>",
|
||||||
|
"admin_user_invitees_button": "👥 Invited users",
|
||||||
|
"admin_user_invitees_message_title": "👥 <b>Users invited by {user}</b>\n\nTotal: <b>{total}</b>. Page {current}/{total_pages}.",
|
||||||
|
"admin_user_invitees_empty": "This user has not invited anyone yet.",
|
||||||
|
"admin_user_invitee_item": "{index}. {user}{suffix}",
|
||||||
|
"admin_user_invitee_registered_suffix": " · registered {date}",
|
||||||
|
"admin_user_invitees_error": "❌ Failed to load invited users",
|
||||||
"admin_user_links_section_title": "🔗 <b>Links</b>",
|
"admin_user_links_section_title": "🔗 <b>Links</b>",
|
||||||
"admin_user_subscription_url_label": "📡 <b>Subscription:</b>",
|
"admin_user_subscription_url_label": "📡 <b>Subscription:</b>",
|
||||||
"admin_user_ref_bot_link_label": "🤖 <b>Referral link (bot):</b>",
|
"admin_user_ref_bot_link_label": "🤖 <b>Referral link (bot):</b>",
|
||||||
@@ -685,7 +692,7 @@
|
|||||||
"email_support_row_remaining": "Remaining",
|
"email_support_row_remaining": "Remaining",
|
||||||
"email_support_row_panel_status": "Panel status",
|
"email_support_row_panel_status": "Panel status",
|
||||||
"email_support_cta_open_ticket": "Open ticket",
|
"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_loading": "Loading...",
|
||||||
"wa_back": "Back",
|
"wa_back": "Back",
|
||||||
"wa_next": "Next",
|
"wa_next": "Next",
|
||||||
@@ -1247,6 +1254,14 @@
|
|||||||
"admin_user_section_profile": "Profile",
|
"admin_user_section_profile": "Profile",
|
||||||
"admin_user_label_registration": "Registration",
|
"admin_user_label_registration": "Registration",
|
||||||
"admin_user_label_ref_code": "Referral Code",
|
"admin_user_label_ref_code": "Referral Code",
|
||||||
|
"admin_user_label_invited_by": "Invited by",
|
||||||
|
"admin_user_label_invited_users": "Invited users",
|
||||||
|
"admin_user_invited_by_none": "—",
|
||||||
|
"admin_user_invitees_open": "Show",
|
||||||
|
"admin_user_open_related": "Open user card",
|
||||||
|
"admin_user_invitees_title": "Invited users",
|
||||||
|
"admin_user_invitees_description": "{name} · {count}",
|
||||||
|
"admin_user_col_user": "User",
|
||||||
"admin_user_section_links": "Links",
|
"admin_user_section_links": "Links",
|
||||||
"admin_user_copy_tooltip": "Copy",
|
"admin_user_copy_tooltip": "Copy",
|
||||||
"admin_user_sub_link_copied": "Subscription link copied",
|
"admin_user_sub_link_copied": "Subscription link copied",
|
||||||
@@ -1268,11 +1283,11 @@
|
|||||||
"user_premium_override_card_title": "Premium traffic",
|
"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_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_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",
|
"user_regular_override_status_unlimited": "Current: unlimited",
|
||||||
"regular_override_saved": "Main traffic override saved",
|
"regular_override_saved": "Main traffic override saved",
|
||||||
"user_traffic_override_title": "Traffic overrides",
|
"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": "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_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",
|
"user_regular_override_status_bonus": "Main traffic now: +{gb} GB",
|
||||||
@@ -1373,6 +1388,9 @@
|
|||||||
"admin_ads_col_registrations": "Registrations",
|
"admin_ads_col_registrations": "Registrations",
|
||||||
"admin_ads_col_conversions": "Conversions",
|
"admin_ads_col_conversions": "Conversions",
|
||||||
"admin_no_data": "No data",
|
"admin_no_data": "No data",
|
||||||
|
"admin_pagination_meta": "{current}/{total}",
|
||||||
|
"admin_prev_page": "Back",
|
||||||
|
"admin_next_page": "Next",
|
||||||
"admin_settings_hint": "Changes in the admin panel take precedence over .env. The 'Reset' button returns the value from environment variables.",
|
"admin_settings_hint": "Changes in the admin panel take precedence over .env. The 'Reset' button returns the value from environment variables.",
|
||||||
"admin_settings_legacy_tariffs_warning_title": "remnawave-tg-shop legacy compatibility",
|
"admin_settings_legacy_tariffs_warning_title": "remnawave-tg-shop legacy compatibility",
|
||||||
"admin_settings_legacy_tariffs_warning_body": "These fields are only for the old mode without a JSON catalog. They do not apply when tariffs are configured in the dedicated Tariffs section.",
|
"admin_settings_legacy_tariffs_warning_body": "These fields are only for the old mode without a JSON catalog. They do not apply when tariffs are configured in the dedicated Tariffs section.",
|
||||||
|
|||||||
+22
-4
@@ -374,8 +374,8 @@
|
|||||||
"traffic_warning_premium_depleted": "⛔️ Лимит премиум-серверов тарифа <b>{tariff_name}</b> израсходован.\n\n<b>Текущий период:</b>\nизрасходовано — <b>{used}</b>\nдоступно — <b>{remaining}</b>\nлимит — <b>{limit_total}</b>\n\nЛимит действует на:\n{servers}\n\nДоступ может быть ограничен до сброса периода или докупки премиум-трафика.\n\nДокупить можно через кнопку ниже.",
|
"traffic_warning_premium_depleted": "⛔️ Лимит премиум-серверов тарифа <b>{tariff_name}</b> израсходован.\n\n<b>Текущий период:</b>\nизрасходовано — <b>{used}</b>\nдоступно — <b>{remaining}</b>\nлимит — <b>{limit_total}</b>\n\nЛимит действует на:\n{servers}\n\nДоступ может быть ограничен до сброса периода или докупки премиум-трафика.\n\nДокупить можно через кнопку ниже.",
|
||||||
"traffic_warning_premium_generic_servers": "• серверы с премиум-доступом по вашему тарифу",
|
"traffic_warning_premium_generic_servers": "• серверы с премиум-доступом по вашему тарифу",
|
||||||
"traffic_warning_premium_servers_more": "… и ещё серверов: {count}",
|
"traffic_warning_premium_servers_more": "… и ещё серверов: {count}",
|
||||||
"traffic_warn_btn_topup_webapp_regular": "Докупить трафик (мини-приложение)",
|
"traffic_warn_btn_topup_webapp_regular": "Докупить трафик",
|
||||||
"traffic_warn_btn_topup_webapp_premium": "Докупить премиум-трафик (мини-приложение)",
|
"traffic_warn_btn_topup_webapp_premium": "Докупить премиум-трафик",
|
||||||
"traffic_warn_btn_topup_regular": "Докупить трафик",
|
"traffic_warn_btn_topup_regular": "Докупить трафик",
|
||||||
"traffic_warn_btn_topup_premium": "Докупить премиум-трафик",
|
"traffic_warn_btn_topup_premium": "Докупить премиум-трафик",
|
||||||
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
|
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
|
||||||
@@ -440,6 +440,13 @@
|
|||||||
"admin_user_referral_revenue_label": "💸 <b>Доход по рефералам:</b>",
|
"admin_user_referral_revenue_label": "💸 <b>Доход по рефералам:</b>",
|
||||||
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
|
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
|
||||||
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
|
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
|
||||||
|
"admin_user_invited_by_label": "🎁 <b>Пригласил:</b>",
|
||||||
|
"admin_user_invitees_button": "👥 Приглашённые",
|
||||||
|
"admin_user_invitees_message_title": "👥 <b>Пользователи, приглашённые {user}</b>\n\nВсего: <b>{total}</b>. Страница {current}/{total_pages}.",
|
||||||
|
"admin_user_invitees_empty": "Пользователь пока никого не пригласил.",
|
||||||
|
"admin_user_invitee_item": "{index}. {user}{suffix}",
|
||||||
|
"admin_user_invitee_registered_suffix": " · регистрация {date}",
|
||||||
|
"admin_user_invitees_error": "❌ Ошибка загрузки приглашённых пользователей",
|
||||||
"admin_user_links_section_title": "🔗 <b>Ссылки</b>",
|
"admin_user_links_section_title": "🔗 <b>Ссылки</b>",
|
||||||
"admin_user_subscription_url_label": "📡 <b>Подписка:</b>",
|
"admin_user_subscription_url_label": "📡 <b>Подписка:</b>",
|
||||||
"admin_user_ref_bot_link_label": "🤖 <b>Реф. ссылка (бот):</b>",
|
"admin_user_ref_bot_link_label": "🤖 <b>Реф. ссылка (бот):</b>",
|
||||||
@@ -685,7 +692,7 @@
|
|||||||
"email_support_row_remaining": "Осталось",
|
"email_support_row_remaining": "Осталось",
|
||||||
"email_support_row_panel_status": "Статус в панели",
|
"email_support_row_panel_status": "Статус в панели",
|
||||||
"email_support_cta_open_ticket": "Открыть тикет",
|
"email_support_cta_open_ticket": "Открыть тикет",
|
||||||
"email_support_cta_open_mini_app": "Открыть в Mini App",
|
"email_support_cta_open_mini_app": "Открыть обращение",
|
||||||
"wa_loading": "Загрузка...",
|
"wa_loading": "Загрузка...",
|
||||||
"wa_back": "Назад",
|
"wa_back": "Назад",
|
||||||
"wa_next": "Далее",
|
"wa_next": "Далее",
|
||||||
@@ -1247,6 +1254,14 @@
|
|||||||
"admin_user_section_profile": "Профиль",
|
"admin_user_section_profile": "Профиль",
|
||||||
"admin_user_label_registration": "Регистрация",
|
"admin_user_label_registration": "Регистрация",
|
||||||
"admin_user_label_ref_code": "Реф. код",
|
"admin_user_label_ref_code": "Реф. код",
|
||||||
|
"admin_user_label_invited_by": "Пригласил",
|
||||||
|
"admin_user_label_invited_users": "Приглашённые",
|
||||||
|
"admin_user_invited_by_none": "—",
|
||||||
|
"admin_user_invitees_open": "Показать",
|
||||||
|
"admin_user_open_related": "Открыть карточку",
|
||||||
|
"admin_user_invitees_title": "Приглашённые пользователи",
|
||||||
|
"admin_user_invitees_description": "{name} · {count}",
|
||||||
|
"admin_user_col_user": "Пользователь",
|
||||||
"admin_user_section_links": "Ссылки",
|
"admin_user_section_links": "Ссылки",
|
||||||
"admin_user_copy_tooltip": "Скопировать",
|
"admin_user_copy_tooltip": "Скопировать",
|
||||||
"admin_user_sub_link_copied": "Ссылка на подписку скопирована",
|
"admin_user_sub_link_copied": "Ссылка на подписку скопирована",
|
||||||
@@ -1272,7 +1287,7 @@
|
|||||||
"user_regular_override_status_unlimited": "Сейчас: безлимит",
|
"user_regular_override_status_unlimited": "Сейчас: безлимит",
|
||||||
"regular_override_saved": "Оверрайд основного трафика сохранён",
|
"regular_override_saved": "Оверрайд основного трафика сохранён",
|
||||||
"user_traffic_override_title": "Оверрайд трафика",
|
"user_traffic_override_title": "Оверрайд трафика",
|
||||||
"user_traffic_override_hint": "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
|
"user_traffic_override_hint": "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.",
|
||||||
"user_regular_override_bonus": "Доп. основной трафик, GB",
|
"user_regular_override_bonus": "Доп. основной трафик, GB",
|
||||||
"user_regular_override_bonus_hint": "Постоянный бонус к лимиту основного трафика (не то же самое, что докупка ГБ).",
|
"user_regular_override_bonus_hint": "Постоянный бонус к лимиту основного трафика (не то же самое, что докупка ГБ).",
|
||||||
"user_regular_override_status_bonus": "Основной сейчас: +{gb} GB",
|
"user_regular_override_status_bonus": "Основной сейчас: +{gb} GB",
|
||||||
@@ -1373,6 +1388,9 @@
|
|||||||
"admin_ads_col_registrations": "Регистрации",
|
"admin_ads_col_registrations": "Регистрации",
|
||||||
"admin_ads_col_conversions": "Конверсии",
|
"admin_ads_col_conversions": "Конверсии",
|
||||||
"admin_no_data": "Нет данных",
|
"admin_no_data": "Нет данных",
|
||||||
|
"admin_pagination_meta": "{current}/{total}",
|
||||||
|
"admin_prev_page": "Назад",
|
||||||
|
"admin_next_page": "Вперёд",
|
||||||
"admin_settings_hint": "Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения.",
|
"admin_settings_hint": "Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения.",
|
||||||
"admin_settings_legacy_tariffs_warning_title": "Совместимость с remnawave-tg-shop legacy",
|
"admin_settings_legacy_tariffs_warning_title": "Совместимость с remnawave-tg-shop legacy",
|
||||||
"admin_settings_legacy_tariffs_warning_body": "Эти поля нужны только для старого режима без JSON-каталога. Если тарифы настроены через отдельный раздел «Тарифы», они не применяются.",
|
"admin_settings_legacy_tariffs_warning_body": "Эти поля нужны только для старого режима без JSON-каталога. Если тарифы настроены через отдельный раздел «Тарифы», они не применяются.",
|
||||||
|
|||||||
@@ -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, 0))
|
||||||
self.assertIsNone(await service.admin_grant_topup(AsyncMock(), 1, -10))
|
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):
|
async def test_premium_grant_clears_limited_state_when_balance_covers_overuse(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
settings = _make_settings(_tariffs_config_payload(premium=True), tmpdir)
|
settings = _make_settings(_tariffs_config_payload(premium=True), tmpdir)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -46,6 +46,23 @@ class MiniAppUrlTests(unittest.TestCase):
|
|||||||
subscription_mini_app_topup_url(s, "regular"),
|
subscription_mini_app_topup_url(s, "regular"),
|
||||||
"https://app.example.com/webapp?topup=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):
|
def test_subscription_mini_app_path_url(self):
|
||||||
s = Settings(
|
s = Settings(
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ class SubscriptionServiceCalculationTests(unittest.TestCase):
|
|||||||
["fallback-a", "fallback-b"],
|
["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:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
settings = _make_settings(_tariffs_config_payload(), tmpdir)
|
settings = _make_settings(_tariffs_config_payload(), tmpdir)
|
||||||
service = _make_service(settings)
|
service = _make_service(settings)
|
||||||
@@ -117,7 +117,7 @@ class SubscriptionServiceCalculationTests(unittest.TestCase):
|
|||||||
regular_unlimited_override=True,
|
regular_unlimited_override=True,
|
||||||
traffic_used_bytes=2 * (1024**5),
|
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):
|
def test_premium_effective_limit_ignores_negative_balances(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def test_support_ticket_closed_email_uses_user_language():
|
|||||||
assert '<html lang="ru"' in content.html
|
assert '<html lang="ru"' in content.html
|
||||||
assert "Ticket #7 was closed" not in content.text
|
assert "Ticket #7 was closed" not in content.text
|
||||||
assert "Тема: Проблема с подключением" in content.text
|
assert "Тема: Проблема с подключением" in content.text
|
||||||
assert "Открыть в Mini App" in content.html
|
assert "Открыть обращение" in content.html
|
||||||
|
|
||||||
|
|
||||||
def test_support_admin_email_localizes_snapshot_rows_for_recipient():
|
def test_support_admin_email_localizes_snapshot_rows_for_recipient():
|
||||||
|
|||||||
@@ -34,6 +34,31 @@ def _tariffs_config_payload() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
class TariffWorkerTests(unittest.IsolatedAsyncioTestCase):
|
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):
|
async def test_db_tick_retries_deadlock_once(self):
|
||||||
class FakeSession:
|
class FakeSession:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class FakeResult:
|
|||||||
def scalar_one_or_none(self):
|
def scalar_one_or_none(self):
|
||||||
return self._scalar_value
|
return self._scalar_value
|
||||||
|
|
||||||
|
def scalar_one(self):
|
||||||
|
return self._scalar_value
|
||||||
|
|
||||||
def scalars(self):
|
def scalars(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -71,6 +74,53 @@ class UserDalStatisticsTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIn("TRIAL", sql)
|
self.assertIn("TRIAL", sql)
|
||||||
|
|
||||||
|
|
||||||
|
class UserDalReferralTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_get_users_referred_by_filters_with_pagination(self):
|
||||||
|
invited = [SimpleNamespace(user_id=1001), SimpleNamespace(user_id=1002)]
|
||||||
|
session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(invited)))
|
||||||
|
|
||||||
|
result = await user_dal.get_users_referred_by(session, 42, limit=2, offset=10)
|
||||||
|
|
||||||
|
self.assertEqual(result, invited)
|
||||||
|
stmt = session.execute.await_args.args[0]
|
||||||
|
sql = str(
|
||||||
|
stmt.compile(
|
||||||
|
dialect=postgresql.dialect(),
|
||||||
|
compile_kwargs={"literal_binds": True},
|
||||||
|
)
|
||||||
|
).upper()
|
||||||
|
self.assertIn("REFERRED_BY_ID = 42", sql)
|
||||||
|
self.assertIn("ORDER BY", sql)
|
||||||
|
self.assertIn("LIMIT 2", sql)
|
||||||
|
self.assertIn("OFFSET 10", sql)
|
||||||
|
|
||||||
|
async def test_count_users_referred_by_counts_matching_rows(self):
|
||||||
|
session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(3)))
|
||||||
|
|
||||||
|
result = await user_dal.count_users_referred_by(session, 42)
|
||||||
|
|
||||||
|
self.assertEqual(result, 3)
|
||||||
|
stmt = session.execute.await_args.args[0]
|
||||||
|
sql = str(
|
||||||
|
stmt.compile(
|
||||||
|
dialect=postgresql.dialect(),
|
||||||
|
compile_kwargs={"literal_binds": True},
|
||||||
|
)
|
||||||
|
).upper()
|
||||||
|
self.assertIn("COUNT", sql)
|
||||||
|
self.assertIn("REFERRED_BY_ID = 42", sql)
|
||||||
|
|
||||||
|
async def test_get_referrer_for_user_uses_referred_by_id(self):
|
||||||
|
referrer = SimpleNamespace(user_id=7)
|
||||||
|
session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(referrer)))
|
||||||
|
|
||||||
|
result = await user_dal.get_referrer_for_user(
|
||||||
|
session, SimpleNamespace(user_id=42, referred_by_id=7)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(result, referrer)
|
||||||
|
|
||||||
|
|
||||||
class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
|
class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self):
|
async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self):
|
||||||
user = SimpleNamespace(user_id=42, panel_user_uuid="panel-main")
|
user = SimpleNamespace(user_id=42, panel_user_uuid="panel-main")
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ class WebAppRouteContractTests(unittest.TestCase):
|
|||||||
("GET", "/api/admin/stats"): "admin_stats_route",
|
("GET", "/api/admin/stats"): "admin_stats_route",
|
||||||
("GET", "/api/admin/users"): "admin_users_list_route",
|
("GET", "/api/admin/users"): "admin_users_list_route",
|
||||||
("GET", "/api/admin/users/{user_id}"): "admin_user_detail_route",
|
("GET", "/api/admin/users/{user_id}"): "admin_user_detail_route",
|
||||||
|
("GET", "/api/admin/users/{user_id}/referrals"): "admin_user_referrals_route",
|
||||||
("GET", "/api/admin/users/{user_id}/avatar"): "admin_user_avatar_route",
|
("GET", "/api/admin/users/{user_id}/avatar"): "admin_user_avatar_route",
|
||||||
("POST", "/api/admin/users/{user_id}/ban"): "admin_user_ban_route",
|
("POST", "/api/admin/users/{user_id}/ban"): "admin_user_ban_route",
|
||||||
("POST", "/api/admin/users/{user_id}/message"): "admin_user_message_route",
|
("POST", "/api/admin/users/{user_id}/message"): "admin_user_message_route",
|
||||||
|
|||||||
Reference in New Issue
Block a user