#286: request() now throws C2ApiError — message is FastAPI's `detail` or a plain sentence for the status (401/403/404/429/5xx), with toString() so the many String(e) call sites read cleanly. status/body stay on the error; the Discord parsers rebuild the legacy "C2 API error N: body" text via legacyErrorText(), so their invite/code parsing is unchanged. #284: Resolve, Regenerate summary, alert-rule toggle/delete/load, Acknowledge, trip deletes, system delete and Restart OP25 now surface failures (ErrorBanner where the page has one, alert() where the page already uses it). Rules tab no longer says "No alert rules configured" when the load failed. Confirms added for Resolve (no reopen path exists), rule delete and trip delete. Reopen deliberately not built — manual resolve doesn't set resolved_at, so reopening is a lifecycle change. Frontend unverified: no Node on the authoring box (#207). Closes #286 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
551 lines
27 KiB
TypeScript
551 lines
27 KiB
TypeScript
import { auth } from "@/lib/firebase";
|
|
import { C2ApiError } from "@/lib/c2error";
|
|
import type {
|
|
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
|
|
WeatherAlertsResponse, WindResponse,
|
|
} 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 C2ApiError(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 }),
|
|
}),
|
|
|
|
// Power outages from every admin-enabled provider inside a bbox (server-26#184).
|
|
// `status` explains an empty list: none_enabled / no_source / error / ok.
|
|
getOutages: (bbox: { south: number; west: number; north: number; east: number }) =>
|
|
request<OutagesResponse>(`/outages?${new URLSearchParams({
|
|
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east),
|
|
})}`),
|
|
|
|
// Admin → Outage providers. Same licence gate as cameras: a gated provider
|
|
// answers 409 with `requires_acknowledgement` until `acknowledge_licence` is sent.
|
|
listOutageProviders: () => request<{ providers: OutageProviderAdmin[] }>("/admin/outage-providers"),
|
|
setOutageProvider: (id: string, enabled: boolean, acknowledgeLicence = false) =>
|
|
request<OutageProviderAdmin>(`/admin/outage-providers/${encodeURIComponent(id)}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
|
|
}),
|
|
|
|
// Weather context (server-26#237): active NWS alerts intersecting a bbox (bbox
|
|
// optional — omitted, the whole served area), and nearest-METAR wind. Both are
|
|
// cached server-side; an upstream failure comes back as `error`, not an HTTP error.
|
|
getWeatherAlerts: (bbox?: { south: number; west: number; north: number; east: number } | null) =>
|
|
request<WeatherAlertsResponse>(`/weather/alerts${bbox ? `?${new URLSearchParams({
|
|
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east),
|
|
})}` : ""}`),
|
|
getWind: (lat: number, lon: number) =>
|
|
request<WindResponse>(`/weather/wind?${new URLSearchParams({ lat: String(lat), lon: String(lon) })}`),
|
|
|
|
// 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) }),
|
|
};
|