Node-page picker and the admin Add form now show, per voice channel, whether
a radio bot can stream there, from S3's voice-check routes:
- Badge per channel: Ready / Needs fix (names the missing permission) /
Couldn't check / Bots busy / No radio bot / Not a voice channel / Limit N.
- The chosen channel shows "<bot> can't Speak in #dispatch: <what removed it>"
plus c2-core's fix sentence, and an expandable per-bot list.
- Footer: "Permissions checked 40s ago · Re-check" (refresh=1, rate limited
server-side; a limited re-check says so). Invite links when no bot fits.
- Start is disabled only on a definite refusal (needs_fix, bad_channel);
unknown fails open. A channel this system already plays in is judged by
the bot that carries it, so Update/move is never blocked by the other bots.
- Admin Add form disables bots that are definitely denied in the bot list.
- 409 refusals (missing_permissions, preferred_busy, preferred_other_node,
bad_channel) are shown verbatim, multi-line.
Org owners (owner decision Q2) get "Radio bot per system" on
Settings > Organization: their own systems, their own BYO bots, set/clear
through S3's PUT /tokens/{id}/prefer/{system}. New GET /tokens/org/preferred
lists only the caller org's systems and bots (no pool bots, no token
strings; a pool bot an admin chose is flagged, not named).
The prefer route now drops the 5-minute doc_get_cached entry for every
system it rewrites, so a new preference applies to the next start.
Stacked on feat/225-s3-perm-check: must merge after S3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
521 lines
26 KiB
TypeScript
521 lines
26 KiB
TypeScript
import { auth } from "@/lib/firebase";
|
|
import type { AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, TalkgroupPending } from "@/lib/types";
|
|
import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord";
|
|
|
|
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
|
|
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|
const user = auth.currentUser;
|
|
const token = user ? await user.getIdToken() : null;
|
|
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
...options,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...(options?.headers as Record<string, string> | undefined),
|
|
},
|
|
});
|
|
if (!res.ok) throw new Error(`C2 API error ${res.status}: ${await res.text()}`);
|
|
if (res.status === 204) return undefined as T;
|
|
return res.json();
|
|
}
|
|
|
|
export const c2api = {
|
|
// Nodes
|
|
getNodes: () => request<unknown[]>("/nodes"),
|
|
getNode: (id: string) => request<unknown>(`/nodes/${id}`),
|
|
sendCommand: (nodeId: string, payload: object) =>
|
|
request(`/nodes/${nodeId}/command`, { method: "POST", body: JSON.stringify(payload) }),
|
|
assignSystem: (nodeId: string, systemId: string, hardwarePreset: string, ppmOverride?: number) => {
|
|
const params = new URLSearchParams({ hardware_preset: hardwarePreset });
|
|
if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride));
|
|
return request(`/nodes/${nodeId}/config/${systemId}?${params}`, { method: "POST" });
|
|
},
|
|
ackOverride: (nodeId: string, timeoutMinutes: number = 1440) =>
|
|
request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }),
|
|
resetOverride: (nodeId: string) =>
|
|
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
|
|
updateNode: (
|
|
id: string,
|
|
body: {
|
|
node_type?: string;
|
|
enforce_override_timeout?: boolean;
|
|
secondary_sdr_priority?: string[];
|
|
sdr_pins?: Record<string, string | null>;
|
|
},
|
|
) =>
|
|
request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
|
|
|
// Systems
|
|
getSystems: () => request<unknown[]>("/systems"),
|
|
createSystem: (body: object) =>
|
|
request("/systems", { method: "POST", body: JSON.stringify(body) }),
|
|
updateSystem: (id: string, body: object) =>
|
|
request(`/systems/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
|
deleteSystem: (id: string) =>
|
|
request(`/systems/${id}`, { method: "DELETE" }),
|
|
|
|
// Tokens (radio bots) — every route is platform-admin only (server-26#204 item 6)
|
|
getTokens: () => request<unknown[]>("/tokens"),
|
|
addToken: (body: { name: string; token: string; org_id?: string | null }) =>
|
|
request<RadioBotAddResult>("/tokens", { method: "POST", body: JSON.stringify(body) }),
|
|
deleteToken: (id: string) =>
|
|
request(`/tokens/${id}`, { method: "DELETE" }),
|
|
/** Re-check one radio bot with Discord: token valid? which servers is it in? */
|
|
refreshToken: (id: string) =>
|
|
request<RadioBot>(`/tokens/${id}/refresh`, { method: "POST" }),
|
|
/** Admin last resort: force-release every radio-bot lease. */
|
|
flushTokens: () =>
|
|
request<{ released: number }>("/tokens/flush", { method: "POST" }),
|
|
|
|
// Discord radio channels — web admin page (server-26#213 item 2)
|
|
getDiscordAdmin: () => request<DiscordAdminOverview>("/discord/admin/overview"),
|
|
linkDiscordGuild: (guildId: string, body: { org_id: string; guild_name?: string }) =>
|
|
request<{ ok: boolean; stopped: string[] }>(`/discord/guilds/${encodeURIComponent(guildId)}/link`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
/** Without stream_id: ADD a stream to the voice channel (a channel can carry
|
|
* several, each its own bot + system; never replaces one). With stream_id:
|
|
* restart / edit that stream. */
|
|
startDiscordStream: (body: {
|
|
guild_id: string;
|
|
channel_id: string;
|
|
system_id: string;
|
|
mode: "persistent" | "adhoc";
|
|
channel_name?: string;
|
|
preferred_token_id?: string;
|
|
stream_id?: string;
|
|
}) => request<DiscordStream>("/discord/streams", { method: "POST", body: JSON.stringify(body) }),
|
|
/** Permanently remove a radio channel (stopped through desired state first). Admin only. */
|
|
deleteDiscordStream: (streamId: string) =>
|
|
request<{ ok: boolean; stream_id: string }>(`/discord/streams/${encodeURIComponent(streamId)}`, {
|
|
method: "DELETE",
|
|
}),
|
|
/** Remove a Discord server from DRB: its radio channels are deleted and it is unlinked with no fallback org. */
|
|
removeDiscordGuild: (guildId: string, leaveRadioBots: boolean) =>
|
|
request<{
|
|
ok: boolean;
|
|
guild_id: string;
|
|
streams_removed: string[];
|
|
radio_bots: { name: string; left: boolean; error: string | null }[];
|
|
}>(`/discord/guilds/${encodeURIComponent(guildId)}?leave_radio_bots=${leaveRadioBots ? "true" : "false"}`, {
|
|
method: "DELETE",
|
|
}),
|
|
/** Stop ONE stream (its voice channel may carry others, which keep playing). */
|
|
stopDiscordStream: (guildId: string, streamId: string) =>
|
|
request<{ stopped: string[] }>(`/discord/guilds/${encodeURIComponent(guildId)}/stop`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ stream_id: streamId, reason: "stopped from web admin" }),
|
|
}),
|
|
|
|
// Node approval
|
|
approveNode: (id: string) =>
|
|
request(`/nodes/${id}/approve`, { method: "POST" }),
|
|
/** node-26#15: flag the node for its host updater (HTTPS) and nudge it over MQTT. */
|
|
requestNodeUpdate: (id: string) =>
|
|
request<{ ok: boolean; update_requested_at: string; mqtt_sent: boolean }>(
|
|
`/nodes/${id}/request-update`,
|
|
{ method: "POST" },
|
|
),
|
|
/** Fleet-wide Update Node: every approved node; pending/rejected are skipped. */
|
|
requestUpdateAllNodes: () =>
|
|
request<{ ok: boolean; update_requested_at: string; requested: string[]; mqtt_sent: string[]; skipped: string[] }>(
|
|
`/nodes/request-update-all`,
|
|
{ method: "POST" },
|
|
),
|
|
rejectNode: (id: string) =>
|
|
request(`/nodes/${id}/reject`, { method: "POST" }),
|
|
|
|
// Node-page Discord picker (server-26#213 item 3) — org-scoped server side.
|
|
getNodeDiscordOptions: (nodeId: string) =>
|
|
request<import("@/lib/discordPicker").DiscordJoinOptions>(`/discord/nodes/${nodeId}/join-options`),
|
|
startNodeDiscordStream: (nodeId: string, body: { guild_id: string; channel_id: string; mode: "adhoc" | "persistent" }) =>
|
|
request<import("@/lib/discordPicker").DiscordStartResult>(`/discord/nodes/${nodeId}/streams`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
/** Omit streamId to stop every radio channel this node carries. */
|
|
stopNodeDiscordStreams: (nodeId: string, streamId?: string) =>
|
|
request<{ ok: boolean; stopped: string[] }>(`/discord/nodes/${nodeId}/streams/stop`, {
|
|
method: "POST",
|
|
body: JSON.stringify(streamId ? { stream_id: streamId } : {}),
|
|
}),
|
|
/** Permanently remove one radio channel from this node (org owner or admin). */
|
|
removeNodeDiscordStream: (nodeId: string, streamId: string) =>
|
|
request<{ ok: boolean; stream_id: string }>(
|
|
`/discord/nodes/${nodeId}/streams/${encodeURIComponent(streamId)}`,
|
|
{ method: "DELETE" },
|
|
),
|
|
/** Per-bot permission check for every voice channel in one server (server-26#225).
|
|
* refresh=true bypasses the cache (rate limited server-side: 1 per 10 s). Raw
|
|
* body — run it through discordPicker.normalizeVoiceCheck. */
|
|
getNodeVoiceCheck: (nodeId: string, guildId: string, refresh = false) =>
|
|
request<unknown>(
|
|
`/discord/nodes/${encodeURIComponent(nodeId)}/guilds/${encodeURIComponent(guildId)}/voice-check?refresh=${refresh ? 1 : 0}`,
|
|
),
|
|
/** Org owner: this org's systems + its own radio bots, for "preferred bot per system" (server-26#225 Q2). */
|
|
getOrgPreferredBots: () =>
|
|
request<import("@/lib/discordPicker").OrgPreferredBots>("/tokens/org/preferred"),
|
|
/** Admin: the same check for one channel (the Add form takes a raw channel ID). */
|
|
getGuildVoiceCheck: (guildId: string, channelId: string, refresh = false) =>
|
|
request<unknown>(
|
|
`/discord/guilds/${encodeURIComponent(guildId)}/voice-check?channel_id=${encodeURIComponent(channelId)}&refresh=${refresh ? 1 : 0}`,
|
|
),
|
|
deleteNode: (id: string) =>
|
|
request(`/nodes/${id}`, { method: "DELETE" }),
|
|
|
|
// Calls
|
|
getCall: (callId: string) => request<import("@/lib/types").CallRecord>(`/calls/${callId}`),
|
|
getCalls: (params?: Record<string, string>) => {
|
|
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
|
return request<unknown[]>(`/calls${qs}`);
|
|
},
|
|
/**
|
|
* Paged, filterable call archive — backs the /calls page. Distinct from
|
|
* getCalls(), which returns every call unordered and cannot page.
|
|
* `next_cursor` is null when the scan reached the end of the collection.
|
|
*/
|
|
searchCalls: (params: {
|
|
limit?: number;
|
|
cursor?: string | null;
|
|
system_id?: string;
|
|
node_id?: string;
|
|
talkgroup_id?: number;
|
|
link?: "any" | "orphan" | "linked";
|
|
transcript?: "any" | "yes" | "no";
|
|
q?: string;
|
|
date_from?: string;
|
|
date_to?: string;
|
|
}) => {
|
|
const qs = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
|
|
}
|
|
return request<{
|
|
calls: import("@/lib/types").CallRecord[];
|
|
next_cursor: string | null;
|
|
scanned: number;
|
|
matched: number;
|
|
window_exhausted: boolean;
|
|
}>(`/calls/search?${qs.toString()}`);
|
|
},
|
|
patchTranscript: (callId: string, transcript: string) =>
|
|
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }),
|
|
closeStallCalls: (olderThanMinutes: number, dryRun: boolean) =>
|
|
request<{ dry_run: boolean; older_than_minutes: number; count: number; call_ids: string[] }>(`/calls/close-stale?older_than_minutes=${olderThanMinutes}&dry_run=${dryRun}`, { method: "POST" }),
|
|
|
|
// STT eval harness (server-26#163) — separate from patchTranscript above,
|
|
// which is a production correction with real side effects (re-extraction,
|
|
// incident unlinking, vocabulary learning). This is pure measurement.
|
|
getEvalQueue: (limit: number, cursor?: string | null) => {
|
|
const qs = new URLSearchParams({ limit: String(limit) });
|
|
if (cursor) qs.set("cursor", cursor);
|
|
return request<{
|
|
calls: import("@/lib/types").CallRecord[];
|
|
next_cursor: string | null;
|
|
scanned: number;
|
|
matched: number;
|
|
window_exhausted: boolean;
|
|
}>(`/calls/eval-queue?${qs.toString()}`);
|
|
},
|
|
getEvalStats: () =>
|
|
request<{ eval_count: number; raw_wer: number | null; corrected_wer: number | null }>(
|
|
"/calls/eval-stats"
|
|
),
|
|
putEvalTranscript: (callId: string, text: string) =>
|
|
request<{ ok: boolean; call_id: string }>(`/calls/${callId}/eval-transcript`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ text }),
|
|
}),
|
|
|
|
// Incidents
|
|
getIncidents: (params?: { status?: string; type?: string }) => {
|
|
const qs = params ? "?" + new URLSearchParams(params as Record<string, string>).toString() : "";
|
|
return request<unknown[]>(`/incidents${qs}`);
|
|
},
|
|
getIncident: (id: string) => request<unknown>(`/incidents/${id}`),
|
|
createIncident: (body: object) =>
|
|
request("/incidents", { method: "POST", body: JSON.stringify(body) }),
|
|
updateIncident: (id: string, body: object) =>
|
|
request(`/incidents/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
|
deleteIncident: (id: string) =>
|
|
request(`/incidents/${id}`, { method: "DELETE" }),
|
|
linkCallToIncident: (incidentId: string, callId: string) =>
|
|
request<{ ok: boolean; incident_ids: string[] }>(
|
|
`/incidents/${incidentId}/calls/${callId}`, { method: "POST" }),
|
|
unlinkCallFromIncident: (incidentId: string, callId: string) =>
|
|
request<{ ok: boolean; incident_emptied: boolean }>(
|
|
`/incidents/${incidentId}/calls/${callId}`, { method: "DELETE" }),
|
|
summarizeIncident: (id: string) =>
|
|
request(`/incidents/${id}/summarize`, { method: "POST" }),
|
|
|
|
// Alerts
|
|
getAlerts: (acknowledged?: boolean) => {
|
|
const qs = acknowledged !== undefined ? `?acknowledged=${acknowledged}` : "";
|
|
return request<unknown[]>(`/alerts${qs}`);
|
|
},
|
|
acknowledgeAlert: (id: string) =>
|
|
request(`/alerts/${id}/acknowledge`, { method: "POST" }),
|
|
getAlertRules: () => request<unknown[]>("/alert-rules"),
|
|
createAlertRule: (body: object) =>
|
|
request("/alert-rules", { method: "POST", body: JSON.stringify(body) }),
|
|
updateAlertRule: (id: string, body: object) =>
|
|
request(`/alert-rules/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
|
deleteAlertRule: (id: string) =>
|
|
request(`/alert-rules/${id}`, { method: "DELETE" }),
|
|
|
|
// Node key management
|
|
reissueNodeKey: (nodeId: string) =>
|
|
request(`/nodes/${nodeId}/reissue-key`, { method: "POST" }),
|
|
|
|
// Ten-codes
|
|
getTenCodes: (systemId: string) =>
|
|
request<{ ten_codes: Record<string, string> }>(`/systems/${systemId}/ten-codes`),
|
|
updateTenCodes: (systemId: string, ten_codes: Record<string, string>) =>
|
|
request(`/systems/${systemId}/ten-codes`, { method: "PUT", body: JSON.stringify({ ten_codes }) }),
|
|
|
|
// Area context — ground truth for the transcript corrector (server-26#36).
|
|
// Its own routes rather than fields on updateSystem(), which sends only
|
|
// {name, type, config} and would otherwise wipe them on every save.
|
|
getAreaContext: (systemId: string) =>
|
|
request<{ area_context: AreaContext }>(`/systems/${systemId}/area-context`),
|
|
// Only the operator-set fields go up. center/radius_km/resolved_* are the
|
|
// backend's — it geocodes them from the place and merges them back, and the
|
|
// response carries the anchor its edit produced.
|
|
updateAreaContext: (systemId: string, area: AreaContext) =>
|
|
request<{ ok: boolean; area_context: AreaContext }>(
|
|
`/systems/${systemId}/area-context`,
|
|
{
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
municipality: area.municipality ?? null,
|
|
county: area.county ?? null,
|
|
state: area.state ?? null,
|
|
local_knowledge: area.local_knowledge ?? [],
|
|
}),
|
|
},
|
|
),
|
|
|
|
// Talkgroup-level pending local knowledge (server-26#37). Proposals land on
|
|
// the talkgroup and are never promoted to the system automatically.
|
|
getTalkgroupPending: (systemId: string) =>
|
|
request<{ talkgroups: TalkgroupPending[] }>(`/systems/${systemId}/talkgroup-pending`),
|
|
approveTalkgroupTerm: (systemId: string, talkgroupId: number, term: string) =>
|
|
request(`/systems/${systemId}/talkgroup-pending/approve`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ talkgroup_id: talkgroupId, term }),
|
|
}),
|
|
dismissTalkgroupTerm: (systemId: string, talkgroupId: number, term: string) =>
|
|
request(`/systems/${systemId}/talkgroup-pending/dismiss`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ talkgroup_id: talkgroupId, term }),
|
|
}),
|
|
|
|
// Vocabulary
|
|
getVocabulary: (systemId: string) =>
|
|
request<{ vocabulary: string[]; vocabulary_pending: { term: string; source: "induction" | "correction"; added_at: string }[]; vocabulary_bootstrapped: boolean }>(
|
|
`/systems/${systemId}/vocabulary`
|
|
),
|
|
bootstrapVocabulary: (systemId: string) =>
|
|
request<{ added: number; terms: string[] }>(`/systems/${systemId}/vocabulary/bootstrap`, { method: "POST" }),
|
|
addVocabularyTerm: (systemId: string, term: string) =>
|
|
request(`/systems/${systemId}/vocabulary/terms`, { method: "POST", body: JSON.stringify({ term }) }),
|
|
removeVocabularyTerm: (systemId: string, term: string) =>
|
|
request(`/systems/${systemId}/vocabulary/terms`, { method: "DELETE", body: JSON.stringify({ term }) }),
|
|
approvePendingTerm: (systemId: string, term: string) =>
|
|
request(`/systems/${systemId}/vocabulary/pending/approve`, { method: "POST", body: JSON.stringify({ term }) }),
|
|
dismissPendingTerm: (systemId: string, term: string) =>
|
|
request(`/systems/${systemId}/vocabulary/pending/dismiss`, { method: "POST", body: JSON.stringify({ term }) }),
|
|
|
|
// Feature flags (admin)
|
|
getFeatureFlags: () =>
|
|
request<Record<string, boolean>>("/admin/features"),
|
|
setFeatureFlags: (flags: Record<string, boolean>) =>
|
|
request<Record<string, boolean>>("/admin/features", { method: "PUT", body: JSON.stringify(flags) }),
|
|
getCorrelationDebug: (limit: number, orphanHours: number) =>
|
|
request<unknown>(`/admin/debug/correlation?limit=${limit}&orphan_hours=${orphanHours}`),
|
|
|
|
// Replay (admin) — re-run the pipeline over past calls into a sandbox.
|
|
// See drb-c2-core/app/internal/replay.py.
|
|
estimateReplay: (p: { date_from: string; date_to: string; mode: import("@/lib/types").ReplayMode; system_ids?: string[] }) => {
|
|
const qs = new URLSearchParams({ date_from: p.date_from, date_to: p.date_to, mode: p.mode });
|
|
if (p.system_ids?.length) qs.set("system_ids", p.system_ids.join(","));
|
|
return request<import("@/lib/types").ReplayEstimate>(`/admin/replay/estimate?${qs}`);
|
|
},
|
|
startReplay: (body: {
|
|
date_from: string; date_to: string; mode: import("@/lib/types").ReplayMode;
|
|
system_ids?: string[]; source_run_id?: string | null; label?: string;
|
|
}) =>
|
|
request<import("@/lib/types").ReplayRun>("/admin/replay", { method: "POST", body: JSON.stringify(body) }),
|
|
listReplays: () =>
|
|
request<{ runs: import("@/lib/types").ReplayRun[]; active_run_id: string | null }>("/admin/replay"),
|
|
getReplay: (runId: string) => request<import("@/lib/types").ReplayRun>(`/admin/replay/${runId}`),
|
|
cancelReplay: (runId: string) =>
|
|
request<{ ok: boolean }>(`/admin/replay/${runId}/cancel`, { method: "POST" }),
|
|
deleteReplay: (runId: string) =>
|
|
request<{ ok: boolean }>(`/admin/replay/${runId}`, { method: "DELETE" }),
|
|
getReplayIncidents: (runId: string) =>
|
|
request<import("@/lib/types").ReplayIncidents>(`/admin/replay/${runId}/incidents`),
|
|
|
|
// Preferred bot token per system. Platform admin: any bot, any system. Org
|
|
// owner: their own org's bots for their own org's systems (server-26#225 Q2).
|
|
setPreferredToken: (tokenId: string, systemId: string) =>
|
|
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),
|
|
|
|
// Trips
|
|
getTrips: () => request<import("@/lib/types").TripRecord[]>("/trips"),
|
|
getTrip: (id: string) =>
|
|
request<import("@/lib/types").TripRecord & { events: import("@/lib/types").TripEvent[] }>(`/trips/${id}`),
|
|
createTrip: (body: object) =>
|
|
request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
|
|
deleteTrip: (id: string) =>
|
|
request(`/trips/${id}`, { method: "DELETE" }),
|
|
updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) =>
|
|
request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }),
|
|
setTripVisibility: (id: string, visibility: "public" | "private") =>
|
|
request<{ visibility: string }>(`/trips/${id}/visibility`, { method: "PUT", body: JSON.stringify({ visibility }) }),
|
|
inviteToTrip: (id: string, discord_user_id: string) =>
|
|
request(`/trips/${id}/invite/${discord_user_id}`, { method: "POST" }),
|
|
revokeInvite: (id: string, discord_user_id: string) =>
|
|
request(`/trips/${id}/invite/${discord_user_id}`, { method: "DELETE" }),
|
|
generateLinkCode: () =>
|
|
request<{ code?: string; expires_minutes?: number; already_linked?: boolean; discord_user_id?: string }>("/auth/link/generate", { method: "POST" }),
|
|
getLinkStatus: () =>
|
|
request<{ linked: boolean; discord_user_id?: string; discord_username?: string; linked_at?: string }>("/auth/link/status"),
|
|
unlinkDiscord: () =>
|
|
request("/auth/link", { method: "DELETE" }),
|
|
createTripEvent: (tripId: string, body: object) =>
|
|
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
|
updateTripEvent: (tripId: string, eventId: string, body: object) =>
|
|
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events/${eventId}`, { method: "PATCH", body: JSON.stringify(body) }),
|
|
deleteTripEvent: (tripId: string, eventId: string) =>
|
|
request(`/trips/${tripId}/events/${eventId}`, { method: "DELETE" }),
|
|
tripChat: (tripId: string, message: string, history: { role: string; content: string }[]) =>
|
|
request<{ reply: string; suggestions: import("@/lib/types").TripEvent[] }>(
|
|
`/trips/${tripId}/chat`,
|
|
{ method: "POST", body: JSON.stringify({ message, history }) }
|
|
),
|
|
|
|
// Places
|
|
searchPlaces: (query: string, near: string) =>
|
|
request<import("@/lib/types").PlaceResult[]>(
|
|
`/places/search?${new URLSearchParams({ query, near }).toString()}`
|
|
),
|
|
getDirections: (origin: string, destination: string) =>
|
|
request<{ duration_text: string | null; duration_seconds: number | null; distance_text: string | null }>(
|
|
`/places/directions?${new URLSearchParams({ origin, destination }).toString()}`
|
|
),
|
|
|
|
// Per-system AI flag overrides
|
|
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>
|
|
request<{ ok: boolean; ai_flags: Record<string, boolean> }>(`/systems/${systemId}/ai-flags`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(flags),
|
|
}),
|
|
|
|
// User management (admin only)
|
|
listUsers: () =>
|
|
request<import("@/lib/types").UserRecord[]>("/admin/users"),
|
|
createUser: (body: { email: string; role: string; display_name?: string; owned_node_ids?: string[] }) =>
|
|
request<import("@/lib/types").UserRecord & { invite_link?: string | null }>("/admin/users", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
getUser: (uid: string) =>
|
|
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`),
|
|
updateUser: (
|
|
uid: string,
|
|
body: { role?: string; owned_node_ids?: string[]; display_name?: string; org_id?: string },
|
|
) =>
|
|
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
disableUser: (uid: string) =>
|
|
request<{ ok: boolean }>(`/admin/users/${uid}/disable`, { method: "POST" }),
|
|
enableUser: (uid: string) =>
|
|
request<{ ok: boolean }>(`/admin/users/${uid}/enable`, { method: "POST" }),
|
|
deleteUser: (uid: string) =>
|
|
request<{ ok: boolean }>(`/admin/users/${uid}`, { method: "DELETE" }),
|
|
|
|
// Audit log (admin only)
|
|
getAuditLog: (limit = 50, offset = 0) =>
|
|
request<import("@/lib/types").AuditEntry[]>(`/admin/audit?limit=${limit}&offset=${offset}`),
|
|
|
|
// Session recording — called on each explicit sign-in
|
|
recordSession: () =>
|
|
request<{ ok: boolean }>("/auth/session", { method: "POST" }),
|
|
|
|
// Org provisioning (SAAS_PLAN.md B4) — called once from /onboarding right
|
|
// after a Firebase account exists but before it has an org_id claim.
|
|
signup: (orgName: string) =>
|
|
request<{ org_id: string; org_name: string; already_provisioned: boolean }>("/auth/signup", {
|
|
method: "POST",
|
|
body: JSON.stringify({ org_name: orgName }),
|
|
}),
|
|
|
|
// Organization profile
|
|
getOrg: () =>
|
|
request<{ org_id: string; name: string; created_at: string }>("/org"),
|
|
updateOrg: (name: string) =>
|
|
request<{ ok: boolean; name: string }>("/org", { method: "PATCH", body: JSON.stringify({ name }) }),
|
|
|
|
// Per-org enrollment tokens (SAAS_PLAN.md B2b)
|
|
listEnrollmentTokens: () =>
|
|
request<{ token_id: string; label: string; created_at: string; revoked: boolean; uses: number }[]>(
|
|
"/org/enrollment-tokens"
|
|
),
|
|
mintEnrollmentToken: (label: string) =>
|
|
request<{ token_id: string; token: string; label: string }>("/org/enrollment-tokens", {
|
|
method: "POST",
|
|
body: JSON.stringify({ label }),
|
|
}),
|
|
revokeEnrollmentToken: (tokenId: string) =>
|
|
request(`/org/enrollment-tokens/${tokenId}`, { method: "DELETE" }),
|
|
|
|
// Discord server ↔ org linking (server-26#213). Owner mints a single-use
|
|
// code; someone with Manage Server redeems it with /drb link in Discord.
|
|
createDiscordLinkCode: () =>
|
|
request<{ code: string; expires_at: string; expires_minutes: number; command: string }>(
|
|
"/discord/link-codes",
|
|
{ method: "POST" }
|
|
),
|
|
listLinkedDiscordGuilds: () =>
|
|
request<{
|
|
guilds: { guild_id: string; guild_name: string | null; linked_at: string | null; linked_via: string }[];
|
|
unlinked_guilds_restricted: boolean;
|
|
}>("/discord/linked-guilds"),
|
|
unlinkDiscordGuild: (guildId: string) =>
|
|
request<{ ok: boolean; guild_id: string; streams_stopped: number }>(
|
|
`/discord/linked-guilds/${encodeURIComponent(guildId)}`,
|
|
{ method: "DELETE" }
|
|
),
|
|
|
|
// 511NY cameras/events inside a map bbox (server-26#183)
|
|
get511: (bbox: { south: number; west: number; north: number; east: number }, layers: string) =>
|
|
request<Ny511Response>(`/traffic/511?${new URLSearchParams({
|
|
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east), layers,
|
|
})}`),
|
|
|
|
// Public cameras from every admin-enabled provider inside a bbox (live ops wall)
|
|
getCameras: (bbox: { south: number; west: number; north: number; east: number }) =>
|
|
request<CamerasResponse>(`/cameras?${new URLSearchParams({
|
|
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east),
|
|
})}`),
|
|
|
|
// Admin → Camera providers. A licence-gated provider answers 409 with
|
|
// `requires_acknowledgement` until `acknowledge_licence` is sent.
|
|
listCameraProviders: () => request<{ providers: CameraProviderAdmin[] }>("/admin/camera-providers"),
|
|
setCameraProvider: (id: string, enabled: boolean, acknowledgeLicence = false) =>
|
|
request<CameraProviderAdmin>(`/admin/camera-providers/${encodeURIComponent(id)}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
|
|
}),
|
|
|
|
// Public waitlist — no auth, see routers/waitlist.py
|
|
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
|
|
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
|
|
};
|