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(path: string, options?: RequestInit): Promise { 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 | 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("/nodes"), getNode: (id: string) => request(`/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; }, ) => request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }), // Systems getSystems: () => request("/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("/tokens"), addToken: (body: { name: string; token: string; org_id?: string | null }) => request("/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(`/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("/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("/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(`/discord/nodes/${nodeId}/join-options`), startNodeDiscordStream: (nodeId: string, body: { guild_id: string; channel_id: string; mode: "adhoc" | "persistent" }) => request(`/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( `/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("/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( `/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(`/calls/${callId}`), getCalls: (params?: Record) => { const qs = params ? "?" + new URLSearchParams(params).toString() : ""; return request(`/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).toString() : ""; return request(`/incidents${qs}`); }, getIncident: (id: string) => request(`/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(`/alerts${qs}`); }, acknowledgeAlert: (id: string) => request(`/alerts/${id}/acknowledge`, { method: "POST" }), getAlertRules: () => request("/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 }>(`/systems/${systemId}/ten-codes`), updateTenCodes: (systemId: string, ten_codes: Record) => 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>("/admin/features"), setFeatureFlags: (flags: Record) => request>("/admin/features", { method: "PUT", body: JSON.stringify(flags) }), getCorrelationDebug: (limit: number, orphanHours: number) => request(`/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(`/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("/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(`/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(`/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("/trips"), getTrip: (id: string) => request(`/trips/${id}`), createTrip: (body: object) => request("/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(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }), updateTripEvent: (tripId: string, eventId: string, body: object) => request(`/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( `/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 }>(`/systems/${systemId}/ai-flags`, { method: "PUT", body: JSON.stringify(flags), }), // User management (admin only) listUsers: () => request("/admin/users"), createUser: (body: { email: string; role: string; display_name?: string; owned_node_ids?: string[] }) => request("/admin/users", { method: "POST", body: JSON.stringify(body), }), getUser: (uid: string) => request(`/admin/users/${uid}`), updateUser: ( uid: string, body: { role?: string; owned_node_ids?: string[]; display_name?: string; org_id?: string }, ) => request(`/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(`/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(`/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(`/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(`/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(`/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(`/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(`/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(`/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) }), };