From 56796d9f22f0f4305c83a8fab6bc4a73fd898b56 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 23:37:59 +0300 Subject: [PATCH] feat(admin): add never-subscribed broadcast target with audience counts Add a broadcast audience for users who registered but never had any subscription or trial (no Subscription rows at all), backed by a new get_user_ids_without_any_subscription DAL helper and a 'never' target in the webapp broadcast route. Add GET /api/admin/broadcast/audience-counts so the audience dropdown shows the recipient count next to each option, with graceful fallback when counts are unavailable. --- .../bot/app/web/admin_api_impl/broadcast.py | 21 ++++++++++++++++- backend/bot/app/web/admin_api_impl/routes.py | 1 + backend/db/dal/user_dal.py | 23 +++++++++++++++++++ .../admin/sections/BroadcastSection.svelte | 17 +++++++++++--- .../src/lib/admin/stores/broadcastStore.js | 17 ++++++++++++++ frontend/src/lib/webapp/mockApi.js | 6 +++++ locales/en.json | 1 + locales/ru.json | 1 + tests/test_webapp_route_contract.py | 1 + 9 files changed, 84 insertions(+), 4 deletions(-) diff --git a/backend/bot/app/web/admin_api_impl/broadcast.py b/backend/bot/app/web/admin_api_impl/broadcast.py index b19bc0a..7daae95 100644 --- a/backend/bot/app/web/admin_api_impl/broadcast.py +++ b/backend/bot/app/web/admin_api_impl/broadcast.py @@ -9,7 +9,7 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: target = str(payload.get("target") or "all").strip().lower() if not text: return _error(400, "empty_text") - if target not in {"all", "active", "inactive", "expired"}: + if target not in {"all", "active", "inactive", "expired", "never"}: target = "all" queue_manager = get_queue_manager() @@ -24,6 +24,8 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: user_ids = await user_dal.get_user_ids_without_active_subscription(session) elif target == "expired": user_ids = await user_dal.get_user_ids_with_expired_subscription(session) + elif target == "never": + user_ids = await user_dal.get_user_ids_without_any_subscription(session) else: user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) @@ -54,3 +56,20 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: ) return _ok({"queued": sent, "failed": failed, "target": target}) + + +async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response: + """Return how many users each broadcast audience currently resolves to.""" + _require_admin_user_id(request) + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + counts = { + "all": len(await user_dal.get_all_active_user_ids_for_broadcast(session)), + "active": len(await user_dal.get_user_ids_with_active_subscription(session)), + "inactive": len(await user_dal.get_user_ids_without_active_subscription(session)), + "expired": len(await user_dal.get_user_ids_with_expired_subscription(session)), + "never": len(await user_dal.get_user_ids_without_any_subscription(session)), + } + + return _ok({"counts": counts}) diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py index 6b0a10c..a7e418c 100644 --- a/backend/bot/app/web/admin_api_impl/routes.py +++ b/backend/bot/app/web/admin_api_impl/routes.py @@ -57,6 +57,7 @@ def setup_admin_routes(app: web.Application) -> None: router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route) router.add_get("/api/admin/support/stats", admin_support_stats_route) + router.add_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route) router.add_post("/api/admin/broadcast", admin_broadcast_route) router.add_post("/api/admin/sync", admin_sync_route) diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index 6a3c1af..bc58eef 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -857,6 +857,29 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis return result.scalars().all() +async def get_user_ids_without_any_subscription(session: AsyncSession) -> List[int]: + """Return non-banned user IDs who never had any subscription or trial. + + These are users who registered but have no ``Subscription`` rows at all — + no active, no expired and no trial history. In other words, accounts that + signed up and never did anything. + """ + any_sub = aliased(Subscription) + + stmt = ( + select(User.user_id) + .outerjoin(any_sub, any_sub.user_id == User.user_id) + .where( + and_( + User.is_banned == False, + any_sub.user_id.is_(None), + ) + ) + ) + result = await session.execute(stmt) + return result.scalars().all() + + def _expired_subscription_exists_for_user(now: datetime): expired_subs = aliased(Subscription) normalized_status = func.lower(func.coalesce(expired_subs.status_from_panel, "")) diff --git a/frontend/src/admin/sections/BroadcastSection.svelte b/frontend/src/admin/sections/BroadcastSection.svelte index 5c6c79d..849bac0 100644 --- a/frontend/src/admin/sections/BroadcastSection.svelte +++ b/frontend/src/admin/sections/BroadcastSection.svelte @@ -1,16 +1,27 @@