Compare commits
39 Commits
3d51db80d0
..
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 |
@@ -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-frontend/.env
|
||||||
drb-c2-core/gcp-key.json
|
drb-c2-core/gcp-key.json
|
||||||
|
|
||||||
|
# Terraform
|
||||||
|
infra/.terraform/
|
||||||
|
infra/terraform.tfstate
|
||||||
|
infra/terraform.tfstate.backup
|
||||||
|
infra/terraform.tfstate.*.backup
|
||||||
|
infra/.terraform.lock.hcl
|
||||||
|
infra/terraform.tfvars
|
||||||
|
infra/tf.log
|
||||||
|
infra/ansible/inventory.ini
|
||||||
|
infra/ansible/group_vars/all.yml
|
||||||
|
infra/ansible/vault.yml
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|||||||
@@ -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
|
- mosquitto_data:/mosquitto/data
|
||||||
|
|
||||||
c2-core:
|
c2-core:
|
||||||
|
image: ${REGISTRY}/c2-core:${TAG:-latest}
|
||||||
build: ./drb-c2-core
|
build: ./drb-c2-core
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8888:8000"
|
- "8888:8000"
|
||||||
env_file: ./drb-c2-core/.env
|
env_file: ./drb-c2-core/.env
|
||||||
volumes:
|
|
||||||
- ./drb-c2-core/gcp-key.json:/app/gcp-key.json:ro
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- mosquitto
|
- mosquitto
|
||||||
|
|
||||||
discord-bot:
|
discord-bot:
|
||||||
|
image: ${REGISTRY}/discord-bot:${TAG:-latest}
|
||||||
build: ./drb-server-discord-bot
|
build: ./drb-server-discord-bot
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: ./drb-server-discord-bot/.env
|
env_file: ./drb-server-discord-bot/.env
|
||||||
@@ -35,6 +35,7 @@ services:
|
|||||||
- c2-core
|
- c2-core
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
image: ${REGISTRY}/frontend:${TAG:-latest}
|
||||||
build: ./drb-frontend
|
build: ./drb-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.14-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# OpenAI (STT + intelligence)
|
# OpenAI (STT + intelligence)
|
||||||
openai_api_key: Optional[str] = None
|
openai_api_key: Optional[str] = None
|
||||||
stt_model: str = "gpt-4o-transcribe" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
|
stt_model: str = "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
|
||||||
|
|
||||||
# Google Maps (geocoding)
|
# Google Maps (geocoding)
|
||||||
google_maps_api_key: Optional[str] = None
|
google_maps_api_key: Optional[str] = None
|
||||||
@@ -51,7 +51,11 @@ class Settings(BaseSettings):
|
|||||||
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
|
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
|
||||||
service_key: Optional[str] = None
|
service_key: Optional[str] = None
|
||||||
|
|
||||||
# CORS — comma-separated list of allowed origins, or "*" for all
|
# Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
|
||||||
|
upload_max_bytes: int = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
|
||||||
|
# Defaults to "*" for local development only.
|
||||||
cors_origins: list[str] = ["*"]
|
cors_origins: list[str] = ["*"]
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -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 typing import Optional
|
||||||
from fastapi import HTTPException, Security
|
from fastapi import HTTPException, Security
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
@@ -26,7 +29,7 @@ async def require_service_or_firebase_token(
|
|||||||
if not credentials:
|
if not credentials:
|
||||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
if settings.service_key and token == settings.service_key:
|
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
||||||
return {"service": True}
|
return {"service": True}
|
||||||
try:
|
try:
|
||||||
return firebase_auth.verify_id_token(token)
|
return firebase_auth.verify_id_token(token)
|
||||||
@@ -34,11 +37,96 @@ async def require_service_or_firebase_token(
|
|||||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||||
|
|
||||||
|
|
||||||
|
def get_role(decoded: dict) -> str:
|
||||||
|
"""Extract the effective role from a decoded Firebase token.
|
||||||
|
|
||||||
|
Checks the granular ``role`` claim first, then falls back to the legacy
|
||||||
|
``admin`` boolean so existing tokens continue to work during the transition.
|
||||||
|
"""
|
||||||
|
if decoded.get("role") == "admin" or decoded.get("admin"):
|
||||||
|
return "admin"
|
||||||
|
role = decoded.get("role", "viewer")
|
||||||
|
return role if role in ("admin", "operator", "viewer") else "viewer"
|
||||||
|
|
||||||
|
|
||||||
async def require_admin_token(
|
async def require_admin_token(
|
||||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Verify a Firebase ID token AND require the admin custom claim."""
|
"""Verify a Firebase ID token AND require the admin role.
|
||||||
|
|
||||||
|
Accepts both the legacy ``admin: True`` boolean claim and the newer
|
||||||
|
``role: "admin"`` claim so tokens issued before the role migration still work.
|
||||||
|
"""
|
||||||
decoded = await require_firebase_token(credentials)
|
decoded = await require_firebase_token(credentials)
|
||||||
if not decoded.get("admin"):
|
if get_role(decoded) != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin access required")
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
return decoded
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
|
async def require_service_key(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||||
|
) -> dict:
|
||||||
|
"""Accept only the internal service key — used for bot-only endpoints."""
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||||
|
if not settings.service_key:
|
||||||
|
raise HTTPException(status_code=503, detail="Service key not configured")
|
||||||
|
if not secrets.compare_digest(credentials.credentials, settings.service_key):
|
||||||
|
raise HTTPException(status_code=403, detail="Service key required")
|
||||||
|
return {"service": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def require_service_key_or_admin(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||||
|
) -> dict:
|
||||||
|
"""Accept either the internal service key or a Firebase admin token.
|
||||||
|
|
||||||
|
Used for endpoints that the Discord bot (service key) and dashboard admins
|
||||||
|
(Firebase + admin claim) both need to call, but regular Firebase users must not.
|
||||||
|
"""
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||||
|
token = credentials.credentials
|
||||||
|
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
||||||
|
return {"service": True}
|
||||||
|
try:
|
||||||
|
decoded = firebase_auth.verify_id_token(token)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||||
|
if get_role(decoded) != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Simple in-memory sliding-window rate limiter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Not persistent across restarts; good enough for a single-instance deployment.
|
||||||
|
# Key format is caller-defined (e.g. "{uid}:{endpoint}").
|
||||||
|
|
||||||
|
class _RateLimiter:
|
||||||
|
def __init__(self, max_calls: int, window_seconds: int):
|
||||||
|
self.max_calls = max_calls
|
||||||
|
self.window = window_seconds
|
||||||
|
self._log: dict[str, deque] = defaultdict(deque)
|
||||||
|
|
||||||
|
def check(self, key: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
q = self._log[key]
|
||||||
|
while q and now - q[0] > self.window:
|
||||||
|
q.popleft()
|
||||||
|
if len(q) >= self.max_calls:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Rate limit exceeded. Please wait before trying again.",
|
||||||
|
)
|
||||||
|
q.append(now)
|
||||||
|
|
||||||
|
|
||||||
|
# Shared limiter instances
|
||||||
|
# trip chat: 20 requests per user per 5 minutes
|
||||||
|
trip_chat_limiter = _RateLimiter(max_calls=20, window_seconds=300)
|
||||||
|
# per-incident summarize: 5 per incident per 10 minutes
|
||||||
|
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
||||||
|
# vocabulary bootstrap: 2 per system per hour
|
||||||
|
bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600)
|
||||||
|
|||||||
@@ -1030,6 +1030,18 @@ def _call_fits_incident(
|
|||||||
if dist_km > proximity_km:
|
if dist_km > proximity_km:
|
||||||
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but location_conflict dist={dist_km:.2f}km → unit_loc_conflict")
|
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but location_conflict dist={dist_km:.2f}km → unit_loc_conflict")
|
||||||
return False, "unit_loc_conflict"
|
return False, "unit_loc_conflict"
|
||||||
|
elif call_embedding and idle_min >= 15:
|
||||||
|
# Call has geocode but incident doesn't — fall back to content
|
||||||
|
# divergence as a location proxy. Without this, stale incidents
|
||||||
|
# that never geocoded absorb unrelated calls purely on unit
|
||||||
|
# overlap (e.g. a patrol officer working a second scene 70 min
|
||||||
|
# after the original call).
|
||||||
|
inc_emb_u = inc.get("embedding")
|
||||||
|
if inc_emb_u:
|
||||||
|
sim = _cosine_similarity(call_embedding, inc_emb_u)
|
||||||
|
if sim < 0.82:
|
||||||
|
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but content_divergence (has_call_coords/no_inc_coords) sim={sim:.3f} → content_divergence")
|
||||||
|
return False, "content_divergence"
|
||||||
elif call_embedding and idle_min >= 15:
|
elif call_embedding and idle_min >= 15:
|
||||||
# No geocode available AND old incident: use content divergence as a
|
# No geocode available AND old incident: use content divergence as a
|
||||||
# location-proxy veto. After 15+ minutes an officer at a completely
|
# location-proxy veto. After 15+ minutes an officer at a completely
|
||||||
@@ -1211,7 +1223,7 @@ async def _update_incident(
|
|||||||
talkgroup_name
|
talkgroup_name
|
||||||
or (f"TGID {talkgroup_id}" if talkgroup_id else inc.get("title", "").split(" — ")[-1])
|
or (f"TGID {talkgroup_id}" if talkgroup_id else inc.get("title", "").split(" — ")[-1])
|
||||||
)
|
)
|
||||||
if primary_tag and best_location and primary_tag.lower() != best_location.lower():
|
if primary_tag and best_location and best_coords and primary_tag.lower() != best_location.lower():
|
||||||
updates["title"] = f"{primary_tag} at {best_location}"
|
updates["title"] = f"{primary_tag} at {best_location}"
|
||||||
elif primary_tag and tg_label:
|
elif primary_tag and tg_label:
|
||||||
updates["title"] = f"{primary_tag} — {tg_label}"
|
updates["title"] = f"{primary_tag} — {tg_label}"
|
||||||
@@ -1259,7 +1271,7 @@ async def _create_incident(
|
|||||||
# Build a descriptive title from tags + location when available
|
# Build a descriptive title from tags + location when available
|
||||||
content_tags = [t for t in tags if t != "auto-generated"]
|
content_tags = [t for t in tags if t != "auto-generated"]
|
||||||
primary_tag = _tag_to_title(content_tags[0]) if content_tags else None
|
primary_tag = _tag_to_title(content_tags[0]) if content_tags else None
|
||||||
if primary_tag and location and primary_tag.lower() != location.lower():
|
if primary_tag and location and location_coords and primary_tag.lower() != location.lower():
|
||||||
title = f"{primary_tag} at {location}"
|
title = f"{primary_tag} at {location}"
|
||||||
elif primary_tag:
|
elif primary_tag:
|
||||||
title = f"{primary_tag} — {tg_label}"
|
title = f"{primary_tag} — {tg_label}"
|
||||||
|
|||||||
@@ -5,7 +5,21 @@ from app.config import settings
|
|||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
|
||||||
async def upload_audio(data: bytes, filename: str) -> Optional[str]:
|
def _safe_audio_filename(filename: str, call_id: str) -> str:
|
||||||
|
"""Return a safe GCS object name derived from the call_id.
|
||||||
|
|
||||||
|
We ignore the client-supplied filename entirely and derive the name from the
|
||||||
|
call_id (which we control) to prevent path traversal via crafted filenames.
|
||||||
|
The original extension is preserved only if it's a known audio type.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
ext = os.path.splitext(filename)[-1].lower() if filename else ""
|
||||||
|
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
|
||||||
|
ext = ".mp3"
|
||||||
|
return f"{call_id}{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
|
||||||
"""Upload audio bytes to GCS and return a signed URL, or None if disabled."""
|
"""Upload audio bytes to GCS and return a signed URL, or None if disabled."""
|
||||||
if not settings.gcs_bucket:
|
if not settings.gcs_bucket:
|
||||||
logger.info("GCS_BUCKET not configured — skipping audio upload.")
|
logger.info("GCS_BUCKET not configured — skipping audio upload.")
|
||||||
@@ -21,7 +35,8 @@ async def upload_audio(data: bytes, filename: str) -> Optional[str]:
|
|||||||
client = storage.Client()
|
client = storage.Client()
|
||||||
signing_creds = None
|
signing_creds = None
|
||||||
bucket = client.bucket(settings.gcs_bucket)
|
bucket = client.bucket(settings.gcs_bucket)
|
||||||
blob = bucket.blob(f"calls/{filename}")
|
safe_name = _safe_audio_filename(filename, call_id)
|
||||||
|
blob = bucket.blob(f"calls/{safe_name}")
|
||||||
blob.upload_from_string(data, content_type="audio/mpeg")
|
blob.upload_from_string(data, content_type="audio/mpeg")
|
||||||
if signing_creds:
|
if signing_creds:
|
||||||
return blob.generate_signed_url(
|
return blob.generate_signed_url(
|
||||||
|
|||||||
@@ -40,16 +40,9 @@ async def transcribe_call(
|
|||||||
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
||||||
return None, []
|
return None, []
|
||||||
|
|
||||||
# Load vocabulary for this system (empty list if none yet)
|
|
||||||
vocabulary: list[str] = []
|
|
||||||
if system_id:
|
|
||||||
from app.internal.vocabulary_learner import get_vocabulary
|
|
||||||
vocab_data = await get_vocabulary(system_id)
|
|
||||||
vocabulary = vocab_data.get("vocabulary") or []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transcript, segments = await asyncio.to_thread(
|
transcript, segments = await asyncio.to_thread(
|
||||||
_sync_transcribe, gcs_uri, talkgroup_name, vocabulary
|
_sync_transcribe, gcs_uri, talkgroup_name
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Transcription failed for call {call_id}: {e}")
|
logger.warning(f"Transcription failed for call {call_id}: {e}")
|
||||||
@@ -74,7 +67,6 @@ async def transcribe_call(
|
|||||||
def _sync_transcribe(
|
def _sync_transcribe(
|
||||||
gcs_uri: str,
|
gcs_uri: str,
|
||||||
talkgroup_name: Optional[str] = None,
|
talkgroup_name: Optional[str] = None,
|
||||||
vocabulary: Optional[list[str]] = None,
|
|
||||||
) -> tuple[Optional[str], list[dict]]:
|
) -> tuple[Optional[str], list[dict]]:
|
||||||
"""Download audio from GCS and transcribe with OpenAI Whisper."""
|
"""Download audio from GCS and transcribe with OpenAI Whisper."""
|
||||||
from google.cloud import storage as gcs
|
from google.cloud import storage as gcs
|
||||||
@@ -108,13 +100,16 @@ def _sync_transcribe(
|
|||||||
try:
|
try:
|
||||||
blob.download_to_filename(tmp_path)
|
blob.download_to_filename(tmp_path)
|
||||||
|
|
||||||
from app.internal.vocabulary_learner import build_whisper_vocab_prompt
|
tg_prefix = f"Talkgroup: {talkgroup_name}. " if talkgroup_name else ""
|
||||||
vocab_prefix = build_whisper_vocab_prompt(vocabulary or [])
|
# Vocabulary is intentionally excluded from the Whisper prompt.
|
||||||
tg_prefix = f"Talkgroup: {talkgroup_name}. " if talkgroup_name else ""
|
# whisper-1 treats the prompt as a transcription prior and echoes
|
||||||
prompt = tg_prefix + vocab_prefix + _WHISPER_PROMPT
|
# vocabulary terms into noise/silence, polluting downstream extraction.
|
||||||
|
# Vocabulary context is applied in the GPT extraction step instead,
|
||||||
|
# where it is used as reference rather than a transcription prior.
|
||||||
|
prompt = tg_prefix + _WHISPER_PROMPT
|
||||||
|
|
||||||
# Only whisper-1 supports verbose_json (per-segment timestamps + no_speech_prob).
|
# Only whisper-1 supports verbose_json (per-segment timestamps + no_speech_prob).
|
||||||
# Newer models (gpt-4o-transcribe, gpt-4o-mini-transcribe) only accept json/text.
|
# gpt-4o-transcribe and gpt-4o-mini-transcribe only support json/text.
|
||||||
use_verbose = settings.stt_model == "whisper-1"
|
use_verbose = settings.stt_model == "whisper-1"
|
||||||
|
|
||||||
openai_client = OpenAI(api_key=settings.openai_api_key)
|
openai_client = OpenAI(api_key=settings.openai_api_key)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import asyncio
|
|||||||
import difflib
|
import difflib
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
@@ -250,8 +251,8 @@ async def vocabulary_induction_loop() -> None:
|
|||||||
f"interval: {settings.vocabulary_induction_interval_hours}h, "
|
f"interval: {settings.vocabulary_induction_interval_hours}h, "
|
||||||
f"sample budget: {settings.vocabulary_induction_sample_tokens} tokens"
|
f"sample budget: {settings.vocabulary_induction_sample_tokens} tokens"
|
||||||
)
|
)
|
||||||
|
await asyncio.sleep(30) # short startup grace period before first pass
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(interval)
|
|
||||||
try:
|
try:
|
||||||
flags = await get_flags()
|
flags = await get_flags()
|
||||||
if flags["vocabulary_learning_enabled"]:
|
if flags["vocabulary_learning_enabled"]:
|
||||||
@@ -260,6 +261,7 @@ async def vocabulary_induction_loop() -> None:
|
|||||||
logger.info("Vocabulary learning disabled — skipping induction pass")
|
logger.info("Vocabulary learning disabled — skipping induction pass")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Vocabulary induction pass failed: {e}")
|
logger.error(f"Vocabulary induction pass failed: {e}")
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
async def _run_induction_pass() -> None:
|
async def _run_induction_pass() -> None:
|
||||||
@@ -296,6 +298,7 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
|||||||
random.shuffle(all_calls)
|
random.shuffle(all_calls)
|
||||||
char_budget = settings.vocabulary_induction_sample_tokens * 4
|
char_budget = settings.vocabulary_induction_sample_tokens * 4
|
||||||
transcript_block = ""
|
transcript_block = ""
|
||||||
|
sampled_call_docs: list[dict] = []
|
||||||
sampled = 0
|
sampled = 0
|
||||||
for call in all_calls:
|
for call in all_calls:
|
||||||
text = call.get("transcript_corrected") or call.get("transcript") or ""
|
text = call.get("transcript_corrected") or call.get("transcript") or ""
|
||||||
@@ -305,6 +308,7 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
|||||||
break
|
break
|
||||||
tg = call.get("talkgroup_name") or f"TGID {call.get('talkgroup_id', '?')}"
|
tg = call.get("talkgroup_name") or f"TGID {call.get('talkgroup_id', '?')}"
|
||||||
transcript_block += f"[{tg}] {text}\n"
|
transcript_block += f"[{tg}] {text}\n"
|
||||||
|
sampled_call_docs.append(call)
|
||||||
sampled += 1
|
sampled += 1
|
||||||
|
|
||||||
if sampled < 3:
|
if sampled < 3:
|
||||||
@@ -321,11 +325,16 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
|||||||
pending_lower = {p["term"].lower() for p in existing_pending}
|
pending_lower = {p["term"].lower() for p in existing_pending}
|
||||||
vocab_lower = {t.lower() for t in existing_vocab}
|
vocab_lower = {t.lower() for t in existing_vocab}
|
||||||
|
|
||||||
to_queue = [
|
to_queue = []
|
||||||
{"term": t, "source": "induction", "added_at": now}
|
for t in new_terms:
|
||||||
for t in new_terms
|
if t.lower() in vocab_lower or t.lower() in pending_lower:
|
||||||
if t.lower() not in vocab_lower and t.lower() not in pending_lower
|
continue
|
||||||
]
|
to_queue.append({
|
||||||
|
"term": t,
|
||||||
|
"source": "induction",
|
||||||
|
"added_at": now,
|
||||||
|
"source_call_ids": _find_source_calls(t, sampled_call_docs),
|
||||||
|
})
|
||||||
if not to_queue:
|
if not to_queue:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -342,6 +351,30 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
|||||||
# Internal sync helpers
|
# Internal sync helpers
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _find_source_calls(term: str, sampled_calls: list[dict], max_results: int = 3) -> list[str]:
|
||||||
|
"""
|
||||||
|
Find which sampled calls most likely produced this induction suggestion.
|
||||||
|
Splits the proposed term into tokens and searches call transcripts for overlap.
|
||||||
|
Falls back to the first two sampled calls when no token match is found
|
||||||
|
(e.g. fully garbled terms like "why vac" → "YVAC" have no word overlap).
|
||||||
|
"""
|
||||||
|
tokens = [t.lower() for t in re.split(r"[^a-zA-Z0-9]+", term) if len(t) >= 2]
|
||||||
|
matched: list[str] = []
|
||||||
|
if tokens:
|
||||||
|
for call in sampled_calls:
|
||||||
|
call_id = call.get("call_id")
|
||||||
|
if not call_id:
|
||||||
|
continue
|
||||||
|
text = (call.get("transcript_corrected") or call.get("transcript") or "").lower()
|
||||||
|
if any(tok in text for tok in tokens):
|
||||||
|
matched.append(call_id)
|
||||||
|
if len(matched) >= max_results:
|
||||||
|
break
|
||||||
|
if not matched:
|
||||||
|
matched = [c["call_id"] for c in sampled_calls[:2] if c.get("call_id")]
|
||||||
|
return matched
|
||||||
|
|
||||||
|
|
||||||
_STOP_WORDS = {
|
_STOP_WORDS = {
|
||||||
"the", "and", "for", "are", "was", "were", "this", "that", "with",
|
"the", "and", "for", "are", "was", "were", "this", "that", "with",
|
||||||
"have", "has", "had", "but", "not", "from", "they", "will", "what",
|
"have", "has", "had", "but", "not", "from", "they", "will", "what",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop
|
|||||||
from app.internal.recorrelation_sweep import recorrelation_loop
|
from app.internal.recorrelation_sweep import recorrelation_loop
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
|
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
|
||||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin
|
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
|
|
||||||
@@ -68,8 +68,12 @@ app.include_router(calls.router, dependencies=[Depends(require_service_or_fi
|
|||||||
app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
|
app.include_router(trips.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
|
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(upload.router) # auth is per-node, handled inline
|
app.include_router(upload.router) # auth is per-node, handled inline
|
||||||
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
||||||
|
app.include_router(users.router) # auth: admin only
|
||||||
|
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -134,3 +134,48 @@ class AlertEvent(BaseModel):
|
|||||||
transcript_snippet: Optional[str] = None
|
transcript_snippet: Optional[str] = None
|
||||||
triggered_at: Optional[datetime] = None
|
triggered_at: Optional[datetime] = None
|
||||||
acknowledged: bool = False
|
acknowledged: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Trips
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TripCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
location: str
|
||||||
|
maps_link: Optional[str] = None
|
||||||
|
start_date: str # YYYY-MM-DD
|
||||||
|
end_date: str # YYYY-MM-DD
|
||||||
|
available_tags: List[str] = [] # tag labels configured for this trip
|
||||||
|
overlap_tags: List[str] = [] # subset of available_tags that allow time overlap
|
||||||
|
visibility: str = "public" # "public" | "private"
|
||||||
|
invited_discord_ids: List[str] = [] # discord user IDs allowed on private trips
|
||||||
|
|
||||||
|
|
||||||
|
class TripEventCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
date: str # YYYY-MM-DD, must fall within parent trip range
|
||||||
|
start_time: Optional[str] = None # HH:MM (24h)
|
||||||
|
end_time: Optional[str] = None # HH:MM (24h)
|
||||||
|
location: Optional[str] = None # inherits trip location if None
|
||||||
|
maps_link: Optional[str] = None
|
||||||
|
place_id: Optional[str] = None # Google Place ID
|
||||||
|
notes: Optional[str] = None
|
||||||
|
tags: List[str] = [] # tag labels applied to this event
|
||||||
|
|
||||||
|
|
||||||
|
class TripEventUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
date: Optional[str] = None
|
||||||
|
start_time: Optional[str] = None
|
||||||
|
end_time: Optional[str] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
maps_link: Optional[str] = None
|
||||||
|
place_id: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AttendeeAction(BaseModel):
|
||||||
|
discord_user_id: str
|
||||||
|
discord_username: Optional[str] = None
|
||||||
|
|||||||
@@ -5,6 +5,21 @@ from app.internal.auth import require_admin_token, require_firebase_token
|
|||||||
from app.internal.feature_flags import get_flags, set_flags
|
from app.internal.feature_flags import get_flags, set_flags
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
|
async def _get_ai_enabled_system_ids(global_flags: dict) -> set[str]:
|
||||||
|
"""Return system_ids where at least one AI function (STT or correlation) is effectively on."""
|
||||||
|
global_stt = global_flags.get("stt_enabled", True)
|
||||||
|
global_corr = global_flags.get("correlation_enabled", True)
|
||||||
|
all_systems = await fstore.collection_list("systems")
|
||||||
|
enabled: set[str] = set()
|
||||||
|
for system in all_systems:
|
||||||
|
sid = system.get("system_id")
|
||||||
|
if not sid:
|
||||||
|
continue
|
||||||
|
ai_flags = system.get("ai_flags") or {}
|
||||||
|
if ai_flags.get("stt_enabled", global_stt) or ai_flags.get("correlation_enabled", global_corr):
|
||||||
|
enabled.add(sid)
|
||||||
|
return enabled
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
@@ -73,10 +88,18 @@ async def debug_correlation(
|
|||||||
"skip_reason": call.get("skip_reason"),
|
"skip_reason": call.get("skip_reason"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Fetch recent incidents ────────────────────────────────────────────────
|
# ── Determine which systems have AI active ────────────────────────────────
|
||||||
|
global_flags = await get_flags()
|
||||||
|
ai_systems = await _get_ai_enabled_system_ids(global_flags)
|
||||||
|
|
||||||
|
# ── Fetch recent incidents (AI-enabled systems only) ──────────────────────
|
||||||
all_incidents = await fstore.collection_list("incidents")
|
all_incidents = await fstore.collection_list("incidents")
|
||||||
all_incidents.sort(key=lambda i: i.get("updated_at", ""), reverse=True)
|
all_incidents.sort(key=lambda i: i.get("updated_at", ""), reverse=True)
|
||||||
incidents = all_incidents[:limit]
|
ai_incidents = [
|
||||||
|
i for i in all_incidents
|
||||||
|
if any(sid in ai_systems for sid in (i.get("system_ids") or []))
|
||||||
|
]
|
||||||
|
incidents = ai_incidents[:limit]
|
||||||
|
|
||||||
# ── Fetch all linked call docs in parallel ────────────────────────────────
|
# ── Fetch all linked call docs in parallel ────────────────────────────────
|
||||||
all_call_ids: list[str] = []
|
all_call_ids: list[str] = []
|
||||||
@@ -98,15 +121,18 @@ async def debug_correlation(
|
|||||||
]
|
]
|
||||||
incident_records.append(rec)
|
incident_records.append(rec)
|
||||||
|
|
||||||
# ── Recent orphaned calls ─────────────────────────────────────────────────
|
# ── Recent orphaned calls (AI-enabled systems only) ───────────────────────
|
||||||
|
# Use a single-field range query to avoid requiring a composite Firestore index;
|
||||||
|
# filter status and system in Python.
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=orphan_hours)
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=orphan_hours)
|
||||||
recent_ended = await fstore.collection_where("calls", [
|
recent_calls = await fstore.collection_where("calls", [
|
||||||
("status", "==", "ended"),
|
|
||||||
("ended_at", ">=", cutoff),
|
("ended_at", ">=", cutoff),
|
||||||
])
|
])
|
||||||
orphans = [
|
orphans = [
|
||||||
_call_summary(c) for c in recent_ended
|
_call_summary(c) for c in recent_calls
|
||||||
if not c.get("incident_ids") and not c.get("incident_id")
|
if c.get("status") == "ended"
|
||||||
|
and not c.get("incident_ids") and not c.get("incident_id")
|
||||||
|
and c.get("system_id") in ai_systems
|
||||||
]
|
]
|
||||||
orphans.sort(key=lambda c: c.get("started_at", ""), reverse=True)
|
orphans.sort(key=lambda c: c.get("started_at", ""), reverse=True)
|
||||||
|
|
||||||
@@ -136,3 +162,15 @@ async def debug_correlation(
|
|||||||
"incidents": incident_records,
|
"incidents": incident_records,
|
||||||
"orphaned_calls": orphans[:250],
|
"orphaned_calls": orphans[:250],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit")
|
||||||
|
async def get_audit_log(
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
_=Depends(require_admin_token),
|
||||||
|
):
|
||||||
|
"""Return paginated audit log entries, most recent first."""
|
||||||
|
entries = await fstore.collection_list("audit_log")
|
||||||
|
entries.sort(key=lambda e: e.get("timestamp", ""), reverse=True)
|
||||||
|
return entries[offset: offset + limit]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -59,6 +60,50 @@ async def reprocess_call(call_id: str, background_tasks: BackgroundTasks):
|
|||||||
return {"ok": True, "call_id": call_id}
|
return {"ok": True, "call_id": call_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/close-stale")
|
||||||
|
async def close_stale_calls(
|
||||||
|
older_than_minutes: int = Query(30, ge=1, le=1440, description="Close active calls started more than this many minutes ago."),
|
||||||
|
dry_run: bool = Query(False, description="If true, return what would be closed without writing."),
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Find and close calls stuck in 'active' status — e.g. because a node rebooted
|
||||||
|
before sending an end-call event. Returns the list of affected call IDs.
|
||||||
|
"""
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=older_than_minutes)
|
||||||
|
active_calls = await fstore.collection_list("calls", status="active")
|
||||||
|
|
||||||
|
stale = []
|
||||||
|
for call in active_calls:
|
||||||
|
started_raw = call.get("started_at")
|
||||||
|
if not started_raw:
|
||||||
|
continue
|
||||||
|
if isinstance(started_raw, datetime):
|
||||||
|
started = started_raw if started_raw.tzinfo else started_raw.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
started = datetime.fromisoformat(str(started_raw).replace("Z", "+00:00"))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if started < cutoff:
|
||||||
|
stale.append(call)
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
for call in stale:
|
||||||
|
await fstore.doc_set("calls", call["call_id"], {
|
||||||
|
"status": "ended",
|
||||||
|
"ended_at": now_iso,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"older_than_minutes": older_than_minutes,
|
||||||
|
"count": len(stale),
|
||||||
|
"call_ids": [c["call_id"] for c in stale],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{call_id}/transcript")
|
@router.patch("/{call_id}/transcript")
|
||||||
async def patch_transcript(
|
async def patch_transcript(
|
||||||
call_id: str,
|
call_id: str,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
|
||||||
from app.models import IncidentCreate, IncidentUpdate
|
from app.models import IncidentCreate, IncidentUpdate
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.auth import require_admin_token
|
from app.internal.auth import require_admin_token, require_service_or_firebase_token, summarize_limiter
|
||||||
|
|
||||||
router = APIRouter(prefix="/incidents", tags=["incidents"])
|
router = APIRouter(prefix="/incidents", tags=["incidents"])
|
||||||
|
|
||||||
@@ -20,7 +20,10 @@ async def list_incidents(status: Optional[str] = None, type: Optional[str] = Non
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/summarize")
|
@router.post("/summarize")
|
||||||
async def summarize_all_stale(background_tasks: BackgroundTasks):
|
async def summarize_all_stale(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""Immediately run the summarizer pass on all stale incidents (don't wait for the next interval)."""
|
"""Immediately run the summarizer pass on all stale incidents (don't wait for the next interval)."""
|
||||||
from app.internal.summarizer import _run_summary_pass
|
from app.internal.summarizer import _run_summary_pass
|
||||||
background_tasks.add_task(_run_summary_pass)
|
background_tasks.add_task(_run_summary_pass)
|
||||||
@@ -76,12 +79,18 @@ async def delete_incident(incident_id: str, _: dict = Depends(require_admin_toke
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{incident_id}/summarize")
|
@router.post("/{incident_id}/summarize")
|
||||||
async def summarize_incident(incident_id: str, background_tasks: BackgroundTasks):
|
async def summarize_incident(
|
||||||
|
incident_id: str,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
decoded: dict = Depends(require_service_or_firebase_token),
|
||||||
|
):
|
||||||
"""Immediately run the summarizer for a specific incident."""
|
"""Immediately run the summarizer for a specific incident."""
|
||||||
from app.internal.summarizer import _summarize_incident
|
from app.internal.summarizer import _summarize_incident
|
||||||
inc = await fstore.doc_get("incidents", incident_id)
|
inc = await fstore.doc_get("incidents", incident_id)
|
||||||
if not inc:
|
if not inc:
|
||||||
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
||||||
|
# Rate limit by incident ID to prevent repeated expensive LLM calls
|
||||||
|
summarize_limiter.check(incident_id)
|
||||||
background_tasks.add_task(_summarize_incident, inc)
|
background_tasks.add_task(_summarize_incident, inc)
|
||||||
return {"ok": True, "incident_id": incident_id}
|
return {"ok": True, "incident_id": incident_id}
|
||||||
|
|
||||||
|
|||||||
@@ -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.models import CommandPayload
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.mqtt_handler import mqtt_handler
|
from app.internal.mqtt_handler import mqtt_handler
|
||||||
from app.internal.auth import require_admin_token
|
from app.internal.auth import require_admin_token, require_service_key_or_admin
|
||||||
from app.routers.tokens import assign_token, release_token
|
from app.routers.tokens import assign_token, release_token
|
||||||
|
|
||||||
router = APIRouter(prefix="/nodes", tags=["nodes"])
|
router = APIRouter(prefix="/nodes", tags=["nodes"])
|
||||||
@@ -55,7 +55,11 @@ async def reject_node(node_id: str, _: dict = Depends(require_admin_token)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{node_id}/command")
|
@router.post("/{node_id}/command")
|
||||||
async def send_command(node_id: str, cmd: CommandPayload):
|
async def send_command(
|
||||||
|
node_id: str,
|
||||||
|
cmd: CommandPayload,
|
||||||
|
_: dict = Depends(require_service_key_or_admin),
|
||||||
|
):
|
||||||
node = await fstore.doc_get("nodes", node_id)
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
if not node:
|
if not node:
|
||||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||||
@@ -108,6 +112,7 @@ async def assign_system(
|
|||||||
system_id: str,
|
system_id: str,
|
||||||
hardware_preset: str = Query("rtl-sdr-v3"),
|
hardware_preset: str = Query("rtl-sdr-v3"),
|
||||||
ppm_override: Optional[float] = Query(None),
|
ppm_override: Optional[float] = Query(None),
|
||||||
|
_: dict = Depends(require_service_key_or_admin),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Assign a system to a node. Fetches the system config from Firestore
|
Assign a system to a node. Fetches the system config from Firestore
|
||||||
|
|||||||
@@ -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
|
import uuid
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
from app.models import SystemCreate, SystemRecord
|
from app.models import SystemCreate, SystemRecord
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.auth import require_admin_token, bootstrap_limiter
|
||||||
|
|
||||||
router = APIRouter(prefix="/systems", tags=["systems"])
|
router = APIRouter(prefix="/systems", tags=["systems"])
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ async def get_system(system_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_system(body: SystemCreate):
|
async def create_system(body: SystemCreate, _: dict = Depends(require_admin_token)):
|
||||||
system_id = str(uuid.uuid4())
|
system_id = str(uuid.uuid4())
|
||||||
doc = SystemRecord(system_id=system_id, **body.model_dump())
|
doc = SystemRecord(system_id=system_id, **body.model_dump())
|
||||||
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
|
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
|
||||||
@@ -43,7 +44,7 @@ async def create_system(body: SystemCreate):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{system_id}")
|
@router.put("/{system_id}")
|
||||||
async def update_system(system_id: str, body: SystemCreate):
|
async def update_system(system_id: str, body: SystemCreate, _: dict = Depends(require_admin_token)):
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
@@ -52,7 +53,7 @@ async def update_system(system_id: str, body: SystemCreate):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{system_id}", status_code=204)
|
@router.delete("/{system_id}", status_code=204)
|
||||||
async def delete_system(system_id: str):
|
async def delete_system(system_id: str, _: dict = Depends(require_admin_token)):
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
@@ -62,7 +63,11 @@ async def delete_system(system_id: str):
|
|||||||
# ── Per-system AI flag overrides ──────────────────────────────────────────────
|
# ── Per-system AI flag overrides ──────────────────────────────────────────────
|
||||||
|
|
||||||
@router.put("/{system_id}/ai-flags")
|
@router.put("/{system_id}/ai-flags")
|
||||||
async def update_system_ai_flags(system_id: str, body: AiFlagsBody):
|
async def update_system_ai_flags(
|
||||||
|
system_id: str,
|
||||||
|
body: AiFlagsBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Set per-system AI flag overrides. Only fields included in the body are
|
Set per-system AI flag overrides. Only fields included in the body are
|
||||||
written; omitted fields remain unchanged (or absent, meaning inherit global).
|
written; omitted fields remain unchanged (or absent, meaning inherit global).
|
||||||
@@ -95,7 +100,11 @@ async def get_ten_codes(system_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{system_id}/ten-codes")
|
@router.put("/{system_id}/ten-codes")
|
||||||
async def update_ten_codes(system_id: str, body: TenCodesBody):
|
async def update_ten_codes(
|
||||||
|
system_id: str,
|
||||||
|
body: TenCodesBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""Replace the ten-code dictionary for a system."""
|
"""Replace the ten-code dictionary for a system."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -117,18 +126,26 @@ async def get_vocabulary(system_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{system_id}/vocabulary/bootstrap", status_code=202)
|
@router.post("/{system_id}/vocabulary/bootstrap", status_code=202)
|
||||||
async def bootstrap_vocabulary(system_id: str):
|
async def bootstrap_vocabulary(
|
||||||
|
system_id: str,
|
||||||
|
decoded: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge."""
|
"""Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
|
bootstrap_limiter.check(system_id)
|
||||||
from app.internal.vocabulary_learner import bootstrap_system_vocabulary
|
from app.internal.vocabulary_learner import bootstrap_system_vocabulary
|
||||||
terms = await bootstrap_system_vocabulary(system_id)
|
terms = await bootstrap_system_vocabulary(system_id)
|
||||||
return {"added": len(terms), "terms": terms}
|
return {"added": len(terms), "terms": terms}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{system_id}/vocabulary/terms")
|
@router.post("/{system_id}/vocabulary/terms")
|
||||||
async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
async def add_vocabulary_term(
|
||||||
|
system_id: str,
|
||||||
|
body: VocabularyTermBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""Manually add a term to the approved vocabulary."""
|
"""Manually add a term to the approved vocabulary."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -139,7 +156,11 @@ async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{system_id}/vocabulary/terms")
|
@router.delete("/{system_id}/vocabulary/terms")
|
||||||
async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
async def remove_vocabulary_term(
|
||||||
|
system_id: str,
|
||||||
|
body: VocabularyTermBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""Remove a term from the approved vocabulary."""
|
"""Remove a term from the approved vocabulary."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -150,7 +171,11 @@ async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{system_id}/vocabulary/pending/approve")
|
@router.post("/{system_id}/vocabulary/pending/approve")
|
||||||
async def approve_pending(system_id: str, body: VocabularyTermBody):
|
async def approve_pending(
|
||||||
|
system_id: str,
|
||||||
|
body: VocabularyTermBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""Move a pending induction suggestion into the approved vocabulary."""
|
"""Move a pending induction suggestion into the approved vocabulary."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -161,7 +186,11 @@ async def approve_pending(system_id: str, body: VocabularyTermBody):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{system_id}/vocabulary/pending/dismiss")
|
@router.post("/{system_id}/vocabulary/pending/dismiss")
|
||||||
async def dismiss_pending(system_id: str, body: VocabularyTermBody):
|
async def dismiss_pending(
|
||||||
|
system_id: str,
|
||||||
|
body: VocabularyTermBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""Dismiss a pending induction suggestion without adding it."""
|
"""Dismiss a pending induction suggestion without adding it."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.auth import require_admin_token
|
||||||
|
|
||||||
router = APIRouter(prefix="/tokens", tags=["tokens"])
|
router = APIRouter(prefix="/tokens", tags=["tokens"])
|
||||||
|
|
||||||
@@ -22,13 +23,13 @@ async def list_tokens():
|
|||||||
"""List all tokens. The actual token string is masked for safety."""
|
"""List all tokens. The actual token string is masked for safety."""
|
||||||
tokens = await fstore.collection_list("bot_tokens")
|
tokens = await fstore.collection_list("bot_tokens")
|
||||||
return [
|
return [
|
||||||
{**t, "token": t["token"][:10] + "…" + t["token"][-4:]}
|
{**t, "token": "•••" + t["token"][-4:]}
|
||||||
for t in tokens
|
for t in tokens
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def add_token(body: TokenCreate):
|
async def add_token(body: TokenCreate, _: dict = Depends(require_admin_token)):
|
||||||
token_id = str(uuid.uuid4())
|
token_id = str(uuid.uuid4())
|
||||||
doc = {
|
doc = {
|
||||||
"token_id": token_id,
|
"token_id": token_id,
|
||||||
@@ -43,7 +44,7 @@ async def add_token(body: TokenCreate):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/flush", status_code=200)
|
@router.post("/flush", status_code=200)
|
||||||
async def flush_tokens():
|
async def flush_tokens(_: dict = Depends(require_admin_token)):
|
||||||
"""Force-release all in-use tokens (admin utility — use when tokens get orphaned)."""
|
"""Force-release all in-use tokens (admin utility — use when tokens get orphaned)."""
|
||||||
def _find():
|
def _find():
|
||||||
from app.internal.firestore import db
|
from app.internal.firestore import db
|
||||||
@@ -61,7 +62,11 @@ async def flush_tokens():
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{token_id}/prefer/{system_id}", status_code=200)
|
@router.put("/{token_id}/prefer/{system_id}", status_code=200)
|
||||||
async def set_preferred_system(token_id: str, system_id: str):
|
async def set_preferred_system(
|
||||||
|
token_id: str,
|
||||||
|
system_id: str,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Mark this token as the preferred bot for a system.
|
Mark this token as the preferred bot for a system.
|
||||||
When a discord_join is issued for any node in that system, this token
|
When a discord_join is issued for any node in that system, this token
|
||||||
@@ -89,7 +94,7 @@ async def set_preferred_system(token_id: str, system_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{token_id}", status_code=204)
|
@router.delete("/{token_id}", status_code=204)
|
||||||
async def delete_token(token_id: str):
|
async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
|
||||||
existing = await fstore.doc_get("bot_tokens", token_id)
|
existing = await fstore.doc_get("bot_tokens", token_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(404, "Token not found.")
|
raise HTTPException(404, "Token not found.")
|
||||||
|
|||||||
@@ -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.storage import upload_audio
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
router = APIRouter(tags=["upload"])
|
router = APIRouter(tags=["upload"])
|
||||||
|
|
||||||
@@ -43,9 +44,10 @@ async def upload_call_audio(
|
|||||||
data = await file.read()
|
data = await file.read()
|
||||||
if not data:
|
if not data:
|
||||||
raise HTTPException(400, "Empty file.")
|
raise HTTPException(400, "Empty file.")
|
||||||
|
if len(data) > settings.upload_max_bytes:
|
||||||
|
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
|
||||||
|
|
||||||
filename = file.filename
|
audio_url = await upload_audio(data, file.filename or "", call_id=call_id)
|
||||||
audio_url = await upload_audio(data, filename)
|
|
||||||
|
|
||||||
if audio_url:
|
if audio_url:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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 ./
|
COPY package.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY . .
|
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
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20-slim AS runner
|
FROM node:20-slim AS runner
|
||||||
|
|||||||
+833
-65
@@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { UserRecord, AuditEntry, UserRole } from "@/lib/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared primitives
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface FeatureFlags {
|
interface FeatureFlags {
|
||||||
stt_enabled: boolean;
|
stt_enabled: boolean;
|
||||||
@@ -61,6 +66,99 @@ function Toggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string | null | undefined) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDatetime(iso: string | null | undefined) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleString("en-US", {
|
||||||
|
month: "short", day: "numeric", year: "numeric",
|
||||||
|
hour: "numeric", minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_COLORS: Record<UserRole, string> = {
|
||||||
|
admin: "bg-indigo-900 text-indigo-300",
|
||||||
|
operator: "bg-green-900 text-green-300",
|
||||||
|
viewer: "bg-gray-800 text-gray-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
function RoleBadge({ role }: { role: UserRole }) {
|
||||||
|
const labels: Record<UserRole, string> = { admin: "Admin", operator: "Operator", viewer: "Viewer" };
|
||||||
|
return (
|
||||||
|
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${ROLE_COLORS[role]}`}>
|
||||||
|
{labels[role]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI Features tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function FeaturesTab() {
|
||||||
|
const [flags, setFlags] = useState<FeatureFlags | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
c2api.getFeatureFlags()
|
||||||
|
.then((f) => setFlags(f as unknown as FeatureFlags))
|
||||||
|
.catch((e) => setError(String(e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleToggle(key: keyof FeatureFlags, value: boolean) {
|
||||||
|
if (!flags) return;
|
||||||
|
setSaving(key);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await c2api.setFeatureFlags({ [key]: value });
|
||||||
|
setFlags(updated as unknown as FeatureFlags);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||||||
|
<p className="text-red-400 text-sm font-mono">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
||||||
|
{FLAG_META.map(({ key, label, description }) => (
|
||||||
|
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-white text-sm font-semibold">{label}</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
enabled={flags?.[key] ?? true}
|
||||||
|
onChange={(val) => handleToggle(key, val)}
|
||||||
|
disabled={saving === key}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Correlation Debug tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function CorrelationDebugTab() {
|
function CorrelationDebugTab() {
|
||||||
const [limit, setLimit] = useState(20);
|
const [limit, setLimit] = useState(20);
|
||||||
const [orphanHours, setOrphanHours] = useState(48);
|
const [orphanHours, setOrphanHours] = useState(48);
|
||||||
@@ -121,7 +219,6 @@ function CorrelationDebugTab() {
|
|||||||
orphans_by_talkgroup?: Array<{ talkgroup_id?: number; talkgroup_name?: string; count: number; no_type_count: number; sweep_exhausted_count: number }>;
|
orphans_by_talkgroup?: Array<{ talkgroup_id?: number; talkgroup_name?: string; count: number; no_type_count: number; sweep_exhausted_count: number }>;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
// Aggregate corr_path and corr_fit_signal counts across all incident calls.
|
|
||||||
const pathCounts: Record<string, number> = {};
|
const pathCounts: Record<string, number> = {};
|
||||||
const signalCounts: Record<string, number> = {};
|
const signalCounts: Record<string, number> = {};
|
||||||
if (meta?.incidents) {
|
if (meta?.incidents) {
|
||||||
@@ -245,91 +342,762 @@ function CorrelationDebugTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPage() {
|
// ---------------------------------------------------------------------------
|
||||||
const { isAdmin } = useAuth();
|
// User detail panel
|
||||||
const router = useRouter();
|
// ---------------------------------------------------------------------------
|
||||||
const [tab, setTab] = useState<"features" | "correlation">("features");
|
|
||||||
|
|
||||||
const [flags, setFlags] = useState<FeatureFlags | null>(null);
|
function UserDetailPanel({
|
||||||
const [loading, setLoading] = useState(true);
|
user,
|
||||||
const [saving, setSaving] = useState<string | null>(null);
|
onClose,
|
||||||
|
onUpdated,
|
||||||
|
currentUid,
|
||||||
|
}: {
|
||||||
|
user: UserRecord;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdated: (u: UserRecord) => void;
|
||||||
|
currentUid: string;
|
||||||
|
}) {
|
||||||
|
const [detail, setDetail] = useState<UserRecord>(user);
|
||||||
|
const [editRole, setEditRole] = useState<UserRole>(user.role);
|
||||||
|
const [editNodes, setEditNodes] = useState<string>(user.owned_node_ids.join(", "));
|
||||||
|
const [editName, setEditName] = useState<string>(user.display_name ?? "");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [toggling, setToggling] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showSessions, setShowSessions] = useState(false);
|
||||||
|
|
||||||
|
// Fetch full detail (sessions) lazily
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin) {
|
c2api.getUser(user.uid)
|
||||||
router.replace("/dashboard");
|
.then((d) => setDetail(d))
|
||||||
return;
|
.catch(() => {});
|
||||||
}
|
}, [user.uid]);
|
||||||
c2api.getFeatureFlags()
|
|
||||||
.then((f) => setFlags(f as unknown as FeatureFlags))
|
|
||||||
.catch((e) => setError(String(e)))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [isAdmin, router]);
|
|
||||||
|
|
||||||
async function handleToggle(key: keyof FeatureFlags, value: boolean) {
|
async function handleSave() {
|
||||||
if (!flags) return;
|
setSaving(true);
|
||||||
setSaving(key);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const nodes = editRole === "operator"
|
||||||
|
? editNodes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
try {
|
try {
|
||||||
const updated = await c2api.setFeatureFlags({ [key]: value });
|
const updated = await c2api.updateUser(user.uid, {
|
||||||
setFlags(updated as unknown as FeatureFlags);
|
role: editRole,
|
||||||
|
owned_node_ids: nodes,
|
||||||
|
display_name: editName || undefined,
|
||||||
|
});
|
||||||
|
onUpdated(updated);
|
||||||
|
setDetail((d) => ({ ...d, ...updated }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(null);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAdmin) return null;
|
async function handleToggleDisabled() {
|
||||||
|
setToggling(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (detail.disabled) {
|
||||||
|
await c2api.enableUser(user.uid);
|
||||||
|
} else {
|
||||||
|
await c2api.disableUser(user.uid);
|
||||||
|
}
|
||||||
|
const next = { ...detail, disabled: !detail.disabled };
|
||||||
|
setDetail(next);
|
||||||
|
onUpdated(next);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setToggling(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!confirm(`Permanently delete ${detail.email}? This cannot be undone.`)) return;
|
||||||
|
setDeleting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await c2api.deleteUser(user.uid);
|
||||||
|
onUpdated({ ...detail, uid: "__deleted__" });
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelf = user.uid === currentUid;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-5 font-mono">
|
||||||
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
<p className="text-white font-semibold">{detail.email}</p>
|
||||||
{(["features", "correlation"] as const).map((t) => (
|
<p className="text-xs text-gray-500 mt-0.5">{detail.uid}</p>
|
||||||
<button
|
</div>
|
||||||
key={t}
|
<button onClick={onClose} className="text-gray-600 hover:text-gray-300 transition-colors text-xl leading-none">×</button>
|
||||||
onClick={() => setTab(t)}
|
|
||||||
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
|
|
||||||
tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t === "features" ? "AI Features" : "Correlation Debug"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "features" && (
|
{error && (
|
||||||
<section className="space-y-3">
|
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||||||
{error && (
|
<p className="text-red-400 text-xs">{error}</p>
|
||||||
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
</div>
|
||||||
<p className="text-red-400 text-sm font-mono">{error}</p>
|
)}
|
||||||
</div>
|
|
||||||
)}
|
<div className="space-y-3">
|
||||||
{loading ? (
|
<div>
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<label className="text-xs text-gray-400 block mb-1">Display Name</label>
|
||||||
) : (
|
<input
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
value={editName}
|
||||||
{FLAG_META.map(({ key, label, description }) => (
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
<div className="min-w-0">
|
placeholder="Full name"
|
||||||
<p className="text-white text-sm font-semibold">{label}</p>
|
/>
|
||||||
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p>
|
</div>
|
||||||
</div>
|
|
||||||
<Toggle
|
<div>
|
||||||
enabled={flags?.[key] ?? true}
|
<label className="text-xs text-gray-400 block mb-1">Role</label>
|
||||||
onChange={(val) => handleToggle(key, val)}
|
<select
|
||||||
disabled={saving === key}
|
value={editRole}
|
||||||
/>
|
onChange={(e) => setEditRole(e.target.value as UserRole)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="admin">Admin — full access</option>
|
||||||
|
<option value="operator">Operator — owns nodes</option>
|
||||||
|
<option value="viewer">Viewer — read-only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editRole === "operator" && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">
|
||||||
|
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={editNodes}
|
||||||
|
onChange={(e) => setEditNodes(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="node-abc123, node-def456"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Save changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Status</span>
|
||||||
|
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
|
||||||
|
{detail.disabled ? "Disabled" : "Active"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Discord</span>
|
||||||
|
<span className="text-gray-300">
|
||||||
|
{detail.discord_linked
|
||||||
|
? `@${detail.discord_username ?? detail.discord_user_id}`
|
||||||
|
: "Not linked"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Created</span>
|
||||||
|
<span className="text-gray-300">{fmtDate(detail.creation_time)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Last sign-in</span>
|
||||||
|
<span className="text-gray-300">{fmtDate(detail.last_sign_in)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(detail.sessions?.length ?? 0) > 0 && (
|
||||||
|
<div className="border-t border-gray-800 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSessions((v) => !v)}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>{showSessions ? "▲" : "▼"}</span>
|
||||||
|
<span>Login history ({detail.sessions?.length} recent)</span>
|
||||||
|
</button>
|
||||||
|
{showSessions && (
|
||||||
|
<div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto">
|
||||||
|
{detail.sessions?.map((s) => (
|
||||||
|
<div key={s.session_id} className="text-xs text-gray-400 flex justify-between gap-4">
|
||||||
|
<span>{fmtDatetime(s.timestamp)}</span>
|
||||||
|
<span className="text-gray-600 truncate">{s.ip ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "correlation" && <CorrelationDebugTab />}
|
<div className="border-t border-gray-800 pt-4 flex gap-4 flex-wrap">
|
||||||
|
{!isSelf ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleDisabled}
|
||||||
|
disabled={toggling}
|
||||||
|
className="text-xs text-yellow-500 hover:text-yellow-400 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{toggling ? "…" : detail.disabled ? "Enable account" : "Disable account"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="text-xs text-red-500 hover:text-red-400 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting…" : "Delete user"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-600">Cannot disable or delete your own account.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Create User modal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function CreateUserModal({
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (u: UserRecord) => void;
|
||||||
|
}) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [role, setRole] = useState<UserRole>("viewer");
|
||||||
|
const [nodeIds, setNodeIds] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
const owned_node_ids = role === "operator"
|
||||||
|
? nodeIds.split(",").map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
try {
|
||||||
|
const created = await c2api.createUser({
|
||||||
|
email,
|
||||||
|
role,
|
||||||
|
display_name: displayName || undefined,
|
||||||
|
owned_node_ids,
|
||||||
|
});
|
||||||
|
onCreated(created);
|
||||||
|
if (created.invite_link) {
|
||||||
|
setInviteLink(created.invite_link);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLink() {
|
||||||
|
if (!inviteLink) return;
|
||||||
|
navigator.clipboard?.writeText(inviteLink).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inviteLink) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 space-y-4 font-mono">
|
||||||
|
<h2 className="text-white font-semibold">User Created</h2>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Share this one-time invite link with the new user so they can set their password.
|
||||||
|
It expires after use.
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={copyLink}
|
||||||
|
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? "Copied!" : "Copy link"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg py-2 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 font-mono">
|
||||||
|
<h2 className="text-white font-semibold mb-4">Create User</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">
|
||||||
|
Display Name <span className="text-gray-600">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Jane Smith"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Role</label>
|
||||||
|
<select
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value as UserRole)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="admin">Admin — full access</option>
|
||||||
|
<option value="operator">Operator — owns nodes</option>
|
||||||
|
<option value="viewer">Viewer — read-only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{role === "operator" && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">
|
||||||
|
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={nodeIds}
|
||||||
|
onChange={(e) => setNodeIds(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="node-abc123, node-def456"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
<div className="flex gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? "Creating…" : "Create user"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Users tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function UsersTab({ currentUid }: { currentUid: string }) {
|
||||||
|
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedUid, setSelectedUid] = useState<string | null>(null);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await c2api.listUsers();
|
||||||
|
setUsers(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadUsers(); }, [loadUsers]);
|
||||||
|
|
||||||
|
function handleUpdated(updated: UserRecord) {
|
||||||
|
if (updated.uid === "__deleted__") {
|
||||||
|
setUsers((prev) => prev.filter((u) => u.uid !== selectedUid));
|
||||||
|
setSelectedUid(null);
|
||||||
|
} else {
|
||||||
|
setUsers((prev) => prev.map((u) => u.uid === updated.uid ? { ...u, ...updated } : u));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreated(created: UserRecord) {
|
||||||
|
setUsers((prev) => [...prev, created]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedUser = users.find((u) => u.uid === selectedUid);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{showCreate && (
|
||||||
|
<CreateUserModal
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onCreated={(u) => { handleCreated(u); setShowCreate(false); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-gray-500 font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
+ Create user
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||||||
|
<p className="text-red-400 text-sm font-mono">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<p className="text-gray-600 text-sm font-mono">No users found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="border border-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-xs font-mono">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
|
||||||
|
<th className="px-4 py-2.5 text-left">Email</th>
|
||||||
|
<th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
|
||||||
|
<th className="px-4 py-2.5 text-left">Role</th>
|
||||||
|
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Discord</th>
|
||||||
|
<th className="px-4 py-2.5 text-left hidden md:table-cell">Last sign-in</th>
|
||||||
|
<th className="px-4 py-2.5 text-left">Status</th>
|
||||||
|
<th className="px-4 py-2.5 w-16"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr
|
||||||
|
key={u.uid}
|
||||||
|
className={`border-t border-gray-800 transition-colors ${
|
||||||
|
selectedUid === u.uid ? "bg-gray-800/60" : "hover:bg-gray-900/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2.5 text-gray-200">{u.email ?? "—"}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-400 hidden lg:table-cell">{u.display_name ?? "—"}</td>
|
||||||
|
<td className="px-4 py-2.5"><RoleBadge role={u.role} /></td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-500 hidden sm:table-cell">
|
||||||
|
{u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-500 hidden md:table-cell">{fmtDate(u.last_sign_in)}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
{u.disabled
|
||||||
|
? <span className="text-red-500">Disabled</span>
|
||||||
|
: <span className="text-green-500">Active</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedUid(selectedUid === u.uid ? null : u.uid)}
|
||||||
|
className="text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
|
>
|
||||||
|
{selectedUid === u.uid ? "Close" : "Edit"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedUser && (
|
||||||
|
<UserDetailPanel
|
||||||
|
user={selectedUser}
|
||||||
|
onClose={() => setSelectedUid(null)}
|
||||||
|
onUpdated={handleUpdated}
|
||||||
|
currentUid={currentUid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audit Log tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function AuditLogTab() {
|
||||||
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const PAGE = 50;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
c2api.getAuditLog(PAGE, 0)
|
||||||
|
.then((data) => {
|
||||||
|
setEntries(data);
|
||||||
|
setHasMore(data.length === PAGE);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(String(e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const more = await c2api.getAuditLog(PAGE, entries.length);
|
||||||
|
setEntries((prev) => [...prev, ...more]);
|
||||||
|
setHasMore(more.length === PAGE);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionColor(action: string) {
|
||||||
|
if (action.includes("delete")) return "text-red-400";
|
||||||
|
if (action.includes("disable")) return "text-yellow-400";
|
||||||
|
if (action.includes("create")) return "text-green-400";
|
||||||
|
return "text-indigo-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||||||
|
<p className="text-red-400 text-sm font-mono">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<p className="text-gray-600 text-sm font-mono">No audit entries yet.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="border border-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-xs font-mono">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
|
||||||
|
<th className="px-4 py-2.5 text-left">Time</th>
|
||||||
|
<th className="px-4 py-2.5 text-left">Action</th>
|
||||||
|
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
|
||||||
|
<th className="px-4 py-2.5 text-left hidden md:table-cell">Target</th>
|
||||||
|
<th className="px-4 py-2.5 text-left">Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.log_id} className="border-t border-gray-800 hover:bg-gray-900/40">
|
||||||
|
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
|
||||||
|
<td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-400 hidden sm:table-cell">{e.actor_email}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-400 hidden md:table-cell">{e.target_email ?? "—"}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-600 max-w-xs truncate">
|
||||||
|
{Object.keys(e.details).length > 0
|
||||||
|
? Object.entries(e.details)
|
||||||
|
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
||||||
|
.join(" · ")
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
className="text-sm font-mono text-indigo-400 hover:text-indigo-300 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loadingMore ? "Loading…" : "Load more"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stale Calls tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StaleCallsTab() {
|
||||||
|
const [minutes, setMinutes] = useState(30);
|
||||||
|
const [result, setResult] = useState<{ dry_run: boolean; count: number; call_ids: string[] } | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function run(dryRun: boolean) {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const res = await c2api.closeStallCalls(minutes, dryRun);
|
||||||
|
setResult(res);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p className="text-xs text-gray-500 font-mono">
|
||||||
|
Finds calls stuck in <span className="text-gray-300">active</span> status because a node rebooted before sending an end-call event.
|
||||||
|
Preview first, then close.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Older than (minutes)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1} max={1440}
|
||||||
|
value={minutes}
|
||||||
|
onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))}
|
||||||
|
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => run(true)}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "Working…" : "Preview"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => run(false)}
|
||||||
|
disabled={loading || result === null || result.count === 0}
|
||||||
|
className="bg-red-700 hover:bg-red-600 disabled:opacity-50 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{result && !result.dry_run ? "Closed" : result?.count ? `Close ${result.count} calls` : "Close calls"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||||||
|
<p className="text-red-400 text-sm font-mono">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2">
|
||||||
|
<p className="text-sm font-mono text-white">
|
||||||
|
{result.dry_run ? "Preview: " : "Closed: "}
|
||||||
|
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
|
||||||
|
{result.count} stale call{result.count !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{result.count === 0 && <span className="text-gray-500"> — nothing to clear</span>}
|
||||||
|
</p>
|
||||||
|
{result.call_ids.length > 0 && (
|
||||||
|
<div className="max-h-40 overflow-y-auto space-y-0.5">
|
||||||
|
{result.call_ids.map((id) => (
|
||||||
|
<p key={id} className="text-xs font-mono text-gray-400">{id}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main admin page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type AdminTab = "features" | "correlation" | "users" | "audit" | "calls";
|
||||||
|
|
||||||
|
const TAB_LABELS: { key: AdminTab; label: string }[] = [
|
||||||
|
{ key: "features", label: "AI Features" },
|
||||||
|
{ key: "correlation", label: "Correlation Debug" },
|
||||||
|
{ key: "calls", label: "Calls" },
|
||||||
|
{ key: "users", label: "Users" },
|
||||||
|
{ key: "audit", label: "Audit Log" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const { user, isAdmin } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [tab, setTab] = useState<AdminTab>("features");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) router.replace("/dashboard");
|
||||||
|
}, [isAdmin, router]);
|
||||||
|
|
||||||
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
|
// Users/Audit tabs benefit from full width; everything else is narrow
|
||||||
|
const wide = tab === "users" || tab === "audit";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
|
||||||
|
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
||||||
|
{TAB_LABELS.map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setTab(key)}
|
||||||
|
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
|
||||||
|
tab === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "features" && <FeaturesTab />}
|
||||||
|
{tab === "correlation" && <CorrelationDebugTab />}
|
||||||
|
{tab === "calls" && <StaleCallsTab />}
|
||||||
|
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
||||||
|
{tab === "audit" && <AuditLogTab />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
|
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
|
||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
@@ -18,6 +19,7 @@ export default function LoginPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await signInWithEmailAndPassword(auth, email, password);
|
await signInWithEmailAndPassword(auth, email, password);
|
||||||
|
c2api.recordSession().catch(() => {});
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Invalid email or password.");
|
setError("Invalid email or password.");
|
||||||
@@ -31,6 +33,7 @@ export default function LoginPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await signInWithPopup(auth, new GoogleAuthProvider());
|
await signInWithPopup(auth, new GoogleAuthProvider());
|
||||||
|
c2api.recordSession().catch(() => {});
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Google sign-in failed. Try again.");
|
setError("Google sign-in failed. Try again.");
|
||||||
|
|||||||
@@ -2,48 +2,12 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
|
||||||
import { useNodes } from "@/lib/useNodes";
|
import { useNodes } from "@/lib/useNodes";
|
||||||
import { useActiveCalls } from "@/lib/useCalls";
|
import { useActiveCalls } from "@/lib/useCalls";
|
||||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
import { useActiveIncidents } from "@/lib/useIncidents";
|
||||||
import type { IncidentRecord } from "@/lib/types";
|
|
||||||
|
|
||||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||||
|
|
||||||
const TYPE_COLORS: Record<string, string> = {
|
|
||||||
fire: "border-red-800 bg-red-950 text-red-300",
|
|
||||||
police: "border-blue-800 bg-blue-950 text-blue-300",
|
|
||||||
ems: "border-yellow-800 bg-yellow-950 text-yellow-300",
|
|
||||||
accident: "border-orange-800 bg-orange-950 text-orange-300",
|
|
||||||
other: "border-gray-700 bg-gray-900 text-gray-300",
|
|
||||||
};
|
|
||||||
|
|
||||||
function IncidentCard({ incident }: { incident: IncidentRecord }) {
|
|
||||||
const cls = TYPE_COLORS[incident.type ?? "other"] ?? TYPE_COLORS.other;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/incidents/${incident.incident_id}`}
|
|
||||||
className={`block border rounded-lg p-3 hover:brightness-110 transition-all ${cls}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
|
||||||
<span className="text-xs font-mono font-semibold uppercase tracking-wide">
|
|
||||||
{incident.type ?? "other"}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs opacity-60 font-mono">
|
|
||||||
{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-bold leading-tight">{incident.title ?? "Incident"}</p>
|
|
||||||
{incident.location && (
|
|
||||||
<p className="text-xs opacity-70 mt-1 font-mono truncate">{incident.location}</p>
|
|
||||||
)}
|
|
||||||
{!incident.location_coords && (
|
|
||||||
<p className="text-xs opacity-40 mt-1 font-mono italic">location not geocoded yet</p>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MapPage() {
|
export default function MapPage() {
|
||||||
const { nodes, loading } = useNodes();
|
const { nodes, loading } = useNodes();
|
||||||
const activeCalls = useActiveCalls();
|
const activeCalls = useActiveCalls();
|
||||||
@@ -69,7 +33,7 @@ export default function MapPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setKiosk(false)}
|
onClick={() => setKiosk(false)}
|
||||||
title="Exit fullscreen"
|
title="Exit fullscreen"
|
||||||
className="absolute top-3 left-3 z-[1002] bg-gray-950/90 border border-gray-700 rounded px-3 py-1.5 text-xs font-mono text-gray-300 hover:text-white hover:border-gray-500 transition-colors flex items-center gap-1.5"
|
className="absolute bottom-[5.5rem] left-3 z-[1002] bg-gray-950/90 border border-gray-700 rounded px-3 py-1.5 text-xs font-mono text-gray-300 hover:text-white hover:border-gray-500 transition-colors flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/>
|
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/>
|
||||||
@@ -97,11 +61,11 @@ export default function MapPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-96 text-gray-600 font-mono text-sm">
|
<div className="flex items-center justify-center h-[calc(100vh-10rem)] border border-gray-800 rounded-lg text-gray-600 font-mono text-sm">
|
||||||
Loading map…
|
Loading map…
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[50vh] sm:h-[65vh] min-h-[400px]">
|
<div className="w-full h-[calc(100vh-10rem)] border border-gray-800 rounded-lg overflow-hidden">
|
||||||
<MapView
|
<MapView
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
activeCalls={activeCalls}
|
activeCalls={activeCalls}
|
||||||
@@ -111,18 +75,6 @@ export default function MapPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{incidents.length > 0 && (
|
|
||||||
<section>
|
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
|
||||||
Active Incidents ({incidents.length})
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
|
||||||
{incidents.map((inc) => (
|
|
||||||
<IncidentCard key={inc.incident_id} incident={inc} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useNodes } from "@/lib/useNodes";
|
import { useNodes } from "@/lib/useNodes";
|
||||||
import { useSystems } from "@/lib/useSystems";
|
import { useSystems } from "@/lib/useSystems";
|
||||||
import { NodeCard } from "@/components/NodeCard";
|
import { NodeCard } from "@/components/NodeCard";
|
||||||
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import type { NodeRecord } from "@/lib/types";
|
import type { NodeRecord } from "@/lib/types";
|
||||||
|
|
||||||
export default function NodesPage() {
|
export default function NodesPage() {
|
||||||
|
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
const { nodes, loading } = useNodes();
|
const { nodes, loading } = useNodes();
|
||||||
const { systems } = useSystems();
|
const { systems } = useSystems();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
|
||||||
|
}, [authLoading, isAdmin, isOperator, router]);
|
||||||
|
|
||||||
|
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||||
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
|
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
|
||||||
|
|
||||||
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState, Fragment } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useSystems } from "@/lib/useSystems";
|
import { useSystems } from "@/lib/useSystems";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
|
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
|
||||||
|
|
||||||
// ── P25 structured config types ───────────────────────────────────────────────
|
// ── P25 structured config types ───────────────────────────────────────────────
|
||||||
@@ -739,6 +741,123 @@ function SystemForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Preferred bot token panel ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TokenOption {
|
||||||
|
token_id: string;
|
||||||
|
name: string;
|
||||||
|
in_use: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; initialTokenId?: string | null }) {
|
||||||
|
const [preferredId, setPreferredId] = useState<string | null>(initialTokenId ?? null);
|
||||||
|
const [tokens, setTokens] = useState<TokenOption[] | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (tokens !== null) return;
|
||||||
|
try {
|
||||||
|
const data = await c2api.getTokens();
|
||||||
|
setTokens(data as TokenOption[]);
|
||||||
|
} catch {
|
||||||
|
setTokens([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (!open) load();
|
||||||
|
setOpen((v) => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSet(tokenId: string) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await c2api.setPreferredToken(tokenId, systemId);
|
||||||
|
setPreferredId(tokenId);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClear() {
|
||||||
|
if (!preferredId) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await c2api.setPreferredToken(preferredId, "_none");
|
||||||
|
setPreferredId(null);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentToken = tokens?.find((t) => t.token_id === preferredId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 border-t border-gray-800 pt-3">
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>{open ? "▲" : "▼"}</span>
|
||||||
|
<span>
|
||||||
|
Preferred Bot Token
|
||||||
|
{preferredId && <span className="ml-1.5 text-indigo-400">● set</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-3 space-y-2 font-mono text-xs">
|
||||||
|
{tokens === null ? (
|
||||||
|
<p className="text-gray-600 italic">Loading tokens…</p>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<p className="text-gray-600 italic">No tokens in pool.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
When a node on this system joins a voice channel, this token is tried first.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{tokens.map((t) => (
|
||||||
|
<label key={t.token_id} className="flex items-center gap-2.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`preferred-token-${systemId}`}
|
||||||
|
checked={preferredId === t.token_id}
|
||||||
|
onChange={() => handleSet(t.token_id)}
|
||||||
|
disabled={saving}
|
||||||
|
className="accent-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-gray-600" : "text-gray-300"}`}>
|
||||||
|
{t.name}
|
||||||
|
{t.in_use && <span className="ml-1.5 text-green-600">in use</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{preferredId && (
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={saving}
|
||||||
|
className="text-gray-600 hover:text-gray-400 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Clear preference (use any free token)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!preferredId && (
|
||||||
|
<p className="text-gray-700">No preference — any free token will be used.</p>
|
||||||
|
)}
|
||||||
|
{currentToken && (
|
||||||
|
<p className="text-indigo-500">Preferred: {currentToken.name}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Per-system AI flags panel ─────────────────────────────────────────────────
|
// ── Per-system AI flags panel ─────────────────────────────────────────────────
|
||||||
|
|
||||||
interface SystemAiFlags {
|
interface SystemAiFlags {
|
||||||
@@ -829,6 +948,54 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Source call audio player ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SourceCallPlayer({ callId }: { callId: string }) {
|
||||||
|
const [call, setCall] = useState<{ audio_url?: string | null; transcript?: string | null; transcript_corrected?: string | null } | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
if (!open && !call) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const c = await c2api.getCall(callId);
|
||||||
|
setCall(c as unknown as typeof call);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOpen((v) => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcript = call?.transcript_corrected || call?.transcript;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-xs">
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-indigo-500 hover:text-indigo-400 transition-colors disabled:opacity-50"
|
||||||
|
title={callId}
|
||||||
|
>
|
||||||
|
{loading ? "loading…" : open ? "▲ source" : "▶ source"}
|
||||||
|
</button>
|
||||||
|
{open && call && (
|
||||||
|
<div className="mt-1.5 space-y-1 pl-2 border-l border-gray-700">
|
||||||
|
{call.audio_url ? (
|
||||||
|
<audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} />
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-600 italic">No audio</p>
|
||||||
|
)}
|
||||||
|
{transcript && (
|
||||||
|
<p className="text-gray-500 italic line-clamp-2">{transcript}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Vocabulary panel ──────────────────────────────────────────────────────────
|
// ── Vocabulary panel ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function VocabularyPanel({ systemId }: { systemId: string }) {
|
function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||||
@@ -979,13 +1146,24 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
<p className="text-gray-500 uppercase tracking-wider mb-1.5">
|
<p className="text-gray-500 uppercase tracking-wider mb-1.5">
|
||||||
Induction suggestions ({pending.length})
|
Induction suggestions ({pending.length})
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
{pending.map((p) => (
|
{pending.map((p) => (
|
||||||
<div key={p.term} className="flex items-center gap-2">
|
<div key={p.term} className="space-y-1">
|
||||||
<span className="text-gray-300 flex-1">{p.term}</span>
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-gray-600">{p.source}</span>
|
<span className="text-gray-300 flex-1">{p.term}</span>
|
||||||
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button>
|
<span className="text-gray-600">{p.source}</span>
|
||||||
<button onClick={() => handleDismiss(p.term)} className="text-gray-600 hover:text-red-400 transition-colors px-1">✕</button>
|
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button>
|
||||||
|
<button onClick={() => handleDismiss(p.term)} className="text-gray-600 hover:text-red-400 transition-colors px-1">✕</button>
|
||||||
|
</div>
|
||||||
|
{p.source_call_ids && p.source_call_ids.length > 0 && (
|
||||||
|
<div className="pl-1 space-y-1">
|
||||||
|
{p.source_call_ids.map((id: string) => (
|
||||||
|
<Fragment key={id}>
|
||||||
|
<SourceCallPlayer callId={id} />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1002,7 +1180,15 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
// ── Systems list page ─────────────────────────────────────────────────────────
|
// ── Systems list page ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function SystemsPage() {
|
export default function SystemsPage() {
|
||||||
|
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
const { systems, loading } = useSystems();
|
const { systems, loading } = useSystems();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
|
||||||
|
}, [authLoading, isAdmin, isOperator, router]);
|
||||||
|
|
||||||
|
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||||
const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
|
const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
|
||||||
const [editIsDuplicate, setEditIsDuplicate] = useState(false);
|
const [editIsDuplicate, setEditIsDuplicate] = useState(false);
|
||||||
|
|
||||||
@@ -1098,6 +1284,7 @@ export default function SystemsPage() {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />
|
||||||
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
|
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
|
||||||
<VocabularyPanel systemId={s.system_id} />
|
<VocabularyPanel systemId={s.system_id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface TokenRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TokensPage() {
|
export default function TokensPage() {
|
||||||
const { isAdmin, loading: authLoading } = useAuth();
|
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [tokens, setTokens] = useState<TokenRecord[]>([]);
|
const [tokens, setTokens] = useState<TokenRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -26,8 +26,8 @@ export default function TokensPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !isAdmin) router.replace("/dashboard");
|
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
|
||||||
}, [authLoading, isAdmin, router]);
|
}, [authLoading, isAdmin, isOperator, router]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -67,7 +67,7 @@ export default function TokensPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authLoading || !isAdmin) return null;
|
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 { createContext, useContext, useEffect, useState } from "react";
|
||||||
import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth";
|
import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth";
|
||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
|
import type { UserRole } from "@/lib/types";
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
role: UserRole | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
isOperator: boolean;
|
||||||
|
ownedNodeIds: string[];
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType>({
|
const AuthContext = createContext<AuthContextType>({
|
||||||
user: null,
|
user: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
|
role: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
isOperator: false,
|
||||||
|
ownedNodeIds: [],
|
||||||
signOut: async () => {},
|
signOut: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [role, setRole] = useState<UserRole | null>(null);
|
||||||
|
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return onAuthStateChanged(auth, async (u) => {
|
return onAuthStateChanged(auth, async (u) => {
|
||||||
@@ -30,12 +38,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (u) {
|
if (u) {
|
||||||
document.cookie = "drb_session=1; path=/; SameSite=Strict";
|
document.cookie = "drb_session=1; path=/; SameSite=Strict";
|
||||||
// Read custom claims to determine admin status
|
|
||||||
const result = await u.getIdTokenResult(true);
|
const result = await u.getIdTokenResult(true);
|
||||||
setIsAdmin(!!result.claims.admin);
|
const claims = result.claims;
|
||||||
|
|
||||||
|
// Derive role: prefer granular "role" claim, fall back to legacy "admin" boolean
|
||||||
|
let effectiveRole: UserRole = "viewer";
|
||||||
|
if (claims.role === "admin" || claims.admin) {
|
||||||
|
effectiveRole = "admin";
|
||||||
|
} else if (claims.role === "operator") {
|
||||||
|
effectiveRole = "operator";
|
||||||
|
} else if (claims.role === "viewer") {
|
||||||
|
effectiveRole = "viewer";
|
||||||
|
}
|
||||||
|
|
||||||
|
setRole(effectiveRole);
|
||||||
|
setOwnedNodeIds((claims.owned_node_ids as string[]) ?? []);
|
||||||
} else {
|
} else {
|
||||||
document.cookie = "drb_session=; path=/; max-age=0";
|
document.cookie = "drb_session=; path=/; max-age=0";
|
||||||
setIsAdmin(false);
|
setRole(null);
|
||||||
|
setOwnedNodeIds([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -45,8 +66,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
document.cookie = "drb_session=; path=/; max-age=0";
|
document.cookie = "drb_session=; path=/; max-age=0";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAdmin = role === "admin";
|
||||||
|
const isOperator = role === "operator";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, isAdmin, signOut }}>
|
<AuthContext.Provider value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, signOut }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ function FanIncidentLayer({
|
|||||||
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
|
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
|
||||||
<a
|
<a
|
||||||
href={`/incidents/${inc.incident_id}`}
|
href={`/incidents/${inc.incident_id}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }}
|
||||||
className="text-xs text-blue-600 hover:underline block mt-0.5"
|
className="text-xs text-blue-600 hover:underline block mt-0.5"
|
||||||
>
|
>
|
||||||
View incident →
|
View incident →
|
||||||
@@ -451,6 +452,11 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
/>
|
/>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
|
{/* Overlay: News / RSS alerts — placeholder for future integration */}
|
||||||
|
<LayersControl.Overlay name="News Alerts">
|
||||||
|
<FeatureGroup />
|
||||||
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
{/* Overlay: ADS-B — placeholder for future integration */}
|
{/* Overlay: ADS-B — placeholder for future integration */}
|
||||||
<LayersControl.Overlay name="ADS-B">
|
<LayersControl.Overlay name="ADS-B">
|
||||||
<FeatureGroup />
|
<FeatureGroup />
|
||||||
@@ -512,13 +518,9 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
|
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||||
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
|
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
|
||||||
const unitCount = inc.units?.length ?? 0;
|
const unitCount = inc.units?.length ?? 0;
|
||||||
return (
|
const baseClass = "w-full text-left bg-gray-950/85 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs font-mono hover:brightness-110 transition-all";
|
||||||
<button
|
const cardBody = (
|
||||||
key={inc.incident_id}
|
<>
|
||||||
onClick={() => handleIncidentSelect(inc)}
|
|
||||||
className="w-full text-left bg-gray-950/85 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs font-mono hover:brightness-110 transition-all"
|
|
||||||
style={{ borderColor: color + "55" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5 mb-0.5">
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
<span
|
<span
|
||||||
className="inline-block w-2 h-2 rounded-sm flex-shrink-0"
|
className="inline-block w-2 h-2 rounded-sm flex-shrink-0"
|
||||||
@@ -544,9 +546,31 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!inc.location_coords && (
|
{!inc.location_coords && (
|
||||||
<p className="text-gray-700 italic mt-0.5">no coords</p>
|
<p className="text-[10px] text-blue-700 mt-1">View details →</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
</>
|
||||||
|
);
|
||||||
|
if (inc.location_coords) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={inc.incident_id}
|
||||||
|
onClick={() => handleIncidentSelect(inc)}
|
||||||
|
className={baseClass}
|
||||||
|
style={{ borderColor: color + "55" }}
|
||||||
|
>
|
||||||
|
{cardBody}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={inc.incident_id}
|
||||||
|
href={`/incidents/${inc.incident_id}`}
|
||||||
|
className={`block ${baseClass}`}
|
||||||
|
style={{ borderColor: color + "55" }}
|
||||||
|
>
|
||||||
|
{cardBody}
|
||||||
|
</a>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -564,22 +588,39 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
<div className="bg-gray-950/95 border-t border-gray-800 max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
|
<div className="bg-gray-950/95 border-t border-gray-800 max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
|
||||||
{incidents.map((inc) => {
|
{incidents.map((inc) => {
|
||||||
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
|
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||||
return (
|
const label = (
|
||||||
<button
|
<>
|
||||||
key={inc.incident_id}
|
|
||||||
onClick={() => {
|
|
||||||
setDrawerOpen(false);
|
|
||||||
handleIncidentSelect(inc);
|
|
||||||
}}
|
|
||||||
className="w-full text-left border rounded px-2 py-1.5 text-xs font-mono"
|
|
||||||
style={{ borderColor: color + "55" }}
|
|
||||||
>
|
|
||||||
<span className="font-semibold" style={{ color }}>
|
<span className="font-semibold" style={{ color }}>
|
||||||
{inc.type ?? "other"}
|
{inc.type ?? "other"}
|
||||||
</span>
|
</span>
|
||||||
{" — "}
|
{" — "}
|
||||||
<span className="text-white">{inc.title ?? "Incident"}</span>
|
<span className="text-white">{inc.title ?? "Incident"}</span>
|
||||||
</button>
|
</>
|
||||||
|
);
|
||||||
|
if (inc.location_coords) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={inc.incident_id}
|
||||||
|
onClick={() => {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
handleIncidentSelect(inc);
|
||||||
|
}}
|
||||||
|
className="w-full text-left border rounded px-2 py-1.5 text-xs font-mono"
|
||||||
|
style={{ borderColor: color + "55" }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={inc.incident_id}
|
||||||
|
href={`/incidents/${inc.incident_id}`}
|
||||||
|
className="block w-full text-left border rounded px-2 py-1.5 text-xs font-mono"
|
||||||
|
style={{ borderColor: color + "55" }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,25 +2,32 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useUnconfiguredNodes } from "@/lib/useNodes";
|
import { useUnconfiguredNodes } from "@/lib/useNodes";
|
||||||
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { useTheme } from "@/components/ThemeProvider";
|
import { useTheme } from "@/components/ThemeProvider";
|
||||||
|
|
||||||
const links = [
|
// Links visible to all authenticated roles (viewer+)
|
||||||
{ href: "/dashboard", label: "Dashboard" },
|
const viewerLinks = [
|
||||||
{ href: "/nodes", label: "Nodes" },
|
{ href: "/dashboard", label: "Dashboard" },
|
||||||
{ href: "/systems", label: "Systems" },
|
{ href: "/calls", label: "Calls" },
|
||||||
{ href: "/calls", label: "Calls" },
|
{ href: "/incidents", label: "Incidents" },
|
||||||
{ href: "/incidents", label: "Incidents" },
|
{ href: "/map", label: "Map" },
|
||||||
{ href: "/map", label: "Map" },
|
{ href: "/alerts", label: "Alerts" },
|
||||||
{ href: "/alerts", label: "Alerts" },
|
{ href: "/trips", label: "Trips" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Additional links for operators and admins
|
||||||
|
const operatorLinks = [
|
||||||
|
{ href: "/nodes", label: "Nodes" },
|
||||||
|
{ href: "/systems", label: "Systems" },
|
||||||
|
{ href: "/tokens", label: "Tokens" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Admin-only links
|
||||||
const adminLinks = [
|
const adminLinks = [
|
||||||
{ href: "/tokens", label: "Tokens" },
|
{ href: "/admin", label: "Admin" },
|
||||||
{ href: "/admin", label: "Admin" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function SunIcon() {
|
function SunIcon() {
|
||||||
@@ -48,8 +55,9 @@ function MoonIcon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
const { user, isAdmin, signOut } = useAuth();
|
const { user, isAdmin, isOperator } = useAuth();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const { nodes: pending } = useUnconfiguredNodes();
|
const { nodes: pending } = useUnconfiguredNodes();
|
||||||
const unackedAlerts = useUnacknowledgedAlerts();
|
const unackedAlerts = useUnacknowledgedAlerts();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
@@ -57,7 +65,11 @@ export function Nav() {
|
|||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const allLinks = [...links, ...(isAdmin ? adminLinks : [])];
|
const allLinks = [
|
||||||
|
...viewerLinks,
|
||||||
|
...(isAdmin || isOperator ? operatorLinks : []),
|
||||||
|
...(isAdmin ? adminLinks : []),
|
||||||
|
];
|
||||||
|
|
||||||
function navLinkClass(href: string) {
|
function navLinkClass(href: string) {
|
||||||
return `text-sm font-mono transition-colors shrink-0 ${
|
return `text-sm font-mono transition-colors shrink-0 ${
|
||||||
@@ -100,12 +112,17 @@ export function Nav() {
|
|||||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Sign out (desktop) */}
|
{/* Profile avatar (desktop) */}
|
||||||
<button
|
<button
|
||||||
onClick={signOut}
|
onClick={() => router.push("/profile")}
|
||||||
className="hidden md:block text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
className={`hidden md:flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold transition-colors ${
|
||||||
|
pathname.startsWith("/profile")
|
||||||
|
? "bg-indigo-600 text-white"
|
||||||
|
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
|
||||||
|
}`}
|
||||||
|
title="Profile"
|
||||||
>
|
>
|
||||||
Sign out
|
{(user?.displayName || user?.email || "?")[0].toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Hamburger (mobile) */}
|
{/* Hamburger (mobile) */}
|
||||||
@@ -153,12 +170,15 @@ export function Nav() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<div className="border-t border-gray-800 pt-3 mt-1">
|
<div className="border-t border-gray-800 pt-3 mt-1">
|
||||||
<button
|
<Link
|
||||||
onClick={signOut}
|
href="/profile"
|
||||||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
|
||||||
|
pathname.startsWith("/profile") ? "text-white" : "text-gray-500"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Sign out
|
Profile
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -56,12 +56,15 @@ export const c2api = {
|
|||||||
request(`/nodes/${id}`, { method: "DELETE" }),
|
request(`/nodes/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
// Calls
|
// Calls
|
||||||
|
getCall: (callId: string) => request<import("@/lib/types").CallRecord>(`/calls/${callId}`),
|
||||||
getCalls: (params?: Record<string, string>) => {
|
getCalls: (params?: Record<string, string>) => {
|
||||||
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
||||||
return request<unknown[]>(`/calls${qs}`);
|
return request<unknown[]>(`/calls${qs}`);
|
||||||
},
|
},
|
||||||
patchTranscript: (callId: string, transcript: string) =>
|
patchTranscript: (callId: string, transcript: string) =>
|
||||||
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }),
|
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }),
|
||||||
|
closeStallCalls: (olderThanMinutes: number, dryRun: boolean) =>
|
||||||
|
request<{ dry_run: boolean; older_than_minutes: number; count: number; call_ids: string[] }>(`/calls/close-stale?older_than_minutes=${olderThanMinutes}&dry_run=${dryRun}`, { method: "POST" }),
|
||||||
|
|
||||||
// Incidents
|
// Incidents
|
||||||
getIncidents: (params?: { status?: string; type?: string }) => {
|
getIncidents: (params?: { status?: string; type?: string }) => {
|
||||||
@@ -129,10 +132,88 @@ export const c2api = {
|
|||||||
getCorrelationDebug: (limit: number, orphanHours: number) =>
|
getCorrelationDebug: (limit: number, orphanHours: number) =>
|
||||||
request<unknown>(`/admin/debug/correlation?limit=${limit}&orphan_hours=${orphanHours}`),
|
request<unknown>(`/admin/debug/correlation?limit=${limit}&orphan_hours=${orphanHours}`),
|
||||||
|
|
||||||
|
// Preferred bot token per system
|
||||||
|
setPreferredToken: (tokenId: string, systemId: string) =>
|
||||||
|
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),
|
||||||
|
|
||||||
|
// Trips
|
||||||
|
getTrips: () => request<import("@/lib/types").TripRecord[]>("/trips"),
|
||||||
|
getTrip: (id: string) =>
|
||||||
|
request<import("@/lib/types").TripRecord & { events: import("@/lib/types").TripEvent[] }>(`/trips/${id}`),
|
||||||
|
createTrip: (body: object) =>
|
||||||
|
request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
deleteTrip: (id: string) =>
|
||||||
|
request(`/trips/${id}`, { method: "DELETE" }),
|
||||||
|
updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) =>
|
||||||
|
request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }),
|
||||||
|
setTripVisibility: (id: string, visibility: "public" | "private") =>
|
||||||
|
request<{ visibility: string }>(`/trips/${id}/visibility`, { method: "PUT", body: JSON.stringify({ visibility }) }),
|
||||||
|
inviteToTrip: (id: string, discord_user_id: string) =>
|
||||||
|
request(`/trips/${id}/invite/${discord_user_id}`, { method: "POST" }),
|
||||||
|
revokeInvite: (id: string, discord_user_id: string) =>
|
||||||
|
request(`/trips/${id}/invite/${discord_user_id}`, { method: "DELETE" }),
|
||||||
|
generateLinkCode: () =>
|
||||||
|
request<{ code?: string; expires_minutes?: number; already_linked?: boolean; discord_user_id?: string }>("/auth/link/generate", { method: "POST" }),
|
||||||
|
getLinkStatus: () =>
|
||||||
|
request<{ linked: boolean; discord_user_id?: string; discord_username?: string; linked_at?: string }>("/auth/link/status"),
|
||||||
|
unlinkDiscord: () =>
|
||||||
|
request("/auth/link", { method: "DELETE" }),
|
||||||
|
createTripEvent: (tripId: string, body: object) =>
|
||||||
|
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
updateTripEvent: (tripId: string, eventId: string, body: object) =>
|
||||||
|
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events/${eventId}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
|
deleteTripEvent: (tripId: string, eventId: string) =>
|
||||||
|
request(`/trips/${tripId}/events/${eventId}`, { method: "DELETE" }),
|
||||||
|
tripChat: (tripId: string, message: string, history: { role: string; content: string }[]) =>
|
||||||
|
request<{ reply: string; suggestions: import("@/lib/types").TripEvent[] }>(
|
||||||
|
`/trips/${tripId}/chat`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ message, history }) }
|
||||||
|
),
|
||||||
|
|
||||||
|
// Places
|
||||||
|
searchPlaces: (query: string, near: string) =>
|
||||||
|
request<import("@/lib/types").PlaceResult[]>(
|
||||||
|
`/places/search?${new URLSearchParams({ query, near }).toString()}`
|
||||||
|
),
|
||||||
|
getDirections: (origin: string, destination: string) =>
|
||||||
|
request<{ duration_text: string | null; duration_seconds: number | null; distance_text: string | null }>(
|
||||||
|
`/places/directions?${new URLSearchParams({ origin, destination }).toString()}`
|
||||||
|
),
|
||||||
|
|
||||||
// Per-system AI flag overrides
|
// Per-system AI flag overrides
|
||||||
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>
|
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>
|
||||||
request<{ ok: boolean; ai_flags: Record<string, boolean> }>(`/systems/${systemId}/ai-flags`, {
|
request<{ ok: boolean; ai_flags: Record<string, boolean> }>(`/systems/${systemId}/ai-flags`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(flags),
|
body: JSON.stringify(flags),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// User management (admin only)
|
||||||
|
listUsers: () =>
|
||||||
|
request<import("@/lib/types").UserRecord[]>("/admin/users"),
|
||||||
|
createUser: (body: { email: string; role: string; display_name?: string; owned_node_ids?: string[] }) =>
|
||||||
|
request<import("@/lib/types").UserRecord & { invite_link?: string | null }>("/admin/users", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
getUser: (uid: string) =>
|
||||||
|
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`),
|
||||||
|
updateUser: (uid: string, body: { role?: string; owned_node_ids?: string[]; display_name?: string }) =>
|
||||||
|
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
disableUser: (uid: string) =>
|
||||||
|
request<{ ok: boolean }>(`/admin/users/${uid}/disable`, { method: "POST" }),
|
||||||
|
enableUser: (uid: string) =>
|
||||||
|
request<{ ok: boolean }>(`/admin/users/${uid}/enable`, { method: "POST" }),
|
||||||
|
deleteUser: (uid: string) =>
|
||||||
|
request<{ ok: boolean }>(`/admin/users/${uid}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// Audit log (admin only)
|
||||||
|
getAuditLog: (limit = 50, offset = 0) =>
|
||||||
|
request<import("@/lib/types").AuditEntry[]>(`/admin/audit?limit=${limit}&offset=${offset}`),
|
||||||
|
|
||||||
|
// Session recording — called on each explicit sign-in
|
||||||
|
recordSession: () =>
|
||||||
|
request<{ ok: boolean }>("/auth/session", { method: "POST" }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,44 @@
|
|||||||
export type NodeStatus = "online" | "offline" | "recording" | "unconfigured";
|
export type NodeStatus = "online" | "offline" | "recording" | "unconfigured";
|
||||||
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
||||||
|
export type UserRole = "admin" | "operator" | "viewer";
|
||||||
|
|
||||||
|
export interface UserRecord {
|
||||||
|
uid: string;
|
||||||
|
email: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
role: UserRole;
|
||||||
|
owned_node_ids: string[];
|
||||||
|
disabled: boolean;
|
||||||
|
creation_time: string | null;
|
||||||
|
last_sign_in: string | null;
|
||||||
|
discord_linked: boolean;
|
||||||
|
discord_username: string | null;
|
||||||
|
discord_user_id: string | null;
|
||||||
|
// only present on GET /admin/users/{uid}
|
||||||
|
sessions?: UserSession[];
|
||||||
|
// only present on POST /admin/users response
|
||||||
|
invite_link?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSession {
|
||||||
|
session_id: string;
|
||||||
|
uid: string;
|
||||||
|
email: string;
|
||||||
|
timestamp: string;
|
||||||
|
ip: string | null;
|
||||||
|
user_agent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
log_id: string;
|
||||||
|
action: string;
|
||||||
|
actor_uid: string;
|
||||||
|
actor_email: string;
|
||||||
|
target_uid: string | null;
|
||||||
|
target_email: string | null;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NodeRecord {
|
export interface NodeRecord {
|
||||||
node_id: string;
|
node_id: string;
|
||||||
@@ -19,6 +58,7 @@ export interface VocabularyPendingTerm {
|
|||||||
term: string;
|
term: string;
|
||||||
source: "induction" | "correction";
|
source: "induction" | "correction";
|
||||||
added_at: string;
|
added_at: string;
|
||||||
|
source_call_ids?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemRecord {
|
export interface SystemRecord {
|
||||||
@@ -30,6 +70,7 @@ export interface SystemRecord {
|
|||||||
vocabulary_pending?: VocabularyPendingTerm[];
|
vocabulary_pending?: VocabularyPendingTerm[];
|
||||||
vocabulary_bootstrapped?: boolean;
|
vocabulary_bootstrapped?: boolean;
|
||||||
ten_codes?: Record<string, string>; // {"10-10": "Commercial Alarm", ...}
|
ten_codes?: Record<string, string>; // {"10-10": "Commercial Alarm", ...}
|
||||||
|
preferred_token_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptSegment {
|
export interface TranscriptSegment {
|
||||||
@@ -96,6 +137,49 @@ export interface AlertRule {
|
|||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TripEvent {
|
||||||
|
event_id: string;
|
||||||
|
trip_id: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
start_time: string | null;
|
||||||
|
end_time: string | null;
|
||||||
|
location: string;
|
||||||
|
location_inherited: boolean;
|
||||||
|
maps_link: string | null;
|
||||||
|
place_id: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
tags: string[];
|
||||||
|
attendees: Record<string, string>;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaceResult {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
place_id: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
maps_link: string;
|
||||||
|
rating?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TripRecord {
|
||||||
|
trip_id: string;
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
maps_link: string | null;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
attendees: Record<string, string>;
|
||||||
|
available_tags: string[];
|
||||||
|
overlap_tags: string[];
|
||||||
|
visibility: "public" | "private";
|
||||||
|
invited_discord_ids: string[];
|
||||||
|
created_at: string;
|
||||||
|
events?: TripEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AlertEvent {
|
export interface AlertEvent {
|
||||||
alert_id: string;
|
alert_id: string;
|
||||||
rule_id: string;
|
rule_id: string;
|
||||||
|
|||||||
@@ -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",
|
"react-dom": "^18.3.0",
|
||||||
"firebase": "^10.12.0",
|
"firebase": "^10.12.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react-leaflet": "^4.2.1"
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-markdown": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.4.0",
|
"typescript": "^5.4.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.14-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class DRBBot(commands.Bot):
|
|||||||
|
|
||||||
async def setup_hook(self):
|
async def setup_hook(self):
|
||||||
await self.load_extension("app.commands.radio")
|
await self.load_extension("app.commands.radio")
|
||||||
|
await self.load_extension("app.commands.trips")
|
||||||
|
|
||||||
if settings.dev_guild_id:
|
if settings.dev_guild_id:
|
||||||
guild = discord.Object(id=settings.dev_guild_id)
|
guild = discord.Object(id=settings.dev_guild_id)
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
from discord.ext import commands
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from app.internal.c2_client import c2
|
||||||
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Date / time helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_date(s: str) -> Optional[date]:
|
||||||
|
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%m-%d-%Y"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s.strip(), fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time(s: str) -> Optional[str]:
|
||||||
|
"""Normalize to HH:MM (24h). Returns None if unparseable."""
|
||||||
|
for fmt in ("%H:%M", "%I:%M %p", "%I:%M%p", "%I %p"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s.strip().upper(), fmt).strftime("%H:%M")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_date(iso: str) -> str:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(iso, "%Y-%m-%d").strftime("%b %-d, %Y")
|
||||||
|
except Exception:
|
||||||
|
return iso
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_time(t: Optional[str]) -> str:
|
||||||
|
if not t:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return datetime.strptime(t, "%H:%M").strftime("%-I:%M %p")
|
||||||
|
except Exception:
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _date_range(start_iso: str, end_iso: str):
|
||||||
|
"""Yield ISO date strings from start to end inclusive."""
|
||||||
|
try:
|
||||||
|
current = datetime.strptime(start_iso, "%Y-%m-%d").date()
|
||||||
|
end = datetime.strptime(end_iso, "%Y-%m-%d").date()
|
||||||
|
while current <= end:
|
||||||
|
yield current.strftime("%Y-%m-%d")
|
||||||
|
current += timedelta(days=1)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cog
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _user_can_see_trip(trip: dict, discord_user_id: str) -> bool:
|
||||||
|
if trip.get("visibility", "public") == "public":
|
||||||
|
return True
|
||||||
|
if discord_user_id in trip.get("attendees", {}):
|
||||||
|
return True
|
||||||
|
if discord_user_id in trip.get("invited_discord_ids", []):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class TripCommands(commands.Cog):
|
||||||
|
def __init__(self, bot: commands.Bot):
|
||||||
|
self.bot = bot
|
||||||
|
|
||||||
|
trip_group = app_commands.Group(name="trip", description="Manage trips and itineraries.")
|
||||||
|
event_group = app_commands.Group(
|
||||||
|
name="event", description="Manage events within a trip.", parent=trip_group
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Autocomplete
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def trip_autocomplete(
|
||||||
|
self, interaction: discord.Interaction, current: str
|
||||||
|
) -> list[app_commands.Choice[str]]:
|
||||||
|
trips = await c2.get_trips()
|
||||||
|
user_id = str(interaction.user.id)
|
||||||
|
return [
|
||||||
|
app_commands.Choice(name=t["name"], value=t["trip_id"])
|
||||||
|
for t in trips
|
||||||
|
if current.lower() in t["name"].lower() and _user_can_see_trip(t, user_id)
|
||||||
|
][:25]
|
||||||
|
|
||||||
|
async def event_autocomplete(
|
||||||
|
self, interaction: discord.Interaction, current: str
|
||||||
|
) -> list[app_commands.Choice[str]]:
|
||||||
|
trip_id = interaction.namespace.trip
|
||||||
|
if not trip_id:
|
||||||
|
return []
|
||||||
|
trip = await c2.get_trip(trip_id)
|
||||||
|
if not trip:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
app_commands.Choice(name=e["title"], value=e["event_id"])
|
||||||
|
for e in trip.get("events", [])
|
||||||
|
if current.lower() in e["title"].lower()
|
||||||
|
][:25]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /trip create
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@trip_group.command(name="create", description="Create a new trip.")
|
||||||
|
@app_commands.describe(
|
||||||
|
name="Trip name",
|
||||||
|
location="Primary destination or location",
|
||||||
|
start_date="Start date (YYYY-MM-DD or MM/DD/YYYY)",
|
||||||
|
end_date="End date (YYYY-MM-DD or MM/DD/YYYY)",
|
||||||
|
maps_link="Optional Google Maps link for the destination",
|
||||||
|
)
|
||||||
|
async def trip_create(
|
||||||
|
self,
|
||||||
|
interaction: discord.Interaction,
|
||||||
|
name: str,
|
||||||
|
location: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
maps_link: Optional[str] = None,
|
||||||
|
):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
|
||||||
|
start = _parse_date(start_date)
|
||||||
|
end = _parse_date(end_date)
|
||||||
|
if not start or not end:
|
||||||
|
await interaction.followup.send("Invalid date format. Use YYYY-MM-DD.")
|
||||||
|
return
|
||||||
|
if end < start:
|
||||||
|
await interaction.followup.send("End date must be on or after start date.")
|
||||||
|
return
|
||||||
|
|
||||||
|
trip = await c2.create_trip({
|
||||||
|
"name": name,
|
||||||
|
"location": location,
|
||||||
|
"maps_link": maps_link,
|
||||||
|
"start_date": start.strftime("%Y-%m-%d"),
|
||||||
|
"end_date": end.strftime("%Y-%m-%d"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if not trip:
|
||||||
|
await interaction.followup.send("Failed to create trip.")
|
||||||
|
return
|
||||||
|
|
||||||
|
embed = discord.Embed(title=f"Trip Created: {name}", color=0x5865f2)
|
||||||
|
embed.add_field(name="Location", value=location, inline=True)
|
||||||
|
embed.add_field(
|
||||||
|
name="Dates",
|
||||||
|
value=f"{_fmt_date(start.strftime('%Y-%m-%d'))} — {_fmt_date(end.strftime('%Y-%m-%d'))}",
|
||||||
|
inline=True,
|
||||||
|
)
|
||||||
|
if maps_link:
|
||||||
|
embed.add_field(name="Maps", value=f"[Open]({maps_link})", inline=True)
|
||||||
|
embed.set_footer(text="Use /trip join to RSVP • /trip event add to build the itinerary")
|
||||||
|
await interaction.followup.send(embed=embed)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /trip list
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@trip_group.command(name="list", description="List all trips.")
|
||||||
|
async def trip_list(self, interaction: discord.Interaction):
|
||||||
|
await interaction.response.defer()
|
||||||
|
|
||||||
|
trips = await c2.get_trips()
|
||||||
|
if not trips:
|
||||||
|
await interaction.followup.send("No trips found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
today = date.today().strftime("%Y-%m-%d")
|
||||||
|
trips.sort(key=lambda t: t.get("start_date", ""))
|
||||||
|
|
||||||
|
user_id = str(interaction.user.id)
|
||||||
|
trips = [t for t in trips if _user_can_see_trip(t, user_id)]
|
||||||
|
|
||||||
|
embed = discord.Embed(title="Trips", color=0x2b2d31)
|
||||||
|
for t in trips[:10]:
|
||||||
|
upcoming = t.get("start_date", "") >= today
|
||||||
|
status = "Upcoming" if upcoming else "Past"
|
||||||
|
dates = (
|
||||||
|
f"{_fmt_date(t.get('start_date', ''))} — "
|
||||||
|
f"{_fmt_date(t.get('end_date', ''))}"
|
||||||
|
)
|
||||||
|
attendee_count = len(t.get("attendees", {}))
|
||||||
|
field_name = f"{t['name']} [{status}]"[:256]
|
||||||
|
embed.add_field(
|
||||||
|
name=field_name,
|
||||||
|
value=f"{t.get('location', '?')}\n{dates}\n{attendee_count} going",
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
await interaction.followup.send(embed=embed)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /trip view
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@trip_group.command(name="view", description="View the full itinerary for a trip.")
|
||||||
|
@app_commands.describe(trip="The trip to view.")
|
||||||
|
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||||
|
async def trip_view(self, interaction: discord.Interaction, trip: str):
|
||||||
|
await interaction.response.defer()
|
||||||
|
|
||||||
|
data = await c2.get_trip(trip)
|
||||||
|
if not data:
|
||||||
|
await interaction.followup.send("Trip not found.")
|
||||||
|
return
|
||||||
|
if not _user_can_see_trip(data, str(interaction.user.id)):
|
||||||
|
await interaction.followup.send("This trip is private.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
attendee_names = list(data.get("attendees", {}).values())
|
||||||
|
desc_lines = [
|
||||||
|
f"{_fmt_date(data['start_date'])} — {_fmt_date(data['end_date'])} • {data['location']}",
|
||||||
|
]
|
||||||
|
if data.get("maps_link"):
|
||||||
|
desc_lines.append(f"[View on Maps]({data['maps_link']})")
|
||||||
|
desc_lines.append(
|
||||||
|
f"Going: {', '.join(attendee_names)}" if attendee_names else "No attendees yet"
|
||||||
|
)
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=data["name"][:256],
|
||||||
|
description="\n".join(desc_lines)[:4096],
|
||||||
|
color=0x5865f2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Group events by date
|
||||||
|
events_by_date: dict[str, list] = {}
|
||||||
|
for e in data.get("events", []):
|
||||||
|
events_by_date.setdefault(e["date"], []).append(e)
|
||||||
|
|
||||||
|
# Track total embed chars (Discord limit: 6000)
|
||||||
|
embed_chars = len(embed.title or "") + len(embed.description or "")
|
||||||
|
field_count = 0
|
||||||
|
for day_iso in _date_range(data["start_date"], data["end_date"]):
|
||||||
|
day_events = events_by_date.get(day_iso)
|
||||||
|
if not day_events:
|
||||||
|
continue
|
||||||
|
if field_count >= 24 or embed_chars >= 5800:
|
||||||
|
embed.add_field(name="...", value="More events not shown.", inline=False)
|
||||||
|
break
|
||||||
|
|
||||||
|
day_label = datetime.strptime(day_iso, "%Y-%m-%d").strftime("%A, %b %-d")
|
||||||
|
lines = []
|
||||||
|
for e in sorted(day_events, key=lambda x: x.get("start_time") or ""):
|
||||||
|
time_str = _fmt_time(e.get("start_time"))
|
||||||
|
line = f"**{time_str}** {e['title']}" if time_str else f"- {e['title']}"
|
||||||
|
|
||||||
|
loc = e.get("location")
|
||||||
|
if loc and not e.get("location_inherited"):
|
||||||
|
line += f"\n\u3000\u3000{loc}"
|
||||||
|
if e.get("maps_link"):
|
||||||
|
line += f" ([Maps]({e['maps_link']}))"
|
||||||
|
if e.get("notes"):
|
||||||
|
line += f"\n\u3000\u3000_{e['notes']}_"
|
||||||
|
|
||||||
|
event_tags = e.get("tags") or []
|
||||||
|
if event_tags:
|
||||||
|
line += f"\n\u3000\u3000`{'` `'.join(event_tags)}`"
|
||||||
|
|
||||||
|
event_att = list(e.get("attendees", {}).values())
|
||||||
|
if event_att:
|
||||||
|
line += f"\n\u3000\u3000{', '.join(event_att)}"
|
||||||
|
|
||||||
|
lines.append(line)
|
||||||
|
|
||||||
|
field_name = f"— {day_label} —"
|
||||||
|
field_value = "\n".join(lines)
|
||||||
|
if len(field_value) > 1024:
|
||||||
|
field_value = field_value[:1021] + "…"
|
||||||
|
embed.add_field(name=field_name, value=field_value, inline=False)
|
||||||
|
embed_chars += len(field_name) + len(field_value)
|
||||||
|
field_count += 1
|
||||||
|
|
||||||
|
if not events_by_date:
|
||||||
|
embed.add_field(
|
||||||
|
name="No events yet",
|
||||||
|
value="Use `/trip event add` to build the itinerary.",
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
await interaction.followup.send(embed=embed)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /trip delete
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@trip_group.command(name="delete", description="Delete a trip and all its events.")
|
||||||
|
@app_commands.describe(trip="The trip to delete.")
|
||||||
|
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||||
|
async def trip_delete(self, interaction: discord.Interaction, trip: str):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
ok = await c2.delete_trip(trip)
|
||||||
|
if ok:
|
||||||
|
await interaction.followup.send("Trip deleted.")
|
||||||
|
else:
|
||||||
|
await interaction.followup.send("Trip not found or failed to delete.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /trip join / /trip leave
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@trip_group.command(name="join", description="RSVP to a trip.")
|
||||||
|
@app_commands.describe(trip="The trip to join.")
|
||||||
|
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||||
|
async def trip_join(self, interaction: discord.Interaction, trip: str):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
result = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name)
|
||||||
|
if result is True:
|
||||||
|
await interaction.followup.send("You're on the trip!")
|
||||||
|
elif result == "private":
|
||||||
|
await interaction.followup.send("This trip is private — you need an invite to join.")
|
||||||
|
else:
|
||||||
|
await interaction.followup.send("Failed to join trip.")
|
||||||
|
|
||||||
|
@trip_group.command(name="leave", description="Remove yourself from a trip.")
|
||||||
|
@app_commands.describe(trip="The trip to leave.")
|
||||||
|
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||||
|
async def trip_leave(self, interaction: discord.Interaction, trip: str):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
ok = await c2.leave_trip(trip, str(interaction.user.id))
|
||||||
|
if ok:
|
||||||
|
await interaction.followup.send("You've been removed from the trip.")
|
||||||
|
else:
|
||||||
|
await interaction.followup.send("Failed to leave trip.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /trip event add
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@event_group.command(name="add", description="Add an event to a trip's itinerary.")
|
||||||
|
@app_commands.describe(
|
||||||
|
trip="The trip to add this event to.",
|
||||||
|
title="Event title",
|
||||||
|
date="Date of the event (YYYY-MM-DD or MM/DD/YYYY)",
|
||||||
|
start_time="Start time (e.g. 14:00 or 2:00 PM) — optional",
|
||||||
|
end_time="End time (e.g. 16:00 or 4:00 PM) — optional",
|
||||||
|
location="Location override (optional, inherits trip location if omitted)",
|
||||||
|
maps_link="Google Maps link for this event (optional)",
|
||||||
|
notes="Any additional notes (optional)",
|
||||||
|
)
|
||||||
|
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||||
|
async def event_add(
|
||||||
|
self,
|
||||||
|
interaction: discord.Interaction,
|
||||||
|
trip: str,
|
||||||
|
title: str,
|
||||||
|
date: str,
|
||||||
|
start_time: Optional[str] = None,
|
||||||
|
end_time: Optional[str] = None,
|
||||||
|
location: Optional[str] = None,
|
||||||
|
maps_link: Optional[str] = None,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
|
||||||
|
parsed_date = _parse_date(date)
|
||||||
|
if not parsed_date:
|
||||||
|
await interaction.followup.send("Invalid date format. Use YYYY-MM-DD.")
|
||||||
|
return
|
||||||
|
|
||||||
|
parsed_start = _parse_time(start_time) if start_time else None
|
||||||
|
parsed_end = _parse_time(end_time) if end_time else None
|
||||||
|
|
||||||
|
if start_time and parsed_start is None:
|
||||||
|
await interaction.followup.send("Couldn't parse start time. Try `14:00` or `2:00 PM`.")
|
||||||
|
return
|
||||||
|
if end_time and parsed_end is None:
|
||||||
|
await interaction.followup.send("Couldn't parse end time. Try `16:00` or `4:00 PM`.")
|
||||||
|
return
|
||||||
|
|
||||||
|
event = await c2.create_trip_event(trip, {
|
||||||
|
"title": title,
|
||||||
|
"date": parsed_date.strftime("%Y-%m-%d"),
|
||||||
|
"start_time": parsed_start,
|
||||||
|
"end_time": parsed_end,
|
||||||
|
"location": location,
|
||||||
|
"maps_link": maps_link,
|
||||||
|
"notes": notes,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not event:
|
||||||
|
await interaction.followup.send(
|
||||||
|
"Failed to create event. Make sure the date falls within the trip range."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
time_display = f" at {_fmt_time(parsed_start)}" if parsed_start else ""
|
||||||
|
await interaction.followup.send(
|
||||||
|
f"Added **{title}**{time_display} on {_fmt_date(parsed_date.strftime('%Y-%m-%d'))}."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /trip event remove
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@event_group.command(name="remove", description="Remove an event from a trip.")
|
||||||
|
@app_commands.describe(trip="The trip.", event="The event to remove.")
|
||||||
|
@app_commands.autocomplete(trip=trip_autocomplete, event=event_autocomplete)
|
||||||
|
async def event_remove(self, interaction: discord.Interaction, trip: str, event: str):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
ok = await c2.delete_trip_event(trip, event)
|
||||||
|
if ok:
|
||||||
|
await interaction.followup.send("Event removed.")
|
||||||
|
else:
|
||||||
|
await interaction.followup.send("Event not found or failed to remove.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /trip event join / /trip event leave
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@event_group.command(name="join", description="Join an event (you must be on the trip first).")
|
||||||
|
@app_commands.describe(trip="The trip.", event="The event to join.")
|
||||||
|
@app_commands.autocomplete(trip=trip_autocomplete, event=event_autocomplete)
|
||||||
|
async def event_join(self, interaction: discord.Interaction, trip: str, event: str):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
result = await c2.join_trip_event(
|
||||||
|
trip, event, str(interaction.user.id), interaction.user.display_name
|
||||||
|
)
|
||||||
|
if result is True:
|
||||||
|
await interaction.followup.send("You're in for this event!")
|
||||||
|
elif result == "not_on_trip":
|
||||||
|
await interaction.followup.send(
|
||||||
|
"You need to join the trip first — use `/trip join`."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await interaction.followup.send("Failed to join event.")
|
||||||
|
|
||||||
|
@event_group.command(name="leave", description="Leave an event.")
|
||||||
|
@app_commands.describe(trip="The trip.", event="The event to leave.")
|
||||||
|
@app_commands.autocomplete(trip=trip_autocomplete, event=event_autocomplete)
|
||||||
|
async def event_leave(self, interaction: discord.Interaction, trip: str, event: str):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
ok = await c2.leave_trip_event(trip, event, str(interaction.user.id))
|
||||||
|
if ok:
|
||||||
|
await interaction.followup.send("You've been removed from the event.")
|
||||||
|
else:
|
||||||
|
await interaction.followup.send("Failed to leave event.")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /trip invite
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@trip_group.command(name="invite", description="Invite a Discord user to a private trip.")
|
||||||
|
@app_commands.describe(trip="The trip.", user="The user to invite.")
|
||||||
|
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||||
|
async def trip_invite(self, interaction: discord.Interaction, trip: str, user: discord.Member):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
ok = await c2.invite_to_trip(trip, str(user.id))
|
||||||
|
if ok:
|
||||||
|
await interaction.followup.send(f"Invited {user.display_name} to the trip.")
|
||||||
|
else:
|
||||||
|
await interaction.followup.send("Failed to send invite.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /trip privacy
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@trip_group.command(name="privacy", description="Set a trip to public or private.")
|
||||||
|
@app_commands.describe(trip="The trip.", visibility="public or private")
|
||||||
|
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||||
|
@app_commands.choices(visibility=[
|
||||||
|
app_commands.Choice(name="Public — anyone can see and join", value="public"),
|
||||||
|
app_commands.Choice(name="Private — invite only", value="private"),
|
||||||
|
])
|
||||||
|
async def trip_privacy(self, interaction: discord.Interaction, trip: str, visibility: str):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
ok = await c2.set_trip_visibility(trip, visibility)
|
||||||
|
if ok:
|
||||||
|
await interaction.followup.send(f"Trip is now **{visibility}**.")
|
||||||
|
else:
|
||||||
|
await interaction.followup.send("Failed to update trip privacy.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /link
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app_commands.command(name="link", description="Link your Discord account to your DRB web account.")
|
||||||
|
@app_commands.describe(code="The 6-character code from the web app (Settings → Link Discord).")
|
||||||
|
async def link_account(self, interaction: discord.Interaction, code: str):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
result = await c2.link_discord_account(
|
||||||
|
code.upper().strip(),
|
||||||
|
str(interaction.user.id),
|
||||||
|
interaction.user.display_name,
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
msgs = {
|
||||||
|
"invalid_code": "Invalid code. Generate a new one from the web app.",
|
||||||
|
"expired": "Code has expired. Generate a new one from the web app.",
|
||||||
|
"already_linked": "This Discord account is already linked to a different web account.",
|
||||||
|
"failed": "Something went wrong. Try again.",
|
||||||
|
}
|
||||||
|
await interaction.followup.send(msgs.get(result["error"], "Failed to link account."))
|
||||||
|
else:
|
||||||
|
await interaction.followup.send("Your Discord account is now linked to your DRB web account.")
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(bot: commands.Bot):
|
||||||
|
await bot.add_cog(TripCommands(bot))
|
||||||
@@ -68,5 +68,187 @@ class C2Client:
|
|||||||
return node
|
return node
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Trips
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_trips(self) -> list:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.get(f"{self.base}/trips", headers=self._headers())
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 get_trips failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_trip(self, trip_id: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.get(f"{self.base}/trips/{trip_id}", headers=self._headers())
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 get_trip failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def create_trip(self, payload: dict) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(f"{self.base}/trips", json=payload, headers=self._headers())
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 create_trip failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_trip(self, trip_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.delete(f"{self.base}/trips/{trip_id}", headers=self._headers())
|
||||||
|
r.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 delete_trip failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def invite_to_trip(self, trip_id: str, discord_user_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base}/trips/{trip_id}/invite/{discord_user_id}",
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 invite_to_trip failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def set_trip_visibility(self, trip_id: str, visibility: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.put(
|
||||||
|
f"{self.base}/trips/{trip_id}/visibility",
|
||||||
|
json={"visibility": visibility},
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 set_trip_visibility failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def link_discord_account(self, code: str, discord_user_id: str, discord_username: str) -> dict:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base}/auth/link",
|
||||||
|
json={"code": code, "discord_user_id": discord_user_id, "discord_username": discord_username},
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
if r.status_code == 404:
|
||||||
|
return {"error": "invalid_code"}
|
||||||
|
if r.status_code == 410:
|
||||||
|
return {"error": "expired"}
|
||||||
|
if r.status_code == 409:
|
||||||
|
return {"error": "already_linked"}
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 link_discord_account failed: {e}")
|
||||||
|
return {"error": "failed"}
|
||||||
|
|
||||||
|
async def join_trip(self, trip_id: str, user_id: str, username: str) -> bool | str:
|
||||||
|
"""Returns True on success, 'private' on 403, False on other errors."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base}/trips/{trip_id}/join",
|
||||||
|
json={"discord_user_id": user_id, "discord_username": username},
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
if r.status_code == 403:
|
||||||
|
return "private"
|
||||||
|
r.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 join_trip failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def leave_trip(self, trip_id: str, user_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base}/trips/{trip_id}/leave",
|
||||||
|
json={"discord_user_id": user_id},
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 leave_trip failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def create_trip_event(self, trip_id: str, payload: dict) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base}/trips/{trip_id}/events",
|
||||||
|
json=payload,
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 create_trip_event failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_trip_event(self, trip_id: str, event_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.delete(
|
||||||
|
f"{self.base}/trips/{trip_id}/events/{event_id}",
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 delete_trip_event failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def join_trip_event(
|
||||||
|
self, trip_id: str, event_id: str, user_id: str, username: str
|
||||||
|
) -> bool | str:
|
||||||
|
"""Returns True on success, 'not_on_trip' on 403, False on other errors."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base}/trips/{trip_id}/events/{event_id}/join",
|
||||||
|
json={"discord_user_id": user_id, "discord_username": username},
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
if r.status_code == 403:
|
||||||
|
return "not_on_trip"
|
||||||
|
r.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 join_trip_event failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def leave_trip_event(self, trip_id: str, event_id: str, user_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base}/trips/{trip_id}/events/{event_id}/leave",
|
||||||
|
json={"discord_user_id": user_id},
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 leave_trip_event failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
c2 = C2Client()
|
c2 = C2Client()
|
||||||
|
|||||||
@@ -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