Compare commits
41 Commits
cbcc85f7b1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c6684ea61b | |||
| fa5f91c0fa | |||
| 57ff9f8ea3 | |||
| 9fdcad1c46 | |||
| 33700448bf | |||
| 3defdf18dc | |||
| 1f17b6c0d2 | |||
| 961cc6f36e | |||
| d290b89736 | |||
| 758c6f4115 | |||
| 6ae4d398f8 | |||
| 981f03ac06 | |||
| 47430827d4 | |||
| 4dd3343026 | |||
| fce189d8c9 | |||
| 3fb3bca034 | |||
| a0fdf2486e | |||
| e7622c7e6d | |||
| 21d15d0426 | |||
| 21268ab477 | |||
| 522748f07a | |||
| af4079d648 | |||
| 39c002d090 | |||
| 4295bdf4d2 | |||
| 18d96193ab | |||
| a1c91c5ed3 | |||
| f0a0ea508a | |||
| d64259bb18 | |||
| 7b9aefbcc5 | |||
| 8edb717dd2 | |||
| fb096d582d | |||
| a4962d7b0e | |||
| 4e0e0fc79f | |||
| e55412d8c7 | |||
| 9842b18799 | |||
| fe6bf55c0e | |||
| f65873d690 | |||
| 913fe0cbee | |||
| 032eef311f | |||
| 3d51db80d0 | |||
| 683b05beb1 |
@@ -0,0 +1,98 @@
|
||||
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 }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_C2_URL=https://api.${{ secrets.DRB_DOMAIN }}
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY=${{ secrets.FIREBASE_API_KEY }}
|
||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=${{ secrets.FIREBASE_AUTH_DOMAIN }}
|
||||
NEXT_PUBLIC_FIREBASE_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=${{ secrets.FIREBASE_STORAGE_BUCKET }}
|
||||
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_APP_ID=${{ secrets.FIREBASE_APP_ID }}
|
||||
NEXT_PUBLIC_FIRESTORE_DATABASE=${{ secrets.FIRESTORE_DATABASE }}
|
||||
|
||||
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
@@ -5,6 +5,18 @@ drb-server-discord-bot/.env
|
||||
drb-frontend/.env
|
||||
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
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -245,9 +245,13 @@ Edge node ──► audio upload ──► GCS storage
|
||||
│
|
||||
▼
|
||||
[2] INTELLIGENCE EXTRACTION (GPT-4o-mini)
|
||||
Scene detection, entity extraction:
|
||||
tags, incident_type, location, units,
|
||||
vehicles, severity, resolved flag
|
||||
Scene detection — splits multi-incident recordings
|
||||
Speaker role inference — dispatch vs. unit patterns
|
||||
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)
|
||||
+ embedding (text-embedding-3-small)
|
||||
→ CallRecord.tags, .location, .units, etc.
|
||||
|
||||
@@ -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
@@ -17,17 +17,17 @@ services:
|
||||
- mosquitto_data:/mosquitto/data
|
||||
|
||||
c2-core:
|
||||
image: ${REGISTRY}/c2-core:${TAG:-latest}
|
||||
build: ./drb-c2-core
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8888:8000"
|
||||
env_file: ./drb-c2-core/.env
|
||||
volumes:
|
||||
- ./drb-c2-core/gcp-key.json:/app/gcp-key.json:ro
|
||||
depends_on:
|
||||
- mosquitto
|
||||
|
||||
discord-bot:
|
||||
image: ${REGISTRY}/discord-bot:${TAG:-latest}
|
||||
build: ./drb-server-discord-bot
|
||||
restart: unless-stopped
|
||||
env_file: ./drb-server-discord-bot/.env
|
||||
@@ -35,6 +35,7 @@ services:
|
||||
- c2-core
|
||||
|
||||
frontend:
|
||||
image: ${REGISTRY}/frontend:${TAG:-latest}
|
||||
build: ./drb-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.14-slim
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# OpenAI (STT + intelligence)
|
||||
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_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
|
||||
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] = ["*"]
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -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)
|
||||
@@ -1,3 +1,6 @@
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from typing import Optional
|
||||
from fastapi import HTTPException, Security
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
@@ -26,7 +29,7 @@ async def require_service_or_firebase_token(
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||
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}
|
||||
try:
|
||||
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")
|
||||
|
||||
|
||||
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(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||
) -> 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)
|
||||
if not decoded.get("admin"):
|
||||
if get_role(decoded) != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
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:
|
||||
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"
|
||||
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:
|
||||
# No geocode available AND old incident: use content divergence as a
|
||||
# location-proxy veto. After 15+ minutes an officer at a completely
|
||||
@@ -1211,7 +1223,7 @@ async def _update_incident(
|
||||
talkgroup_name
|
||||
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}"
|
||||
elif primary_tag and 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
|
||||
content_tags = [t for t in tags if t != "auto-generated"]
|
||||
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}"
|
||||
elif primary_tag:
|
||||
title = f"{primary_tag} — {tg_label}"
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
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"
|
||||
@@ -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
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -127,10 +127,16 @@ class MQTTHandler:
|
||||
status = payload.get("status")
|
||||
if not status:
|
||||
return
|
||||
await fstore.doc_update("nodes", node_id, {
|
||||
"status": status,
|
||||
"last_seen": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
try:
|
||||
await fstore.doc_update("nodes", node_id, {
|
||||
"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
|
||||
|
||||
@@ -5,7 +5,21 @@ from app.config import settings
|
||||
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."""
|
||||
if not settings.gcs_bucket:
|
||||
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()
|
||||
signing_creds = None
|
||||
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")
|
||||
if signing_creds:
|
||||
return blob.generate_signed_url(
|
||||
|
||||
@@ -40,16 +40,9 @@ async def transcribe_call(
|
||||
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
||||
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:
|
||||
transcript, segments = await asyncio.to_thread(
|
||||
_sync_transcribe, gcs_uri, talkgroup_name, vocabulary
|
||||
_sync_transcribe, gcs_uri, talkgroup_name
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Transcription failed for call {call_id}: {e}")
|
||||
@@ -74,7 +67,6 @@ async def transcribe_call(
|
||||
def _sync_transcribe(
|
||||
gcs_uri: str,
|
||||
talkgroup_name: Optional[str] = None,
|
||||
vocabulary: Optional[list[str]] = None,
|
||||
) -> tuple[Optional[str], list[dict]]:
|
||||
"""Download audio from GCS and transcribe with OpenAI Whisper."""
|
||||
from google.cloud import storage as gcs
|
||||
@@ -108,13 +100,16 @@ def _sync_transcribe(
|
||||
try:
|
||||
blob.download_to_filename(tmp_path)
|
||||
|
||||
from app.internal.vocabulary_learner import build_whisper_vocab_prompt
|
||||
vocab_prefix = build_whisper_vocab_prompt(vocabulary or [])
|
||||
tg_prefix = f"Talkgroup: {talkgroup_name}. " if talkgroup_name else ""
|
||||
prompt = tg_prefix + vocab_prefix + _WHISPER_PROMPT
|
||||
tg_prefix = f"Talkgroup: {talkgroup_name}. " if talkgroup_name else ""
|
||||
# Vocabulary is intentionally excluded from the Whisper prompt.
|
||||
# whisper-1 treats the prompt as a transcription prior and echoes
|
||||
# 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).
|
||||
# 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"
|
||||
|
||||
openai_client = OpenAI(api_key=settings.openai_api_key)
|
||||
|
||||
@@ -18,6 +18,7 @@ import asyncio
|
||||
import difflib
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
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"sample budget: {settings.vocabulary_induction_sample_tokens} tokens"
|
||||
)
|
||||
await asyncio.sleep(30) # short startup grace period before first pass
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
flags = await get_flags()
|
||||
if flags["vocabulary_learning_enabled"]:
|
||||
@@ -260,6 +261,7 @@ async def vocabulary_induction_loop() -> None:
|
||||
logger.info("Vocabulary learning disabled — skipping induction pass")
|
||||
except Exception as e:
|
||||
logger.error(f"Vocabulary induction pass failed: {e}")
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
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)
|
||||
char_budget = settings.vocabulary_induction_sample_tokens * 4
|
||||
transcript_block = ""
|
||||
sampled_call_docs: list[dict] = []
|
||||
sampled = 0
|
||||
for call in all_calls:
|
||||
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
|
||||
tg = call.get("talkgroup_name") or f"TGID {call.get('talkgroup_id', '?')}"
|
||||
transcript_block += f"[{tg}] {text}\n"
|
||||
sampled_call_docs.append(call)
|
||||
sampled += 1
|
||||
|
||||
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}
|
||||
vocab_lower = {t.lower() for t in existing_vocab}
|
||||
|
||||
to_queue = [
|
||||
{"term": t, "source": "induction", "added_at": now}
|
||||
for t in new_terms
|
||||
if t.lower() not in vocab_lower and t.lower() not in pending_lower
|
||||
]
|
||||
to_queue = []
|
||||
for t in new_terms:
|
||||
if t.lower() in vocab_lower or t.lower() 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:
|
||||
return
|
||||
|
||||
@@ -342,6 +351,30 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
||||
# 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 = {
|
||||
"the", "and", "for", "are", "was", "were", "this", "that", "with",
|
||||
"have", "has", "had", "but", "not", "from", "they", "will", "what",
|
||||
|
||||
@@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop
|
||||
from app.internal.recorrelation_sweep import recorrelation_loop
|
||||
from app.config import settings
|
||||
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
|
||||
|
||||
|
||||
@@ -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(incidents.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(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")
|
||||
|
||||
@@ -134,3 +134,48 @@ class AlertEvent(BaseModel):
|
||||
transcript_snippet: Optional[str] = None
|
||||
triggered_at: Optional[datetime] = None
|
||||
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
|
||||
|
||||
@@ -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 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"])
|
||||
|
||||
|
||||
@@ -73,10 +88,18 @@ async def debug_correlation(
|
||||
"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.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 ────────────────────────────────
|
||||
all_call_ids: list[str] = []
|
||||
@@ -98,15 +121,18 @@ async def debug_correlation(
|
||||
]
|
||||
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)
|
||||
recent_ended = await fstore.collection_where("calls", [
|
||||
("status", "==", "ended"),
|
||||
recent_calls = await fstore.collection_where("calls", [
|
||||
("ended_at", ">=", cutoff),
|
||||
])
|
||||
orphans = [
|
||||
_call_summary(c) for c in recent_ended
|
||||
if not c.get("incident_ids") and not c.get("incident_id")
|
||||
_call_summary(c) for c in recent_calls
|
||||
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)
|
||||
|
||||
@@ -136,3 +162,15 @@ async def debug_correlation(
|
||||
"incidents": incident_records,
|
||||
"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]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends
|
||||
from pydantic import BaseModel
|
||||
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}
|
||||
|
||||
|
||||
@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")
|
||||
async def patch_transcript(
|
||||
call_id: str,
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
|
||||
from app.models import IncidentCreate, IncidentUpdate
|
||||
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"])
|
||||
|
||||
@@ -20,7 +20,10 @@ async def list_incidents(status: Optional[str] = None, type: Optional[str] = Non
|
||||
|
||||
|
||||
@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)."""
|
||||
from app.internal.summarizer import _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")
|
||||
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."""
|
||||
from app.internal.summarizer import _summarize_incident
|
||||
inc = await fstore.doc_get("incidents", incident_id)
|
||||
if not inc:
|
||||
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)
|
||||
return {"ok": True, "incident_id": incident_id}
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -4,7 +4,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from app.models import CommandPayload
|
||||
from app.internal import firestore as fstore
|
||||
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
|
||||
|
||||
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")
|
||||
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)
|
||||
if not node:
|
||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||
@@ -108,6 +112,7 @@ async def assign_system(
|
||||
system_id: str,
|
||||
hardware_preset: str = Query("rtl-sdr-v3"),
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, Optional
|
||||
from app.models import SystemCreate, SystemRecord
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.auth import require_admin_token, bootstrap_limiter
|
||||
|
||||
router = APIRouter(prefix="/systems", tags=["systems"])
|
||||
|
||||
@@ -35,7 +36,7 @@ async def get_system(system_id: str):
|
||||
|
||||
|
||||
@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())
|
||||
doc = SystemRecord(system_id=system_id, **body.model_dump())
|
||||
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}")
|
||||
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)
|
||||
if not existing:
|
||||
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)
|
||||
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)
|
||||
if not existing:
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
@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
|
||||
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")
|
||||
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."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
@@ -117,18 +126,26 @@ async def get_vocabulary(system_id: str):
|
||||
|
||||
|
||||
@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."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
bootstrap_limiter.check(system_id)
|
||||
from app.internal.vocabulary_learner import bootstrap_system_vocabulary
|
||||
terms = await bootstrap_system_vocabulary(system_id)
|
||||
return {"added": len(terms), "terms": 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."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
@@ -139,7 +156,11 @@ async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
||||
|
||||
|
||||
@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."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
@@ -150,7 +171,11 @@ async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
||||
|
||||
|
||||
@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."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
@@ -161,7 +186,11 @@ async def approve_pending(system_id: str, body: VocabularyTermBody):
|
||||
|
||||
|
||||
@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."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.auth import require_admin_token
|
||||
|
||||
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."""
|
||||
tokens = await fstore.collection_list("bot_tokens")
|
||||
return [
|
||||
{**t, "token": t["token"][:10] + "…" + t["token"][-4:]}
|
||||
{**t, "token": "•••" + t["token"][-4:]}
|
||||
for t in tokens
|
||||
]
|
||||
|
||||
|
||||
@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())
|
||||
doc = {
|
||||
"token_id": token_id,
|
||||
@@ -43,7 +44,7 @@ async def add_token(body: TokenCreate):
|
||||
|
||||
|
||||
@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)."""
|
||||
def _find():
|
||||
from app.internal.firestore import db
|
||||
@@ -61,7 +62,11 @@ async def flush_tokens():
|
||||
|
||||
|
||||
@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.
|
||||
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)
|
||||
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)
|
||||
if not existing:
|
||||
raise HTTPException(404, "Token not found.")
|
||||
|
||||
@@ -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,6 +4,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from app.internal.storage import upload_audio
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.logger import logger
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(tags=["upload"])
|
||||
|
||||
@@ -43,9 +44,10 @@ async def upload_call_audio(
|
||||
data = await file.read()
|
||||
if not data:
|
||||
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, filename)
|
||||
audio_url = await upload_audio(data, file.filename or "", call_id=call_id)
|
||||
|
||||
if audio_url:
|
||||
try:
|
||||
|
||||
@@ -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}
|
||||
@@ -4,6 +4,17 @@ WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
|
||||
# Build-time public vars — baked into the Next.js bundle by the CI workflow
|
||||
ARG NEXT_PUBLIC_C2_URL
|
||||
ARG NEXT_PUBLIC_FIREBASE_API_KEY
|
||||
ARG NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN
|
||||
ARG NEXT_PUBLIC_FIREBASE_PROJECT_ID
|
||||
ARG NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET
|
||||
ARG NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
|
||||
ARG NEXT_PUBLIC_FIREBASE_APP_ID
|
||||
ARG NEXT_PUBLIC_FIRESTORE_DATABASE
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-slim AS runner
|
||||
|
||||
+833
-65
@@ -2,8 +2,13 @@
|
||||
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { UserRecord, AuditEntry, UserRole } from "@/lib/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FeatureFlags {
|
||||
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() {
|
||||
const [limit, setLimit] = useState(20);
|
||||
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 }>;
|
||||
} | null;
|
||||
|
||||
// Aggregate corr_path and corr_fit_signal counts across all incident calls.
|
||||
const pathCounts: Record<string, number> = {};
|
||||
const signalCounts: Record<string, number> = {};
|
||||
if (meta?.incidents) {
|
||||
@@ -245,91 +342,762 @@ function CorrelationDebugTab() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<"features" | "correlation">("features");
|
||||
// ---------------------------------------------------------------------------
|
||||
// User detail panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const [flags, setFlags] = useState<FeatureFlags | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
function UserDetailPanel({
|
||||
user,
|
||||
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 [showSessions, setShowSessions] = useState(false);
|
||||
|
||||
// Fetch full detail (sessions) lazily
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
router.replace("/dashboard");
|
||||
return;
|
||||
}
|
||||
c2api.getFeatureFlags()
|
||||
.then((f) => setFlags(f as unknown as FeatureFlags))
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [isAdmin, router]);
|
||||
c2api.getUser(user.uid)
|
||||
.then((d) => setDetail(d))
|
||||
.catch(() => {});
|
||||
}, [user.uid]);
|
||||
|
||||
async function handleToggle(key: keyof FeatureFlags, value: boolean) {
|
||||
if (!flags) return;
|
||||
setSaving(key);
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const nodes = editRole === "operator"
|
||||
? editNodes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
try {
|
||||
const updated = await c2api.setFeatureFlags({ [key]: value });
|
||||
setFlags(updated as unknown as FeatureFlags);
|
||||
const updated = await c2api.updateUser(user.uid, {
|
||||
role: editRole,
|
||||
owned_node_ids: nodes,
|
||||
display_name: editName || undefined,
|
||||
});
|
||||
onUpdated(updated);
|
||||
setDetail((d) => ({ ...d, ...updated }));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} 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 (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
|
||||
|
||||
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
||||
{(["features", "correlation"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
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 className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-5 font-mono">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-white font-semibold">{detail.email}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{detail.uid}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-600 hover:text-gray-300 transition-colors text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{tab === "features" && (
|
||||
<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}
|
||||
/>
|
||||
{error && (
|
||||
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||||
<p className="text-red-400 text-xs">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Display Name</label>
|
||||
<input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
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"
|
||||
placeholder="Full name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Role</label>
|
||||
<select
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
|
||||
import { auth } from "@/lib/firebase";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -18,6 +19,7 @@ export default function LoginPage() {
|
||||
setError(null);
|
||||
try {
|
||||
await signInWithEmailAndPassword(auth, email, password);
|
||||
c2api.recordSession().catch(() => {});
|
||||
router.push("/dashboard");
|
||||
} catch {
|
||||
setError("Invalid email or password.");
|
||||
@@ -31,6 +33,7 @@ export default function LoginPage() {
|
||||
setError(null);
|
||||
try {
|
||||
await signInWithPopup(auth, new GoogleAuthProvider());
|
||||
c2api.recordSession().catch(() => {});
|
||||
router.push("/dashboard");
|
||||
} catch {
|
||||
setError("Google sign-in failed. Try again.");
|
||||
|
||||
@@ -2,48 +2,12 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useNodes } from "@/lib/useNodes";
|
||||
import { useActiveCalls } from "@/lib/useCalls";
|
||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
||||
import type { IncidentRecord } from "@/lib/types";
|
||||
|
||||
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() {
|
||||
const { nodes, loading } = useNodes();
|
||||
const activeCalls = useActiveCalls();
|
||||
@@ -69,7 +33,7 @@ export default function MapPage() {
|
||||
<button
|
||||
onClick={() => setKiosk(false)}
|
||||
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">
|
||||
<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>
|
||||
|
||||
{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…
|
||||
</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
|
||||
nodes={nodes}
|
||||
activeCalls={activeCalls}
|
||||
@@ -111,18 +75,6 @@ export default function MapPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useNodes } from "@/lib/useNodes";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { NodeCard } from "@/components/NodeCard";
|
||||
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import type { NodeRecord } from "@/lib/types";
|
||||
|
||||
export default function NodesPage() {
|
||||
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const { nodes, loading } = useNodes();
|
||||
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 systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
"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 { c2api } from "@/lib/c2api";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/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 ─────────────────────────────────────────────────
|
||||
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
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">
|
||||
Induction suggestions ({pending.length})
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-2">
|
||||
{pending.map((p) => (
|
||||
<div key={p.term} className="flex items-center gap-2">
|
||||
<span className="text-gray-300 flex-1">{p.term}</span>
|
||||
<span className="text-gray-600">{p.source}</span>
|
||||
<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 key={p.term} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-300 flex-1">{p.term}</span>
|
||||
<span className="text-gray-600">{p.source}</span>
|
||||
<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>
|
||||
@@ -1002,7 +1180,15 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
// ── Systems list page ─────────────────────────────────────────────────────────
|
||||
|
||||
export default function SystemsPage() {
|
||||
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
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 [editIsDuplicate, setEditIsDuplicate] = useState(false);
|
||||
|
||||
@@ -1098,6 +1284,7 @@ export default function SystemsPage() {
|
||||
Delete
|
||||
</button>
|
||||
</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 ?? {}} />
|
||||
<VocabularyPanel systemId={s.system_id} />
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ interface TokenRecord {
|
||||
}
|
||||
|
||||
export default function TokensPage() {
|
||||
const { isAdmin, loading: authLoading } = useAuth();
|
||||
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [tokens, setTokens] = useState<TokenRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -26,8 +26,8 @@ export default function TokensPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAdmin) router.replace("/dashboard");
|
||||
}, [authLoading, isAdmin, router]);
|
||||
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
|
||||
}, [authLoading, isAdmin, isOperator, router]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -67,7 +67,7 @@ export default function TokensPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading || !isAdmin) return null;
|
||||
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,25 +3,33 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth";
|
||||
import { auth } from "@/lib/firebase";
|
||||
import type { UserRole } from "@/lib/types";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
role: UserRole | null;
|
||||
isAdmin: boolean;
|
||||
isOperator: boolean;
|
||||
ownedNodeIds: string[];
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
loading: true,
|
||||
role: null,
|
||||
isAdmin: false,
|
||||
isOperator: false,
|
||||
ownedNodeIds: [],
|
||||
signOut: async () => {},
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [role, setRole] = useState<UserRole | null>(null);
|
||||
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
return onAuthStateChanged(auth, async (u) => {
|
||||
@@ -30,12 +38,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (u) {
|
||||
document.cookie = "drb_session=1; path=/; SameSite=Strict";
|
||||
// Read custom claims to determine admin status
|
||||
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 {
|
||||
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";
|
||||
}
|
||||
|
||||
const isAdmin = role === "admin";
|
||||
const isOperator = role === "operator";
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, isAdmin, signOut }}>
|
||||
<AuthContext.Provider value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, signOut }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -293,6 +293,7 @@ function FanIncidentLayer({
|
||||
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
|
||||
<a
|
||||
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"
|
||||
>
|
||||
View incident →
|
||||
@@ -451,6 +452,11 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
||||
/>
|
||||
</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 */}
|
||||
<LayersControl.Overlay name="ADS-B">
|
||||
<FeatureGroup />
|
||||
@@ -512,13 +518,9 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
||||
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
|
||||
const unitCount = inc.units?.length ?? 0;
|
||||
return (
|
||||
<button
|
||||
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" }}
|
||||
>
|
||||
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";
|
||||
const cardBody = (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span
|
||||
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>
|
||||
{!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>
|
||||
@@ -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">
|
||||
{incidents.map((inc) => {
|
||||
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||
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" }}
|
||||
>
|
||||
const label = (
|
||||
<>
|
||||
<span className="font-semibold" style={{ color }}>
|
||||
{inc.type ?? "other"}
|
||||
</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>
|
||||
|
||||
@@ -2,25 +2,32 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useUnconfiguredNodes } from "@/lib/useNodes";
|
||||
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
|
||||
const links = [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/nodes", label: "Nodes" },
|
||||
{ href: "/systems", label: "Systems" },
|
||||
{ href: "/calls", label: "Calls" },
|
||||
{ href: "/incidents", label: "Incidents" },
|
||||
{ href: "/map", label: "Map" },
|
||||
{ href: "/alerts", label: "Alerts" },
|
||||
// Links visible to all authenticated roles (viewer+)
|
||||
const viewerLinks = [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/calls", label: "Calls" },
|
||||
{ href: "/incidents", label: "Incidents" },
|
||||
{ href: "/map", label: "Map" },
|
||||
{ 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 = [
|
||||
{ href: "/tokens", label: "Tokens" },
|
||||
{ href: "/admin", label: "Admin" },
|
||||
{ href: "/admin", label: "Admin" },
|
||||
];
|
||||
|
||||
function SunIcon() {
|
||||
@@ -48,8 +55,9 @@ function MoonIcon() {
|
||||
}
|
||||
|
||||
export function Nav() {
|
||||
const { user, isAdmin, signOut } = useAuth();
|
||||
const { user, isAdmin, isOperator } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { nodes: pending } = useUnconfiguredNodes();
|
||||
const unackedAlerts = useUnacknowledgedAlerts();
|
||||
const { theme, toggle } = useTheme();
|
||||
@@ -57,7 +65,11 @@ export function Nav() {
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const allLinks = [...links, ...(isAdmin ? adminLinks : [])];
|
||||
const allLinks = [
|
||||
...viewerLinks,
|
||||
...(isAdmin || isOperator ? operatorLinks : []),
|
||||
...(isAdmin ? adminLinks : []),
|
||||
];
|
||||
|
||||
function navLinkClass(href: string) {
|
||||
return `text-sm font-mono transition-colors shrink-0 ${
|
||||
@@ -100,12 +112,17 @@ export function Nav() {
|
||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
|
||||
{/* Sign out (desktop) */}
|
||||
{/* Profile avatar (desktop) */}
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="hidden md:block text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||
onClick={() => router.push("/profile")}
|
||||
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>
|
||||
|
||||
{/* Hamburger (mobile) */}
|
||||
@@ -153,12 +170,15 @@ export function Nav() {
|
||||
</Link>
|
||||
))}
|
||||
<div className="border-t border-gray-800 pt-3 mt-1">
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||
<Link
|
||||
href="/profile"
|
||||
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
|
||||
</button>
|
||||
Profile
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -56,12 +56,15 @@ export const c2api = {
|
||||
request(`/nodes/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Calls
|
||||
getCall: (callId: string) => request<import("@/lib/types").CallRecord>(`/calls/${callId}`),
|
||||
getCalls: (params?: Record<string, string>) => {
|
||||
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
||||
return request<unknown[]>(`/calls${qs}`);
|
||||
},
|
||||
patchTranscript: (callId: string, transcript: string) =>
|
||||
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }),
|
||||
closeStallCalls: (olderThanMinutes: number, dryRun: boolean) =>
|
||||
request<{ dry_run: boolean; older_than_minutes: number; count: number; call_ids: string[] }>(`/calls/close-stale?older_than_minutes=${olderThanMinutes}&dry_run=${dryRun}`, { method: "POST" }),
|
||||
|
||||
// Incidents
|
||||
getIncidents: (params?: { status?: string; type?: string }) => {
|
||||
@@ -129,10 +132,88 @@ export const c2api = {
|
||||
getCorrelationDebug: (limit: number, orphanHours: number) =>
|
||||
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
|
||||
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>
|
||||
request<{ ok: boolean; ai_flags: Record<string, boolean> }>(`/systems/${systemId}/ai-flags`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(flags),
|
||||
}),
|
||||
|
||||
// User management (admin only)
|
||||
listUsers: () =>
|
||||
request<import("@/lib/types").UserRecord[]>("/admin/users"),
|
||||
createUser: (body: { email: string; role: string; display_name?: string; owned_node_ids?: string[] }) =>
|
||||
request<import("@/lib/types").UserRecord & { invite_link?: string | null }>("/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
getUser: (uid: string) =>
|
||||
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`),
|
||||
updateUser: (uid: string, body: { role?: string; owned_node_ids?: string[]; display_name?: string }) =>
|
||||
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" }),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
export type NodeStatus = "online" | "offline" | "recording" | "unconfigured";
|
||||
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 {
|
||||
node_id: string;
|
||||
@@ -19,6 +58,7 @@ export interface VocabularyPendingTerm {
|
||||
term: string;
|
||||
source: "induction" | "correction";
|
||||
added_at: string;
|
||||
source_call_ids?: string[];
|
||||
}
|
||||
|
||||
export interface SystemRecord {
|
||||
@@ -30,6 +70,7 @@ export interface SystemRecord {
|
||||
vocabulary_pending?: VocabularyPendingTerm[];
|
||||
vocabulary_bootstrapped?: boolean;
|
||||
ten_codes?: Record<string, string>; // {"10-10": "Commercial Alarm", ...}
|
||||
preferred_token_id?: string | null;
|
||||
}
|
||||
|
||||
export interface TranscriptSegment {
|
||||
@@ -96,6 +137,49 @@ export interface AlertRule {
|
||||
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 {
|
||||
alert_id: string;
|
||||
rule_id: string;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -14,7 +14,8 @@
|
||||
"react-dom": "^18.3.0",
|
||||
"firebase": "^10.12.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1"
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-markdown": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.14-slim
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class DRBBot(commands.Bot):
|
||||
|
||||
async def setup_hook(self):
|
||||
await self.load_extension("app.commands.radio")
|
||||
await self.load_extension("app.commands.trips")
|
||||
|
||||
if 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 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()
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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/"
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user