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.
This commit is contained in:
3252a8
2026-06-01 23:37:59 +03:00
parent 619a13128c
commit 56796d9f22
9 changed files with 84 additions and 4 deletions
@@ -6,6 +6,7 @@ export function createBroadcastStore({ api, onToast, at }) {
broadcastText: "",
broadcastBusy: false,
broadcastResult: null,
broadcastCounts: null,
});
const BROADCAST_TARGET_OPTIONS = [
@@ -13,8 +14,23 @@ export function createBroadcastStore({ api, onToast, at }) {
{ value: "active", label: at("broadcast_target_active", {}, "С подпиской") },
{ value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") },
{ value: "expired", label: at("broadcast_target_expired", {}, "Expired subscription") },
{
value: "never",
label: at("broadcast_target_never", {}, "Без подписки и без истории"),
},
];
async function loadCounts() {
try {
const res = await api("/admin/broadcast/audience-counts");
if (res?.ok && res.counts) {
state.update((s) => ({ ...s, broadcastCounts: res.counts }));
}
} catch {
// Counts are advisory; ignore failures and keep plain labels.
}
}
async function runBroadcast() {
let text = "";
let target = "";
@@ -56,6 +72,7 @@ export function createBroadcastStore({ api, onToast, at }) {
update: state.update,
runBroadcast,
updateField,
loadCounts,
BROADCAST_TARGET_OPTIONS,
};
}
+6
View File
@@ -658,6 +658,12 @@ function demoApiResponse(path, cleanPath, options, context) {
const params = queryParams(path);
if (cleanPath === "/admin/stats") return clone(DEMO_DATASET.stats);
if (cleanPath === "/admin/broadcast/audience-counts") {
return {
ok: true,
counts: { all: 1280, active: 742, inactive: 538, expired: 311, never: 227 },
};
}
if (cleanPath === "/admin/sync") return { ok: true, status: "queued" };
if (cleanPath === "/admin/payments") {