diff --git a/drb-c2-core/app/routers/tokens.py b/drb-c2-core/app/routers/tokens.py
index bfb6f50..9c23005 100644
--- a/drb-c2-core/app/routers/tokens.py
+++ b/drb-c2-core/app/routers/tokens.py
@@ -1,5 +1,5 @@
import uuid
-from fastapi import APIRouter, HTTPException, Depends
+from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from typing import Optional
from app.internal import firestore as fstore
@@ -12,6 +12,7 @@ from app.internal.auth import (
get_role,
require_admin_token,
require_firebase_token,
+ resolve_org_scope,
)
from app.internal.logger import logger
@@ -101,6 +102,16 @@ async def flush_tokens(_: dict = Depends(require_admin_token)):
return {"released": released}
+def _forget_cached_system(system_id: str) -> None:
+ """start_stream reads systems through fstore.doc_get_cached (5 min TTL):
+ drop the entry so a new preference applies to the next start instead of
+ up to five minutes later (server-26#225 S4)."""
+ cache = getattr(fstore, "_doc_cache", None)
+ path = getattr(fstore, "_path", None)
+ if isinstance(cache, dict) and callable(path):
+ cache.pop(f"{path('systems')}/{system_id}", None)
+
+
def _may_edit_system(decoded: dict, system: Optional[dict]) -> bool:
"""Platform admin: any system. Org owner: only their own org's systems —
a shared system (no org_id) serves every org, so a preference there is
@@ -167,6 +178,7 @@ async def set_preferred_system(
raise HTTPException(403, "That system is shared by every organization; only a platform admin "
"can change its radio bot.")
await fstore.doc_set("systems", old_id, {"preferred_token_id": None})
+ _forget_cached_system(old_id)
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": None})
await write_audit(uid, email, "discord.token.prefer", details={
"token_id": token_id, "system_id": None, "previous_system_id": old_id or None})
@@ -183,6 +195,7 @@ async def set_preferred_system(
old_sys = await fstore.doc_get("systems", old_sys_id)
if old_sys and old_sys.get("preferred_token_id") == token_id and _may_edit_system(decoded, old_sys):
await fstore.doc_set("systems", old_sys_id, {"preferred_token_id": None})
+ _forget_cached_system(old_sys_id)
old_tok_id = system_doc.get("preferred_token_id")
if old_tok_id and old_tok_id != token_id:
old_tok = await fstore.doc_get("bot_tokens", old_tok_id)
@@ -190,6 +203,7 @@ async def set_preferred_system(
await fstore.doc_set("bot_tokens", old_tok_id, {"preferred_for_system_id": None})
await fstore.doc_set("systems", system_id, {"preferred_token_id": token_id})
+ _forget_cached_system(system_id)
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": system_id})
await write_audit(uid, email, "discord.token.prefer", details={
"token_id": token_id, "system_id": system_id, "previous_token_id": old_tok_id,
@@ -197,6 +211,52 @@ async def set_preferred_system(
return {"ok": True, "preferred_for_system_id": system_id}
+@router.get("/org/preferred")
+async def org_preferred_bots(
+ org_id: Optional[str] = Query(None, description="Platform-admin only: act for this org."),
+ decoded: dict = Depends(require_firebase_token),
+):
+ """What an org owner needs to choose a preferred radio bot per system
+ (server-26#225 S4, owner decision Q2): the org's own systems, each with
+ its preferred bot, and the org's own bring-your-own bots — the only bots
+ an owner may choose (PUT /tokens/{id}/prefer/{system_id}). GET /tokens
+ stays platform-admin only; this returns no pool bots and no token strings.
+ A pool bot a platform admin chose for one of these systems is flagged,
+ not named."""
+ if get_role(decoded) != "admin" and get_org_role(decoded) != "owner":
+ raise HTTPException(403, "Only the organization owner or a platform admin can choose a system's radio bot.")
+ org = resolve_org_scope(decoded, org_id)
+ systems = sorted(await fstore.collection_list("systems", org_id=org),
+ key=lambda s: str(s.get("name") or s.get("system_id") or "").lower())
+ bots = sorted((t for t in await fstore.collection_list("bot_tokens", org_id=org) if t.get("token_id")),
+ key=lambda t: str(t.get("name") or "").lower())
+ names = {t["token_id"]: t.get("name") or t["token_id"] for t in bots}
+ out_systems = []
+ for s in systems:
+ if not s.get("system_id"):
+ continue
+ pref = s.get("preferred_token_id")
+ out_systems.append({
+ "system_id": s["system_id"],
+ "name": s.get("name") or s["system_id"],
+ "preferred_token_id": pref if pref in names else None,
+ "preferred_is_platform_bot": bool(pref) and pref not in names,
+ "preferred_name": names.get(pref) if pref in names else None,
+ })
+ return {
+ "org_id": org,
+ "systems": out_systems,
+ "bots": [{
+ "token_id": t["token_id"],
+ "name": names[t["token_id"]],
+ "valid": t.get("valid"),
+ "server_count": len(t["guild_ids"]) if isinstance(t.get("guild_ids"), list) else None,
+ "preferred_for_system_id": t.get("preferred_for_system_id"),
+ "invite_url": leases.invite_url(t.get("application_id")),
+ } for t in bots],
+ }
+
+
@router.delete("/{token_id}", status_code=204)
async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("bot_tokens", token_id)
diff --git a/drb-c2-core/tests/test_tokens_org_preferred.py b/drb-c2-core/tests/test_tokens_org_preferred.py
new file mode 100644
index 0000000..5194104
--- /dev/null
+++ b/drb-c2-core/tests/test_tokens_org_preferred.py
@@ -0,0 +1,113 @@
+"""
+Org-owner "radio bot per system" card (server-26#225 S4, owner decision Q2):
+GET /tokens/org/preferred lists the owner's own systems and own BYO bots (no
+pool bots, no token strings); a preference set through
+PUT /tokens/{id}/prefer/{system} (S3) applies to the very next start, not after
+the 5-minute systems cache.
+"""
+import pytest
+from fastapi.testclient import TestClient
+
+from app.internal import firestore as fstore
+from app.internal.auth import require_firebase_token, require_service_or_firebase_token
+from app.main import app
+from tests.fake_firestore import FakeFirestore
+
+client = TestClient(app)
+ORG, OTHER = "org-a", "org-b"
+OWNER = {"uid": "u-own", "org_id": ORG, "org_role": "owner"}
+MEMBER = {"uid": "u-mem", "org_id": ORG, "org_role": "member", "role": "operator"}
+STRANGER = {"uid": "u-x", "org_id": OTHER, "org_role": "owner"}
+ADMIN_NO_ORG = {"uid": "u-admin", "role": "admin"}
+
+
+@pytest.fixture
+def fs(monkeypatch):
+ fake = FakeFirestore.install(monkeypatch)
+ fake.put("systems", "s-a", {"system_id": "s-a", "name": "County P25", "org_id": ORG})
+ fake.put("systems", "s-a2", {"system_id": "s-a2", "name": "City Fire", "org_id": ORG})
+ fake.put("systems", "s-b", {"system_id": "s-b", "name": "Other County", "org_id": OTHER})
+ fake.put("systems", "s-shared", {"system_id": "s-shared", "name": "Shared", "org_id": None})
+ fake.put("bot_tokens", "byo-a", {"token_id": "byo-a", "name": "Alpha Radio", "token": "secret-a",
+ "org_id": ORG, "valid": True, "guild_ids": ["100", "101"],
+ "application_id": "555"})
+ fake.put("bot_tokens", "byo-a-bad", {"token_id": "byo-a-bad", "name": "Broken", "token": "secret-bad",
+ "org_id": ORG, "valid": False})
+ fake.put("bot_tokens", "byo-b", {"token_id": "byo-b", "name": "Bravo Radio", "token": "secret-b",
+ "org_id": OTHER, "valid": True})
+ fake.put("bot_tokens", "pool-1", {"token_id": "pool-1", "name": "DRB Radio 1", "token": "secret-p",
+ "org_id": None, "valid": True})
+ yield fake
+ app.dependency_overrides.clear()
+
+
+def as_(who):
+ # /tokens is mounted behind require_service_or_firebase_token (main.py).
+ app.dependency_overrides[require_service_or_firebase_token] = lambda: who
+ app.dependency_overrides[require_firebase_token] = lambda: who
+
+
+def test_owner_lists_own_systems_and_own_bots_only(fs):
+ as_(OWNER)
+ r = client.get("/tokens/org/preferred")
+ assert r.status_code == 200, r.text
+ body = r.json()
+ assert body["org_id"] == ORG
+ assert [s["system_id"] for s in body["systems"]] == ["s-a2", "s-a"] # own systems, by name
+ assert {b["token_id"] for b in body["bots"]} == {"byo-a", "byo-a-bad"} # no pool, no other org
+ alpha = next(b for b in body["bots"] if b["token_id"] == "byo-a")
+ assert alpha["server_count"] == 2 and alpha["invite_url"] and alpha["valid"] is True
+ assert "secret" not in repr(body) # never a token string
+
+
+def test_member_is_refused(fs):
+ as_(MEMBER)
+ assert client.get("/tokens/org/preferred").status_code == 403
+
+
+def test_admin_without_org_gets_403_not_every_org(fs):
+ as_(ADMIN_NO_ORG)
+ assert client.get("/tokens/org/preferred").status_code == 403
+ # With ?org_id= a platform admin may act for that org (resolve_org_scope).
+ body = client.get(f"/tokens/org/preferred?org_id={OTHER}").json()
+ assert [s["system_id"] for s in body["systems"]] == ["s-b"]
+
+
+def test_non_admin_org_override_is_ignored(fs):
+ as_(STRANGER)
+ body = client.get(f"/tokens/org/preferred?org_id={ORG}").json()
+ assert body["org_id"] == OTHER
+ assert [s["system_id"] for s in body["systems"]] == ["s-b"]
+ assert [b["token_id"] for b in body["bots"]] == ["byo-b"]
+
+
+def test_platform_pick_on_owner_system_is_flagged_not_named(fs):
+ fs.put("systems", "s-a", {"system_id": "s-a", "name": "County P25", "org_id": ORG,
+ "preferred_token_id": "pool-1"})
+ as_(OWNER)
+ s = next(x for x in client.get("/tokens/org/preferred").json()["systems"] if x["system_id"] == "s-a")
+ assert s["preferred_token_id"] is None and s["preferred_name"] is None
+ assert s["preferred_is_platform_bot"] is True
+ assert "DRB Radio 1" not in repr(s)
+
+
+def test_owner_set_then_list_round_trip_and_cache_is_dropped(fs):
+ stale = (1e18, {"system_id": "s-a", "org_id": ORG}) # what start_stream would read for 5 min
+ fstore._doc_cache[f"{fstore._path('systems')}/s-a"] = stale
+ fstore._doc_cache[f"{fstore._path('systems')}/s-a2"] = stale
+ as_(OWNER)
+ assert client.put("/tokens/byo-a/prefer/s-a").status_code == 200
+ assert f"{fstore._path('systems')}/s-a" not in fstore._doc_cache
+ s = next(x for x in client.get("/tokens/org/preferred").json()["systems"] if x["system_id"] == "s-a")
+ assert s["preferred_token_id"] == "byo-a" and s["preferred_name"] == "Alpha Radio"
+
+ # Moving the bot to another system drops both systems' cached copies.
+ fstore._doc_cache[f"{fstore._path('systems')}/s-a"] = stale
+ assert client.put("/tokens/byo-a/prefer/s-a2").status_code == 200
+ assert f"{fstore._path('systems')}/s-a" not in fstore._doc_cache
+ assert f"{fstore._path('systems')}/s-a2" not in fstore._doc_cache
+
+ fstore._doc_cache[f"{fstore._path('systems')}/s-a2"] = stale
+ assert client.put("/tokens/byo-a/prefer/_none").status_code == 200
+ assert f"{fstore._path('systems')}/s-a2" not in fstore._doc_cache
+ assert fs.get("systems", "s-a2")["preferred_token_id"] is None
diff --git a/drb-frontend/app/settings/organization/page.tsx b/drb-frontend/app/settings/organization/page.tsx
index c5d4d97..ac7d2a2 100644
--- a/drb-frontend/app/settings/organization/page.tsx
+++ b/drb-frontend/app/settings/organization/page.tsx
@@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Skeleton } from "@/components/ui/Skeleton";
import { DiscordServersCard } from "@/components/DiscordServersCard";
+import { PreferredBotsCard } from "@/components/discord/PreferredBotsCard";
function StatTile({ label, value }: { label: string; value: string | number }) {
return (
@@ -121,6 +122,8 @@ export default function OrganizationSettingsPage() {
+
+
diff --git a/drb-frontend/components/NodeDiscordRadio.tsx b/drb-frontend/components/NodeDiscordRadio.tsx
index 2cc019e..d1dc128 100644
--- a/drb-frontend/components/NodeDiscordRadio.tsx
+++ b/drb-frontend/components/NodeDiscordRadio.tsx
@@ -10,17 +10,27 @@
// plays another system ADDS this node's system beside it; only a stream of
// this node's own system there is restarted / moved here.
// Layout follows design-canvas/discord-system-mockup.html "Radio channels".
+// Each voice channel shows whether a radio bot can actually stream there
+// (server-26#225): Ready / Needs fix (which permission, what removed it, where
+// to fix it) / Couldn't check / Limit N. Start is disabled only on a definite
+// deny; an unanswered check fails open.
import { useCallback, useEffect, useMemo, useState } from "react";
import { c2api } from "@/lib/c2api";
import {
+ blocksStart,
+ channelVerdict,
+ normalizeVoiceCheck,
parseC2Error,
type DiscordGuildOption,
type DiscordInvite,
type DiscordJoinOptions,
type DiscordStreamView,
+ type ParsedC2Error,
type StreamState,
+ type VoiceCheck,
} from "@/lib/discordPicker";
+import { BotDetails, CheckFooter, FixText, VerdictBadge } from "@/components/discord/VoiceCheckBits";
const POLL_MS = 10_000;
@@ -93,12 +103,53 @@ function DiscordPicker({
const [channelId, setChannelId] = useState("");
const [mode, setMode] = useState<"adhoc" | "persistent">("adhoc");
const [sending, setSending] = useState(false);
- const [error, setError] = useState<{ message: string; invite: DiscordInvite[] } | null>(null);
+ const [error, setError] = useState
(null);
+ // Permission check per server: the result, whether a load is in flight, and
+ // why the last load failed (shown under the footer; the badges fail open).
+ const [checks, setChecks] = useState<
+ Record
+ >({});
const guild: DiscordGuildOption | undefined = usable.find((g) => g.guild_id === guildId);
const channel = guild?.voice_channels.find((c) => c.channel_id === channelId);
const atNodeCap = options.limits.node_used >= options.limits.node_max;
const channelOnHere = channel?.stream?.node_id === nodeId;
+ const check = guildId ? checks[guildId] : undefined;
+ const checkOn = check?.data?.enabled !== false;
+ const verdictOf = (c: DiscordGuildOption["voice_channels"][number]) => {
+ const row = check?.data && checkOn ? check.data.channels[c.channel_id] : undefined;
+ const v = channelVerdict(row);
+ // This system already plays here: c2-core counts its bot as busy (it holds
+ // the channel), so the channel can read "Needs fix"/"Bots busy" for a NEW
+ // bot while a restart/move keeps the one that works. Judge by that bot.
+ const own = c.stream?.token_name ? row?.bots.find((b) => b.name === c.stream?.token_name) : undefined;
+ if (v && own?.ok === true && v.kind !== "ready") return { kind: "ready" as const, limit: v.limit };
+ return v;
+ };
+ const chosenVerdict = channel ? verdictOf(channel) : null;
+ const definiteDeny = blocksStart(chosenVerdict);
+ const showInvite = chosenVerdict?.kind === "fix" || chosenVerdict?.kind === "no_bot" || chosenVerdict?.kind === "busy";
+
+ const runCheck = useCallback(
+ async (gid: string, refresh: boolean) => {
+ setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: true, error: null, limited: false } }));
+ try {
+ const data = normalizeVoiceCheck(await c2api.getNodeVoiceCheck(nodeId, gid, refresh));
+ setChecks((m) => ({ ...m, [gid]: { data, busy: false, error: null, limited: refresh && data.refreshed === false } }));
+ } catch (err) {
+ const e = parseC2Error(err);
+ // 404 = route not deployed yet or server not visible: no badges (fail-open).
+ const msg = e.status === 404 ? null : e.message;
+ setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: false, error: msg, limited: false } }));
+ }
+ },
+ [nodeId],
+ );
+
+ // First look at a server's channels: load its check (cached server-side).
+ useEffect(() => {
+ if (guild?.synced && !checks[guild.guild_id]) runCheck(guild.guild_id, false);
+ }, [guild, checks, runCheck]);
const grouped = useMemo(() => {
const out: { category: string | null; channels: DiscordGuildOption["voice_channels"] }[] = [];
@@ -119,7 +170,11 @@ function DiscordPicker({
onStarted();
onClose();
} catch (err) {
- setError(parseC2Error(err));
+ const e = parseC2Error(err);
+ setError(e);
+ // The refusal may be newer than the badges (C2 drops its cache entry on
+ // a permission refusal) — reload them so the row matches the error.
+ if (e.code === "missing_permissions") runCheck(guild.guild_id, false);
} finally {
setSending(false);
}
@@ -209,6 +264,7 @@ function DiscordPicker({
{grp.channels.map((c) => {
const here = c.stream?.node_id === nodeId;
const others = (c.streams ?? []).filter((x) => x.stream_id !== c.stream?.stream_id);
+ const verdict = verdictOf(c);
return (
- 🔊 {c.name}
+
+ 🔊 {c.name}
+ {checkOn && }
+
+ {verdict && c.channel_id === channelId && }
{c.stream && (
{here
@@ -241,6 +301,9 @@ function DiscordPicker({
{!c.stream && ` — this adds ${options.system_name ?? "this node's system"} beside it with another bot`}
)}
+ {c.channel_id === channelId && checkOn && (
+
+ )}
);
@@ -248,6 +311,15 @@ function DiscordPicker({
))}
+ runCheck(guild.guild_id, true)}
+ />
)}
@@ -299,10 +371,10 @@ function DiscordPicker({
)}
{error && (
-
+
{error.message}
-
+
)}
@@ -310,7 +382,8 @@ function DiscordPicker({