diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index f578cbd..31587c4 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -1172,10 +1172,6 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response: actor_id = _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) settings: Settings = request.app["settings"] - panel_service = request.app.get("panel_service") - subscription_service = request.app.get("subscription_service") - if panel_service is None or subscription_service is None: - return _error(503, "service_unavailable") async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: @@ -1183,16 +1179,17 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response: if not user: return _error(404, "not_found") - active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) - if active: - await session.delete(active) + reset_at = await user_dal.mark_trial_eligibility_reset(session, target_id) + if reset_at is None: + await session.rollback() + return _error(404, "not_found") - await message_log_dal.create_message_log( + await message_log_dal.create_message_log_no_commit( session, { "user_id": actor_id, "event_type": "admin_reset_trial_webapp", - "content": f"Reset trial for user_id={target_id}", + "content": f"Reset trial eligibility for user_id={target_id}", "is_admin_event": True, "target_user_id": target_id, }, diff --git a/backend/bot/app/web/templates/open_app_gateway.html b/backend/bot/app/web/templates/open_app_gateway.html index 5bc02c9..287e051 100644 --- a/backend/bot/app/web/templates/open_app_gateway.html +++ b/backend/bot/app/web/templates/open_app_gateway.html @@ -109,6 +109,8 @@ let attempted = false; let pageLeft = false; let state = "opening"; + let closeAttemptTimer = null; + const CLOSE_ATTEMPT_DELAY_MS = 2500; function hasControlChars(value) { return Array.from(String(value || "")).some((char) => { @@ -167,7 +169,10 @@ function markDone() { if (state === "done" || isUnsafe) return; render("done"); - window.setTimeout(tryCloseWindow, 120); + if (closeAttemptTimer) window.clearTimeout(closeAttemptTimer); + closeAttemptTimer = window.setTimeout(() => { + if (pageLeft || document.hidden) tryCloseWindow(); + }, CLOSE_ATTEMPT_DELAY_MS); } function notePageLeft() { @@ -201,7 +206,6 @@ render("done"); }); window.addEventListener("pagehide", notePageLeft); - window.addEventListener("blur", notePageLeft); document.addEventListener("visibilitychange", () => { if (!attempted) return; if (document.hidden) { diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index b08a98d..075daed 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -67,7 +67,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A trial_available = bool( settings.TRIAL_ENABLED and settings.TRIAL_DURATION_DAYS > 0 - and not await subscription_service.has_had_any_subscription(session, user_id) + and not await subscription_service.has_trial_blocking_subscription(session, user_id) ) avatar = await _ensure_cached_telegram_avatar(request, session, db_user) try: diff --git a/backend/bot/handlers/admin/user_management.py b/backend/bot/handlers/admin/user_management.py index cc29674..de463bf 100644 --- a/backend/bot/handlers/admin/user_management.py +++ b/backend/bot/handlers/admin/user_management.py @@ -932,8 +932,7 @@ async def handle_reset_trial( _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) try: - # Delete all user subscriptions to reset trial eligibility - await subscription_dal.delete_all_user_subscriptions(session, user.user_id) + await user_dal.mark_trial_eligibility_reset(session, user.user_id) await session.commit() await callback.answer(_("admin_user_trial_reset_success"), show_alert=True) diff --git a/backend/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py index 9db11a5..44cc598 100644 --- a/backend/bot/handlers/user/start.py +++ b/backend/bot/handlers/user/start.py @@ -24,6 +24,10 @@ from bot.services.referral_service import ReferralService from bot.services.subscription_service import SubscriptionService from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED from bot.utils.callback_answer import safe_answer_callback +from bot.utils.channel_subscription import ( + is_required_channel_access_error, + normalize_required_channel_id, +) from bot.utils.install_links import ( append_install_share_link_text, ensure_user_install_guide_links, @@ -45,12 +49,12 @@ async def should_show_trial_button( if not settings.TRIAL_ENABLED: return False - if hasattr(subscription_service, "has_had_any_subscription") and callable( - getattr(subscription_service, "has_had_any_subscription") + if hasattr(subscription_service, "has_trial_blocking_subscription") and callable( + getattr(subscription_service, "has_trial_blocking_subscription") ): - return not await subscription_service.has_had_any_subscription(session, user_id) + return not await subscription_service.has_trial_blocking_subscription(session, user_id) - logging.error("Method has_had_any_subscription is missing in SubscriptionService!") + logging.error("Method has_trial_blocking_subscription is missing in SubscriptionService!") return False @@ -215,7 +219,7 @@ async def ensure_required_channel_subscription( Verify that the user is a member of the required channel (if configured). Returns True when access can proceed, False when user must subscribe first. """ - required_channel_id = settings.REQUIRED_CHANNEL_ID + required_channel_id = normalize_required_channel_id(settings.REQUIRED_CHANNEL_ID) if not required_channel_id: return True @@ -279,6 +283,29 @@ async def ensure_required_channel_subscription( if status_value in allowed_statuses: is_member = True except TelegramBadRequest as bad_request: + if is_required_channel_access_error(bad_request): + logging.error( + "Required channel check failed due to channel access/configuration error " + "(configured=%s, normalized=%s): %s", + settings.REQUIRED_CHANNEL_ID, + required_channel_id, + bad_request, + ) + error_text = translate("channel_subscription_check_failed") + if isinstance(event, types.CallbackQuery): + try: + await event.answer(error_text, show_alert=True) + except Exception: + pass + if message_obj: + try: + await message_obj.answer(error_text) + except Exception: + pass + else: + await event.answer(error_text) + return False + logging.info( "Required channel check: user %s not subscribed (details: %s)", user_id, diff --git a/backend/bot/handlers/user/trial_handler.py b/backend/bot/handlers/user/trial_handler.py index dd99177..767a9ef 100644 --- a/backend/bot/handlers/user/trial_handler.py +++ b/backend/bot/handlers/user/trial_handler.py @@ -46,7 +46,7 @@ async def request_trial_confirmation_handler( return if settings.TRIAL_ENABLED: - if not await subscription_service.has_had_any_subscription(session, user_id): + if not await subscription_service.has_trial_blocking_subscription(session, user_id): pass if not settings.TRIAL_ENABLED: @@ -60,7 +60,7 @@ async def request_trial_confirmation_handler( pass return - if await subscription_service.has_had_any_subscription(session, user_id): + if await subscription_service.has_trial_blocking_subscription(session, user_id): await callback.message.edit_text( _("trial_already_had_subscription_or_trial"), reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False), @@ -147,8 +147,9 @@ async def request_trial_confirmation_handler( await callback.answer(final_message_text_in_chat, show_alert=True) except Exception: pass - if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription( - session, user_id + if ( + settings.TRIAL_ENABLED + and not await subscription_service.has_trial_blocking_subscription(session, user_id) ): show_trial_button_after_action = True @@ -218,7 +219,7 @@ async def confirm_activate_trial_handler( callback, settings, i18n_data, subscription_service, session, is_edit=True ) return - if await subscription_service.has_had_any_subscription(session, user_id): + if await subscription_service.has_trial_blocking_subscription(session, user_id): try: await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True) except Exception: @@ -283,8 +284,9 @@ async def confirm_activate_trial_handler( await callback.answer(final_message_text_in_chat, show_alert=True) except Exception: pass - if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription( - session, user_id + if ( + settings.TRIAL_ENABLED + and not await subscription_service.has_trial_blocking_subscription(session, user_id) ): show_trial_button_after_action = True diff --git a/backend/bot/middlewares/channel_subscription.py b/backend/bot/middlewares/channel_subscription.py index d6d52cc..0c760ce 100644 --- a/backend/bot/middlewares/channel_subscription.py +++ b/backend/bot/middlewares/channel_subscription.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard from bot.middlewares.i18n import JsonI18n +from bot.utils.channel_subscription import normalize_required_channel_id from config.settings import Settings from db.dal import user_dal @@ -32,7 +33,7 @@ class ChannelSubscriptionMiddleware(BaseMiddleware): event: Update, data: Dict[str, Any], ) -> Any: - required_channel_id = self.settings.REQUIRED_CHANNEL_ID + required_channel_id = normalize_required_channel_id(self.settings.REQUIRED_CHANNEL_ID) if not required_channel_id: return await handler(event, data) diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py index 2e48814..012c4c4 100644 --- a/backend/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -62,6 +62,9 @@ class PaymentContextMixin: async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool: return await subscription_dal.has_any_subscription_for_user(session, user_id) + async def has_trial_blocking_subscription(self, session: AsyncSession, user_id: int) -> bool: + return await subscription_dal.has_trial_blocking_subscription_for_user(session, user_id) + async def has_active_subscription(self, session: AsyncSession, user_id: int) -> bool: """Return True if user currently has an active subscription (end_date in future).""" try: diff --git a/backend/bot/services/subscription_service_impl/trial.py b/backend/bot/services/subscription_service_impl/trial.py index 636e042..a963a85 100644 --- a/backend/bot/services/subscription_service_impl/trial.py +++ b/backend/bot/services/subscription_service_impl/trial.py @@ -22,7 +22,7 @@ class TrialSubscriptionMixin: "message_key": "user_not_found_for_trial", } - if await self.has_had_any_subscription(session, user_id): + if await self.has_trial_blocking_subscription(session, user_id): return { "eligible": False, "activated": False, diff --git a/backend/bot/utils/channel_subscription.py b/backend/bot/utils/channel_subscription.py new file mode 100644 index 0000000..72ba65c --- /dev/null +++ b/backend/bot/utils/channel_subscription.py @@ -0,0 +1,40 @@ +from typing import Optional + + +def normalize_required_channel_id(value: object) -> Optional[int]: + if value is None: + return None + + raw = str(value).strip() + if not raw: + return None + + try: + channel_id = int(raw) + except (TypeError, ValueError): + return None + + if channel_id == 0: + return None + + if channel_id > 0: + return int(f"-100{channel_id}") + + raw_abs = str(abs(channel_id)) + if raw.startswith("-100"): + return channel_id + if abs(channel_id) < 1_000_000_000: + return channel_id + return -int(f"100{raw_abs}") + + +def is_required_channel_access_error(error: BaseException) -> bool: + message = str(error).lower() + configuration_markers = ( + "chat not found", + "bot is not a member", + "not enough rights", + "have no rights", + "kicked", + ) + return any(marker in message for marker in configuration_markers) diff --git a/backend/config/settings.py b/backend/config/settings.py index 23bbfd6..bd09406 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -1018,6 +1018,7 @@ class Settings(BaseSettings): "LOG_SUPPORT_THREAD_ID", "BACKUP_CHAT_ID", "BACKUP_THREAD_ID", + "REQUIRED_CHANNEL_ID", mode="before", ) @classmethod diff --git a/backend/db/dal/subscription_dal.py b/backend/db/dal/subscription_dal.py index a59d900..d81a723 100644 --- a/backend/db/dal/subscription_dal.py +++ b/backend/db/dal/subscription_dal.py @@ -4,12 +4,12 @@ import secrets from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional -from sqlalchemy import delete, func, or_, update +from sqlalchemy import and_, delete, func, or_, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload -from db.models import Subscription, SubscriptionNotification +from db.models import Subscription, SubscriptionNotification, User INSTALL_SHARE_TOKEN_BYTES = 16 @@ -252,7 +252,7 @@ async def deactivate_all_user_subscriptions(session: AsyncSession, user_id: int) async def delete_all_user_subscriptions(session: AsyncSession, user_id: int) -> int: - """Completely delete all user subscriptions (for trial reset)""" + """Completely delete all user subscriptions.""" stmt = delete(Subscription).where(Subscription.user_id == user_id) result = await session.execute(stmt) if result.rowcount > 0: @@ -284,6 +284,28 @@ async def has_any_subscription_for_user(session: AsyncSession, user_id: int) -> return result.scalar_one_or_none() is not None +async def has_trial_blocking_subscription_for_user(session: AsyncSession, user_id: int) -> bool: + now_utc = datetime.now(timezone.utc) + reset_at = ( + select(User.trial_eligibility_reset_at).where(User.user_id == user_id).scalar_subquery() + ) + subscription_anchor = func.coalesce(Subscription.start_date, Subscription.end_date) + stmt = ( + select(Subscription.subscription_id) + .where( + Subscription.user_id == user_id, + or_( + reset_at.is_(None), + and_(Subscription.is_active == True, Subscription.end_date > now_utc), + subscription_anchor > reset_at, + ), + ) + .limit(1) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() is not None + + async def get_subscriptions_near_expiration( session: AsyncSession, days_threshold: int ) -> List[Subscription]: diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index 4d0308b..6a3c1af 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -261,6 +261,20 @@ async def create_email_user( ) +async def mark_trial_eligibility_reset( + session: AsyncSession, + user_id: int, + *, + reset_at: Optional[datetime] = None, +) -> Optional[datetime]: + reset_at = reset_at or datetime.now(timezone.utc) + stmt = update(User).where(User.user_id == user_id).values(trial_eligibility_reset_at=reset_at) + result = await session.execute(stmt) + if result.rowcount <= 0: + return None + return reset_at + + async def _has_active_panel_subscription( session: AsyncSession, user_id: int, panel_user_uuid: str ) -> bool: diff --git a/backend/db/migrator.py b/backend/db/migrator.py index c09e732..73b3e3c 100644 --- a/backend/db/migrator.py +++ b/backend/db/migrator.py @@ -1061,6 +1061,15 @@ def _migration_0032_add_telegram_notification_status(connection: Connection) -> connection.execute(text(f"ALTER TABLE users ADD COLUMN {column} {ddl_type}")) +def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) -> None: + inspector = inspect(connection) + columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} + if "trial_eligibility_reset_at" not in columns: + connection.execute( + text("ALTER TABLE users ADD COLUMN trial_eligibility_reset_at TIMESTAMPTZ") + ) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -1233,6 +1242,11 @@ MIGRATIONS: List[Migration] = [ description="Track whether the bot can message Telegram-linked users", upgrade=_migration_0032_add_telegram_notification_status, ), + Migration( + id="0033_add_trial_eligibility_reset_marker", + description="Track admin resets of per-user trial eligibility without deleting history", + upgrade=_migration_0033_add_trial_eligibility_reset_marker, + ), ] diff --git a/backend/db/models.py b/backend/db/models.py index 209abde..78bb8c3 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -47,6 +47,7 @@ class User(Base): referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True) lifetime_used_traffic_bytes = Column(BigInteger, nullable=True) lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True) + trial_eligibility_reset_at = Column(DateTime(timezone=True), nullable=True) channel_subscription_verified = Column(Boolean, nullable=True) channel_subscription_checked_at = Column(DateTime(timezone=True), nullable=True) channel_subscription_verified_for = Column(BigInteger, nullable=True) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index c71616c..6c0eb53 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -183,6 +183,7 @@ let languageClickGuardArmed = false; let languageClickGuardTimer = null; let languageClickGuardArmTimer = null; + let guestLanguage = ""; let emailAvatarUrl = ""; let avatarHashToken = ""; let token = MOCK ? "local-preview" : ""; @@ -213,12 +214,13 @@ const i18n = createI18n({ messages: I18N, defaultLang: "ru", - getLang: () => user?.language_code || CFG.language || "ru", + getLang: () => user?.language_code || guestLanguage || CFG.language || "ru", }); const normalizeLangCode = i18n.normalizeLangCode; const t = i18n.t; const termUnitLabel = i18n.termUnitLabel; const languageName = i18n.languageName; + guestLanguage = normalizeLangCode(CFG.language || "ru"); const apiClient = createApiClient({ apiBase: CFG.apiBase, csrfCookieName: CSRF_COOKIE_NAME, @@ -460,7 +462,7 @@ activeTab = "settings"; } $: referral = data?.referral || MOCK_SOURCE.data.referral; - $: currentLang = normalizeLangCode(user?.language_code || CFG.language || "ru"); + $: currentLang = normalizeLangCode(user?.language_code || guestLanguage || CFG.language || "ru"); $: languageCodes = uniqueLanguageCodes( WEBAPP_LANGUAGE_ORDER, CFG.languages, @@ -939,6 +941,13 @@ }, 260); } + function updateGuestLanguage(nextValue) { + const language = normalizeLangCode(nextValue); + setLanguageMenuOpen(false); + if (!language || language === currentLang) return; + guestLanguage = language; + } + function readTelegramMiniAppInitDataFromLocation() { return telegramSdk.readInitDataFromLocation(); } @@ -2212,7 +2221,15 @@ {telegramLoginUnavailableMessage} {privacyPolicyUrl} {userAgreementUrl} + {currentLang} + {currentLanguageOption} + {languageOptions} + {languageMenuOpen} + {languageClickGuard} + {languageClickGuardArmed} {t} + {setLanguageMenuOpen} + updateLoginLanguage={updateGuestLanguage} requestEmailCode={() => authStore.requestEmailCode((s) => (screen = s))} loginWithEmailPassword={authStore.loginWithEmailPassword} verifyEmailCode={authStore.verifyEmailCode} diff --git a/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte index bd47e25..c37f59e 100644 --- a/frontend/src/admin/AdminPanel.svelte +++ b/frontend/src/admin/AdminPanel.svelte @@ -228,6 +228,7 @@ let adminLanguageClickGuardArmed = false; let adminLanguageClickGuardTimer = null; let adminLanguageClickGuardArmTimer = null; + $: adminLanguageGuardActive = isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard); function readReduceMotion() { return ( @@ -295,6 +296,8 @@ } function changeLanguage(value) { + adminLanguageMenuOpen = false; + clearAdminLanguageClickGuard(); onLanguageChange(value, { section: "admin", adminSection: active }); } @@ -472,8 +475,6 @@ function setAdminLanguageMenuOpen(open) { adminLanguageMenuOpen = Boolean(open); clearAdminLanguageClickGuard(); - // Desktop doesn't need the click-guard overlay and it can block - // option clicks in portaled select content. if (!isCompact) return; if (adminLanguageMenuOpen) { adminLanguageClickGuard = true; @@ -557,7 +558,11 @@ } -
+
{#if sidebarOpen} {/if} - {#if isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard)} + {#if adminLanguageGuardActive} {/if} -
diff --git a/frontend/src/webapp/screens/AppLaunchScreen.svelte b/frontend/src/webapp/screens/AppLaunchScreen.svelte index d996fb7..ea7c286 100644 --- a/frontend/src/webapp/screens/AppLaunchScreen.svelte +++ b/frontend/src/webapp/screens/AppLaunchScreen.svelte @@ -6,7 +6,7 @@ const AUTO_OPEN_DELAY_MS = 80; const MANUAL_STATE_DELAY_MS = 1600; const DONE_STATE_DELAY_MS = 900; - const CLOSE_ATTEMPT_DELAY_MS = 120; + const CLOSE_ATTEMPT_DELAY_MS = 2500; export let brand = {}; export let appLaunchTarget = ""; @@ -54,7 +54,6 @@ autoOpenTimer = window.setTimeout(openTarget, AUTO_OPEN_DELAY_MS); window.addEventListener("pagehide", notePageLeft); - window.addEventListener("blur", notePageLeft); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { @@ -63,7 +62,6 @@ clearTimer(doneStateTimer); clearTimer(closeAttemptTimer); window.removeEventListener("pagehide", notePageLeft); - window.removeEventListener("blur", notePageLeft); document.removeEventListener("visibilitychange", handleVisibilityChange); }; }); @@ -91,7 +89,9 @@ if (!attempted || state === "done" || !activeTarget) return; state = "done"; clearTimer(closeAttemptTimer); - closeAttemptTimer = window.setTimeout(tryCloseWindow, CLOSE_ATTEMPT_DELAY_MS); + closeAttemptTimer = window.setTimeout(() => { + if (pageLeft || document.hidden) tryCloseWindow(); + }, CLOSE_ATTEMPT_DELAY_MS); } function notePageLeft() { diff --git a/frontend/src/webapp/screens/HomeScreen.svelte b/frontend/src/webapp/screens/HomeScreen.svelte index 334a595..429ee1f 100644 --- a/frontend/src/webapp/screens/HomeScreen.svelte +++ b/frontend/src/webapp/screens/HomeScreen.svelte @@ -343,7 +343,7 @@ {/if}