Compare commits

...

39 Commits

Author SHA1 Message Date
Logan 57ff9f8ea3 Merge remote-tracking branch 'origin/main' into build-infrastructure 2026-06-22 02:34:26 -04:00
Logan 9fdcad1c46 deploy via Gitea CI registry; provision GCP infra with Terraform
- Terraform: e2-micro VM (us-east1-b, free tier), static IP, SSH/web
  firewall rules, IAM bindings for Firestore + GCS; imports existing
  drb-calls bucket and c2-server Firestore database into state
- Gitea CI: build c2-core, discord-bot, frontend images and push to
  git.vpn.cusano.net registry; SSH deploy pulls pre-built images (no
  build on VM)
- Ansible: first-time setup only — git clone, env files from vault,
  Caddyfile, docker login + compose pull + up; no rsync or on-VM builds
- docker-compose: add image: ${REGISTRY}/name:latest alongside build:
  so local dev and CI registry both work
- gitignore: add Terraform state, lock, tfvars, ansible secrets
2026-06-22 02:31:28 -04:00
Logan 33700448bf add Terraform + Ansible infrastructure for GCP deployment
Provisions e2-micro VM (us-east1-b, free tier) with static IP, SSH and
web firewall rules, Docker + Caddy startup script, and IAM bindings for
Firestore and GCS access via ADC. Imports existing drb-calls bucket and
c2-server Firestore database into state. Ansible roles handle first-time
setup (swap, docker group) and all subsequent deploys via rsync + docker
compose, with secrets managed via Ansible Vault. DNS stays on AWS Route 53.
2026-06-22 02:03:36 -04:00
Logan 3defdf18dc stale calls fix 2026-06-22 00:06:10 -04:00
Logan 1f17b6c0d2 feat: add role-based user management, audit log, and session tracking
Introduces a full user management system with three roles (admin, operator,
viewer), an audit log, and per-session login history.

Backend:
- app/internal/audit.py: write_audit() helper → audit_log Firestore collection
- app/internal/auth.py: get_role() helper; require_admin_token accepts both
  legacy admin:true claim and new role:"admin" claim for backward compat
- app/routers/users.py: CRUD under /admin/users — list, create (returns
  one-time invite link), get (with sessions), patch role/nodes/name,
  disable, enable, delete; operator role requires ≥1 owned node
- app/routers/links.py: POST /auth/session records sign-in events to
  user_sessions Firestore collection
- app/routers/admin.py: GET /admin/audit paginated endpoint
- app/main.py: register users router

Frontend:
- AuthProvider: exposes role, isAdmin, isOperator, ownedNodeIds from claims
- Nav: role-gated links — viewers get dashboard/calls/incidents/map/alerts/
  trips; operators add nodes/systems/tokens; admins add admin
- admin/page.tsx: new Users tab (list table, create modal, inline edit panel
  with role/nodes editor, disable/enable/delete, login history) and Audit
  Log tab (paginated, color-coded actions)
- login/page.tsx: calls recordSession() on email and Google sign-in
- nodes, systems, tokens pages: role guards redirect viewers to dashboard
- profile/page.tsx: shows accurate role badge and label
- lib/types.ts: UserRole, UserRecord, UserSession, AuditEntry types
- lib/c2api.ts: user management methods + recordSession

Firestore collections added: user_profiles, audit_log, user_sessions
Firebase custom claims schema: { role, owned_node_ids, admin (legacy) }
2026-06-22 00:02:09 -04:00
Logan 961cc6f36e add button to clear stale 'active' calls 2026-06-21 23:45:28 -04:00
Logan d290b89736 New /profile page
Avatar (initials) + display name, email, admin badge
Account section: email, UID, role, join date, last sign-in
Discord section: link status with username/user ID/linked date, or the get-code flow if unlinked, plus unlink button
Sign out button at the bottom
2026-06-21 23:31:10 -04:00
Logan 758c6f4115 discord link banner 2026-06-21 23:23:36 -04:00
Logan 6ae4d398f8 add trips permissions 2026-06-21 20:00:48 -04:00
Logan 981f03ac06 allow overlap (note) tags 2026-06-21 15:52:15 -04:00
Logan 47430827d4 Fix discord trip itinerary 2026-06-21 15:47:07 -04:00
Logan 4dd3343026 add event editing 2026-06-21 15:35:57 -04:00
Logan fce189d8c9 assistant updates 2026-06-21 15:11:30 -04:00
Logan 3fb3bca034 add tags
Trip-level tags: admins configure available tags in the trip header (inline add/remove pills). The AI can also create new tags via the add_tag tool.
Event tags: selectable in the Add Event modal, shown as colored pills on event cards in the timeline, and on AI suggestion cards.
AI integration: sees available tags in its system prompt, applies them when proposing events, can create new ones with add_tag.
Discord: tags shown as inline code blocks under each event in /trip view.
Colors: auto-assigned from an 8-color palette by tag index, consistent everywhere.
2026-06-21 15:00:37 -04:00
Logan a0fdf2486e chat fixes
Focus: textarea gets refocused via inputRef after the AI response (or error) lands
Persistence: chat history saved to localStorage keyed by trip ID, loaded on mount — survives refreshes
2026-06-21 14:55:34 -04:00
Logan e7622c7e6d chat box fixes 2026-06-21 14:47:17 -04:00
Logan 21d15d0426 assistant markdown update 2026-06-21 14:38:53 -04:00
Logan 21268ab477 fix: migrate Places and Routes to new GCP APIs
Switch from legacy Places textsearch and Directions APIs (disabled on
this project) to Places API (New) and Routes API (New). Both places.py
and the assistant's _places_search helper updated. Also fixes uid()
recursive self-call in trips page and adds Places API response logging.
2026-06-21 14:35:12 -04:00
Logan 522748f07a debugging for trips assistant 2026-06-21 14:31:26 -04:00
Logan af4079d648 fix build 2026-06-21 14:15:09 -04:00
Logan 39c002d090 Fix assistant 2026-06-21 14:08:33 -04:00
Logan 4295bdf4d2 Merge remote-tracking branch 'origin/main' into build-infrastructure 2026-06-21 13:51:58 -04:00
Logan 18d96193ab Security fixes
auth.py

secrets.compare_digest replaces == for service key comparison (timing-safe)
Added require_service_key — bot-only endpoints (trip/event join/leave)
Added require_service_key_or_admin — node commands/config (bot via service key OR dashboard admin via Firebase)
Added _RateLimiter with three shared instances: trip_chat_limiter (20/5min per user), summarize_limiter (5/10min per incident), bootstrap_limiter (2/hr per system)
nodes.py

send_command and assign_system now require require_service_key_or_admin — the Discord bot can still call them via service key, but regular Firebase users are blocked
tokens.py

add_token, flush_tokens, set_preferred_system, delete_token all require require_admin_token
Token masking changed from token[:10] + "…" + token[-4:] to "•••" + token[-4:]
systems.py

All write endpoints (create, update, delete, ai-flags, ten-codes, vocabulary writes, bootstrap) now require require_admin_token
bootstrap_vocabulary also calls bootstrap_limiter.check(system_id)
incidents.py

POST /incidents/summarize (bulk) now requires require_admin_token
POST /incidents/{id}/summarize now calls summarize_limiter.check(incident_id)
trips.py

join_trip, leave_trip, join_event, leave_event require require_service_key — only the Discord bot can set Discord attendee identity
delete_trip, delete_event require require_service_key_or_admin
trip_chat rate-limited per caller UID, history stripped to user/assistant roles only, user message truncated to 2000 chars, Maps query strings capped at 200 chars
upload.py

Rejects files larger than settings.upload_max_bytes (default 100MB) with 413
storage.py

_safe_audio_filename() derives GCS object name from call_id + allowlisted extension, completely ignoring the client-supplied filename
config.py

Added upload_max_bytes: int = 100 * 1024 * 1024
Both Dockerfiles — python:3.14-slim → python:3.12-slim
2026-06-21 13:40:08 -04:00
Logan a1c91c5ed3 Initial infra attempt 2026-06-21 13:37:03 -04:00
Logan f0a0ea508a adjust assistant height 2026-06-21 13:19:45 -04:00
Logan d64259bb18 Fix auth 2026-06-21 10:14:52 -04:00
Logan 7b9aefbcc5 Add UI to trips 2026-06-21 10:12:33 -04:00
Logan 8edb717dd2 Add trips to UI
lib/types.ts — TripRecord and TripEvent types

lib/c2api.ts — getTrips, getTrip, createTrip, deleteTrip, createTripEvent, deleteTripEvent

lib/useTrips.ts — Firestore realtime hook on the trips collection, ordered by start date

app/trips/page.tsx — List page split into Upcoming / Past sections, card click navigates to detail, "+ New Trip" modal for admins with all fields including date range and maps link

app/trips/[id]/page.tsx — Detail page fetched via C2 API (gets trip + events in one call), day-by-day itinerary with time, location, maps link, notes, and Discord attendees. Add Event modal (date constrained to trip range). Admin-only delete trip + remove event.

components/Nav.tsx — Trips link added to the nav
2026-06-20 23:34:45 -04:00
Logan fb096d582d feat: add /trip slash commands + add trips & itinerary system
New /trips router with full CRUD, attendee management, and nested
events. Events validate date is within parent trip range and inherit
trip location when not explicitly set. Leaving a trip cascades
removal from all its events.

New TripCommands cog with /trip create, list, view, delete, join,
leave and /trip event add, remove, join, leave. Event autocomplete
is scoped to the selected trip. Enforces must-be-on-trip rule for
event joins with a clear error message.
2026-06-20 23:25:08 -04:00
Logan a4962d7b0e map fixes 2026-06-20 23:19:41 -04:00
Logan 4e0e0fc79f Backend (incident_correlator.py):
- Create path (line ~1274): title only uses "at {location}" when location_coords is also set
- Update path (line ~1226): same guard — best_coords must be truthy alongside best_location

Frontend (MapView.tsx):
- Desktop sidebar: cards with location_coords → <button> fly-to; cards without → <a href> that navigates to the incident page with "View details →" text
- Mobile drawer: same split — with coords fly-to+close, without coords navigate via <a>
- Removed the "no coords" italic placeholder text; the card behavior itself makes it clear
2026-06-07 03:34:15 -04:00
Logan e55412d8c7 UI Updates
app/map/page.tsx

Removed IncidentCard component and the incidents grid below the map — the on-map sidebar inside MapView is the single display
Moved kiosk exit button from top-3 left-3 (overlapping zoom controls) to bottom-[5.5rem] left-3
components/MapView.tsx

Fixed popup "View incident →" link — adds stopPropagation() + window.location.href to prevent Leaflet intercepting the click
Added "View details →" link on each sidebar incident card so you can navigate from the map panel without opening a popup
Added "News Alerts" overlay layer (placeholder, ready for RSS/feed integration)
lib/types.ts

Added preferred_token_id?: string | null to SystemRecord
lib/c2api.ts

Added setPreferredToken(tokenId, systemId) calling PUT /tokens/{tokenId}/prefer/{systemId} (backend already existed)
app/systems/page.tsx

Added PreferredTokenPanel component — loads the token pool lazily on expand, shows radio buttons to set/clear the preferred token, displayed on each system card above the AI flags panel
2026-06-03 01:08:21 -04:00
Logan 9842b18799 Fix correlation false-merge, switch STT to whisper-1 without vocab prompt
- correlator: unit_overlap on dispatch channels now applies content
  divergence check when the call has geocoded coords but the incident
  doesn't; previously this gap caused unrelated calls to merge into
  stale incidents (e.g. patrol officer at a second scene 70 min later)
- STT: switch default model from gpt-4o-transcribe to whisper-1, which
  faithfully transcribes all exchanges in multi-PTT recordings; gpt-4o
  was silently dropping utterances, starving the correlation engine
- STT: remove vocabulary from the Whisper prompt; whisper-1 echoes
  prompted terms into noise/silence, skewing extracted incident data;
  vocabulary context is now applied exclusively in the GPT extraction
  step (build_gpt_vocab_block) where it is used as reference only
2026-06-03 00:51:25 -04:00
Logan fe6bf55c0e Fix fetch failure 2026-06-03 00:19:12 -04:00
Logan f65873d690 Fix TypeScript key prop error on SourceCallPlayer map
Wrap SourceCallPlayer in Fragment to avoid the broken JSX env treating
key as a component prop on the custom component.
2026-06-01 01:56:51 -04:00
Logan 913fe0cbee Add source call audio playback to vocabulary suggestions
When the induction loop proposes a new vocabulary term, it now records
which sampled call(s) most likely produced the suggestion. Admins see
a collapsible "▶ source" player under each pending term showing the
audio clip and transcript, so they can hear what was actually said
before approving or dismissing.

- vocabulary_learner: track sampled call docs, attach source_call_ids
  to each pending term via word-overlap search with fallback
- types: VocabularyPendingTerm.source_call_ids?: string[]
- c2api: add getCall(id) using existing GET /calls/{call_id} endpoint
- VocabularyPanel: SourceCallPlayer component — lazy-loads call on
  first expand, shows audio controls + transcript snippet
2026-06-01 01:45:03 -04:00
Logan 032eef311f Fix vocabulary induction loop running too late
The loop slept 24h before its first pass, so suggestions would never
appear unless the server was up for a full day. Move the sleep to the
end so the first induction pass runs ~30s after startup.
2026-06-01 01:26:54 -04:00
Logan 3d51db80d0 Improve extraction accuracy with speaker role inference
Add a SPEAKER ROLES section to the GPT-4o-mini prompt teaching it to
distinguish dispatch voice (names a unit then gives assignment + address)
from unit voice (opens with own callsign + brief status). Applied to
location attribution (dispatch-provided address beats unit position report)
and unit extraction (dispatched units vs. acknowledging units). No extra
API calls — purely prompt-level reasoning on the existing transcript.
2026-06-01 01:17:49 -04:00
Logan 683b05beb1 Silence ERROR log for status messages from deleted nodes
_handle_status was calling doc_update unconditionally, which throws a 404
when a node has been deleted from the UI but is still running and sending
heartbeats. Catch the "No document to update" error and log at info level
instead of bubbling up to the dispatch error handler.
2026-06-01 01:06:49 -04:00
66 changed files with 6259 additions and 250 deletions
+89
View File
@@ -0,0 +1,89 @@
name: Build & Deploy
on:
push:
branches: [main]
env:
# REGISTRY secret = "git.vpn.cusano.net/logan" (full image prefix)
REGISTRY: ${{ secrets.REGISTRY }}
jobs:
build:
name: Build & push images
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: git.vpn.cusano.net
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.BUILD_TOKEN }}
- name: Build & push c2-core
uses: docker/build-push-action@v5
with:
context: ./drb-c2-core
push: true
tags: |
${{ env.REGISTRY }}/c2-core:latest
${{ env.REGISTRY }}/c2-core:${{ gitea.sha }}
- name: Build & push discord-bot
uses: docker/build-push-action@v5
with:
context: ./drb-server-discord-bot
push: true
tags: |
${{ env.REGISTRY }}/discord-bot:latest
${{ env.REGISTRY }}/discord-bot:${{ gitea.sha }}
- name: Build & push frontend
uses: docker/build-push-action@v5
with:
context: ./drb-frontend
push: true
tags: |
${{ env.REGISTRY }}/frontend:latest
${{ env.REGISTRY }}/frontend:${{ gitea.sha }}
deploy:
name: Deploy to VM
needs: build
runs-on: ubuntu-latest
steps:
- name: Write SSH key
run: |
echo "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
- name: Deploy
run: |
ssh -o StrictHostKeyChecking=no \
-o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \
-i /tmp/deploy_key \
drb@${{ secrets.SERVER_IP }} << 'ENDSSH'
set -e
cd /opt/drb
# Update compose files + mosquitto config
git pull origin main
# Pull pre-built images and restart (no build on the VM)
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans
docker image prune -f
ENDSSH
- name: Health check
run: |
sleep 20
curl -f https://api.${{ secrets.DRB_DOMAIN }}/health || \
(echo "Health check failed" && exit 1)
+12
View File
@@ -5,6 +5,18 @@ drb-server-discord-bot/.env
drb-frontend/.env drb-frontend/.env
drb-c2-core/gcp-key.json drb-c2-core/gcp-key.json
# Terraform
infra/.terraform/
infra/terraform.tfstate
infra/terraform.tfstate.backup
infra/terraform.tfstate.*.backup
infra/.terraform.lock.hcl
infra/terraform.tfvars
infra/tf.log
infra/ansible/inventory.ini
infra/ansible/group_vars/all.yml
infra/ansible/vault.yml
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
+7 -3
View File
@@ -245,9 +245,13 @@ Edge node ──► audio upload ──► GCS storage
[2] INTELLIGENCE EXTRACTION (GPT-4o-mini) [2] INTELLIGENCE EXTRACTION (GPT-4o-mini)
Scene detection, entity extraction: Scene detection — splits multi-incident recordings
tags, incident_type, location, units, Speaker role inference — dispatch vs. unit patterns
vehicles, severity, resolved flag used to correctly attribute locations (dispatch-
provided address vs. unit position report) and
units (being dispatched vs. acknowledging)
Entity extraction: tags, incident_type, location,
units, vehicles, severity, resolved flag
+ geocoding (Google Maps) + geocoding (Google Maps)
+ embedding (text-embedding-3-small) + embedding (text-embedding-3-small)
→ CallRecord.tags, .location, .units, etc. → CallRecord.tags, .location, .units, etc.
+26
View File
@@ -0,0 +1,26 @@
# Production overrides — used on the VM.
# Run with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
#
# Differences from dev:
# - MQTT port 1883 is NOT published to the host (stays on the Docker bridge).
# Edge nodes reach it via WireGuard tunnel to the Docker bridge IP.
# - c2-core and frontend ports are only bound to localhost (Caddy proxies them).
# - restart: always (instead of unless-stopped) for hard reboots.
services:
mosquitto:
restart: always
ports: !reset [] # Remove the dev 1883:1883 mapping — internal only
c2-core:
restart: always
ports:
- "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly
discord-bot:
restart: always
frontend:
restart: always
ports:
- "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly
+3 -2
View File
@@ -17,17 +17,17 @@ services:
- mosquitto_data:/mosquitto/data - mosquitto_data:/mosquitto/data
c2-core: c2-core:
image: ${REGISTRY}/c2-core:${TAG:-latest}
build: ./drb-c2-core build: ./drb-c2-core
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8888:8000" - "8888:8000"
env_file: ./drb-c2-core/.env env_file: ./drb-c2-core/.env
volumes:
- ./drb-c2-core/gcp-key.json:/app/gcp-key.json:ro
depends_on: depends_on:
- mosquitto - mosquitto
discord-bot: discord-bot:
image: ${REGISTRY}/discord-bot:${TAG:-latest}
build: ./drb-server-discord-bot build: ./drb-server-discord-bot
restart: unless-stopped restart: unless-stopped
env_file: ./drb-server-discord-bot/.env env_file: ./drb-server-discord-bot/.env
@@ -35,6 +35,7 @@ services:
- c2-core - c2-core
frontend: frontend:
image: ${REGISTRY}/frontend:${TAG:-latest}
build: ./drb-frontend build: ./drb-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.14-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
+6 -2
View File
@@ -19,7 +19,7 @@ class Settings(BaseSettings):
# OpenAI (STT + intelligence) # OpenAI (STT + intelligence)
openai_api_key: Optional[str] = None openai_api_key: Optional[str] = None
stt_model: str = "gpt-4o-transcribe" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe stt_model: str = "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
# Google Maps (geocoding) # Google Maps (geocoding)
google_maps_api_key: Optional[str] = None google_maps_api_key: Optional[str] = None
@@ -51,7 +51,11 @@ class Settings(BaseSettings):
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase # Internal service key — allows server-side services (discord bot) to call C2 without Firebase
service_key: Optional[str] = None service_key: Optional[str] = None
# CORS — comma-separated list of allowed origins, or "*" for all # Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
upload_max_bytes: int = 100 * 1024 * 1024
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
# Defaults to "*" for local development only.
cors_origins: list[str] = ["*"] cors_origins: list[str] = ["*"]
class Config: class Config:
+26
View File
@@ -0,0 +1,26 @@
from datetime import datetime, timezone
from typing import Optional
from uuid import uuid4
from app.internal import firestore as fstore
async def write_audit(
actor_uid: str,
actor_email: str,
action: str,
target_uid: Optional[str] = None,
target_email: Optional[str] = None,
details: Optional[dict] = None,
) -> None:
"""Write an entry to the audit_log collection."""
doc_id = str(uuid4())
await fstore.doc_set("audit_log", doc_id, {
"log_id": doc_id,
"action": action,
"actor_uid": actor_uid,
"actor_email": actor_email,
"target_uid": target_uid,
"target_email": target_email,
"details": details or {},
"timestamp": datetime.now(timezone.utc).isoformat(),
}, merge=False)
+91 -3
View File
@@ -1,3 +1,6 @@
import secrets
import time
from collections import defaultdict, deque
from typing import Optional from typing import Optional
from fastapi import HTTPException, Security from fastapi import HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
@@ -26,7 +29,7 @@ async def require_service_or_firebase_token(
if not credentials: if not credentials:
raise HTTPException(status_code=401, detail="Missing authorization token") raise HTTPException(status_code=401, detail="Missing authorization token")
token = credentials.credentials token = credentials.credentials
if settings.service_key and token == settings.service_key: if settings.service_key and secrets.compare_digest(token, settings.service_key):
return {"service": True} return {"service": True}
try: try:
return firebase_auth.verify_id_token(token) return firebase_auth.verify_id_token(token)
@@ -34,11 +37,96 @@ async def require_service_or_firebase_token(
raise HTTPException(status_code=401, detail="Invalid or expired token") raise HTTPException(status_code=401, detail="Invalid or expired token")
def get_role(decoded: dict) -> str:
"""Extract the effective role from a decoded Firebase token.
Checks the granular ``role`` claim first, then falls back to the legacy
``admin`` boolean so existing tokens continue to work during the transition.
"""
if decoded.get("role") == "admin" or decoded.get("admin"):
return "admin"
role = decoded.get("role", "viewer")
return role if role in ("admin", "operator", "viewer") else "viewer"
async def require_admin_token( async def require_admin_token(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer), credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict: ) -> dict:
"""Verify a Firebase ID token AND require the admin custom claim.""" """Verify a Firebase ID token AND require the admin role.
Accepts both the legacy ``admin: True`` boolean claim and the newer
``role: "admin"`` claim so tokens issued before the role migration still work.
"""
decoded = await require_firebase_token(credentials) decoded = await require_firebase_token(credentials)
if not decoded.get("admin"): if get_role(decoded) != "admin":
raise HTTPException(status_code=403, detail="Admin access required") raise HTTPException(status_code=403, detail="Admin access required")
return decoded return decoded
async def require_service_key(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict:
"""Accept only the internal service key — used for bot-only endpoints."""
if not credentials:
raise HTTPException(status_code=401, detail="Missing authorization token")
if not settings.service_key:
raise HTTPException(status_code=503, detail="Service key not configured")
if not secrets.compare_digest(credentials.credentials, settings.service_key):
raise HTTPException(status_code=403, detail="Service key required")
return {"service": True}
async def require_service_key_or_admin(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict:
"""Accept either the internal service key or a Firebase admin token.
Used for endpoints that the Discord bot (service key) and dashboard admins
(Firebase + admin claim) both need to call, but regular Firebase users must not.
"""
if not credentials:
raise HTTPException(status_code=401, detail="Missing authorization token")
token = credentials.credentials
if settings.service_key and secrets.compare_digest(token, settings.service_key):
return {"service": True}
try:
decoded = firebase_auth.verify_id_token(token)
except Exception:
raise HTTPException(status_code=401, detail="Invalid or expired token")
if get_role(decoded) != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return decoded
# ---------------------------------------------------------------------------
# Simple in-memory sliding-window rate limiter
# ---------------------------------------------------------------------------
# Not persistent across restarts; good enough for a single-instance deployment.
# Key format is caller-defined (e.g. "{uid}:{endpoint}").
class _RateLimiter:
def __init__(self, max_calls: int, window_seconds: int):
self.max_calls = max_calls
self.window = window_seconds
self._log: dict[str, deque] = defaultdict(deque)
def check(self, key: str) -> None:
now = time.monotonic()
q = self._log[key]
while q and now - q[0] > self.window:
q.popleft()
if len(q) >= self.max_calls:
raise HTTPException(
status_code=429,
detail="Rate limit exceeded. Please wait before trying again.",
)
q.append(now)
# Shared limiter instances
# trip chat: 20 requests per user per 5 minutes
trip_chat_limiter = _RateLimiter(max_calls=20, window_seconds=300)
# per-incident summarize: 5 per incident per 10 minutes
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
# vocabulary bootstrap: 2 per system per hour
bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600)
@@ -1030,6 +1030,18 @@ def _call_fits_incident(
if dist_km > proximity_km: if dist_km > proximity_km:
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but location_conflict dist={dist_km:.2f}km → unit_loc_conflict") logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but location_conflict dist={dist_km:.2f}km → unit_loc_conflict")
return False, "unit_loc_conflict" return False, "unit_loc_conflict"
elif call_embedding and idle_min >= 15:
# Call has geocode but incident doesn't — fall back to content
# divergence as a location proxy. Without this, stale incidents
# that never geocoded absorb unrelated calls purely on unit
# overlap (e.g. a patrol officer working a second scene 70 min
# after the original call).
inc_emb_u = inc.get("embedding")
if inc_emb_u:
sim = _cosine_similarity(call_embedding, inc_emb_u)
if sim < 0.82:
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but content_divergence (has_call_coords/no_inc_coords) sim={sim:.3f} → content_divergence")
return False, "content_divergence"
elif call_embedding and idle_min >= 15: elif call_embedding and idle_min >= 15:
# No geocode available AND old incident: use content divergence as a # No geocode available AND old incident: use content divergence as a
# location-proxy veto. After 15+ minutes an officer at a completely # location-proxy veto. After 15+ minutes an officer at a completely
@@ -1211,7 +1223,7 @@ async def _update_incident(
talkgroup_name talkgroup_name
or (f"TGID {talkgroup_id}" if talkgroup_id else inc.get("title", "").split("")[-1]) or (f"TGID {talkgroup_id}" if talkgroup_id else inc.get("title", "").split("")[-1])
) )
if primary_tag and best_location and primary_tag.lower() != best_location.lower(): if primary_tag and best_location and best_coords and primary_tag.lower() != best_location.lower():
updates["title"] = f"{primary_tag} at {best_location}" updates["title"] = f"{primary_tag} at {best_location}"
elif primary_tag and tg_label: elif primary_tag and tg_label:
updates["title"] = f"{primary_tag}{tg_label}" updates["title"] = f"{primary_tag}{tg_label}"
@@ -1259,7 +1271,7 @@ async def _create_incident(
# Build a descriptive title from tags + location when available # Build a descriptive title from tags + location when available
content_tags = [t for t in tags if t != "auto-generated"] content_tags = [t for t in tags if t != "auto-generated"]
primary_tag = _tag_to_title(content_tags[0]) if content_tags else None primary_tag = _tag_to_title(content_tags[0]) if content_tags else None
if primary_tag and location and primary_tag.lower() != location.lower(): if primary_tag and location and location_coords and primary_tag.lower() != location.lower():
title = f"{primary_tag} at {location}" title = f"{primary_tag} at {location}"
elif primary_tag: elif primary_tag:
title = f"{primary_tag}{tg_label}" title = f"{primary_tag}{tg_label}"
+13 -2
View File
@@ -23,6 +23,17 @@ A busy dispatch channel sometimes captures back-to-back conversations about mult
Always respond with the scenes array, even for a single scene. Always respond with the scenes array, even for a single scene.
SPEAKER ROLES:
P25 radio follows a predictable call-and-response pattern. Use it to correctly attribute entities — you do not have explicit speaker labels, but you can infer roles from conversational structure:
- Dispatch voice: opens by naming a unit then giving an assignment ("Unit 7, respond to 123 Main..."), provides incident addresses, says "be advised" / "stand by", reads back unit status. Dispatch speaks TO units.
- Unit voice: opens with the unit's own callsign or a brief status ("Unit 7 en route", "Baker-1 on scene", "Unit 7, 10-97"), acknowledges with "copy" / "10-4", requests info about their assignment. Units speak TO dispatch.
Apply speaker inference to extraction:
- A callsign at the start of a dispatch assignment ("Unit 7, go to...") — that unit is being dispatched. Include it in units.
- A callsign that opens a short acknowledgment ("Unit 7 en route", "Baker-1 copies") — that is the speaker's own ID. Include it in units.
- A location stated in a dispatch assignment is the incident address. Use it as location.
- A location stated by a unit ("I'm at Route 202 and Main") is their current position — use it as location only when no dispatch-provided address is present in the scene.
Response format — a JSON object with a "scenes" array. Each scene: Response format — a JSON object with a "scenes" array. Each scene:
segment_indices: list of 0-based indices into the numbered transmissions (or null if no segments) segment_indices: list of 0-based indices into the numbered transmissions (or null if no segments)
incident_type: one of "fire" | "ems" | "police" | "accident" | "other" | "unknown" incident_type: one of "fire" | "ems" | "police" | "accident" | "other" | "unknown"
@@ -37,9 +48,9 @@ Response format — a JSON object with a "scenes" array. Each scene:
transcript_corrected: corrected text for this scene's transmissions only, or null transcript_corrected: corrected text for this scene's transmissions only, or null
Rules: Rules:
- location: prefer intersections > addresses > mile markers > route+town > route alone > town alone. Empty string if none. - location: prefer intersections > addresses > mile markers > route+town > route alone > town alone. Dispatch-provided addresses take priority over unit-reported positions. Empty string if none.
- tags: describe WHAT happened, not WHERE. Specific, lowercase, hyphenated. Do not use location names, road names, talkgroup names, or place names as tags (wrong: "lower-macy's", "canvas-route-6", "route-202"; right: "suspect-search", "shoplifting", "vehicle-pursuit"). Do not repeat incident_type as a tag. - tags: describe WHAT happened, not WHERE. Specific, lowercase, hyphenated. Do not use location names, road names, talkgroup names, or place names as tags (wrong: "lower-macy's", "canvas-route-6", "route-202"; right: "suspect-search", "shoplifting", "vehicle-pursuit"). Do not repeat incident_type as a tag.
- units: ONLY identifiers that appear verbatim in the transcript. If the word or number is not literally present in the text above, do not include it. Never infer or guess unit IDs. - units: ONLY identifiers that appear verbatim in the transcript. Use speaker role inference to distinguish units being dispatched from units acknowledging — both should be included. Never infer or guess unit IDs not present in the text.
- Do not invent details not present in the transcript. - Do not invent details not present in the transcript.
- incident_type: let the talkgroup channel be your primary signal. Use "fire" ONLY if the talkgroup is clearly a fire/rescue channel OR the transcript explicitly describes active fire, smoke, flames, or structure fire activation. Police or EMS referencing a fire scene → use "police" or "ems". When uncertain, prefer "other" over "fire". - incident_type: let the talkgroup channel be your primary signal. Use "fire" ONLY if the talkgroup is clearly a fire/rescue channel OR the transcript explicitly describes active fire, smoke, flames, or structure fire activation. Police or EMS referencing a fire scene → use "police" or "ems". When uncertain, prefer "other" over "fire".
- ten_codes: interpret radio codes using the department reference provided below. Do not guess codes not listed. - ten_codes: interpret radio codes using the department reference provided below. Do not guess codes not listed.
+10 -4
View File
@@ -127,10 +127,16 @@ class MQTTHandler:
status = payload.get("status") status = payload.get("status")
if not status: if not status:
return return
await fstore.doc_update("nodes", node_id, { try:
"status": status, await fstore.doc_update("nodes", node_id, {
"last_seen": datetime.now(timezone.utc).isoformat(), "status": status,
}) "last_seen": datetime.now(timezone.utc).isoformat(),
})
except Exception as e:
if "No document to update" in str(e):
logger.info(f"Status from deleted/unknown node {node_id} — ignoring (no Firestore doc)")
else:
raise
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Metadata — call_start / call_end events # Metadata — call_start / call_end events
+17 -2
View File
@@ -5,7 +5,21 @@ from app.config import settings
from app.internal.logger import logger from app.internal.logger import logger
async def upload_audio(data: bytes, filename: str) -> Optional[str]: def _safe_audio_filename(filename: str, call_id: str) -> str:
"""Return a safe GCS object name derived from the call_id.
We ignore the client-supplied filename entirely and derive the name from the
call_id (which we control) to prevent path traversal via crafted filenames.
The original extension is preserved only if it's a known audio type.
"""
import os
ext = os.path.splitext(filename)[-1].lower() if filename else ""
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
ext = ".mp3"
return f"{call_id}{ext}"
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
"""Upload audio bytes to GCS and return a signed URL, or None if disabled.""" """Upload audio bytes to GCS and return a signed URL, or None if disabled."""
if not settings.gcs_bucket: if not settings.gcs_bucket:
logger.info("GCS_BUCKET not configured — skipping audio upload.") logger.info("GCS_BUCKET not configured — skipping audio upload.")
@@ -21,7 +35,8 @@ async def upload_audio(data: bytes, filename: str) -> Optional[str]:
client = storage.Client() client = storage.Client()
signing_creds = None signing_creds = None
bucket = client.bucket(settings.gcs_bucket) bucket = client.bucket(settings.gcs_bucket)
blob = bucket.blob(f"calls/{filename}") safe_name = _safe_audio_filename(filename, call_id)
blob = bucket.blob(f"calls/{safe_name}")
blob.upload_from_string(data, content_type="audio/mpeg") blob.upload_from_string(data, content_type="audio/mpeg")
if signing_creds: if signing_creds:
return blob.generate_signed_url( return blob.generate_signed_url(
+9 -14
View File
@@ -40,16 +40,9 @@ async def transcribe_call(
if not gcs_uri or not gcs_uri.startswith("gs://"): if not gcs_uri or not gcs_uri.startswith("gs://"):
return None, [] return None, []
# Load vocabulary for this system (empty list if none yet)
vocabulary: list[str] = []
if system_id:
from app.internal.vocabulary_learner import get_vocabulary
vocab_data = await get_vocabulary(system_id)
vocabulary = vocab_data.get("vocabulary") or []
try: try:
transcript, segments = await asyncio.to_thread( transcript, segments = await asyncio.to_thread(
_sync_transcribe, gcs_uri, talkgroup_name, vocabulary _sync_transcribe, gcs_uri, talkgroup_name
) )
except Exception as e: except Exception as e:
logger.warning(f"Transcription failed for call {call_id}: {e}") logger.warning(f"Transcription failed for call {call_id}: {e}")
@@ -74,7 +67,6 @@ async def transcribe_call(
def _sync_transcribe( def _sync_transcribe(
gcs_uri: str, gcs_uri: str,
talkgroup_name: Optional[str] = None, talkgroup_name: Optional[str] = None,
vocabulary: Optional[list[str]] = None,
) -> tuple[Optional[str], list[dict]]: ) -> tuple[Optional[str], list[dict]]:
"""Download audio from GCS and transcribe with OpenAI Whisper.""" """Download audio from GCS and transcribe with OpenAI Whisper."""
from google.cloud import storage as gcs from google.cloud import storage as gcs
@@ -108,13 +100,16 @@ def _sync_transcribe(
try: try:
blob.download_to_filename(tmp_path) blob.download_to_filename(tmp_path)
from app.internal.vocabulary_learner import build_whisper_vocab_prompt tg_prefix = f"Talkgroup: {talkgroup_name}. " if talkgroup_name else ""
vocab_prefix = build_whisper_vocab_prompt(vocabulary or []) # Vocabulary is intentionally excluded from the Whisper prompt.
tg_prefix = f"Talkgroup: {talkgroup_name}. " if talkgroup_name else "" # whisper-1 treats the prompt as a transcription prior and echoes
prompt = tg_prefix + vocab_prefix + _WHISPER_PROMPT # vocabulary terms into noise/silence, polluting downstream extraction.
# Vocabulary context is applied in the GPT extraction step instead,
# where it is used as reference rather than a transcription prior.
prompt = tg_prefix + _WHISPER_PROMPT
# Only whisper-1 supports verbose_json (per-segment timestamps + no_speech_prob). # Only whisper-1 supports verbose_json (per-segment timestamps + no_speech_prob).
# Newer models (gpt-4o-transcribe, gpt-4o-mini-transcribe) only accept json/text. # gpt-4o-transcribe and gpt-4o-mini-transcribe only support json/text.
use_verbose = settings.stt_model == "whisper-1" use_verbose = settings.stt_model == "whisper-1"
openai_client = OpenAI(api_key=settings.openai_api_key) openai_client = OpenAI(api_key=settings.openai_api_key)
+39 -6
View File
@@ -18,6 +18,7 @@ import asyncio
import difflib import difflib
import json import json
import random import random
import re
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
from app.internal.logger import logger from app.internal.logger import logger
@@ -250,8 +251,8 @@ async def vocabulary_induction_loop() -> None:
f"interval: {settings.vocabulary_induction_interval_hours}h, " f"interval: {settings.vocabulary_induction_interval_hours}h, "
f"sample budget: {settings.vocabulary_induction_sample_tokens} tokens" f"sample budget: {settings.vocabulary_induction_sample_tokens} tokens"
) )
await asyncio.sleep(30) # short startup grace period before first pass
while True: while True:
await asyncio.sleep(interval)
try: try:
flags = await get_flags() flags = await get_flags()
if flags["vocabulary_learning_enabled"]: if flags["vocabulary_learning_enabled"]:
@@ -260,6 +261,7 @@ async def vocabulary_induction_loop() -> None:
logger.info("Vocabulary learning disabled — skipping induction pass") logger.info("Vocabulary learning disabled — skipping induction pass")
except Exception as e: except Exception as e:
logger.error(f"Vocabulary induction pass failed: {e}") logger.error(f"Vocabulary induction pass failed: {e}")
await asyncio.sleep(interval)
async def _run_induction_pass() -> None: async def _run_induction_pass() -> None:
@@ -296,6 +298,7 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
random.shuffle(all_calls) random.shuffle(all_calls)
char_budget = settings.vocabulary_induction_sample_tokens * 4 char_budget = settings.vocabulary_induction_sample_tokens * 4
transcript_block = "" transcript_block = ""
sampled_call_docs: list[dict] = []
sampled = 0 sampled = 0
for call in all_calls: for call in all_calls:
text = call.get("transcript_corrected") or call.get("transcript") or "" text = call.get("transcript_corrected") or call.get("transcript") or ""
@@ -305,6 +308,7 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
break break
tg = call.get("talkgroup_name") or f"TGID {call.get('talkgroup_id', '?')}" tg = call.get("talkgroup_name") or f"TGID {call.get('talkgroup_id', '?')}"
transcript_block += f"[{tg}] {text}\n" transcript_block += f"[{tg}] {text}\n"
sampled_call_docs.append(call)
sampled += 1 sampled += 1
if sampled < 3: if sampled < 3:
@@ -321,11 +325,16 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
pending_lower = {p["term"].lower() for p in existing_pending} pending_lower = {p["term"].lower() for p in existing_pending}
vocab_lower = {t.lower() for t in existing_vocab} vocab_lower = {t.lower() for t in existing_vocab}
to_queue = [ to_queue = []
{"term": t, "source": "induction", "added_at": now} for t in new_terms:
for t in new_terms if t.lower() in vocab_lower or t.lower() in pending_lower:
if t.lower() not in vocab_lower and t.lower() not in pending_lower continue
] to_queue.append({
"term": t,
"source": "induction",
"added_at": now,
"source_call_ids": _find_source_calls(t, sampled_call_docs),
})
if not to_queue: if not to_queue:
return return
@@ -342,6 +351,30 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
# Internal sync helpers # Internal sync helpers
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
def _find_source_calls(term: str, sampled_calls: list[dict], max_results: int = 3) -> list[str]:
"""
Find which sampled calls most likely produced this induction suggestion.
Splits the proposed term into tokens and searches call transcripts for overlap.
Falls back to the first two sampled calls when no token match is found
(e.g. fully garbled terms like "why vac""YVAC" have no word overlap).
"""
tokens = [t.lower() for t in re.split(r"[^a-zA-Z0-9]+", term) if len(t) >= 2]
matched: list[str] = []
if tokens:
for call in sampled_calls:
call_id = call.get("call_id")
if not call_id:
continue
text = (call.get("transcript_corrected") or call.get("transcript") or "").lower()
if any(tok in text for tok in tokens):
matched.append(call_id)
if len(matched) >= max_results:
break
if not matched:
matched = [c["call_id"] for c in sampled_calls[:2] if c.get("call_id")]
return matched
_STOP_WORDS = { _STOP_WORDS = {
"the", "and", "for", "are", "was", "were", "this", "that", "with", "the", "and", "for", "are", "was", "were", "this", "that", "with",
"have", "has", "had", "but", "not", "from", "they", "will", "what", "have", "has", "had", "but", "not", "from", "they", "will", "what",
+5 -1
View File
@@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop
from app.internal.recorrelation_sweep import recorrelation_loop from app.internal.recorrelation_sweep import recorrelation_loop
from app.config import settings from app.config import settings
from app.internal.auth import require_firebase_token, require_service_or_firebase_token from app.internal.auth import require_firebase_token, require_service_or_firebase_token
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.internal import firestore as fstore from app.internal import firestore as fstore
@@ -68,8 +68,12 @@ app.include_router(calls.router, dependencies=[Depends(require_service_or_fi
app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(trips.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(upload.router) # auth is per-node, handled inline app.include_router(upload.router) # auth is per-node, handled inline
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin) app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
app.include_router(users.router) # auth: admin only
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
@app.get("/health") @app.get("/health")
+45
View File
@@ -134,3 +134,48 @@ class AlertEvent(BaseModel):
transcript_snippet: Optional[str] = None transcript_snippet: Optional[str] = None
triggered_at: Optional[datetime] = None triggered_at: Optional[datetime] = None
acknowledged: bool = False acknowledged: bool = False
# ---------------------------------------------------------------------------
# Trips
# ---------------------------------------------------------------------------
class TripCreate(BaseModel):
name: str
location: str
maps_link: Optional[str] = None
start_date: str # YYYY-MM-DD
end_date: str # YYYY-MM-DD
available_tags: List[str] = [] # tag labels configured for this trip
overlap_tags: List[str] = [] # subset of available_tags that allow time overlap
visibility: str = "public" # "public" | "private"
invited_discord_ids: List[str] = [] # discord user IDs allowed on private trips
class TripEventCreate(BaseModel):
title: str
date: str # YYYY-MM-DD, must fall within parent trip range
start_time: Optional[str] = None # HH:MM (24h)
end_time: Optional[str] = None # HH:MM (24h)
location: Optional[str] = None # inherits trip location if None
maps_link: Optional[str] = None
place_id: Optional[str] = None # Google Place ID
notes: Optional[str] = None
tags: List[str] = [] # tag labels applied to this event
class TripEventUpdate(BaseModel):
title: Optional[str] = None
date: Optional[str] = None
start_time: Optional[str] = None
end_time: Optional[str] = None
location: Optional[str] = None
maps_link: Optional[str] = None
place_id: Optional[str] = None
notes: Optional[str] = None
tags: Optional[List[str]] = None
class AttendeeAction(BaseModel):
discord_user_id: str
discord_username: Optional[str] = None
+45 -7
View File
@@ -5,6 +5,21 @@ from app.internal.auth import require_admin_token, require_firebase_token
from app.internal.feature_flags import get_flags, set_flags from app.internal.feature_flags import get_flags, set_flags
from app.internal import firestore as fstore from app.internal import firestore as fstore
async def _get_ai_enabled_system_ids(global_flags: dict) -> set[str]:
"""Return system_ids where at least one AI function (STT or correlation) is effectively on."""
global_stt = global_flags.get("stt_enabled", True)
global_corr = global_flags.get("correlation_enabled", True)
all_systems = await fstore.collection_list("systems")
enabled: set[str] = set()
for system in all_systems:
sid = system.get("system_id")
if not sid:
continue
ai_flags = system.get("ai_flags") or {}
if ai_flags.get("stt_enabled", global_stt) or ai_flags.get("correlation_enabled", global_corr):
enabled.add(sid)
return enabled
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -73,10 +88,18 @@ async def debug_correlation(
"skip_reason": call.get("skip_reason"), "skip_reason": call.get("skip_reason"),
} }
# ── Fetch recent incidents ──────────────────────────────────────────────── # ── Determine which systems have AI active ────────────────────────────────
global_flags = await get_flags()
ai_systems = await _get_ai_enabled_system_ids(global_flags)
# ── Fetch recent incidents (AI-enabled systems only) ──────────────────────
all_incidents = await fstore.collection_list("incidents") all_incidents = await fstore.collection_list("incidents")
all_incidents.sort(key=lambda i: i.get("updated_at", ""), reverse=True) all_incidents.sort(key=lambda i: i.get("updated_at", ""), reverse=True)
incidents = all_incidents[:limit] ai_incidents = [
i for i in all_incidents
if any(sid in ai_systems for sid in (i.get("system_ids") or []))
]
incidents = ai_incidents[:limit]
# ── Fetch all linked call docs in parallel ──────────────────────────────── # ── Fetch all linked call docs in parallel ────────────────────────────────
all_call_ids: list[str] = [] all_call_ids: list[str] = []
@@ -98,15 +121,18 @@ async def debug_correlation(
] ]
incident_records.append(rec) incident_records.append(rec)
# ── Recent orphaned calls ───────────────────────────────────────────────── # ── Recent orphaned calls (AI-enabled systems only) ───────────────────────
# Use a single-field range query to avoid requiring a composite Firestore index;
# filter status and system in Python.
cutoff = datetime.now(timezone.utc) - timedelta(hours=orphan_hours) cutoff = datetime.now(timezone.utc) - timedelta(hours=orphan_hours)
recent_ended = await fstore.collection_where("calls", [ recent_calls = await fstore.collection_where("calls", [
("status", "==", "ended"),
("ended_at", ">=", cutoff), ("ended_at", ">=", cutoff),
]) ])
orphans = [ orphans = [
_call_summary(c) for c in recent_ended _call_summary(c) for c in recent_calls
if not c.get("incident_ids") and not c.get("incident_id") if c.get("status") == "ended"
and not c.get("incident_ids") and not c.get("incident_id")
and c.get("system_id") in ai_systems
] ]
orphans.sort(key=lambda c: c.get("started_at", ""), reverse=True) orphans.sort(key=lambda c: c.get("started_at", ""), reverse=True)
@@ -136,3 +162,15 @@ async def debug_correlation(
"incidents": incident_records, "incidents": incident_records,
"orphaned_calls": orphans[:250], "orphaned_calls": orphans[:250],
} }
@router.get("/audit")
async def get_audit_log(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
_=Depends(require_admin_token),
):
"""Return paginated audit log entries, most recent first."""
entries = await fstore.collection_list("audit_log")
entries.sort(key=lambda e: e.get("timestamp", ""), reverse=True)
return entries[offset: offset + limit]
+45
View File
@@ -1,3 +1,4 @@
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
@@ -59,6 +60,50 @@ async def reprocess_call(call_id: str, background_tasks: BackgroundTasks):
return {"ok": True, "call_id": call_id} return {"ok": True, "call_id": call_id}
@router.post("/close-stale")
async def close_stale_calls(
older_than_minutes: int = Query(30, ge=1, le=1440, description="Close active calls started more than this many minutes ago."),
dry_run: bool = Query(False, description="If true, return what would be closed without writing."),
_: dict = Depends(require_admin_token),
):
"""
Find and close calls stuck in 'active' status — e.g. because a node rebooted
before sending an end-call event. Returns the list of affected call IDs.
"""
cutoff = datetime.now(timezone.utc) - timedelta(minutes=older_than_minutes)
active_calls = await fstore.collection_list("calls", status="active")
stale = []
for call in active_calls:
started_raw = call.get("started_at")
if not started_raw:
continue
if isinstance(started_raw, datetime):
started = started_raw if started_raw.tzinfo else started_raw.replace(tzinfo=timezone.utc)
else:
try:
started = datetime.fromisoformat(str(started_raw).replace("Z", "+00:00"))
except Exception:
continue
if started < cutoff:
stale.append(call)
if not dry_run:
now_iso = datetime.now(timezone.utc).isoformat()
for call in stale:
await fstore.doc_set("calls", call["call_id"], {
"status": "ended",
"ended_at": now_iso,
})
return {
"dry_run": dry_run,
"older_than_minutes": older_than_minutes,
"count": len(stale),
"call_ids": [c["call_id"] for c in stale],
}
@router.patch("/{call_id}/transcript") @router.patch("/{call_id}/transcript")
async def patch_transcript( async def patch_transcript(
call_id: str, call_id: str,
+12 -3
View File
@@ -4,7 +4,7 @@ from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
from app.models import IncidentCreate, IncidentUpdate from app.models import IncidentCreate, IncidentUpdate
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import require_admin_token from app.internal.auth import require_admin_token, require_service_or_firebase_token, summarize_limiter
router = APIRouter(prefix="/incidents", tags=["incidents"]) router = APIRouter(prefix="/incidents", tags=["incidents"])
@@ -20,7 +20,10 @@ async def list_incidents(status: Optional[str] = None, type: Optional[str] = Non
@router.post("/summarize") @router.post("/summarize")
async def summarize_all_stale(background_tasks: BackgroundTasks): async def summarize_all_stale(
background_tasks: BackgroundTasks,
_: dict = Depends(require_admin_token),
):
"""Immediately run the summarizer pass on all stale incidents (don't wait for the next interval).""" """Immediately run the summarizer pass on all stale incidents (don't wait for the next interval)."""
from app.internal.summarizer import _run_summary_pass from app.internal.summarizer import _run_summary_pass
background_tasks.add_task(_run_summary_pass) background_tasks.add_task(_run_summary_pass)
@@ -76,12 +79,18 @@ async def delete_incident(incident_id: str, _: dict = Depends(require_admin_toke
@router.post("/{incident_id}/summarize") @router.post("/{incident_id}/summarize")
async def summarize_incident(incident_id: str, background_tasks: BackgroundTasks): async def summarize_incident(
incident_id: str,
background_tasks: BackgroundTasks,
decoded: dict = Depends(require_service_or_firebase_token),
):
"""Immediately run the summarizer for a specific incident.""" """Immediately run the summarizer for a specific incident."""
from app.internal.summarizer import _summarize_incident from app.internal.summarizer import _summarize_incident
inc = await fstore.doc_get("incidents", incident_id) inc = await fstore.doc_get("incidents", incident_id)
if not inc: if not inc:
raise HTTPException(404, f"Incident '{incident_id}' not found.") raise HTTPException(404, f"Incident '{incident_id}' not found.")
# Rate limit by incident ID to prevent repeated expensive LLM calls
summarize_limiter.check(incident_id)
background_tasks.add_task(_summarize_incident, inc) background_tasks.add_task(_summarize_incident, inc)
return {"ok": True, "incident_id": incident_id} return {"ok": True, "incident_id": incident_id}
+152
View File
@@ -0,0 +1,152 @@
import random
import string
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel
from app.internal import firestore as fstore
from app.internal.auth import require_firebase_token, require_service_key
from app.internal.logger import logger
router = APIRouter(prefix="/auth", tags=["auth"])
_CODE_TTL_MINUTES = 15
def _gen_code() -> str:
return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
# ---------------------------------------------------------------------------
# Web: generate a short-lived linking code
# ---------------------------------------------------------------------------
@router.post("/link/generate")
async def generate_link_code(decoded: dict = Depends(require_firebase_token)):
"""Authenticated Firebase user generates a code to paste into Discord /link."""
firebase_uid = decoded["uid"]
# Check if already linked
existing = await fstore.doc_get("firebase_discord_links", firebase_uid)
if existing and existing.get("discord_user_id"):
return {
"already_linked": True,
"discord_user_id": existing["discord_user_id"],
}
code = _gen_code()
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=_CODE_TTL_MINUTES)).isoformat()
await fstore.doc_set("link_codes", code, {
"firebase_uid": firebase_uid,
"expires_at": expires_at,
}, merge=False)
return {"code": code, "expires_minutes": _CODE_TTL_MINUTES}
# ---------------------------------------------------------------------------
# Discord bot: resolve a code and store the link
# ---------------------------------------------------------------------------
class LinkResolveBody(BaseModel):
code: str
discord_user_id: str
discord_username: str = ""
@router.post("/link")
async def resolve_link_code(body: LinkResolveBody, _: dict = Depends(require_service_key)):
"""Discord bot resolves a linking code and permanently links the accounts."""
doc = await fstore.doc_get("link_codes", body.code.upper().strip())
if not doc:
raise HTTPException(404, "Invalid or expired code.")
expires_at = datetime.fromisoformat(doc["expires_at"])
if datetime.now(timezone.utc) > expires_at:
await fstore.doc_delete("link_codes", body.code)
raise HTTPException(410, "Code has expired. Generate a new one from the web app.")
firebase_uid = doc["firebase_uid"]
# Check if this Discord account is already linked to a different Firebase UID
existing = await fstore.doc_get("discord_links", body.discord_user_id)
if existing and existing.get("firebase_uid") and existing["firebase_uid"] != firebase_uid:
raise HTTPException(409, "This Discord account is already linked to a different account.")
now = datetime.now(timezone.utc).isoformat()
# Store both directions
await fstore.doc_set("discord_links", body.discord_user_id, {
"firebase_uid": firebase_uid,
"discord_username": body.discord_username,
"linked_at": now,
}, merge=False)
await fstore.doc_set("firebase_discord_links", firebase_uid, {
"discord_user_id": body.discord_user_id,
"discord_username": body.discord_username,
"linked_at": now,
}, merge=False)
# Clean up the code
await fstore.doc_delete("link_codes", body.code)
logger.info(f"Linked firebase_uid={firebase_uid} <-> discord_user_id={body.discord_user_id}")
return {"ok": True, "firebase_uid": firebase_uid}
# ---------------------------------------------------------------------------
# Web: check current link status
# ---------------------------------------------------------------------------
@router.get("/link/status")
async def link_status(decoded: dict = Depends(require_firebase_token)):
firebase_uid = decoded["uid"]
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
if link and link.get("discord_user_id"):
return {
"linked": True,
"discord_user_id": link["discord_user_id"],
"discord_username": link.get("discord_username", ""),
"linked_at": link.get("linked_at"),
}
return {"linked": False}
# ---------------------------------------------------------------------------
# Web: unlink
# ---------------------------------------------------------------------------
@router.delete("/link")
async def unlink(decoded: dict = Depends(require_firebase_token)):
firebase_uid = decoded["uid"]
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
if not link or not link.get("discord_user_id"):
raise HTTPException(404, "No linked Discord account.")
discord_user_id = link["discord_user_id"]
await fstore.doc_delete("discord_links", discord_user_id)
await fstore.doc_delete("firebase_discord_links", firebase_uid)
return {"ok": True}
# ---------------------------------------------------------------------------
# Session recording — called by the frontend on each successful sign-in
# ---------------------------------------------------------------------------
@router.post("/session")
async def record_session(request: Request, decoded: dict = Depends(require_firebase_token)):
"""Record a sign-in event for the authenticated user."""
session_id = str(uuid4())
ip = request.client.host if request.client else None
user_agent = request.headers.get("user-agent", "")
await fstore.doc_set("user_sessions", session_id, {
"session_id": session_id,
"uid": decoded["uid"],
"email": decoded.get("email", ""),
"timestamp": datetime.now(timezone.utc).isoformat(),
"ip": ip,
"user_agent": user_agent,
}, merge=False)
return {"ok": True}
+7 -2
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query
from app.models import CommandPayload from app.models import CommandPayload
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.mqtt_handler import mqtt_handler from app.internal.mqtt_handler import mqtt_handler
from app.internal.auth import require_admin_token from app.internal.auth import require_admin_token, require_service_key_or_admin
from app.routers.tokens import assign_token, release_token from app.routers.tokens import assign_token, release_token
router = APIRouter(prefix="/nodes", tags=["nodes"]) router = APIRouter(prefix="/nodes", tags=["nodes"])
@@ -55,7 +55,11 @@ async def reject_node(node_id: str, _: dict = Depends(require_admin_token)):
@router.post("/{node_id}/command") @router.post("/{node_id}/command")
async def send_command(node_id: str, cmd: CommandPayload): async def send_command(
node_id: str,
cmd: CommandPayload,
_: dict = Depends(require_service_key_or_admin),
):
node = await fstore.doc_get("nodes", node_id) node = await fstore.doc_get("nodes", node_id)
if not node: if not node:
raise HTTPException(404, f"Node '{node_id}' not found.") raise HTTPException(404, f"Node '{node_id}' not found.")
@@ -108,6 +112,7 @@ async def assign_system(
system_id: str, system_id: str,
hardware_preset: str = Query("rtl-sdr-v3"), hardware_preset: str = Query("rtl-sdr-v3"),
ppm_override: Optional[float] = Query(None), ppm_override: Optional[float] = Query(None),
_: dict = Depends(require_service_key_or_admin),
): ):
""" """
Assign a system to a node. Fetches the system config from Firestore Assign a system to a node. Fetches the system config from Firestore
+100
View File
@@ -0,0 +1,100 @@
import httpx
from fastapi import APIRouter, HTTPException, Query
from app.config import settings
from app.internal.logger import logger
router = APIRouter(prefix="/places", tags=["places"])
_PLACES_SEARCH_URL = "https://places.googleapis.com/v1/places:searchText"
_ROUTES_URL = "https://routes.googleapis.com/directions/v2:computeRoutes"
_PLACES_FIELDS = "places.id,places.displayName,places.formattedAddress,places.rating,places.googleMapsUri,places.location"
@router.get("/search")
async def search_places(query: str = Query(...), near: str = Query("")):
if not settings.google_maps_api_key:
raise HTTPException(503, "Google Maps API not configured.")
full_query = f"{query} {near}".strip()
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
_PLACES_SEARCH_URL,
json={"textQuery": full_query},
headers={
"X-Goog-Api-Key": settings.google_maps_api_key,
"X-Goog-FieldMask": _PLACES_FIELDS,
},
)
r.raise_for_status()
data = r.json()
except Exception as e:
logger.error(f"Places search failed: {e}")
raise HTTPException(502, "Places search failed.")
return [
{
"name": p.get("displayName", {}).get("text"),
"address": p.get("formattedAddress"),
"place_id": p.get("id"),
"lat": p.get("location", {}).get("latitude"),
"lng": p.get("location", {}).get("longitude"),
"maps_link": p.get("googleMapsUri"),
"rating": p.get("rating"),
}
for p in data.get("places", [])[:6]
]
@router.get("/directions")
async def get_directions(
origin: str = Query(...),
destination: str = Query(...),
):
if not settings.google_maps_api_key:
raise HTTPException(503, "Google Maps API not configured.")
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
_ROUTES_URL,
json={
"origin": {"address": origin},
"destination": {"address": destination},
"travelMode": "DRIVE",
},
headers={
"X-Goog-Api-Key": settings.google_maps_api_key,
"X-Goog-FieldMask": "routes.duration,routes.distanceMeters",
},
)
r.raise_for_status()
data = r.json()
except Exception as e:
logger.error(f"Directions failed: {e}")
raise HTTPException(502, "Directions request failed.")
routes = data.get("routes", [])
if not routes:
return {"duration_text": None, "duration_seconds": None, "distance_text": None}
route = routes[0]
duration_seconds = int(route.get("duration", "0s").rstrip("s") or 0)
distance_m = route.get("distanceMeters", 0)
# Format human-readable strings
hours, rem = divmod(duration_seconds, 3600)
mins = rem // 60
if hours:
duration_text = f"{hours} hr {mins} min" if mins else f"{hours} hr"
else:
duration_text = f"{mins} min"
miles = distance_m / 1609.34
distance_text = f"{miles:.1f} mi"
return {
"duration_text": duration_text,
"duration_seconds": duration_seconds,
"distance_text": distance_text,
}
+40 -11
View File
@@ -1,9 +1,10 @@
import uuid import uuid
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel from pydantic import BaseModel
from typing import Dict, Optional from typing import Dict, Optional
from app.models import SystemCreate, SystemRecord from app.models import SystemCreate, SystemRecord
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import require_admin_token, bootstrap_limiter
router = APIRouter(prefix="/systems", tags=["systems"]) router = APIRouter(prefix="/systems", tags=["systems"])
@@ -35,7 +36,7 @@ async def get_system(system_id: str):
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_system(body: SystemCreate): async def create_system(body: SystemCreate, _: dict = Depends(require_admin_token)):
system_id = str(uuid.uuid4()) system_id = str(uuid.uuid4())
doc = SystemRecord(system_id=system_id, **body.model_dump()) doc = SystemRecord(system_id=system_id, **body.model_dump())
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False) await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
@@ -43,7 +44,7 @@ async def create_system(body: SystemCreate):
@router.put("/{system_id}") @router.put("/{system_id}")
async def update_system(system_id: str, body: SystemCreate): async def update_system(system_id: str, body: SystemCreate, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
raise HTTPException(404, f"System '{system_id}' not found.") raise HTTPException(404, f"System '{system_id}' not found.")
@@ -52,7 +53,7 @@ async def update_system(system_id: str, body: SystemCreate):
@router.delete("/{system_id}", status_code=204) @router.delete("/{system_id}", status_code=204)
async def delete_system(system_id: str): async def delete_system(system_id: str, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
raise HTTPException(404, f"System '{system_id}' not found.") raise HTTPException(404, f"System '{system_id}' not found.")
@@ -62,7 +63,11 @@ async def delete_system(system_id: str):
# ── Per-system AI flag overrides ────────────────────────────────────────────── # ── Per-system AI flag overrides ──────────────────────────────────────────────
@router.put("/{system_id}/ai-flags") @router.put("/{system_id}/ai-flags")
async def update_system_ai_flags(system_id: str, body: AiFlagsBody): async def update_system_ai_flags(
system_id: str,
body: AiFlagsBody,
_: dict = Depends(require_admin_token),
):
""" """
Set per-system AI flag overrides. Only fields included in the body are Set per-system AI flag overrides. Only fields included in the body are
written; omitted fields remain unchanged (or absent, meaning inherit global). written; omitted fields remain unchanged (or absent, meaning inherit global).
@@ -95,7 +100,11 @@ async def get_ten_codes(system_id: str):
@router.put("/{system_id}/ten-codes") @router.put("/{system_id}/ten-codes")
async def update_ten_codes(system_id: str, body: TenCodesBody): async def update_ten_codes(
system_id: str,
body: TenCodesBody,
_: dict = Depends(require_admin_token),
):
"""Replace the ten-code dictionary for a system.""" """Replace the ten-code dictionary for a system."""
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
@@ -117,18 +126,26 @@ async def get_vocabulary(system_id: str):
@router.post("/{system_id}/vocabulary/bootstrap", status_code=202) @router.post("/{system_id}/vocabulary/bootstrap", status_code=202)
async def bootstrap_vocabulary(system_id: str): async def bootstrap_vocabulary(
system_id: str,
decoded: dict = Depends(require_admin_token),
):
"""Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge.""" """Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge."""
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
raise HTTPException(404, f"System '{system_id}' not found.") raise HTTPException(404, f"System '{system_id}' not found.")
bootstrap_limiter.check(system_id)
from app.internal.vocabulary_learner import bootstrap_system_vocabulary from app.internal.vocabulary_learner import bootstrap_system_vocabulary
terms = await bootstrap_system_vocabulary(system_id) terms = await bootstrap_system_vocabulary(system_id)
return {"added": len(terms), "terms": terms} return {"added": len(terms), "terms": terms}
@router.post("/{system_id}/vocabulary/terms") @router.post("/{system_id}/vocabulary/terms")
async def add_vocabulary_term(system_id: str, body: VocabularyTermBody): async def add_vocabulary_term(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Manually add a term to the approved vocabulary.""" """Manually add a term to the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
@@ -139,7 +156,11 @@ async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
@router.delete("/{system_id}/vocabulary/terms") @router.delete("/{system_id}/vocabulary/terms")
async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody): async def remove_vocabulary_term(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Remove a term from the approved vocabulary.""" """Remove a term from the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
@@ -150,7 +171,11 @@ async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
@router.post("/{system_id}/vocabulary/pending/approve") @router.post("/{system_id}/vocabulary/pending/approve")
async def approve_pending(system_id: str, body: VocabularyTermBody): async def approve_pending(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Move a pending induction suggestion into the approved vocabulary.""" """Move a pending induction suggestion into the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
@@ -161,7 +186,11 @@ async def approve_pending(system_id: str, body: VocabularyTermBody):
@router.post("/{system_id}/vocabulary/pending/dismiss") @router.post("/{system_id}/vocabulary/pending/dismiss")
async def dismiss_pending(system_id: str, body: VocabularyTermBody): async def dismiss_pending(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Dismiss a pending induction suggestion without adding it.""" """Dismiss a pending induction suggestion without adding it."""
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
+11 -6
View File
@@ -1,9 +1,10 @@
import uuid import uuid
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from datetime import datetime, timezone from datetime import datetime, timezone
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import require_admin_token
router = APIRouter(prefix="/tokens", tags=["tokens"]) router = APIRouter(prefix="/tokens", tags=["tokens"])
@@ -22,13 +23,13 @@ async def list_tokens():
"""List all tokens. The actual token string is masked for safety.""" """List all tokens. The actual token string is masked for safety."""
tokens = await fstore.collection_list("bot_tokens") tokens = await fstore.collection_list("bot_tokens")
return [ return [
{**t, "token": t["token"][:10] + "" + t["token"][-4:]} {**t, "token": "•••" + t["token"][-4:]}
for t in tokens for t in tokens
] ]
@router.post("", status_code=201) @router.post("", status_code=201)
async def add_token(body: TokenCreate): async def add_token(body: TokenCreate, _: dict = Depends(require_admin_token)):
token_id = str(uuid.uuid4()) token_id = str(uuid.uuid4())
doc = { doc = {
"token_id": token_id, "token_id": token_id,
@@ -43,7 +44,7 @@ async def add_token(body: TokenCreate):
@router.post("/flush", status_code=200) @router.post("/flush", status_code=200)
async def flush_tokens(): async def flush_tokens(_: dict = Depends(require_admin_token)):
"""Force-release all in-use tokens (admin utility — use when tokens get orphaned).""" """Force-release all in-use tokens (admin utility — use when tokens get orphaned)."""
def _find(): def _find():
from app.internal.firestore import db from app.internal.firestore import db
@@ -61,7 +62,11 @@ async def flush_tokens():
@router.put("/{token_id}/prefer/{system_id}", status_code=200) @router.put("/{token_id}/prefer/{system_id}", status_code=200)
async def set_preferred_system(token_id: str, system_id: str): async def set_preferred_system(
token_id: str,
system_id: str,
_: dict = Depends(require_admin_token),
):
""" """
Mark this token as the preferred bot for a system. Mark this token as the preferred bot for a system.
When a discord_join is issued for any node in that system, this token When a discord_join is issued for any node in that system, this token
@@ -89,7 +94,7 @@ async def set_preferred_system(token_id: str, system_id: str):
@router.delete("/{token_id}", status_code=204) @router.delete("/{token_id}", status_code=204)
async def delete_token(token_id: str): async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("bot_tokens", token_id) existing = await fstore.doc_get("bot_tokens", token_id)
if not existing: if not existing:
raise HTTPException(404, "Token not found.") raise HTTPException(404, "Token not found.")
+597
View File
@@ -0,0 +1,597 @@
import uuid
import json
import httpx
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from app.models import TripCreate, TripEventCreate, TripEventUpdate, AttendeeAction
from app.internal import firestore as fstore
from app.config import settings
from app.internal.logger import logger
from app.internal.auth import (
require_service_or_firebase_token,
require_service_key,
require_service_key_or_admin,
trip_chat_limiter,
)
router = APIRouter(prefix="/trips", tags=["trips"])
# ---------------------------------------------------------------------------
# Access control helpers
# ---------------------------------------------------------------------------
async def _discord_id_for_firebase(firebase_uid: str) -> Optional[str]:
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
return (link or {}).get("discord_user_id")
def _trip_is_accessible(trip: dict, *, is_service: bool, firebase_uid: Optional[str], discord_id: Optional[str]) -> bool:
"""Return True if the caller may read this trip."""
if is_service:
return True # bot sees all; it filters client-side per-user
if trip.get("visibility", "public") == "public":
return True
if not firebase_uid:
return False
# attendees keyed by discord_id — check linked discord_id
if discord_id:
if discord_id in trip.get("attendees", {}):
return True
if discord_id in trip.get("invited_discord_ids", []):
return True
return False
# ---------------------------------------------------------------------------
# AI assistant — tool definitions
# ---------------------------------------------------------------------------
_TOOLS = [
{
"type": "function",
"function": {
"name": "search_places",
"description": (
"Search Google Maps for places (restaurants, bars, attractions, hotels, venues). "
"Use this whenever the user asks about specific places or you need to find options."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "What to search for, e.g. 'rooftop bars', 'Italian restaurants'",
},
"near": {
"type": "string",
"description": "Location to search near, e.g. 'downtown Nashville, TN'",
},
},
"required": ["query", "near"],
},
},
},
{
"type": "function",
"function": {
"name": "add_tag",
"description": (
"Add a new tag to the trip's available tag list so it can be used on events. "
"Use this when you want to apply a tag that doesn't exist yet."
),
"parameters": {
"type": "object",
"properties": {
"tag": {
"type": "string",
"description": "Short tag label, e.g. 'must-do', 'nightlife', 'food'",
},
},
"required": ["tag"],
},
},
},
{
"type": "function",
"function": {
"name": "propose_event",
"description": (
"Propose a specific event to add to the itinerary. "
"The user will see a card and can approve or dismiss it. "
"Call this once per proposed event — do not bundle multiple events into one call."
),
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"},
"date": {"type": "string", "description": "YYYY-MM-DD — must be within the trip date range"},
"start_time": {"type": "string", "description": "HH:MM (24h), e.g. '19:30'"},
"end_time": {"type": "string", "description": "HH:MM (24h), e.g. '22:00'"},
"location": {"type": "string", "description": "Full address or place name"},
"maps_link": {"type": "string", "description": "Google Maps URL"},
"notes": {"type": "string", "description": "Brief tips or reasoning"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to apply — must be from the trip's available tags list"},
},
"required": ["title"],
},
},
},
]
_PLACES_SEARCH_URL = "https://places.googleapis.com/v1/places:searchText"
_PLACES_FIELDS = "places.id,places.displayName,places.formattedAddress,places.rating,places.googleMapsUri"
async def _places_search(query: str, near: str) -> list[dict]:
if not settings.google_maps_api_key:
return []
full_query = f"{query} {near}".strip()
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.post(
_PLACES_SEARCH_URL,
json={"textQuery": full_query},
headers={
"X-Goog-Api-Key": settings.google_maps_api_key,
"X-Goog-FieldMask": _PLACES_FIELDS,
},
)
data = r.json()
places = data.get("places", [])
logger.info(f"Places search '{full_query}': count={len(places)}")
if not places and "error" in data:
logger.warning(f"Places API error: {data['error'].get('message', '')}")
return [
{
"name": p.get("displayName", {}).get("text"),
"address": p.get("formattedAddress"),
"place_id": p.get("id"),
"maps_link": p.get("googleMapsUri"),
"rating": p.get("rating"),
}
for p in places[:5]
]
except Exception as e:
logger.error(f"Places search in assistant failed: {e}")
return []
def _build_system_prompt(trip: dict, events: list[dict]) -> str:
by_date: dict[str, list] = {}
for e in sorted(events, key=lambda x: (x.get("date", ""), x.get("start_time") or "")):
by_date.setdefault(e["date"], []).append(e)
lines = []
for date, day_events in sorted(by_date.items()):
lines.append(f"\n {date}:")
for e in day_events:
t = ""
if e.get("start_time"):
t = f" {e['start_time']}"
if e.get("end_time"):
t += f"{e['end_time']}"
loc = f" @ {e['location']}" if e.get("location") and not e.get("location_inherited") else ""
lines.append(f"{e['title']}{t}{loc}")
if e.get("notes"):
lines.append(f" Notes: {e['notes']}")
itinerary = "".join(lines) if lines else "\n (no events yet)"
attendees = ", ".join(trip.get("attendees", {}).values()) or "not specified"
available_tags = trip.get("available_tags") or []
tags_section = f"\nAvailable tags: {', '.join(available_tags)}" if available_tags else ""
return f"""You are a trip planning assistant for the following trip.
Trip: {trip["name"]}
Destination: {trip["location"]}
Dates: {trip["start_date"]} to {trip["end_date"]}
Attendees: {attendees}{tags_section}
Current itinerary:{itinerary}
Guidelines:
- Be conversational and concise — don't over-explain.
- Format all responses using Markdown: use **bold** for place names and key details, bullet lists for options, and [links](url) for Maps links.
- When the user mentions places, activities, or asks for suggestions, search for them with search_places before proposing.
- Use propose_event for each concrete suggestion — one call per event. The user will approve or skip each one.
- When proposing events, apply relevant tags. Before using a tag, check if it exists in the available tags list. If it doesn't, call `add_tag` first to create it, then use it in `propose_event`.
- Be mindful of the existing schedule when assigning times. Avoid obvious conflicts.
- All proposed dates must fall between {trip["start_date"]} and {trip["end_date"]}.
- If the user says something like "everyone should be there by 6", factor that into your time proposals.
- If you don't know a specific address, search for the place first."""
class ChatMsg(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
message: str
history: list[ChatMsg] = []
@router.get("")
async def list_trips(decoded: dict = Depends(require_service_or_firebase_token)):
trips = await fstore.collection_list("trips")
is_service = bool(decoded.get("service"))
firebase_uid = decoded.get("uid")
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
return [t for t in trips if _trip_is_accessible(t, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id)]
@router.post("")
async def create_trip(body: TripCreate):
if body.end_date < body.start_date:
raise HTTPException(400, "end_date must be on or after start_date.")
trip_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat()
doc = {
"trip_id": trip_id,
"name": body.name,
"location": body.location,
"maps_link": body.maps_link,
"start_date": body.start_date,
"end_date": body.end_date,
"attendees": {}, # {discord_user_id: discord_username}
"available_tags": body.available_tags,
"overlap_tags": body.overlap_tags,
"visibility": body.visibility if body.visibility in ("public", "private") else "public",
"invited_discord_ids": body.invited_discord_ids,
"created_at": now,
}
await fstore.doc_set("trips", trip_id, doc, merge=False)
return doc
@router.get("/{trip_id}")
async def get_trip(trip_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
is_service = bool(decoded.get("service"))
firebase_uid = decoded.get("uid")
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
if not _trip_is_accessible(trip, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id):
raise HTTPException(403, "This trip is private.")
events = await fstore.collection_list("trip_events", trip_id=trip_id)
events.sort(key=lambda e: (e["date"], e.get("start_time") or ""))
return {**trip, "events": events}
@router.put("/{trip_id}/tags")
async def update_trip_tags(trip_id: str, body: dict):
"""Replace the trip's available tag list and overlap-allowed tag list."""
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
tags = [str(t) for t in body.get("available_tags", []) if t]
overlap = [str(t) for t in body.get("overlap_tags", []) if t and t in tags]
await fstore.doc_update("trips", trip_id, {"available_tags": tags, "overlap_tags": overlap})
return {"available_tags": tags, "overlap_tags": overlap}
@router.delete("/{trip_id}")
async def delete_trip(trip_id: str, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
events = await fstore.collection_list("trip_events", trip_id=trip_id)
for e in events:
await fstore.doc_delete("trip_events", e["event_id"])
await fstore.doc_delete("trips", trip_id)
return {"ok": True}
@router.post("/{trip_id}/join")
async def join_trip(
trip_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Join a trip as an attendee. Only the Discord bot (service key) may call this."""
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
if trip.get("visibility", "public") == "private":
invited = trip.get("invited_discord_ids", [])
attendees_existing = trip.get("attendees", {})
if body.discord_user_id not in invited and body.discord_user_id not in attendees_existing:
raise HTTPException(403, "This trip is private. You need an invite to join.")
attendees = trip.get("attendees", {})
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
await fstore.doc_update("trips", trip_id, {"attendees": attendees})
return {"ok": True, "attendees": attendees}
@router.put("/{trip_id}/visibility")
async def set_visibility(trip_id: str, body: dict, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
visibility = body.get("visibility", "public")
if visibility not in ("public", "private"):
raise HTTPException(400, "visibility must be 'public' or 'private'.")
await fstore.doc_update("trips", trip_id, {"visibility": visibility})
return {"visibility": visibility}
@router.post("/{trip_id}/invite/{discord_user_id}")
async def invite_user(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
invited = list(set(trip.get("invited_discord_ids", []) + [discord_user_id]))
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
return {"ok": True, "invited_discord_ids": invited}
@router.delete("/{trip_id}/invite/{discord_user_id}")
async def revoke_invite(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
invited = [u for u in trip.get("invited_discord_ids", []) if u != discord_user_id]
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
return {"ok": True, "invited_discord_ids": invited}
@router.post("/{trip_id}/leave")
async def leave_trip(
trip_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Leave a trip. Only the Discord bot (service key) may call this."""
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
attendees = trip.get("attendees", {})
attendees.pop(body.discord_user_id, None)
await fstore.doc_update("trips", trip_id, {"attendees": attendees})
# cascade: remove from all events in this trip
events = await fstore.collection_list("trip_events", trip_id=trip_id)
for e in events:
event_attendees = e.get("attendees", {})
if body.discord_user_id in event_attendees:
event_attendees.pop(body.discord_user_id)
await fstore.doc_update("trip_events", e["event_id"], {"attendees": event_attendees})
return {"ok": True}
@router.post("/{trip_id}/events")
async def create_event(trip_id: str, body: TripEventCreate):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
if not (trip["start_date"] <= body.date <= trip["end_date"]):
raise HTTPException(
400,
f"Event date {body.date} is outside the trip range "
f"{trip['start_date']} {trip['end_date']}.",
)
event_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat()
doc = {
"event_id": event_id,
"trip_id": trip_id,
"title": body.title,
"date": body.date,
"start_time": body.start_time,
"end_time": body.end_time,
"location": body.location if body.location is not None else trip["location"],
"location_inherited": body.location is None,
"maps_link": body.maps_link,
"place_id": body.place_id,
"notes": body.notes,
"tags": body.tags,
"attendees": {},
"created_at": now,
}
await fstore.doc_set("trip_events", event_id, doc, merge=False)
return doc
@router.patch("/{trip_id}/events/{event_id}")
async def update_event(trip_id: str, event_id: str, body: TripEventUpdate):
event = await fstore.doc_get("trip_events", event_id)
if not event or event.get("trip_id") != trip_id:
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
updates: dict = {}
if body.title is not None:
updates["title"] = body.title
if body.date is not None:
if not (trip["start_date"] <= body.date <= trip["end_date"]):
raise HTTPException(400, f"Event date {body.date} is outside the trip range.")
updates["date"] = body.date
if body.start_time is not None:
updates["start_time"] = body.start_time or None
if body.end_time is not None:
updates["end_time"] = body.end_time or None
if body.location is not None:
updates["location"] = body.location
updates["location_inherited"] = False
if body.maps_link is not None:
updates["maps_link"] = body.maps_link or None
if body.place_id is not None:
updates["place_id"] = body.place_id or None
if body.notes is not None:
updates["notes"] = body.notes or None
if body.tags is not None:
updates["tags"] = body.tags
if updates:
await fstore.doc_update("trip_events", event_id, updates)
return {**event, **updates}
@router.delete("/{trip_id}/events/{event_id}")
async def delete_event(
trip_id: str,
event_id: str,
_: dict = Depends(require_service_key_or_admin),
):
event = await fstore.doc_get("trip_events", event_id)
if not event or event.get("trip_id") != trip_id:
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
await fstore.doc_delete("trip_events", event_id)
return {"ok": True}
@router.post("/{trip_id}/events/{event_id}/join")
async def join_event(
trip_id: str,
event_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Join an event. Only the Discord bot (service key) may call this."""
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
if body.discord_user_id not in trip.get("attendees", {}):
raise HTTPException(403, "You must join the trip before joining an event.")
event = await fstore.doc_get("trip_events", event_id)
if not event or event.get("trip_id") != trip_id:
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
attendees = event.get("attendees", {})
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
await fstore.doc_update("trip_events", event_id, {"attendees": attendees})
return {"ok": True, "attendees": attendees}
@router.post("/{trip_id}/events/{event_id}/leave")
async def leave_event(
trip_id: str,
event_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Leave an event. Only the Discord bot (service key) may call this."""
event = await fstore.doc_get("trip_events", event_id)
if not event or event.get("trip_id") != trip_id:
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
attendees = event.get("attendees", {})
attendees.pop(body.discord_user_id, None)
await fstore.doc_update("trip_events", event_id, {"attendees": attendees})
return {"ok": True}
# ---------------------------------------------------------------------------
# AI trip planning assistant
# ---------------------------------------------------------------------------
@router.post("/{trip_id}/chat")
async def trip_chat(
trip_id: str,
body: ChatRequest,
decoded: dict = Depends(require_service_or_firebase_token),
):
if not settings.openai_api_key:
raise HTTPException(503, "OpenAI not configured.")
# Rate limit by caller identity
caller_key = decoded.get("uid") or ("service" if decoded.get("service") else "unknown")
trip_chat_limiter.check(f"{caller_key}:{trip_id}")
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
events = await fstore.collection_list("trip_events", trip_id=trip_id)
from openai import AsyncOpenAI
oai = AsyncOpenAI(api_key=settings.openai_api_key)
# Strip history to only user/assistant roles to prevent prompt injection
safe_history = [
{"role": m.role, "content": m.content}
for m in body.history[-20:]
if m.role in ("user", "assistant")
]
# Truncate message to prevent oversized single requests
user_message = body.message[:2000]
messages: list[dict] = [
{"role": "system", "content": _build_system_prompt(trip, events)},
*safe_history,
{"role": "user", "content": user_message},
]
suggestions: list[dict] = []
reply = ""
for _ in range(6): # max tool-call iterations
response = await oai.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=_TOOLS,
tool_choice="auto",
max_tokens=1000,
)
msg = response.choices[0].message
if not msg.tool_calls:
reply = msg.content or ""
break
# Append assistant message with tool calls
messages.append({
"role": "assistant",
"content": msg.content,
"tool_calls": [tc.model_dump() for tc in msg.tool_calls],
})
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
if tc.function.name == "add_tag":
new_tag = str(args.get("tag", "")).strip()[:50]
if new_tag and new_tag not in trip.get("available_tags", []):
updated_tags = list(trip.get("available_tags") or []) + [new_tag]
trip["available_tags"] = updated_tags
await fstore.doc_update("trips", trip_id, {"available_tags": updated_tags})
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps({"available_tags": trip.get("available_tags", [])}),
})
elif tc.function.name == "search_places":
# Limit query string lengths before hitting the Maps API
query = str(args.get("query", ""))[:200]
near = str(args.get("near", ""))[:200]
results = await _places_search(query, near)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(results),
})
elif tc.function.name == "propose_event":
suggestion = {k: args.get(k) for k in (
"title", "date", "start_time", "end_time", "location", "maps_link", "notes", "tags"
)}
if not isinstance(suggestion.get("tags"), list):
suggestion["tags"] = []
suggestions.append(suggestion)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps({"proposed": True, "title": args.get("title")}),
})
if not reply:
reply = f"Here {'are' if len(suggestions) != 1 else 'is'} {len(suggestions) or 'my'} suggestion{'s' if len(suggestions) != 1 else ''} for your trip."
return {"reply": reply, "suggestions": suggestions}
+4 -2
View File
@@ -4,6 +4,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.internal.storage import upload_audio from app.internal.storage import upload_audio
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.logger import logger from app.internal.logger import logger
from app.config import settings
router = APIRouter(tags=["upload"]) router = APIRouter(tags=["upload"])
@@ -43,9 +44,10 @@ async def upload_call_audio(
data = await file.read() data = await file.read()
if not data: if not data:
raise HTTPException(400, "Empty file.") raise HTTPException(400, "Empty file.")
if len(data) > settings.upload_max_bytes:
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
filename = file.filename audio_url = await upload_audio(data, file.filename or "", call_id=call_id)
audio_url = await upload_audio(data, filename)
if audio_url: if audio_url:
try: try:
+308
View File
@@ -0,0 +1,308 @@
import asyncio
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from firebase_admin import auth as firebase_auth
from app.internal.auth import require_admin_token
from app.internal import firestore as fstore
from app.internal import audit
router = APIRouter(prefix="/admin/users", tags=["users"])
VALID_ROLES = {"admin", "operator", "viewer"}
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class UserCreate(BaseModel):
email: str
role: str = "viewer"
display_name: Optional[str] = None
owned_node_ids: list[str] = []
class UserUpdate(BaseModel):
role: Optional[str] = None
owned_node_ids: Optional[list[str]] = None
display_name: Optional[str] = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ms_to_iso(ms: Optional[int]) -> Optional[str]:
if ms is None:
return None
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()
def _extract_role_nodes(fb_user: firebase_auth.UserRecord) -> tuple[str, list[str]]:
claims = fb_user.custom_claims or {}
if claims.get("role") == "admin" or claims.get("admin"):
role = "admin"
else:
role = claims.get("role", "viewer")
if role not in VALID_ROLES:
role = "viewer"
owned_node_ids = claims.get("owned_node_ids") or []
return role, owned_node_ids
def _format_user(fb_user: firebase_auth.UserRecord, link: Optional[dict] = None) -> dict:
role, owned_node_ids = _extract_role_nodes(fb_user)
return {
"uid": fb_user.uid,
"email": fb_user.email,
"display_name": fb_user.display_name,
"role": role,
"owned_node_ids": owned_node_ids,
"disabled": fb_user.disabled,
"creation_time": _ms_to_iso(fb_user.user_metadata.creation_timestamp),
"last_sign_in": _ms_to_iso(fb_user.user_metadata.last_sign_in_timestamp),
"discord_linked": bool(link and link.get("discord_user_id")),
"discord_username": link.get("discord_username") if link else None,
"discord_user_id": link.get("discord_user_id") if link else None,
}
def _list_fb_users() -> list[firebase_auth.UserRecord]:
users: list[firebase_auth.UserRecord] = []
page = firebase_auth.list_users()
while page:
users.extend(page.users)
page = page.get_next_page()
return users
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("")
async def list_users(decoded: dict = Depends(require_admin_token)):
"""List all Firebase Auth users with role, node ownership, and Discord link status."""
fb_users = await asyncio.to_thread(_list_fb_users)
links: list[Optional[dict]] = await asyncio.gather(*[
fstore.doc_get("firebase_discord_links", u.uid) for u in fb_users
])
return [_format_user(u, lnk) for u, lnk in zip(fb_users, links)]
@router.post("")
async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_token)):
"""Create a new Firebase Auth user and set their role. Returns a one-time invite link."""
if body.role not in VALID_ROLES:
raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}")
if body.role == "operator" and not body.owned_node_ids:
raise HTTPException(400, "Operator role requires at least one owned node.")
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(
firebase_auth.create_user,
email=body.email,
display_name=body.display_name or "",
email_verified=False,
)
except firebase_auth.EmailAlreadyExistsError:
raise HTTPException(409, "A user with this email already exists.")
except Exception as e:
raise HTTPException(400, f"Failed to create user: {e}")
# Set custom claims
claims: dict = {"role": body.role, "owned_node_ids": body.owned_node_ids}
if body.role == "admin":
claims["admin"] = True
await asyncio.to_thread(firebase_auth.set_custom_user_claims, fb_user.uid, claims)
# Write Firestore profile
now = datetime.now(timezone.utc).isoformat()
await fstore.doc_set("user_profiles", fb_user.uid, {
"uid": fb_user.uid,
"email": body.email,
"display_name": body.display_name or "",
"role": body.role,
"owned_node_ids": body.owned_node_ids,
"created_by_uid": decoded["uid"],
"created_at": now,
}, merge=False)
# Generate a one-time invite/password-reset link
invite_link: Optional[str] = None
try:
invite_link = await asyncio.to_thread(firebase_auth.generate_password_reset_link, body.email)
except Exception:
pass
await audit.write_audit(
actor_uid=decoded["uid"],
actor_email=decoded.get("email", ""),
action="user.create",
target_uid=fb_user.uid,
target_email=body.email,
details={"role": body.role, "owned_node_ids": body.owned_node_ids},
)
return {**_format_user(fb_user), "invite_link": invite_link}
@router.get("/{uid}")
async def get_user(uid: str, decoded: dict = Depends(require_admin_token)):
"""Get a single user with full detail, including recent sessions."""
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
except firebase_auth.UserNotFoundError:
raise HTTPException(404, "User not found.")
link, raw_sessions = await asyncio.gather(
fstore.doc_get("firebase_discord_links", uid),
fstore.collection_where("user_sessions", [("uid", "==", uid)]),
)
raw_sessions.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
return {
**_format_user(fb_user, link),
"sessions": raw_sessions[:20],
}
@router.patch("/{uid}")
async def update_user(uid: str, body: UserUpdate, decoded: dict = Depends(require_admin_token)):
"""Update a user's role, owned nodes, or display name."""
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
except firebase_auth.UserNotFoundError:
raise HTTPException(404, "User not found.")
current_role, current_nodes = _extract_role_nodes(fb_user)
new_role = body.role if body.role is not None else current_role
new_nodes = body.owned_node_ids if body.owned_node_ids is not None else current_nodes
if new_role not in VALID_ROLES:
raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}")
if new_role == "operator" and not new_nodes:
raise HTTPException(400, "Operator role requires at least one owned node.")
# Merge with existing claims (preserve any other claims already set)
existing_claims: dict = dict(fb_user.custom_claims or {})
new_claims = {**existing_claims, "role": new_role, "owned_node_ids": new_nodes}
if new_role == "admin":
new_claims["admin"] = True
else:
new_claims.pop("admin", None)
await asyncio.to_thread(firebase_auth.set_custom_user_claims, uid, new_claims)
if body.display_name is not None:
await asyncio.to_thread(firebase_auth.update_user, uid, display_name=body.display_name)
profile_data: dict = {"uid": uid, "role": new_role, "owned_node_ids": new_nodes}
if body.display_name is not None:
profile_data["display_name"] = body.display_name
await fstore.doc_set("user_profiles", uid, profile_data, merge=True)
await audit.write_audit(
actor_uid=decoded["uid"],
actor_email=decoded.get("email", ""),
action="user.update",
target_uid=uid,
target_email=fb_user.email,
details={
"old_role": current_role,
"new_role": new_role,
"old_nodes": current_nodes,
"new_nodes": new_nodes,
},
)
updated: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
link = await fstore.doc_get("firebase_discord_links", uid)
return _format_user(updated, link)
@router.post("/{uid}/disable")
async def disable_user(uid: str, decoded: dict = Depends(require_admin_token)):
"""Disable a user — they can no longer sign in but their data is preserved."""
if uid == decoded.get("uid"):
raise HTTPException(400, "Cannot disable your own account.")
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
except firebase_auth.UserNotFoundError:
raise HTTPException(404, "User not found.")
await asyncio.to_thread(firebase_auth.update_user, uid, disabled=True)
await audit.write_audit(
actor_uid=decoded["uid"],
actor_email=decoded.get("email", ""),
action="user.disable",
target_uid=uid,
target_email=fb_user.email,
)
return {"ok": True}
@router.post("/{uid}/enable")
async def enable_user(uid: str, decoded: dict = Depends(require_admin_token)):
"""Re-enable a previously disabled user."""
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
except firebase_auth.UserNotFoundError:
raise HTTPException(404, "User not found.")
await asyncio.to_thread(firebase_auth.update_user, uid, disabled=False)
await audit.write_audit(
actor_uid=decoded["uid"],
actor_email=decoded.get("email", ""),
action="user.enable",
target_uid=uid,
target_email=fb_user.email,
)
return {"ok": True}
@router.delete("/{uid}")
async def delete_user(uid: str, decoded: dict = Depends(require_admin_token)):
"""Permanently delete a user from Firebase Auth and clean up Firestore data."""
if uid == decoded.get("uid"):
raise HTTPException(400, "Cannot delete your own account.")
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
except firebase_auth.UserNotFoundError:
raise HTTPException(404, "User not found.")
email = fb_user.email
# Clean up Discord link if present
link = await fstore.doc_get("firebase_discord_links", uid)
if link and link.get("discord_user_id"):
await asyncio.gather(
fstore.doc_delete("discord_links", link["discord_user_id"]),
fstore.doc_delete("firebase_discord_links", uid),
)
# Delete Firestore profile (sessions are kept for audit history)
await fstore.doc_delete("user_profiles", uid)
# Delete from Firebase Auth
await asyncio.to_thread(firebase_auth.delete_user, uid)
await audit.write_audit(
actor_uid=decoded["uid"],
actor_email=decoded.get("email", ""),
action="user.delete",
target_uid=uid,
target_email=email,
)
return {"ok": True}
+833 -65
View File
@@ -2,8 +2,13 @@
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { UserRecord, AuditEntry, UserRole } from "@/lib/types";
// ---------------------------------------------------------------------------
// Shared primitives
// ---------------------------------------------------------------------------
interface FeatureFlags { interface FeatureFlags {
stt_enabled: boolean; stt_enabled: boolean;
@@ -61,6 +66,99 @@ function Toggle({
); );
} }
function fmtDate(iso: string | null | undefined) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function fmtDatetime(iso: string | null | undefined) {
if (!iso) return "—";
return new Date(iso).toLocaleString("en-US", {
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit",
});
}
const ROLE_COLORS: Record<UserRole, string> = {
admin: "bg-indigo-900 text-indigo-300",
operator: "bg-green-900 text-green-300",
viewer: "bg-gray-800 text-gray-400",
};
function RoleBadge({ role }: { role: UserRole }) {
const labels: Record<UserRole, string> = { admin: "Admin", operator: "Operator", viewer: "Viewer" };
return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${ROLE_COLORS[role]}`}>
{labels[role]}
</span>
);
}
// ---------------------------------------------------------------------------
// AI Features tab
// ---------------------------------------------------------------------------
function FeaturesTab() {
const [flags, setFlags] = useState<FeatureFlags | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
c2api.getFeatureFlags()
.then((f) => setFlags(f as unknown as FeatureFlags))
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, []);
async function handleToggle(key: keyof FeatureFlags, value: boolean) {
if (!flags) return;
setSaving(key);
setError(null);
try {
const updated = await c2api.setFeatureFlags({ [key]: value });
setFlags(updated as unknown as FeatureFlags);
} catch (e) {
setError(String(e));
} finally {
setSaving(null);
}
}
return (
<section className="space-y-3">
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
{FLAG_META.map(({ key, label, description }) => (
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="text-white text-sm font-semibold">{label}</p>
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p>
</div>
<Toggle
enabled={flags?.[key] ?? true}
onChange={(val) => handleToggle(key, val)}
disabled={saving === key}
/>
</div>
))}
</div>
)}
</section>
);
}
// ---------------------------------------------------------------------------
// Correlation Debug tab
// ---------------------------------------------------------------------------
function CorrelationDebugTab() { function CorrelationDebugTab() {
const [limit, setLimit] = useState(20); const [limit, setLimit] = useState(20);
const [orphanHours, setOrphanHours] = useState(48); const [orphanHours, setOrphanHours] = useState(48);
@@ -121,7 +219,6 @@ function CorrelationDebugTab() {
orphans_by_talkgroup?: Array<{ talkgroup_id?: number; talkgroup_name?: string; count: number; no_type_count: number; sweep_exhausted_count: number }>; orphans_by_talkgroup?: Array<{ talkgroup_id?: number; talkgroup_name?: string; count: number; no_type_count: number; sweep_exhausted_count: number }>;
} | null; } | null;
// Aggregate corr_path and corr_fit_signal counts across all incident calls.
const pathCounts: Record<string, number> = {}; const pathCounts: Record<string, number> = {};
const signalCounts: Record<string, number> = {}; const signalCounts: Record<string, number> = {};
if (meta?.incidents) { if (meta?.incidents) {
@@ -245,91 +342,762 @@ function CorrelationDebugTab() {
); );
} }
export default function AdminPage() { // ---------------------------------------------------------------------------
const { isAdmin } = useAuth(); // User detail panel
const router = useRouter(); // ---------------------------------------------------------------------------
const [tab, setTab] = useState<"features" | "correlation">("features");
const [flags, setFlags] = useState<FeatureFlags | null>(null); function UserDetailPanel({
const [loading, setLoading] = useState(true); user,
const [saving, setSaving] = useState<string | null>(null); onClose,
onUpdated,
currentUid,
}: {
user: UserRecord;
onClose: () => void;
onUpdated: (u: UserRecord) => void;
currentUid: string;
}) {
const [detail, setDetail] = useState<UserRecord>(user);
const [editRole, setEditRole] = useState<UserRole>(user.role);
const [editNodes, setEditNodes] = useState<string>(user.owned_node_ids.join(", "));
const [editName, setEditName] = useState<string>(user.display_name ?? "");
const [saving, setSaving] = useState(false);
const [toggling, setToggling] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showSessions, setShowSessions] = useState(false);
// Fetch full detail (sessions) lazily
useEffect(() => { useEffect(() => {
if (!isAdmin) { c2api.getUser(user.uid)
router.replace("/dashboard"); .then((d) => setDetail(d))
return; .catch(() => {});
} }, [user.uid]);
c2api.getFeatureFlags()
.then((f) => setFlags(f as unknown as FeatureFlags))
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, [isAdmin, router]);
async function handleToggle(key: keyof FeatureFlags, value: boolean) { async function handleSave() {
if (!flags) return; setSaving(true);
setSaving(key);
setError(null); setError(null);
const nodes = editRole === "operator"
? editNodes.split(",").map((s) => s.trim()).filter(Boolean)
: [];
try { try {
const updated = await c2api.setFeatureFlags({ [key]: value }); const updated = await c2api.updateUser(user.uid, {
setFlags(updated as unknown as FeatureFlags); role: editRole,
owned_node_ids: nodes,
display_name: editName || undefined,
});
onUpdated(updated);
setDetail((d) => ({ ...d, ...updated }));
} catch (e) { } catch (e) {
setError(String(e)); setError(e instanceof Error ? e.message : String(e));
} finally { } finally {
setSaving(null); setSaving(false);
} }
} }
if (!isAdmin) return null; async function handleToggleDisabled() {
setToggling(true);
setError(null);
try {
if (detail.disabled) {
await c2api.enableUser(user.uid);
} else {
await c2api.disableUser(user.uid);
}
const next = { ...detail, disabled: !detail.disabled };
setDetail(next);
onUpdated(next);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setToggling(false);
}
}
async function handleDelete() {
if (!confirm(`Permanently delete ${detail.email}? This cannot be undone.`)) return;
setDeleting(true);
setError(null);
try {
await c2api.deleteUser(user.uid);
onUpdated({ ...detail, uid: "__deleted__" });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setDeleting(false);
}
}
const isSelf = user.uid === currentUid;
return ( return (
<div className="max-w-2xl space-y-6"> <div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-5 font-mono">
<h1 className="text-white text-xl font-bold font-mono">Admin</h1> <div className="flex items-start justify-between">
<div>
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit"> <p className="text-white font-semibold">{detail.email}</p>
{(["features", "correlation"] as const).map((t) => ( <p className="text-xs text-gray-500 mt-0.5">{detail.uid}</p>
<button </div>
key={t} <button onClick={onClose} className="text-gray-600 hover:text-gray-300 transition-colors text-xl leading-none">×</button>
onClick={() => setTab(t)}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
}`}
>
{t === "features" ? "AI Features" : "Correlation Debug"}
</button>
))}
</div> </div>
{tab === "features" && ( {error && (
<section className="space-y-3"> <div className="bg-red-950 border border-red-800 rounded-lg p-3">
{error && ( <p className="text-red-400 text-xs">{error}</p>
<div className="bg-red-950 border border-red-800 rounded-lg p-3"> </div>
<p className="text-red-400 text-sm font-mono">{error}</p> )}
</div>
)} <div className="space-y-3">
{loading ? ( <div>
<p className="text-gray-500 text-sm font-mono">Loading</p> <label className="text-xs text-gray-400 block mb-1">Display Name</label>
) : ( <input
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800"> value={editName}
{FLAG_META.map(({ key, label, description }) => ( onChange={(e) => setEditName(e.target.value)}
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4"> className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
<div className="min-w-0"> placeholder="Full name"
<p className="text-white text-sm font-semibold">{label}</p> />
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p> </div>
</div>
<Toggle <div>
enabled={flags?.[key] ?? true} <label className="text-xs text-gray-400 block mb-1">Role</label>
onChange={(val) => handleToggle(key, val)} <select
disabled={saving === key} value={editRole}
/> onChange={(e) => setEditRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
>
<option value="admin">Admin full access</option>
<option value="operator">Operator owns nodes</option>
<option value="viewer">Viewer read-only</option>
</select>
</div>
{editRole === "operator" && (
<div>
<label className="text-xs text-gray-400 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
</label>
<input
value={editNodes}
onChange={(e) => setEditNodes(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="node-abc123, node-def456"
/>
</div>
)}
<button
onClick={handleSave}
disabled={saving}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-1.5 rounded-lg transition-colors"
>
{saving ? "Saving…" : "Save changes"}
</button>
</div>
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-gray-500">Status</span>
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
{detail.disabled ? "Disabled" : "Active"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Discord</span>
<span className="text-gray-300">
{detail.discord_linked
? `@${detail.discord_username ?? detail.discord_user_id}`
: "Not linked"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Created</span>
<span className="text-gray-300">{fmtDate(detail.creation_time)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Last sign-in</span>
<span className="text-gray-300">{fmtDate(detail.last_sign_in)}</span>
</div>
</div>
{(detail.sessions?.length ?? 0) > 0 && (
<div className="border-t border-gray-800 pt-4">
<button
onClick={() => setShowSessions((v) => !v)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1"
>
<span>{showSessions ? "▲" : "▼"}</span>
<span>Login history ({detail.sessions?.length} recent)</span>
</button>
{showSessions && (
<div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto">
{detail.sessions?.map((s) => (
<div key={s.session_id} className="text-xs text-gray-400 flex justify-between gap-4">
<span>{fmtDatetime(s.timestamp)}</span>
<span className="text-gray-600 truncate">{s.ip ?? "—"}</span>
</div> </div>
))} ))}
</div> </div>
)} )}
</section> </div>
)} )}
{tab === "correlation" && <CorrelationDebugTab />} <div className="border-t border-gray-800 pt-4 flex gap-4 flex-wrap">
{!isSelf ? (
<>
<button
onClick={handleToggleDisabled}
disabled={toggling}
className="text-xs text-yellow-500 hover:text-yellow-400 disabled:opacity-50 transition-colors"
>
{toggling ? "…" : detail.disabled ? "Enable account" : "Disable account"}
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="text-xs text-red-500 hover:text-red-400 disabled:opacity-50 transition-colors"
>
{deleting ? "Deleting…" : "Delete user"}
</button>
</>
) : (
<p className="text-xs text-gray-600">Cannot disable or delete your own account.</p>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Create User modal
// ---------------------------------------------------------------------------
function CreateUserModal({
onClose,
onCreated,
}: {
onClose: () => void;
onCreated: (u: UserRecord) => void;
}) {
const [email, setEmail] = useState("");
const [displayName, setDisplayName] = useState("");
const [role, setRole] = useState<UserRole>("viewer");
const [nodeIds, setNodeIds] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
const owned_node_ids = role === "operator"
? nodeIds.split(",").map((s) => s.trim()).filter(Boolean)
: [];
try {
const created = await c2api.createUser({
email,
role,
display_name: displayName || undefined,
owned_node_ids,
});
onCreated(created);
if (created.invite_link) {
setInviteLink(created.invite_link);
} else {
onClose();
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
}
function copyLink() {
if (!inviteLink) return;
navigator.clipboard?.writeText(inviteLink).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
if (inviteLink) {
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 space-y-4 font-mono">
<h2 className="text-white font-semibold">User Created</h2>
<p className="text-xs text-gray-400">
Share this one-time invite link with the new user so they can set their password.
It expires after use.
</p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
</div>
<div className="flex gap-3">
<button
onClick={copyLink}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
{copied ? "Copied!" : "Copy link"}
</button>
<button
onClick={onClose}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg py-2 text-sm transition-colors"
>
Done
</button>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 font-mono">
<h2 className="text-white font-semibold mb-4">Create User</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="user@example.com"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">
Display Name <span className="text-gray-600">(optional)</span>
</label>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="Jane Smith"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
>
<option value="admin">Admin full access</option>
<option value="operator">Operator owns nodes</option>
<option value="viewer">Viewer read-only</option>
</select>
</div>
{role === "operator" && (
<div>
<label className="text-xs text-gray-400 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
</label>
<input
value={nodeIds}
onChange={(e) => setNodeIds(e.target.value)}
required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="node-abc123, node-def456"
/>
</div>
)}
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 pt-1">
<button
type="submit"
disabled={saving}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{saving ? "Creating…" : "Create user"}
</button>
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Users tab
// ---------------------------------------------------------------------------
function UsersTab({ currentUid }: { currentUid: string }) {
const [users, setUsers] = useState<UserRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedUid, setSelectedUid] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const loadUsers = useCallback(async () => {
try {
const data = await c2api.listUsers();
setUsers(data);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadUsers(); }, [loadUsers]);
function handleUpdated(updated: UserRecord) {
if (updated.uid === "__deleted__") {
setUsers((prev) => prev.filter((u) => u.uid !== selectedUid));
setSelectedUid(null);
} else {
setUsers((prev) => prev.map((u) => u.uid === updated.uid ? { ...u, ...updated } : u));
}
}
function handleCreated(created: UserRecord) {
setUsers((prev) => [...prev, created]);
}
const selectedUser = users.find((u) => u.uid === selectedUid);
return (
<div className="space-y-4">
{showCreate && (
<CreateUserModal
onClose={() => setShowCreate(false)}
onCreated={(u) => { handleCreated(u); setShowCreate(false); }}
/>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500 font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
<button
onClick={() => setShowCreate(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
>
+ Create user
</button>
</div>
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : users.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No users found.</p>
) : (
<div className="border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Email</th>
<th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
<th className="px-4 py-2.5 text-left">Role</th>
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Discord</th>
<th className="px-4 py-2.5 text-left hidden md:table-cell">Last sign-in</th>
<th className="px-4 py-2.5 text-left">Status</th>
<th className="px-4 py-2.5 w-16"></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr
key={u.uid}
className={`border-t border-gray-800 transition-colors ${
selectedUid === u.uid ? "bg-gray-800/60" : "hover:bg-gray-900/60"
}`}
>
<td className="px-4 py-2.5 text-gray-200">{u.email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-400 hidden lg:table-cell">{u.display_name ?? "—"}</td>
<td className="px-4 py-2.5"><RoleBadge role={u.role} /></td>
<td className="px-4 py-2.5 text-gray-500 hidden sm:table-cell">
{u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"}
</td>
<td className="px-4 py-2.5 text-gray-500 hidden md:table-cell">{fmtDate(u.last_sign_in)}</td>
<td className="px-4 py-2.5">
{u.disabled
? <span className="text-red-500">Disabled</span>
: <span className="text-green-500">Active</span>
}
</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => setSelectedUid(selectedUid === u.uid ? null : u.uid)}
className="text-indigo-400 hover:text-indigo-300 transition-colors"
>
{selectedUid === u.uid ? "Close" : "Edit"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{selectedUser && (
<UserDetailPanel
user={selectedUser}
onClose={() => setSelectedUid(null)}
onUpdated={handleUpdated}
currentUid={currentUid}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Audit Log tab
// ---------------------------------------------------------------------------
function AuditLogTab() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const PAGE = 50;
useEffect(() => {
c2api.getAuditLog(PAGE, 0)
.then((data) => {
setEntries(data);
setHasMore(data.length === PAGE);
})
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, []);
async function loadMore() {
setLoadingMore(true);
try {
const more = await c2api.getAuditLog(PAGE, entries.length);
setEntries((prev) => [...prev, ...more]);
setHasMore(more.length === PAGE);
} catch (e) {
setError(String(e));
} finally {
setLoadingMore(false);
}
}
function actionColor(action: string) {
if (action.includes("delete")) return "text-red-400";
if (action.includes("disable")) return "text-yellow-400";
if (action.includes("create")) return "text-green-400";
return "text-indigo-400";
}
return (
<div className="space-y-4">
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : entries.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No audit entries yet.</p>
) : (
<>
<div className="border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Time</th>
<th className="px-4 py-2.5 text-left">Action</th>
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
<th className="px-4 py-2.5 text-left hidden md:table-cell">Target</th>
<th className="px-4 py-2.5 text-left">Details</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.log_id} className="border-t border-gray-800 hover:bg-gray-900/40">
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
<td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td>
<td className="px-4 py-2.5 text-gray-400 hidden sm:table-cell">{e.actor_email}</td>
<td className="px-4 py-2.5 text-gray-400 hidden md:table-cell">{e.target_email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-600 max-w-xs truncate">
{Object.keys(e.details).length > 0
? Object.entries(e.details)
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
.join(" · ")
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{hasMore && (
<button
onClick={loadMore}
disabled={loadingMore}
className="text-sm font-mono text-indigo-400 hover:text-indigo-300 disabled:opacity-50 transition-colors"
>
{loadingMore ? "Loading…" : "Load more"}
</button>
)}
</>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Stale Calls tab
// ---------------------------------------------------------------------------
function StaleCallsTab() {
const [minutes, setMinutes] = useState(30);
const [result, setResult] = useState<{ dry_run: boolean; count: number; call_ids: string[] } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function run(dryRun: boolean) {
setLoading(true);
setError(null);
setResult(null);
try {
const res = await c2api.closeStallCalls(minutes, dryRun);
setResult(res);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}
return (
<div className="space-y-5">
<p className="text-xs text-gray-500 font-mono">
Finds calls stuck in <span className="text-gray-300">active</span> status because a node rebooted before sending an end-call event.
Preview first, then close.
</p>
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Older than (minutes)</label>
<input
type="number"
min={1} max={1440}
value={minutes}
onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))}
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
/>
</div>
<button
onClick={() => run(true)}
disabled={loading}
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
>
{loading ? "Working…" : "Preview"}
</button>
<button
onClick={() => run(false)}
disabled={loading || result === null || result.count === 0}
className="bg-red-700 hover:bg-red-600 disabled:opacity-50 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
>
{result && !result.dry_run ? "Closed" : result?.count ? `Close ${result.count} calls` : "Close calls"}
</button>
</div>
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{result && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2">
<p className="text-sm font-mono text-white">
{result.dry_run ? "Preview: " : "Closed: "}
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
{result.count} stale call{result.count !== 1 ? "s" : ""}
</span>
{result.count === 0 && <span className="text-gray-500"> nothing to clear</span>}
</p>
{result.call_ids.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-0.5">
{result.call_ids.map((id) => (
<p key={id} className="text-xs font-mono text-gray-400">{id}</p>
))}
</div>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main admin page
// ---------------------------------------------------------------------------
type AdminTab = "features" | "correlation" | "users" | "audit" | "calls";
const TAB_LABELS: { key: AdminTab; label: string }[] = [
{ key: "features", label: "AI Features" },
{ key: "correlation", label: "Correlation Debug" },
{ key: "calls", label: "Calls" },
{ key: "users", label: "Users" },
{ key: "audit", label: "Audit Log" },
];
export default function AdminPage() {
const { user, isAdmin } = useAuth();
const router = useRouter();
const [tab, setTab] = useState<AdminTab>("features");
useEffect(() => {
if (!isAdmin) router.replace("/dashboard");
}, [isAdmin, router]);
if (!isAdmin) return null;
// Users/Audit tabs benefit from full width; everything else is narrow
const wide = tab === "users" || tab === "audit";
return (
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
{TAB_LABELS.map(({ key, label }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
tab === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
}`}
>
{label}
</button>
))}
</div>
{tab === "features" && <FeaturesTab />}
{tab === "correlation" && <CorrelationDebugTab />}
{tab === "calls" && <StaleCallsTab />}
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
{tab === "audit" && <AuditLogTab />}
</div> </div>
); );
} }
+3
View File
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth"; import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import { c2api } from "@/lib/c2api";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export default function LoginPage() { export default function LoginPage() {
@@ -18,6 +19,7 @@ export default function LoginPage() {
setError(null); setError(null);
try { try {
await signInWithEmailAndPassword(auth, email, password); await signInWithEmailAndPassword(auth, email, password);
c2api.recordSession().catch(() => {});
router.push("/dashboard"); router.push("/dashboard");
} catch { } catch {
setError("Invalid email or password."); setError("Invalid email or password.");
@@ -31,6 +33,7 @@ export default function LoginPage() {
setError(null); setError(null);
try { try {
await signInWithPopup(auth, new GoogleAuthProvider()); await signInWithPopup(auth, new GoogleAuthProvider());
c2api.recordSession().catch(() => {});
router.push("/dashboard"); router.push("/dashboard");
} catch { } catch {
setError("Google sign-in failed. Try again."); setError("Google sign-in failed. Try again.");
+3 -51
View File
@@ -2,48 +2,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Link from "next/link";
import { useNodes } from "@/lib/useNodes"; import { useNodes } from "@/lib/useNodes";
import { useActiveCalls } from "@/lib/useCalls"; import { useActiveCalls } from "@/lib/useCalls";
import { useActiveIncidents } from "@/lib/useIncidents"; import { useActiveIncidents } from "@/lib/useIncidents";
import type { IncidentRecord } from "@/lib/types";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
const TYPE_COLORS: Record<string, string> = {
fire: "border-red-800 bg-red-950 text-red-300",
police: "border-blue-800 bg-blue-950 text-blue-300",
ems: "border-yellow-800 bg-yellow-950 text-yellow-300",
accident: "border-orange-800 bg-orange-950 text-orange-300",
other: "border-gray-700 bg-gray-900 text-gray-300",
};
function IncidentCard({ incident }: { incident: IncidentRecord }) {
const cls = TYPE_COLORS[incident.type ?? "other"] ?? TYPE_COLORS.other;
return (
<Link
href={`/incidents/${incident.incident_id}`}
className={`block border rounded-lg p-3 hover:brightness-110 transition-all ${cls}`}
>
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-xs font-mono font-semibold uppercase tracking-wide">
{incident.type ?? "other"}
</span>
<span className="text-xs opacity-60 font-mono">
{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
</span>
</div>
<p className="text-sm font-bold leading-tight">{incident.title ?? "Incident"}</p>
{incident.location && (
<p className="text-xs opacity-70 mt-1 font-mono truncate">{incident.location}</p>
)}
{!incident.location_coords && (
<p className="text-xs opacity-40 mt-1 font-mono italic">location not geocoded yet</p>
)}
</Link>
);
}
export default function MapPage() { export default function MapPage() {
const { nodes, loading } = useNodes(); const { nodes, loading } = useNodes();
const activeCalls = useActiveCalls(); const activeCalls = useActiveCalls();
@@ -69,7 +33,7 @@ export default function MapPage() {
<button <button
onClick={() => setKiosk(false)} onClick={() => setKiosk(false)}
title="Exit fullscreen" title="Exit fullscreen"
className="absolute top-3 left-3 z-[1002] bg-gray-950/90 border border-gray-700 rounded px-3 py-1.5 text-xs font-mono text-gray-300 hover:text-white hover:border-gray-500 transition-colors flex items-center gap-1.5" className="absolute bottom-[5.5rem] left-3 z-[1002] bg-gray-950/90 border border-gray-700 rounded px-3 py-1.5 text-xs font-mono text-gray-300 hover:text-white hover:border-gray-500 transition-colors flex items-center gap-1.5"
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/> <path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/>
@@ -97,11 +61,11 @@ export default function MapPage() {
</div> </div>
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-96 text-gray-600 font-mono text-sm"> <div className="flex items-center justify-center h-[calc(100vh-10rem)] border border-gray-800 rounded-lg text-gray-600 font-mono text-sm">
Loading map Loading map
</div> </div>
) : ( ) : (
<div className="h-[50vh] sm:h-[65vh] min-h-[400px]"> <div className="w-full h-[calc(100vh-10rem)] border border-gray-800 rounded-lg overflow-hidden">
<MapView <MapView
nodes={nodes} nodes={nodes}
activeCalls={activeCalls} activeCalls={activeCalls}
@@ -111,18 +75,6 @@ export default function MapPage() {
</div> </div>
)} )}
{incidents.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
Active Incidents ({incidents.length})
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{incidents.map((inc) => (
<IncidentCard key={inc.incident_id} incident={inc} />
))}
</div>
</section>
)}
</div> </div>
); );
} }
+11 -1
View File
@@ -1,15 +1,25 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useNodes } from "@/lib/useNodes"; import { useNodes } from "@/lib/useNodes";
import { useSystems } from "@/lib/useSystems"; import { useSystems } from "@/lib/useSystems";
import { NodeCard } from "@/components/NodeCard"; import { NodeCard } from "@/components/NodeCard";
import { NodeConfigModal } from "@/components/NodeConfigModal"; import { NodeConfigModal } from "@/components/NodeConfigModal";
import { useAuth } from "@/components/AuthProvider";
import type { NodeRecord } from "@/lib/types"; import type { NodeRecord } from "@/lib/types";
export default function NodesPage() { export default function NodesPage() {
const { isAdmin, isOperator, loading: authLoading } = useAuth();
const router = useRouter();
const { nodes, loading } = useNodes(); const { nodes, loading } = useNodes();
const { systems } = useSystems(); const { systems } = useSystems();
useEffect(() => {
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
}, [authLoading, isAdmin, isOperator, router]);
if (authLoading || (!isAdmin && !isOperator)) return null;
const [configNode, setConfigNode] = useState<NodeRecord | null>(null); const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s])); const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
+222
View File
@@ -0,0 +1,222 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
interface LinkStatus {
linked: boolean;
discord_user_id?: string;
discord_username?: string;
linked_at?: string;
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric",
});
}
function Initials({ name }: { name: string }) {
const parts = name.trim().split(/\s+/);
const letters = parts.length >= 2
? parts[0][0] + parts[parts.length - 1][0]
: name.slice(0, 2);
return (
<div className="w-16 h-16 rounded-full bg-indigo-700 flex items-center justify-center text-white text-xl font-bold select-none">
{letters.toUpperCase()}
</div>
);
}
export default function ProfilePage() {
const { user, isAdmin, role, signOut } = useAuth();
const router = useRouter();
const [linkStatus, setLinkStatus] = useState<LinkStatus | null>(null);
const [linkLoading, setLinkLoading] = useState(true);
const [code, setCode] = useState<string | null>(null);
const [codeExpiry, setCodeExpiry] = useState<number | null>(null);
const [generating, setGenerating] = useState(false);
const [unlinking, setUnlinking] = useState(false);
useEffect(() => {
if (!user) return;
c2api.getLinkStatus()
.then(setLinkStatus)
.catch(() => setLinkStatus({ linked: false }))
.finally(() => setLinkLoading(false));
}, [user]);
async function generateCode() {
setGenerating(true);
try {
const res = await c2api.generateLinkCode();
if (res.already_linked) {
setLinkStatus((prev) => prev ? { ...prev, linked: true } : prev);
} else if (res.code) {
setCode(res.code);
setCodeExpiry(res.expires_minutes ?? 15);
}
} finally {
setGenerating(false);
}
}
async function unlink() {
setUnlinking(true);
try {
await c2api.unlinkDiscord();
setLinkStatus({ linked: false });
setCode(null);
} finally {
setUnlinking(false);
}
}
async function handleSignOut() {
await signOut();
router.push("/login");
}
if (!user) return null;
const displayName = user.displayName || user.email || "Account";
return (
<div className="max-w-lg space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Initials name={displayName} />
<div>
<h1 className="text-white text-xl font-bold">{displayName}</h1>
{user.displayName && user.email && (
<p className="text-gray-400 text-sm mt-0.5">{user.email}</p>
)}
{role && (
<span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
role === "admin" ? "bg-indigo-900 text-indigo-300" :
role === "operator" ? "bg-green-900 text-green-300" :
"bg-gray-800 text-gray-400"
}`}>
{role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"}
</span>
)}
</div>
</div>
{/* Firebase account */}
<section className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
<div className="px-4 py-3">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Account</p>
<div className="space-y-2">
<Row label="Email" value={user.email ?? "—"} />
<Row label="UID" value={user.uid} mono truncate />
<Row label="Role" value={role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"} />
{user.metadata.creationTime && (
<Row label="Joined" value={fmtDate(user.metadata.creationTime)} />
)}
{user.metadata.lastSignInTime && (
<Row label="Last sign-in" value={fmtDate(user.metadata.lastSignInTime)} />
)}
</div>
</div>
</section>
{/* Discord linking */}
<section className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<div className="px-4 py-3">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-3">Discord</p>
{linkLoading ? (
<p className="text-gray-500 text-sm">Loading</p>
) : linkStatus?.linked ? (
<div className="space-y-3">
<div className="space-y-2">
{linkStatus.discord_username && (
<Row label="Username" value={`@${linkStatus.discord_username}`} />
)}
{linkStatus.discord_user_id && (
<Row label="User ID" value={linkStatus.discord_user_id} mono />
)}
{linkStatus.linked_at && (
<Row label="Linked" value={fmtDate(linkStatus.linked_at)} />
)}
</div>
<div className="pt-1">
<button
onClick={unlink}
disabled={unlinking}
className="text-xs text-red-500 hover:text-red-400 transition-colors disabled:opacity-50"
>
{unlinking ? "Unlinking…" : "Unlink Discord account"}
</button>
</div>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-gray-400">
Link your Discord account to access private trips from both the web and Discord.
</p>
{code ? (
<div className="space-y-2">
<div className="bg-gray-800 rounded-lg px-4 py-3 flex items-center gap-3">
<span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span>
</div>
<p className="text-xs text-gray-400">
Run <span className="font-mono text-gray-200">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
</p>
<button
onClick={generateCode}
disabled={generating}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors disabled:opacity-50"
>
Generate new code
</button>
</div>
) : (
<button
onClick={generateCode}
disabled={generating}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2 transition-colors"
>
{generating ? "Generating…" : "Get link code"}
</button>
)}
</div>
)}
</div>
</section>
{/* Sign out */}
<section className="bg-gray-900 border border-gray-800 rounded-xl">
<div className="px-4 py-3 flex items-center justify-between">
<p className="text-sm text-gray-400">Sign out of this device</p>
<button
onClick={handleSignOut}
className="text-sm text-red-500 hover:text-red-400 transition-colors"
>
Sign out
</button>
</div>
</section>
</div>
);
}
function Row({ label, value, mono = false, truncate = false }: {
label: string;
value: string;
mono?: boolean;
truncate?: boolean;
}) {
return (
<div className="flex items-start justify-between gap-4">
<span className="text-xs text-gray-500 shrink-0">{label}</span>
<span className={`text-sm text-gray-200 text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
{value}
</span>
</div>
);
}
+194 -7
View File
@@ -1,8 +1,10 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useEffect, useRef, useState, Fragment } from "react";
import { useRouter } from "next/navigation";
import { useSystems } from "@/lib/useSystems"; import { useSystems } from "@/lib/useSystems";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { useAuth } from "@/components/AuthProvider";
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types"; import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
// ── P25 structured config types ─────────────────────────────────────────────── // ── P25 structured config types ───────────────────────────────────────────────
@@ -739,6 +741,123 @@ function SystemForm({
); );
} }
// ── Preferred bot token panel ─────────────────────────────────────────────────
interface TokenOption {
token_id: string;
name: string;
in_use: boolean;
}
function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; initialTokenId?: string | null }) {
const [preferredId, setPreferredId] = useState<string | null>(initialTokenId ?? null);
const [tokens, setTokens] = useState<TokenOption[] | null>(null);
const [saving, setSaving] = useState(false);
const [open, setOpen] = useState(false);
async function load() {
if (tokens !== null) return;
try {
const data = await c2api.getTokens();
setTokens(data as TokenOption[]);
} catch {
setTokens([]);
}
}
function toggle() {
if (!open) load();
setOpen((v) => !v);
}
async function handleSet(tokenId: string) {
setSaving(true);
try {
await c2api.setPreferredToken(tokenId, systemId);
setPreferredId(tokenId);
} finally {
setSaving(false);
}
}
async function handleClear() {
if (!preferredId) return;
setSaving(true);
try {
await c2api.setPreferredToken(preferredId, "_none");
setPreferredId(null);
} finally {
setSaving(false);
}
}
const currentToken = tokens?.find((t) => t.token_id === preferredId);
return (
<div className="mt-3 border-t border-gray-800 pt-3">
<button
onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>
Preferred Bot Token
{preferredId && <span className="ml-1.5 text-indigo-400"> set</span>}
</span>
</button>
{open && (
<div className="mt-3 space-y-2 font-mono text-xs">
{tokens === null ? (
<p className="text-gray-600 italic">Loading tokens</p>
) : tokens.length === 0 ? (
<p className="text-gray-600 italic">No tokens in pool.</p>
) : (
<>
<p className="text-gray-600">
When a node on this system joins a voice channel, this token is tried first.
</p>
<div className="space-y-1.5">
{tokens.map((t) => (
<label key={t.token_id} className="flex items-center gap-2.5 cursor-pointer">
<input
type="radio"
name={`preferred-token-${systemId}`}
checked={preferredId === t.token_id}
onChange={() => handleSet(t.token_id)}
disabled={saving}
className="accent-indigo-500"
/>
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-gray-600" : "text-gray-300"}`}>
{t.name}
{t.in_use && <span className="ml-1.5 text-green-600">in use</span>}
</span>
</label>
))}
</div>
{preferredId && (
<button
onClick={handleClear}
disabled={saving}
className="text-gray-600 hover:text-gray-400 transition-colors disabled:opacity-50"
>
Clear preference (use any free token)
</button>
)}
{!preferredId && (
<p className="text-gray-700">No preference any free token will be used.</p>
)}
{currentToken && (
<p className="text-indigo-500">Preferred: {currentToken.name}</p>
)}
</>
)}
</div>
)}
</div>
);
}
// ── Per-system AI flags panel ───────────────────────────────────────────────── // ── Per-system AI flags panel ─────────────────────────────────────────────────
interface SystemAiFlags { interface SystemAiFlags {
@@ -829,6 +948,54 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
); );
} }
// ── Source call audio player ──────────────────────────────────────────────────
function SourceCallPlayer({ callId }: { callId: string }) {
const [call, setCall] = useState<{ audio_url?: string | null; transcript?: string | null; transcript_corrected?: string | null } | null>(null);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
async function toggle() {
if (!open && !call) {
setLoading(true);
try {
const c = await c2api.getCall(callId);
setCall(c as unknown as typeof call);
} finally {
setLoading(false);
}
}
setOpen((v) => !v);
}
const transcript = call?.transcript_corrected || call?.transcript;
return (
<div className="text-xs">
<button
onClick={toggle}
disabled={loading}
className="text-indigo-500 hover:text-indigo-400 transition-colors disabled:opacity-50"
title={callId}
>
{loading ? "loading…" : open ? "▲ source" : "▶ source"}
</button>
{open && call && (
<div className="mt-1.5 space-y-1 pl-2 border-l border-gray-700">
{call.audio_url ? (
<audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} />
) : (
<p className="text-gray-600 italic">No audio</p>
)}
{transcript && (
<p className="text-gray-500 italic line-clamp-2">{transcript}</p>
)}
</div>
)}
</div>
);
}
// ── Vocabulary panel ────────────────────────────────────────────────────────── // ── Vocabulary panel ──────────────────────────────────────────────────────────
function VocabularyPanel({ systemId }: { systemId: string }) { function VocabularyPanel({ systemId }: { systemId: string }) {
@@ -979,13 +1146,24 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
<p className="text-gray-500 uppercase tracking-wider mb-1.5"> <p className="text-gray-500 uppercase tracking-wider mb-1.5">
Induction suggestions ({pending.length}) Induction suggestions ({pending.length})
</p> </p>
<div className="space-y-1"> <div className="space-y-2">
{pending.map((p) => ( {pending.map((p) => (
<div key={p.term} className="flex items-center gap-2"> <div key={p.term} className="space-y-1">
<span className="text-gray-300 flex-1">{p.term}</span> <div className="flex items-center gap-2">
<span className="text-gray-600">{p.source}</span> <span className="text-gray-300 flex-1">{p.term}</span>
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1"></button> <span className="text-gray-600">{p.source}</span>
<button onClick={() => handleDismiss(p.term)} className="text-gray-600 hover:text-red-400 transition-colors px-1"></button> <button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1"></button>
<button onClick={() => handleDismiss(p.term)} className="text-gray-600 hover:text-red-400 transition-colors px-1"></button>
</div>
{p.source_call_ids && p.source_call_ids.length > 0 && (
<div className="pl-1 space-y-1">
{p.source_call_ids.map((id: string) => (
<Fragment key={id}>
<SourceCallPlayer callId={id} />
</Fragment>
))}
</div>
)}
</div> </div>
))} ))}
</div> </div>
@@ -1002,7 +1180,15 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
// ── Systems list page ───────────────────────────────────────────────────────── // ── Systems list page ─────────────────────────────────────────────────────────
export default function SystemsPage() { export default function SystemsPage() {
const { isAdmin, isOperator, loading: authLoading } = useAuth();
const router = useRouter();
const { systems, loading } = useSystems(); const { systems, loading } = useSystems();
useEffect(() => {
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
}, [authLoading, isAdmin, isOperator, router]);
if (authLoading || (!isAdmin && !isOperator)) return null;
const [editing, setEditing] = useState<SystemRecord | null | "new">(null); const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
const [editIsDuplicate, setEditIsDuplicate] = useState(false); const [editIsDuplicate, setEditIsDuplicate] = useState(false);
@@ -1098,6 +1284,7 @@ export default function SystemsPage() {
Delete Delete
</button> </button>
</div> </div>
<PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} /> <AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
<VocabularyPanel systemId={s.system_id} /> <VocabularyPanel systemId={s.system_id} />
</div> </div>
+4 -4
View File
@@ -15,7 +15,7 @@ interface TokenRecord {
} }
export default function TokensPage() { export default function TokensPage() {
const { isAdmin, loading: authLoading } = useAuth(); const { isAdmin, isOperator, loading: authLoading } = useAuth();
const router = useRouter(); const router = useRouter();
const [tokens, setTokens] = useState<TokenRecord[]>([]); const [tokens, setTokens] = useState<TokenRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -26,8 +26,8 @@ export default function TokensPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!authLoading && !isAdmin) router.replace("/dashboard"); if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
}, [authLoading, isAdmin, router]); }, [authLoading, isAdmin, isOperator, router]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
@@ -67,7 +67,7 @@ export default function TokensPage() {
} }
} }
if (authLoading || !isAdmin) return null; if (authLoading || (!isAdmin && !isOperator)) return null;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { useTrips } from "@/lib/useTrips";
import { c2api } from "@/lib/c2api";
import type { TripRecord } from "@/lib/types";
function fmtDate(iso: string) {
return new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function TripCard({ trip, isAdmin, onDelete }: {
trip: TripRecord;
isAdmin: boolean;
onDelete: (id: string) => void;
}) {
const router = useRouter();
const today = new Date().toISOString().slice(0, 10);
const upcoming = trip.start_date >= today;
const attendeeCount = Object.keys(trip.attendees ?? {}).length;
return (
<div
className="bg-gray-900 border border-gray-800 rounded-xl p-5 cursor-pointer hover:border-gray-700 transition-colors"
onClick={() => router.push(`/trips/${trip.trip_id}`)}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${
upcoming ? "bg-indigo-900 text-indigo-300" : "bg-gray-800 text-gray-500"
}`}>
{upcoming ? "Upcoming" : "Past"}
</span>
</div>
<h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3>
<p className="text-gray-400 text-xs mt-1">{trip.location}</p>
<p className="text-gray-500 text-xs font-mono mt-1">
{fmtDate(trip.start_date)} {fmtDate(trip.end_date)}
</p>
{attendeeCount > 0 && (
<p className="text-gray-500 text-xs mt-1">
{attendeeCount} going
</p>
)}
</div>
{isAdmin && (
<button
onClick={(e) => { e.stopPropagation(); onDelete(trip.trip_id); }}
className="text-xs text-red-500 hover:text-red-400 transition-colors shrink-0"
>
Delete
</button>
)}
</div>
</div>
);
}
function CreateModal({ onClose, onCreate }: {
onClose: () => void;
onCreate: (body: object) => Promise<void>;
}) {
const [name, setName] = useState("");
const [location, setLocation] = useState("");
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const [mapsLink, setMapsLink] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (end < start) { setError("End date must be on or after start date."); return; }
setSaving(true);
setError(null);
try {
await onCreate({
name,
location,
start_date: start,
end_date: end,
maps_link: mapsLink || null,
});
onClose();
} catch {
setError("Failed to create trip.");
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<form
onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4"
>
<h2 className="text-white font-bold">New Trip</h2>
<div>
<label className="text-xs text-gray-400 block mb-1">Name</label>
<input
required value={name} onChange={(e) => setName(e.target.value)}
placeholder="Road trip to Nashville"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Location</label>
<input
required value={location} onChange={(e) => setLocation(e.target.value)}
placeholder="Nashville, TN"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-400 block mb-1">Start date</label>
<input
required type="date" value={start} onChange={(e) => setStart(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">End date</label>
<input
required type="date" value={end} onChange={(e) => setEnd(e.target.value)}
min={start}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Google Maps link (optional)</label>
<input
type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)}
placeholder="https://maps.google.com/…"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 justify-end">
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2">
Cancel
</button>
<button
type="submit" disabled={saving}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2"
>
{saving ? "Creating…" : "Create Trip"}
</button>
</div>
</form>
</div>
);
}
export default function TripsPage() {
const { isAdmin } = useAuth();
const { trips, loading } = useTrips();
const [showCreate, setShowCreate] = useState(false);
const today = new Date().toISOString().slice(0, 10);
const upcoming = trips.filter((t) => t.end_date >= today);
const past = trips.filter((t) => t.end_date < today);
async function handleDelete(id: string) {
try { await c2api.deleteTrip(id); }
catch (e) { console.error(e); }
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-white text-xl font-bold font-mono">Trips</h1>
{isAdmin && (
<button
onClick={() => setShowCreate(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg px-4 py-2 transition-colors"
>
+ New Trip
</button>
)}
</div>
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : (
<>
{upcoming.length > 0 && (
<section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Upcoming</h2>
<div className="space-y-3">
{upcoming.map((t) => (
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
))}
</div>
</section>
)}
{past.length > 0 && (
<section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Past</h2>
<div className="space-y-3">
{past.map((t) => (
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
))}
</div>
</section>
)}
{trips.length === 0 && (
<p className="text-gray-600 text-sm font-mono">No trips yet.</p>
)}
</>
)}
{showCreate && (
<CreateModal
onClose={() => setShowCreate(false)}
onCreate={async (body) => { await c2api.createTrip(body); }}
/>
)}
</div>
);
}
+29 -5
View File
@@ -3,25 +3,33 @@
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useState } from "react";
import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth"; import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth";
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import type { UserRole } from "@/lib/types";
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
loading: boolean; loading: boolean;
role: UserRole | null;
isAdmin: boolean; isAdmin: boolean;
isOperator: boolean;
ownedNodeIds: string[];
signOut: () => Promise<void>; signOut: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType>({ const AuthContext = createContext<AuthContextType>({
user: null, user: null,
loading: true, loading: true,
role: null,
isAdmin: false, isAdmin: false,
isOperator: false,
ownedNodeIds: [],
signOut: async () => {}, signOut: async () => {},
}); });
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false); const [role, setRole] = useState<UserRole | null>(null);
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
return onAuthStateChanged(auth, async (u) => { return onAuthStateChanged(auth, async (u) => {
@@ -30,12 +38,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (u) { if (u) {
document.cookie = "drb_session=1; path=/; SameSite=Strict"; document.cookie = "drb_session=1; path=/; SameSite=Strict";
// Read custom claims to determine admin status
const result = await u.getIdTokenResult(true); const result = await u.getIdTokenResult(true);
setIsAdmin(!!result.claims.admin); const claims = result.claims;
// Derive role: prefer granular "role" claim, fall back to legacy "admin" boolean
let effectiveRole: UserRole = "viewer";
if (claims.role === "admin" || claims.admin) {
effectiveRole = "admin";
} else if (claims.role === "operator") {
effectiveRole = "operator";
} else if (claims.role === "viewer") {
effectiveRole = "viewer";
}
setRole(effectiveRole);
setOwnedNodeIds((claims.owned_node_ids as string[]) ?? []);
} else { } else {
document.cookie = "drb_session=; path=/; max-age=0"; document.cookie = "drb_session=; path=/; max-age=0";
setIsAdmin(false); setRole(null);
setOwnedNodeIds([]);
} }
}); });
}, []); }, []);
@@ -45,8 +66,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
document.cookie = "drb_session=; path=/; max-age=0"; document.cookie = "drb_session=; path=/; max-age=0";
} }
const isAdmin = role === "admin";
const isOperator = role === "operator";
return ( return (
<AuthContext.Provider value={{ user, loading, isAdmin, signOut }}> <AuthContext.Provider value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, signOut }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
+61 -20
View File
@@ -293,6 +293,7 @@ function FanIncidentLayer({
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>} {inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
<a <a
href={`/incidents/${inc.incident_id}`} href={`/incidents/${inc.incident_id}`}
onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }}
className="text-xs text-blue-600 hover:underline block mt-0.5" className="text-xs text-blue-600 hover:underline block mt-0.5"
> >
View incident View incident
@@ -451,6 +452,11 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
/> />
</LayersControl.Overlay> </LayersControl.Overlay>
{/* Overlay: News / RSS alerts — placeholder for future integration */}
<LayersControl.Overlay name="News Alerts">
<FeatureGroup />
</LayersControl.Overlay>
{/* Overlay: ADS-B — placeholder for future integration */} {/* Overlay: ADS-B — placeholder for future integration */}
<LayersControl.Overlay name="ADS-B"> <LayersControl.Overlay name="ADS-B">
<FeatureGroup /> <FeatureGroup />
@@ -512,13 +518,9 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other; const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null; const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
const unitCount = inc.units?.length ?? 0; const unitCount = inc.units?.length ?? 0;
return ( const baseClass = "w-full text-left bg-gray-950/85 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs font-mono hover:brightness-110 transition-all";
<button const cardBody = (
key={inc.incident_id} <>
onClick={() => handleIncidentSelect(inc)}
className="w-full text-left bg-gray-950/85 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs font-mono hover:brightness-110 transition-all"
style={{ borderColor: color + "55" }}
>
<div className="flex items-center gap-1.5 mb-0.5"> <div className="flex items-center gap-1.5 mb-0.5">
<span <span
className="inline-block w-2 h-2 rounded-sm flex-shrink-0" className="inline-block w-2 h-2 rounded-sm flex-shrink-0"
@@ -544,9 +546,31 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
)} )}
</div> </div>
{!inc.location_coords && ( {!inc.location_coords && (
<p className="text-gray-700 italic mt-0.5">no coords</p> <p className="text-[10px] text-blue-700 mt-1">View details </p>
)} )}
</button> </>
);
if (inc.location_coords) {
return (
<button
key={inc.incident_id}
onClick={() => handleIncidentSelect(inc)}
className={baseClass}
style={{ borderColor: color + "55" }}
>
{cardBody}
</button>
);
}
return (
<a
key={inc.incident_id}
href={`/incidents/${inc.incident_id}`}
className={`block ${baseClass}`}
style={{ borderColor: color + "55" }}
>
{cardBody}
</a>
); );
})} })}
</div> </div>
@@ -564,22 +588,39 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
<div className="bg-gray-950/95 border-t border-gray-800 max-h-52 overflow-y-auto px-3 py-2 space-y-1.5"> <div className="bg-gray-950/95 border-t border-gray-800 max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
{incidents.map((inc) => { {incidents.map((inc) => {
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other; const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
return ( const label = (
<button <>
key={inc.incident_id}
onClick={() => {
setDrawerOpen(false);
handleIncidentSelect(inc);
}}
className="w-full text-left border rounded px-2 py-1.5 text-xs font-mono"
style={{ borderColor: color + "55" }}
>
<span className="font-semibold" style={{ color }}> <span className="font-semibold" style={{ color }}>
{inc.type ?? "other"} {inc.type ?? "other"}
</span> </span>
{" — "} {" — "}
<span className="text-white">{inc.title ?? "Incident"}</span> <span className="text-white">{inc.title ?? "Incident"}</span>
</button> </>
);
if (inc.location_coords) {
return (
<button
key={inc.incident_id}
onClick={() => {
setDrawerOpen(false);
handleIncidentSelect(inc);
}}
className="w-full text-left border rounded px-2 py-1.5 text-xs font-mono"
style={{ borderColor: color + "55" }}
>
{label}
</button>
);
}
return (
<a
key={inc.incident_id}
href={`/incidents/${inc.incident_id}`}
className="block w-full text-left border rounded px-2 py-1.5 text-xs font-mono"
style={{ borderColor: color + "55" }}
>
{label}
</a>
); );
})} })}
</div> </div>
+42 -22
View File
@@ -2,25 +2,32 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useUnconfiguredNodes } from "@/lib/useNodes"; import { useUnconfiguredNodes } from "@/lib/useNodes";
import { useUnacknowledgedAlerts } from "@/lib/useAlerts"; import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { useTheme } from "@/components/ThemeProvider"; import { useTheme } from "@/components/ThemeProvider";
const links = [ // Links visible to all authenticated roles (viewer+)
{ href: "/dashboard", label: "Dashboard" }, const viewerLinks = [
{ href: "/nodes", label: "Nodes" }, { href: "/dashboard", label: "Dashboard" },
{ href: "/systems", label: "Systems" }, { href: "/calls", label: "Calls" },
{ href: "/calls", label: "Calls" }, { href: "/incidents", label: "Incidents" },
{ href: "/incidents", label: "Incidents" }, { href: "/map", label: "Map" },
{ href: "/map", label: "Map" }, { href: "/alerts", label: "Alerts" },
{ href: "/alerts", label: "Alerts" }, { href: "/trips", label: "Trips" },
]; ];
// Additional links for operators and admins
const operatorLinks = [
{ href: "/nodes", label: "Nodes" },
{ href: "/systems", label: "Systems" },
{ href: "/tokens", label: "Tokens" },
];
// Admin-only links
const adminLinks = [ const adminLinks = [
{ href: "/tokens", label: "Tokens" }, { href: "/admin", label: "Admin" },
{ href: "/admin", label: "Admin" },
]; ];
function SunIcon() { function SunIcon() {
@@ -48,8 +55,9 @@ function MoonIcon() {
} }
export function Nav() { export function Nav() {
const { user, isAdmin, signOut } = useAuth(); const { user, isAdmin, isOperator } = useAuth();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter();
const { nodes: pending } = useUnconfiguredNodes(); const { nodes: pending } = useUnconfiguredNodes();
const unackedAlerts = useUnacknowledgedAlerts(); const unackedAlerts = useUnacknowledgedAlerts();
const { theme, toggle } = useTheme(); const { theme, toggle } = useTheme();
@@ -57,7 +65,11 @@ export function Nav() {
if (!user) return null; if (!user) return null;
const allLinks = [...links, ...(isAdmin ? adminLinks : [])]; const allLinks = [
...viewerLinks,
...(isAdmin || isOperator ? operatorLinks : []),
...(isAdmin ? adminLinks : []),
];
function navLinkClass(href: string) { function navLinkClass(href: string) {
return `text-sm font-mono transition-colors shrink-0 ${ return `text-sm font-mono transition-colors shrink-0 ${
@@ -100,12 +112,17 @@ export function Nav() {
{theme === "dark" ? <SunIcon /> : <MoonIcon />} {theme === "dark" ? <SunIcon /> : <MoonIcon />}
</button> </button>
{/* Sign out (desktop) */} {/* Profile avatar (desktop) */}
<button <button
onClick={signOut} onClick={() => router.push("/profile")}
className="hidden md:block text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors" className={`hidden md:flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold transition-colors ${
pathname.startsWith("/profile")
? "bg-indigo-600 text-white"
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
}`}
title="Profile"
> >
Sign out {(user?.displayName || user?.email || "?")[0].toUpperCase()}
</button> </button>
{/* Hamburger (mobile) */} {/* Hamburger (mobile) */}
@@ -153,12 +170,15 @@ export function Nav() {
</Link> </Link>
))} ))}
<div className="border-t border-gray-800 pt-3 mt-1"> <div className="border-t border-gray-800 pt-3 mt-1">
<button <Link
onClick={signOut} href="/profile"
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors" onClick={() => setMobileOpen(false)}
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
pathname.startsWith("/profile") ? "text-white" : "text-gray-500"
}`}
> >
Sign out Profile
</button> </Link>
</div> </div>
</div> </div>
)} )}
+81
View File
@@ -56,12 +56,15 @@ export const c2api = {
request(`/nodes/${id}`, { method: "DELETE" }), request(`/nodes/${id}`, { method: "DELETE" }),
// Calls // Calls
getCall: (callId: string) => request<import("@/lib/types").CallRecord>(`/calls/${callId}`),
getCalls: (params?: Record<string, string>) => { getCalls: (params?: Record<string, string>) => {
const qs = params ? "?" + new URLSearchParams(params).toString() : ""; const qs = params ? "?" + new URLSearchParams(params).toString() : "";
return request<unknown[]>(`/calls${qs}`); return request<unknown[]>(`/calls${qs}`);
}, },
patchTranscript: (callId: string, transcript: string) => patchTranscript: (callId: string, transcript: string) =>
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }), 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" }),
// Incidents // Incidents
getIncidents: (params?: { status?: string; type?: string }) => { getIncidents: (params?: { status?: string; type?: string }) => {
@@ -129,10 +132,88 @@ export const c2api = {
getCorrelationDebug: (limit: number, orphanHours: number) => getCorrelationDebug: (limit: number, orphanHours: number) =>
request<unknown>(`/admin/debug/correlation?limit=${limit}&orphan_hours=${orphanHours}`), request<unknown>(`/admin/debug/correlation?limit=${limit}&orphan_hours=${orphanHours}`),
// Preferred bot token per system
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 // Per-system AI flag overrides
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) => setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>
request<{ ok: boolean; ai_flags: Record<string, boolean> }>(`/systems/${systemId}/ai-flags`, { request<{ ok: boolean; ai_flags: Record<string, boolean> }>(`/systems/${systemId}/ai-flags`, {
method: "PUT", method: "PUT",
body: JSON.stringify(flags), 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 }) =>
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" }),
}; };
+84
View File
@@ -1,5 +1,44 @@
export type NodeStatus = "online" | "offline" | "recording" | "unconfigured"; export type NodeStatus = "online" | "offline" | "recording" | "unconfigured";
export type ApprovalStatus = "pending" | "approved" | "rejected"; export type ApprovalStatus = "pending" | "approved" | "rejected";
export type UserRole = "admin" | "operator" | "viewer";
export interface UserRecord {
uid: string;
email: string | null;
display_name: string | null;
role: UserRole;
owned_node_ids: string[];
disabled: boolean;
creation_time: string | null;
last_sign_in: string | null;
discord_linked: boolean;
discord_username: string | null;
discord_user_id: string | null;
// only present on GET /admin/users/{uid}
sessions?: UserSession[];
// only present on POST /admin/users response
invite_link?: string | null;
}
export interface UserSession {
session_id: string;
uid: string;
email: string;
timestamp: string;
ip: string | null;
user_agent: string | null;
}
export interface AuditEntry {
log_id: string;
action: string;
actor_uid: string;
actor_email: string;
target_uid: string | null;
target_email: string | null;
details: Record<string, unknown>;
timestamp: string;
}
export interface NodeRecord { export interface NodeRecord {
node_id: string; node_id: string;
@@ -19,6 +58,7 @@ export interface VocabularyPendingTerm {
term: string; term: string;
source: "induction" | "correction"; source: "induction" | "correction";
added_at: string; added_at: string;
source_call_ids?: string[];
} }
export interface SystemRecord { export interface SystemRecord {
@@ -30,6 +70,7 @@ export interface SystemRecord {
vocabulary_pending?: VocabularyPendingTerm[]; vocabulary_pending?: VocabularyPendingTerm[];
vocabulary_bootstrapped?: boolean; vocabulary_bootstrapped?: boolean;
ten_codes?: Record<string, string>; // {"10-10": "Commercial Alarm", ...} ten_codes?: Record<string, string>; // {"10-10": "Commercial Alarm", ...}
preferred_token_id?: string | null;
} }
export interface TranscriptSegment { export interface TranscriptSegment {
@@ -96,6 +137,49 @@ export interface AlertRule {
created_at?: string; created_at?: string;
} }
export interface TripEvent {
event_id: string;
trip_id: string;
title: string;
date: string;
start_time: string | null;
end_time: string | null;
location: string;
location_inherited: boolean;
maps_link: string | null;
place_id: string | null;
notes: string | null;
tags: string[];
attendees: Record<string, string>;
created_at: string;
}
export interface PlaceResult {
name: string;
address: string;
place_id: string;
lat: number;
lng: number;
maps_link: string;
rating?: number;
}
export interface TripRecord {
trip_id: string;
name: string;
location: string;
maps_link: string | null;
start_date: string;
end_date: string;
attendees: Record<string, string>;
available_tags: string[];
overlap_tags: string[];
visibility: "public" | "private";
invited_discord_ids: string[];
created_at: string;
events?: TripEvent[];
}
export interface AlertEvent { export interface AlertEvent {
alert_id: string; alert_id: string;
rule_id: string; rule_id: string;
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect, useState } from "react";
import { collection, onSnapshot, query, orderBy } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase";
import type { TripRecord } from "@/lib/types";
export function useTrips() {
const [trips, setTrips] = useState<TripRecord[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let unsubFirestore: (() => void) | undefined;
const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
if (!user) { setTrips([]); setLoading(false); return; }
const q = query(collection(db, "trips"), orderBy("start_date", "asc"));
unsubFirestore = onSnapshot(
q,
(snap) => {
setTrips(snap.docs.map((d) => d.data() as TripRecord));
setLoading(false);
},
(err) => {
console.error("useTrips:", err);
setLoading(false);
}
);
});
return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); };
}, []);
return { trips, loading };
}
+2 -1
View File
@@ -14,7 +14,8 @@
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"firebase": "^10.12.0", "firebase": "^10.12.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react-leaflet": "^4.2.1" "react-leaflet": "^4.2.1",
"react-markdown": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.4.0", "typescript": "^5.4.0",
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.14-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
+1
View File
@@ -12,6 +12,7 @@ class DRBBot(commands.Bot):
async def setup_hook(self): async def setup_hook(self):
await self.load_extension("app.commands.radio") await self.load_extension("app.commands.radio")
await self.load_extension("app.commands.trips")
if settings.dev_guild_id: if settings.dev_guild_id:
guild = discord.Object(id=settings.dev_guild_id) guild = discord.Object(id=settings.dev_guild_id)
@@ -0,0 +1,515 @@
import discord
from discord import app_commands
from discord.ext import commands
from datetime import datetime, date, timedelta
from typing import Optional
from app.internal.c2_client import c2
from app.internal.logger import logger
# ---------------------------------------------------------------------------
# Date / time helpers
# ---------------------------------------------------------------------------
def _parse_date(s: str) -> Optional[date]:
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%m-%d-%Y"):
try:
return datetime.strptime(s.strip(), fmt).date()
except ValueError:
continue
return None
def _parse_time(s: str) -> Optional[str]:
"""Normalize to HH:MM (24h). Returns None if unparseable."""
for fmt in ("%H:%M", "%I:%M %p", "%I:%M%p", "%I %p"):
try:
return datetime.strptime(s.strip().upper(), fmt).strftime("%H:%M")
except ValueError:
continue
return None
def _fmt_date(iso: str) -> str:
try:
return datetime.strptime(iso, "%Y-%m-%d").strftime("%b %-d, %Y")
except Exception:
return iso
def _fmt_time(t: Optional[str]) -> str:
if not t:
return ""
try:
return datetime.strptime(t, "%H:%M").strftime("%-I:%M %p")
except Exception:
return t
def _date_range(start_iso: str, end_iso: str):
"""Yield ISO date strings from start to end inclusive."""
try:
current = datetime.strptime(start_iso, "%Y-%m-%d").date()
end = datetime.strptime(end_iso, "%Y-%m-%d").date()
while current <= end:
yield current.strftime("%Y-%m-%d")
current += timedelta(days=1)
except Exception:
return
# ---------------------------------------------------------------------------
# Cog
# ---------------------------------------------------------------------------
def _user_can_see_trip(trip: dict, discord_user_id: str) -> bool:
if trip.get("visibility", "public") == "public":
return True
if discord_user_id in trip.get("attendees", {}):
return True
if discord_user_id in trip.get("invited_discord_ids", []):
return True
return False
class TripCommands(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
trip_group = app_commands.Group(name="trip", description="Manage trips and itineraries.")
event_group = app_commands.Group(
name="event", description="Manage events within a trip.", parent=trip_group
)
# ------------------------------------------------------------------
# Autocomplete
# ------------------------------------------------------------------
async def trip_autocomplete(
self, interaction: discord.Interaction, current: str
) -> list[app_commands.Choice[str]]:
trips = await c2.get_trips()
user_id = str(interaction.user.id)
return [
app_commands.Choice(name=t["name"], value=t["trip_id"])
for t in trips
if current.lower() in t["name"].lower() and _user_can_see_trip(t, user_id)
][:25]
async def event_autocomplete(
self, interaction: discord.Interaction, current: str
) -> list[app_commands.Choice[str]]:
trip_id = interaction.namespace.trip
if not trip_id:
return []
trip = await c2.get_trip(trip_id)
if not trip:
return []
return [
app_commands.Choice(name=e["title"], value=e["event_id"])
for e in trip.get("events", [])
if current.lower() in e["title"].lower()
][:25]
# ------------------------------------------------------------------
# /trip create
# ------------------------------------------------------------------
@trip_group.command(name="create", description="Create a new trip.")
@app_commands.describe(
name="Trip name",
location="Primary destination or location",
start_date="Start date (YYYY-MM-DD or MM/DD/YYYY)",
end_date="End date (YYYY-MM-DD or MM/DD/YYYY)",
maps_link="Optional Google Maps link for the destination",
)
async def trip_create(
self,
interaction: discord.Interaction,
name: str,
location: str,
start_date: str,
end_date: str,
maps_link: Optional[str] = None,
):
await interaction.response.defer(ephemeral=True)
start = _parse_date(start_date)
end = _parse_date(end_date)
if not start or not end:
await interaction.followup.send("Invalid date format. Use YYYY-MM-DD.")
return
if end < start:
await interaction.followup.send("End date must be on or after start date.")
return
trip = await c2.create_trip({
"name": name,
"location": location,
"maps_link": maps_link,
"start_date": start.strftime("%Y-%m-%d"),
"end_date": end.strftime("%Y-%m-%d"),
})
if not trip:
await interaction.followup.send("Failed to create trip.")
return
embed = discord.Embed(title=f"Trip Created: {name}", color=0x5865f2)
embed.add_field(name="Location", value=location, inline=True)
embed.add_field(
name="Dates",
value=f"{_fmt_date(start.strftime('%Y-%m-%d'))}{_fmt_date(end.strftime('%Y-%m-%d'))}",
inline=True,
)
if maps_link:
embed.add_field(name="Maps", value=f"[Open]({maps_link})", inline=True)
embed.set_footer(text="Use /trip join to RSVP • /trip event add to build the itinerary")
await interaction.followup.send(embed=embed)
# ------------------------------------------------------------------
# /trip list
# ------------------------------------------------------------------
@trip_group.command(name="list", description="List all trips.")
async def trip_list(self, interaction: discord.Interaction):
await interaction.response.defer()
trips = await c2.get_trips()
if not trips:
await interaction.followup.send("No trips found.")
return
today = date.today().strftime("%Y-%m-%d")
trips.sort(key=lambda t: t.get("start_date", ""))
user_id = str(interaction.user.id)
trips = [t for t in trips if _user_can_see_trip(t, user_id)]
embed = discord.Embed(title="Trips", color=0x2b2d31)
for t in trips[:10]:
upcoming = t.get("start_date", "") >= today
status = "Upcoming" if upcoming else "Past"
dates = (
f"{_fmt_date(t.get('start_date', ''))}"
f"{_fmt_date(t.get('end_date', ''))}"
)
attendee_count = len(t.get("attendees", {}))
field_name = f"{t['name']} [{status}]"[:256]
embed.add_field(
name=field_name,
value=f"{t.get('location', '?')}\n{dates}\n{attendee_count} going",
inline=False,
)
await interaction.followup.send(embed=embed)
# ------------------------------------------------------------------
# /trip view
# ------------------------------------------------------------------
@trip_group.command(name="view", description="View the full itinerary for a trip.")
@app_commands.describe(trip="The trip to view.")
@app_commands.autocomplete(trip=trip_autocomplete)
async def trip_view(self, interaction: discord.Interaction, trip: str):
await interaction.response.defer()
data = await c2.get_trip(trip)
if not data:
await interaction.followup.send("Trip not found.")
return
if not _user_can_see_trip(data, str(interaction.user.id)):
await interaction.followup.send("This trip is private.", ephemeral=True)
return
attendee_names = list(data.get("attendees", {}).values())
desc_lines = [
f"{_fmt_date(data['start_date'])}{_fmt_date(data['end_date'])}{data['location']}",
]
if data.get("maps_link"):
desc_lines.append(f"[View on Maps]({data['maps_link']})")
desc_lines.append(
f"Going: {', '.join(attendee_names)}" if attendee_names else "No attendees yet"
)
embed = discord.Embed(
title=data["name"][:256],
description="\n".join(desc_lines)[:4096],
color=0x5865f2,
)
# Group events by date
events_by_date: dict[str, list] = {}
for e in data.get("events", []):
events_by_date.setdefault(e["date"], []).append(e)
# Track total embed chars (Discord limit: 6000)
embed_chars = len(embed.title or "") + len(embed.description or "")
field_count = 0
for day_iso in _date_range(data["start_date"], data["end_date"]):
day_events = events_by_date.get(day_iso)
if not day_events:
continue
if field_count >= 24 or embed_chars >= 5800:
embed.add_field(name="...", value="More events not shown.", inline=False)
break
day_label = datetime.strptime(day_iso, "%Y-%m-%d").strftime("%A, %b %-d")
lines = []
for e in sorted(day_events, key=lambda x: x.get("start_time") or ""):
time_str = _fmt_time(e.get("start_time"))
line = f"**{time_str}** {e['title']}" if time_str else f"- {e['title']}"
loc = e.get("location")
if loc and not e.get("location_inherited"):
line += f"\n\u3000\u3000{loc}"
if e.get("maps_link"):
line += f" ([Maps]({e['maps_link']}))"
if e.get("notes"):
line += f"\n\u3000\u3000_{e['notes']}_"
event_tags = e.get("tags") or []
if event_tags:
line += f"\n\u3000\u3000`{'` `'.join(event_tags)}`"
event_att = list(e.get("attendees", {}).values())
if event_att:
line += f"\n\u3000\u3000{', '.join(event_att)}"
lines.append(line)
field_name = f"{day_label}"
field_value = "\n".join(lines)
if len(field_value) > 1024:
field_value = field_value[:1021] + ""
embed.add_field(name=field_name, value=field_value, inline=False)
embed_chars += len(field_name) + len(field_value)
field_count += 1
if not events_by_date:
embed.add_field(
name="No events yet",
value="Use `/trip event add` to build the itinerary.",
inline=False,
)
await interaction.followup.send(embed=embed)
# ------------------------------------------------------------------
# /trip delete
# ------------------------------------------------------------------
@trip_group.command(name="delete", description="Delete a trip and all its events.")
@app_commands.describe(trip="The trip to delete.")
@app_commands.autocomplete(trip=trip_autocomplete)
async def trip_delete(self, interaction: discord.Interaction, trip: str):
await interaction.response.defer(ephemeral=True)
ok = await c2.delete_trip(trip)
if ok:
await interaction.followup.send("Trip deleted.")
else:
await interaction.followup.send("Trip not found or failed to delete.")
# ------------------------------------------------------------------
# /trip join / /trip leave
# ------------------------------------------------------------------
@trip_group.command(name="join", description="RSVP to a trip.")
@app_commands.describe(trip="The trip to join.")
@app_commands.autocomplete(trip=trip_autocomplete)
async def trip_join(self, interaction: discord.Interaction, trip: str):
await interaction.response.defer(ephemeral=True)
result = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name)
if result is True:
await interaction.followup.send("You're on the trip!")
elif result == "private":
await interaction.followup.send("This trip is private — you need an invite to join.")
else:
await interaction.followup.send("Failed to join trip.")
@trip_group.command(name="leave", description="Remove yourself from a trip.")
@app_commands.describe(trip="The trip to leave.")
@app_commands.autocomplete(trip=trip_autocomplete)
async def trip_leave(self, interaction: discord.Interaction, trip: str):
await interaction.response.defer(ephemeral=True)
ok = await c2.leave_trip(trip, str(interaction.user.id))
if ok:
await interaction.followup.send("You've been removed from the trip.")
else:
await interaction.followup.send("Failed to leave trip.")
# ------------------------------------------------------------------
# /trip event add
# ------------------------------------------------------------------
@event_group.command(name="add", description="Add an event to a trip's itinerary.")
@app_commands.describe(
trip="The trip to add this event to.",
title="Event title",
date="Date of the event (YYYY-MM-DD or MM/DD/YYYY)",
start_time="Start time (e.g. 14:00 or 2:00 PM) — optional",
end_time="End time (e.g. 16:00 or 4:00 PM) — optional",
location="Location override (optional, inherits trip location if omitted)",
maps_link="Google Maps link for this event (optional)",
notes="Any additional notes (optional)",
)
@app_commands.autocomplete(trip=trip_autocomplete)
async def event_add(
self,
interaction: discord.Interaction,
trip: str,
title: str,
date: str,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
location: Optional[str] = None,
maps_link: Optional[str] = None,
notes: Optional[str] = None,
):
await interaction.response.defer(ephemeral=True)
parsed_date = _parse_date(date)
if not parsed_date:
await interaction.followup.send("Invalid date format. Use YYYY-MM-DD.")
return
parsed_start = _parse_time(start_time) if start_time else None
parsed_end = _parse_time(end_time) if end_time else None
if start_time and parsed_start is None:
await interaction.followup.send("Couldn't parse start time. Try `14:00` or `2:00 PM`.")
return
if end_time and parsed_end is None:
await interaction.followup.send("Couldn't parse end time. Try `16:00` or `4:00 PM`.")
return
event = await c2.create_trip_event(trip, {
"title": title,
"date": parsed_date.strftime("%Y-%m-%d"),
"start_time": parsed_start,
"end_time": parsed_end,
"location": location,
"maps_link": maps_link,
"notes": notes,
})
if not event:
await interaction.followup.send(
"Failed to create event. Make sure the date falls within the trip range."
)
return
time_display = f" at {_fmt_time(parsed_start)}" if parsed_start else ""
await interaction.followup.send(
f"Added **{title}**{time_display} on {_fmt_date(parsed_date.strftime('%Y-%m-%d'))}."
)
# ------------------------------------------------------------------
# /trip event remove
# ------------------------------------------------------------------
@event_group.command(name="remove", description="Remove an event from a trip.")
@app_commands.describe(trip="The trip.", event="The event to remove.")
@app_commands.autocomplete(trip=trip_autocomplete, event=event_autocomplete)
async def event_remove(self, interaction: discord.Interaction, trip: str, event: str):
await interaction.response.defer(ephemeral=True)
ok = await c2.delete_trip_event(trip, event)
if ok:
await interaction.followup.send("Event removed.")
else:
await interaction.followup.send("Event not found or failed to remove.")
# ------------------------------------------------------------------
# /trip event join / /trip event leave
# ------------------------------------------------------------------
@event_group.command(name="join", description="Join an event (you must be on the trip first).")
@app_commands.describe(trip="The trip.", event="The event to join.")
@app_commands.autocomplete(trip=trip_autocomplete, event=event_autocomplete)
async def event_join(self, interaction: discord.Interaction, trip: str, event: str):
await interaction.response.defer(ephemeral=True)
result = await c2.join_trip_event(
trip, event, str(interaction.user.id), interaction.user.display_name
)
if result is True:
await interaction.followup.send("You're in for this event!")
elif result == "not_on_trip":
await interaction.followup.send(
"You need to join the trip first — use `/trip join`."
)
else:
await interaction.followup.send("Failed to join event.")
@event_group.command(name="leave", description="Leave an event.")
@app_commands.describe(trip="The trip.", event="The event to leave.")
@app_commands.autocomplete(trip=trip_autocomplete, event=event_autocomplete)
async def event_leave(self, interaction: discord.Interaction, trip: str, event: str):
await interaction.response.defer(ephemeral=True)
ok = await c2.leave_trip_event(trip, event, str(interaction.user.id))
if ok:
await interaction.followup.send("You've been removed from the event.")
else:
await interaction.followup.send("Failed to leave event.")
# ------------------------------------------------------------------
# /trip invite
# ------------------------------------------------------------------
@trip_group.command(name="invite", description="Invite a Discord user to a private trip.")
@app_commands.describe(trip="The trip.", user="The user to invite.")
@app_commands.autocomplete(trip=trip_autocomplete)
async def trip_invite(self, interaction: discord.Interaction, trip: str, user: discord.Member):
await interaction.response.defer(ephemeral=True)
ok = await c2.invite_to_trip(trip, str(user.id))
if ok:
await interaction.followup.send(f"Invited {user.display_name} to the trip.")
else:
await interaction.followup.send("Failed to send invite.")
# ------------------------------------------------------------------
# /trip privacy
# ------------------------------------------------------------------
@trip_group.command(name="privacy", description="Set a trip to public or private.")
@app_commands.describe(trip="The trip.", visibility="public or private")
@app_commands.autocomplete(trip=trip_autocomplete)
@app_commands.choices(visibility=[
app_commands.Choice(name="Public — anyone can see and join", value="public"),
app_commands.Choice(name="Private — invite only", value="private"),
])
async def trip_privacy(self, interaction: discord.Interaction, trip: str, visibility: str):
await interaction.response.defer(ephemeral=True)
ok = await c2.set_trip_visibility(trip, visibility)
if ok:
await interaction.followup.send(f"Trip is now **{visibility}**.")
else:
await interaction.followup.send("Failed to update trip privacy.")
# ------------------------------------------------------------------
# /link
# ------------------------------------------------------------------
@app_commands.command(name="link", description="Link your Discord account to your DRB web account.")
@app_commands.describe(code="The 6-character code from the web app (Settings → Link Discord).")
async def link_account(self, interaction: discord.Interaction, code: str):
await interaction.response.defer(ephemeral=True)
result = await c2.link_discord_account(
code.upper().strip(),
str(interaction.user.id),
interaction.user.display_name,
)
if "error" in result:
msgs = {
"invalid_code": "Invalid code. Generate a new one from the web app.",
"expired": "Code has expired. Generate a new one from the web app.",
"already_linked": "This Discord account is already linked to a different web account.",
"failed": "Something went wrong. Try again.",
}
await interaction.followup.send(msgs.get(result["error"], "Failed to link account."))
else:
await interaction.followup.send("Your Discord account is now linked to your DRB web account.")
async def setup(bot: commands.Bot):
await bot.add_cog(TripCommands(bot))
@@ -68,5 +68,187 @@ class C2Client:
return node return node
return None return None
# ------------------------------------------------------------------
# Trips
# ------------------------------------------------------------------
async def get_trips(self) -> list:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{self.base}/trips", headers=self._headers())
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"C2 get_trips failed: {e}")
return []
async def get_trip(self, trip_id: str) -> Optional[dict]:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{self.base}/trips/{trip_id}", headers=self._headers())
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"C2 get_trip failed: {e}")
return None
async def create_trip(self, payload: dict) -> Optional[dict]:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(f"{self.base}/trips", json=payload, headers=self._headers())
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"C2 create_trip failed: {e}")
return None
async def delete_trip(self, trip_id: str) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.delete(f"{self.base}/trips/{trip_id}", headers=self._headers())
r.raise_for_status()
return True
except Exception as e:
logger.error(f"C2 delete_trip failed: {e}")
return False
async def invite_to_trip(self, trip_id: str, discord_user_id: str) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.base}/trips/{trip_id}/invite/{discord_user_id}",
headers=self._headers(),
)
r.raise_for_status()
return True
except Exception as e:
logger.error(f"C2 invite_to_trip failed: {e}")
return False
async def set_trip_visibility(self, trip_id: str, visibility: str) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.put(
f"{self.base}/trips/{trip_id}/visibility",
json={"visibility": visibility},
headers=self._headers(),
)
r.raise_for_status()
return True
except Exception as e:
logger.error(f"C2 set_trip_visibility failed: {e}")
return False
async def link_discord_account(self, code: str, discord_user_id: str, discord_username: str) -> dict:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.base}/auth/link",
json={"code": code, "discord_user_id": discord_user_id, "discord_username": discord_username},
headers=self._headers(),
)
if r.status_code == 404:
return {"error": "invalid_code"}
if r.status_code == 410:
return {"error": "expired"}
if r.status_code == 409:
return {"error": "already_linked"}
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"C2 link_discord_account failed: {e}")
return {"error": "failed"}
async def join_trip(self, trip_id: str, user_id: str, username: str) -> bool | str:
"""Returns True on success, 'private' on 403, False on other errors."""
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.base}/trips/{trip_id}/join",
json={"discord_user_id": user_id, "discord_username": username},
headers=self._headers(),
)
if r.status_code == 403:
return "private"
r.raise_for_status()
return True
except Exception as e:
logger.error(f"C2 join_trip failed: {e}")
return False
async def leave_trip(self, trip_id: str, user_id: str) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.base}/trips/{trip_id}/leave",
json={"discord_user_id": user_id},
headers=self._headers(),
)
r.raise_for_status()
return True
except Exception as e:
logger.error(f"C2 leave_trip failed: {e}")
return False
async def create_trip_event(self, trip_id: str, payload: dict) -> Optional[dict]:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.base}/trips/{trip_id}/events",
json=payload,
headers=self._headers(),
)
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"C2 create_trip_event failed: {e}")
return None
async def delete_trip_event(self, trip_id: str, event_id: str) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.delete(
f"{self.base}/trips/{trip_id}/events/{event_id}",
headers=self._headers(),
)
r.raise_for_status()
return True
except Exception as e:
logger.error(f"C2 delete_trip_event failed: {e}")
return False
async def join_trip_event(
self, trip_id: str, event_id: str, user_id: str, username: str
) -> bool | str:
"""Returns True on success, 'not_on_trip' on 403, False on other errors."""
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.base}/trips/{trip_id}/events/{event_id}/join",
json={"discord_user_id": user_id, "discord_username": username},
headers=self._headers(),
)
if r.status_code == 403:
return "not_on_trip"
r.raise_for_status()
return True
except Exception as e:
logger.error(f"C2 join_trip_event failed: {e}")
return False
async def leave_trip_event(self, trip_id: str, event_id: str, user_id: str) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.base}/trips/{trip_id}/events/{event_id}/leave",
json={"discord_user_id": user_id},
headers=self._headers(),
)
r.raise_for_status()
return True
except Exception as e:
logger.error(f"C2 leave_trip_event failed: {e}")
return False
c2 = C2Client() c2 = C2Client()
+14
View File
@@ -0,0 +1,14 @@
# Managed by CI — deployed to /etc/caddy/Caddyfile on the server.
# Caddy handles TLS automatically via Let's Encrypt.
api.{$DRB_DOMAIN} {
reverse_proxy localhost:8888 {
header_up X-Forwarded-For {remote_host}
}
}
app.{$DRB_DOMAIN} {
reverse_proxy localhost:3000 {
header_up X-Forwarded-For {remote_host}
}
}
+40
View File
@@ -0,0 +1,40 @@
.PHONY: tf-init tf-plan tf-apply tf-destroy deploy setup-ansible
ANSIBLE_DIR = ansible
INVENTORY = $(ANSIBLE_DIR)/inventory.ini
# ── Terraform ─────────────────────────────────────────────────────────────────
tf-init:
terraform init
tf-plan:
terraform plan
tf-apply:
terraform apply
@echo ""
@echo "Server IP: $$(terraform output -raw server_ip)"
@echo "Update $(INVENTORY) with this IP, then run: make deploy"
tf-destroy:
@echo "WARNING: This will destroy the VM and all data on it."
@read -p "Type 'yes' to confirm: " confirm && [ "$$confirm" = "yes" ] && terraform destroy
# ── Ansible ───────────────────────────────────────────────────────────────────
# First-time setup: waits for Docker, clones repo, starts stack.
setup:
ansible-playbook -i $(INVENTORY) $(ANSIBLE_DIR)/site.yml --ask-vault-pass
# Update deploy: sync code + restart changed containers. Run this after every push.
deploy:
ansible-playbook -i $(INVENTORY) $(ANSIBLE_DIR)/deploy.yml --ask-vault-pass
# ── Helpers ───────────────────────────────────────────────────────────────────
ip:
@terraform output -raw server_ip
ssh:
ssh drb@$$(terraform output -raw server_ip)
+15
View File
@@ -0,0 +1,15 @@
---
# Lightweight update deploy — runs in ~60s.
# Use this for every code push after the initial site.yml run.
#
# Usage:
# ansible-playbook -i inventory.ini deploy.yml --ask-vault-pass
- name: Deploy DRB update
hosts: drb
become: true
vars_files:
- vault.yml
roles:
- deploy
+9
View File
@@ -0,0 +1,9 @@
# Copy to group_vars/all.yml — safe to commit (no secrets here).
domain: example.com # must match Terraform var.domain
app_dir: /opt/drb
ssh_user: drb
# Path to the local repo root on your machine (used for rsync).
# Trailing slash is intentional — rsync copies contents, not the folder itself.
local_repo_path: "/path/to/Version 5C/Server/"
+8
View File
@@ -0,0 +1,8 @@
# Copy to inventory.ini and replace SERVER_IP with the Terraform output.
# Get it with: cd ../terraform && terraform output server_ip
[drb]
SERVER_IP ansible_user=drb ansible_ssh_private_key_file=~/.ssh/id_ed25519
[drb:vars]
ansible_python_interpreter=/usr/bin/python3
+77
View File
@@ -0,0 +1,77 @@
---
# First-time setup: clone repo, write secrets, pull pre-built images and start stack.
# Images are built and pushed by Gitea CI — this role never builds on the VM.
- name: Clone repo (skipped if already present)
git:
repo: "{{ repo_url }}"
dest: "{{ app_dir }}"
version: main
update: false
become: false
- name: Set ownership of app directory
file:
path: "{{ app_dir }}"
state: directory
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
recurse: true
- name: Template top-level .env (docker-compose MQTT creds + registry)
template:
src: root.env.j2
dest: "{{ app_dir }}/.env"
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
mode: "0600"
- name: Template c2-core .env
template:
src: c2-core.env.j2
dest: "{{ app_dir }}/drb-c2-core/.env"
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
mode: "0600"
- name: Template discord-bot .env
template:
src: discord-bot.env.j2
dest: "{{ app_dir }}/drb-server-discord-bot/.env"
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
mode: "0600"
- name: Template frontend .env
template:
src: frontend.env.j2
dest: "{{ app_dir }}/drb-frontend/.env"
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
mode: "0600"
- name: Deploy Caddyfile
template:
src: Caddyfile.j2
dest: /etc/caddy/Caddyfile
owner: root
group: root
mode: "0644"
notify: Reload Caddy
- name: Log in to container registry
command: >
docker login {{ vault_registry_host }}
-u {{ vault_registry_user }}
-p {{ vault_registry_token }}
no_log: true
- name: Pull pre-built images and start stack
community.docker.docker_compose_v2:
project_src: "{{ app_dir }}"
files:
- docker-compose.yml
- docker-compose.prod.yml
pull: always
build: never
state: present
@@ -0,0 +1,13 @@
# Managed by Ansible — do not edit manually on the server.
api.{{ domain }} {
reverse_proxy localhost:8888 {
header_up X-Forwarded-For {remote_host}
}
}
app.{{ domain }} {
reverse_proxy localhost:3000 {
header_up X-Forwarded-For {remote_host}
}
}
@@ -0,0 +1,20 @@
# drb-c2-core environment — Managed by Ansible. Do not edit manually.
MQTT_BROKER=mosquitto
MQTT_PORT=1883
MQTT_USER={{ vault_mqtt_c2_user }}
MQTT_PASS={{ vault_mqtt_c2_pass }}
# No GCP_CREDENTIALS_PATH — the VM uses Application Default Credentials
# via the GCE metadata server. The Terraform IAM bindings grant the required roles.
FIRESTORE_DATABASE={{ vault_firestore_database }}
GCS_BUCKET={{ vault_gcs_bucket }}
OPENAI_API_KEY={{ vault_openai_api_key }}
GOOGLE_MAPS_API_KEY={{ vault_google_maps_api_key }}
GEMINI_API_KEY={{ vault_gemini_api_key }}
SERVICE_KEY={{ vault_service_key }}
NODE_API_KEY={{ vault_node_api_key }}
CORS_ORIGINS=["https://app.{{ domain }}"]
@@ -0,0 +1,5 @@
# drb-server-discord-bot environment — Managed by Ansible. Do not edit manually.
DISCORD_TOKEN={{ vault_discord_token }}
C2_URL=http://c2-core:8000
C2_SERVICE_KEY={{ vault_service_key }}
@@ -0,0 +1,11 @@
# drb-frontend environment — Managed by Ansible. Do not edit manually.
NEXT_PUBLIC_C2_URL=https://api.{{ domain }}
NEXT_PUBLIC_FIREBASE_API_KEY={{ vault_firebase_api_key }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN={{ vault_firebase_auth_domain }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID={{ vault_firebase_project_id }}
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET={{ vault_firebase_storage_bucket }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID={{ vault_firebase_messaging_sender_id }}
NEXT_PUBLIC_FIREBASE_APP_ID={{ vault_firebase_app_id }}
NEXT_PUBLIC_FIRESTORE_DATABASE={{ vault_firestore_database }}
@@ -0,0 +1,10 @@
# Top-level docker-compose environment — MQTT credentials and registry prefix.
# Managed by Ansible. Do not edit manually.
MQTT_C2_USER={{ vault_mqtt_c2_user }}
MQTT_C2_PASS={{ vault_mqtt_c2_pass }}
MQTT_NODE_USER={{ vault_mqtt_node_user }}
MQTT_NODE_PASS={{ vault_mqtt_node_pass }}
# Container registry prefix — docker compose uses this for image: ${REGISTRY}/name:latest
REGISTRY={{ vault_registry }}
+79
View File
@@ -0,0 +1,79 @@
---
# Full first-time setup: waits for the VM's startup.sh to finish installing
# Docker, then deploys the stack. Safe to re-run — all tasks are idempotent.
#
# Usage:
# ansible-playbook -i inventory.ini site.yml --ask-vault-pass
- name: Bootstrap + deploy DRB server
hosts: drb
become: true
vars_files:
- vault.yml
pre_tasks:
- name: Install rsync
apt:
name: rsync
state: present
update_cache: false
- name: Wait for Docker (startup.sh runs async on first boot)
command: docker info
register: _docker
until: _docker.rc == 0
retries: 30
delay: 10
changed_when: false
- name: Create 2 GB swap file
command: fallocate -l 2G /swapfile
args:
creates: /swapfile
- name: Set swap file permissions
file:
path: /swapfile
mode: "0600"
- name: Format swap file
command: mkswap /swapfile
register: _mkswap
changed_when: _mkswap.rc == 0
- name: Enable swap
command: swapon /swapfile
register: _swapon
failed_when: _swapon.rc != 0 and 'already' not in _swapon.stderr
changed_when: _swapon.rc == 0
- name: Persist swap in fstab
lineinfile:
path: /etc/fstab
line: "/swapfile none swap sw 0 0"
state: present
- name: Set swappiness to 10 (use swap only under pressure)
sysctl:
name: vm.swappiness
value: "10"
sysctl_set: true
state: present
reload: true
- name: Add deploy user to docker group
user:
name: "{{ ssh_user }}"
groups: docker
append: true
- name: Create app directory
file:
path: "{{ app_dir }}"
state: directory
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
mode: "0755"
roles:
- deploy
+40
View File
@@ -0,0 +1,40 @@
# Template for your Ansible Vault secrets file.
# Copy to vault.yml, fill in values, then encrypt:
# ansible-vault encrypt vault.yml
# Edit later with:
# ansible-vault edit vault.yml
# ── MQTT ─────────────────────────────────────────────────────────────────────
vault_mqtt_c2_user: drb-c2-core
vault_mqtt_c2_pass: "CHANGE_ME"
vault_mqtt_node_user: drb-node
vault_mqtt_node_pass: "CHANGE_ME"
# ── C2 Core ───────────────────────────────────────────────────────────────────
vault_service_key: "" # openssl rand -hex 32
vault_node_api_key: "" # openssl rand -hex 32
vault_openai_api_key: ""
vault_google_maps_api_key: ""
vault_gemini_api_key: ""
vault_gcs_bucket: "your-gcs-bucket-name"
vault_firestore_database: "c2-server"
# ── Gitea Container Registry ──────────────────────────────────────────────────
vault_registry_host: "git.vpn.cusano.net"
vault_registry_user: "logan"
vault_registry_token: "" # Gitea access token with package:write scope
vault_registry: "git.vpn.cusano.net/logan" # full image prefix
# ── Discord Bot ───────────────────────────────────────────────────────────────
vault_discord_token: ""
# ── Frontend (Firebase) ───────────────────────────────────────────────────────
vault_firebase_api_key: ""
vault_firebase_auth_domain: ""
vault_firebase_project_id: ""
vault_firebase_storage_bucket: ""
vault_firebase_messaging_sender_id: ""
vault_firebase_app_id: ""
# No GCP key needed — the VM uses Application Default Credentials via the
# GCE metadata server. Terraform grants the required IAM roles at apply time.
+189
View File
@@ -0,0 +1,189 @@
terraform {
required_version = ">= 1.6"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
# Store state in GCS — create the bucket manually once before first apply
# Uncomment once GCS bucket permissions are confirmed working.
# backend "gcs" {
# bucket = "drb-tf-state"
# prefix = "drb/state"
# }
}
provider "google" {
project = var.project_id
region = var.region
}
# Pull live project metadata (number, name) without hardcoding them.
data "google_project" "current" {}
# ---------------------------------------------------------------------------
# Static external IP
# ---------------------------------------------------------------------------
resource "google_compute_address" "drb" {
name = "drb-server-ip"
region = var.region
}
# ---------------------------------------------------------------------------
# Firewall rules
# ---------------------------------------------------------------------------
resource "google_compute_firewall" "allow_web" {
name = "drb-allow-web"
network = "default"
allow {
protocol = "tcp"
ports = ["80", "443"]
}
source_ranges = ["0.0.0.0/0"]
target_tags = ["drb-server"]
}
resource "google_compute_firewall" "allow_ssh" {
name = "drb-allow-ssh"
network = "default"
allow {
protocol = "tcp"
ports = ["22"]
}
# Restrict SSH to your IP(s) and Gitea runner IP
source_ranges = var.allowed_ssh_cidrs
target_tags = ["drb-server"]
}
# MQTT is NOT exposed externally — edge nodes connect via WireGuard (see below)
# If you need to temporarily allow direct MQTT access for testing, uncomment and
# restrict source_ranges to your node IPs.
#
# resource "google_compute_firewall" "allow_mqtt" {
# name = "drb-allow-mqtt"
# network = "default"
# allow {
# protocol = "tcp"
# ports = ["8883"] # TLS MQTT, not 1883
# }
# source_ranges = ["YOUR_NODE_CIDR"]
# target_tags = ["drb-server"]
# }
# ---------------------------------------------------------------------------
# Compute Engine VM
# ---------------------------------------------------------------------------
resource "google_compute_instance" "drb_server" {
name = "drb-server"
machine_type = var.machine_type
zone = var.zone
tags = ["drb-server"]
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
size = 30 # GB — free tier covers 30GB pd-standard on e2-micro
type = "pd-standard"
}
}
network_interface {
network = "default"
access_config {
nat_ip = google_compute_address.drb.address
}
}
metadata = {
ssh-keys = "${var.ssh_user}:${var.ssh_public_key}"
}
# Startup script runs once on first boot to install Docker + Caddy
metadata_startup_script = file("${path.module}/startup.sh")
# The default compute service account with cloud-platform scope gives the VM
# full access to GCS and Firestore in the same project — no key file needed.
service_account {
scopes = ["cloud-platform"]
}
lifecycle {
# Prevent Terraform from destroying + recreating on metadata changes
ignore_changes = [metadata_startup_script]
}
}
# ---------------------------------------------------------------------------
# IAM — grant the VM's default compute SA access to Firestore and GCS.
# Since Firebase/GCS already live in the same project, no key file is needed —
# the VM authenticates via the metadata server (ADC).
# ---------------------------------------------------------------------------
locals {
compute_sa = "serviceAccount:${data.google_project.current.number}-compute@developer.gserviceaccount.com"
}
resource "google_project_iam_member" "drb_firestore" {
project = var.project_id
role = "roles/datastore.user"
member = local.compute_sa
}
resource "google_project_iam_member" "drb_gcs" {
project = var.project_id
role = "roles/storage.objectAdmin"
member = local.compute_sa
}
# ---------------------------------------------------------------------------
# Firestore database — import existing, manages schema/settings going forward
# ---------------------------------------------------------------------------
import {
id = "projects/${var.project_id}/databases/${var.firestore_database}"
to = google_firestore_database.c2
}
resource "google_firestore_database" "c2" {
project = var.project_id
name = var.firestore_database
location_id = var.firestore_location
type = "FIRESTORE_NATIVE"
# Prevent accidental deletion of the live database
deletion_policy = "DELETE"
}
# ---------------------------------------------------------------------------
# GCS bucket — audio recordings. Import existing bucket.
# ---------------------------------------------------------------------------
import {
id = var.audio_bucket_name
to = google_storage_bucket.audio
}
resource "google_storage_bucket" "audio" {
project = var.project_id
name = var.audio_bucket_name
location = var.audio_bucket_location
uniform_bucket_level_access = true
}
# ---------------------------------------------------------------------------
# DNS — managed in AWS Route 53 (cusano.net is there).
# After terraform apply, add these A records in Route 53:
# app.drb.cusano.net → server_ip output
# api.drb.cusano.net → server_ip output
# Or use a single wildcard: *.drb.cusano.net → server_ip
# ---------------------------------------------------------------------------
+22
View File
@@ -0,0 +1,22 @@
output "server_ip" {
value = google_compute_address.drb.address
description = "Static external IP of the DRB server VM"
}
output "app_url" {
value = "https://app.${var.domain}"
}
output "api_url" {
value = "https://api.${var.domain}"
}
output "project_number" {
value = data.google_project.current.number
description = "GCP project number (useful for service account references)"
}
output "ssh_command" {
value = "ssh ${var.ssh_user}@${google_compute_address.drb.address}"
description = "SSH command to reach the server (should rarely be needed)"
}
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# Runs once on first VM boot. Installs Docker, Docker Compose, and Caddy.
set -euxo pipefail
# ── Docker ────────────────────────────────────────────────────────────────────
apt-get update -y
apt-get install -y ca-certificates curl gnupg lsb-release
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/debian $(lsb_release -cs) stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -y
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable docker
systemctl start docker
# Allow drb user to run docker
usermod -aG docker drb 2>/dev/null || true
# ── Caddy (reverse proxy + auto TLS) ─────────────────────────────────────────
apt-get install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
> /etc/apt/sources.list.d/caddy-stable.list
apt-get update -y
apt-get install -y caddy
# ── App directory — clone repo so CI can git pull + docker compose up ─────────
apt-get install -y git
mkdir -p /opt/drb
# Repo is cloned here by initial setup; CI just git pulls and rebuilds.
# Set safe directory for the drb user
git config --global --add safe.directory /opt/drb
chown -R drb:drb /opt/drb 2>/dev/null || true
# ── Caddyfile placeholder (CI will write the real one on first deploy) ────────
cat > /etc/caddy/Caddyfile <<'CADDY'
# This file is managed by CI. Do not edit manually.
# It will be replaced on the first deployment.
:80 {
respond "DRB server — waiting for deployment" 200
}
CADDY
systemctl enable caddy
systemctl reload caddy
echo "Startup complete."
+24
View File
@@ -0,0 +1,24 @@
# Copy to terraform.tfvars and fill in values.
# terraform.tfvars is gitignored — never commit it.
project_id = "your-gcp-project-id" # gcloud config get-value project
region = "us-central1"
zone = "us-central1-a"
domain = "drb.cusano.net" # DNS is on AWS Route 53 — add A records manually after apply
machine_type = "e2-standard-2" # 2 vCPU / 8 GB — adjust if needed
ssh_user = "drb"
ssh_public_key = "ssh-ed25519 AAAA... user@host" # cat ~/.ssh/id_ed25519.pub
# Your IP + any CI runner IPs that need SSH access
allowed_ssh_cidrs = ["YOUR_IP/32"]
# Existing GCS bucket for audio recordings (bucket must already exist — imported into state)
audio_bucket_name = "your-audio-bucket-name"
audio_bucket_location = "US-CENTRAL1" # must match existing bucket location exactly — check GCP console
# Existing Firestore database ID and location (imported into state)
firestore_database = "c2-server"
firestore_location = "nam5" # nam5 = us-central, eur3 = europe, us-east1 = us-east
+66
View File
@@ -0,0 +1,66 @@
variable "project_id" {
description = "GCP project ID"
type = string
}
variable "region" {
description = "GCP region"
type = string
default = "us-central1"
}
variable "zone" {
description = "GCP zone"
type = string
default = "us-central1-a"
}
variable "domain" {
description = "Base domain (e.g. example.com)"
type = string
}
variable "machine_type" {
description = "Compute Engine machine type"
type = string
default = "e2-small"
}
variable "ssh_user" {
description = "SSH username for the VM"
type = string
default = "drb"
}
variable "ssh_public_key" {
description = "SSH public key to authorize on the VM"
type = string
}
variable "allowed_ssh_cidrs" {
description = "CIDR ranges allowed to SSH to the VM (your IP + Gitea runner)"
type = list(string)
}
variable "audio_bucket_name" {
description = "Existing GCS bucket name for call audio recordings"
type = string
}
variable "audio_bucket_location" {
description = "GCS bucket location — must match the existing bucket's location exactly"
type = string
default = "US-CENTRAL1"
}
variable "firestore_database" {
description = "Firestore database ID (e.g. c2-server)"
type = string
default = "c2-server"
}
variable "firestore_location" {
description = "Firestore multi-region location (nam5 = us-central, eur3 = europe)"
type = string
default = "nam5"
}