2026-03-03 22:11:45 +01:00
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from typing import Any, Dict, Optional
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
from app.core.auth_service import AuthService
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
from app.core.database import execute_query, execute_query_single
|
|
|
|
|
|
|
|
|
|
from .mission_service import MissionService
|
|
|
|
|
from .mission_ws import mission_ws_manager
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MissionCallEvent(BaseModel):
|
|
|
|
|
call_id: str = Field(..., min_length=1, max_length=128)
|
|
|
|
|
caller_number: Optional[str] = None
|
|
|
|
|
queue_name: Optional[str] = None
|
|
|
|
|
timestamp: Optional[datetime] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MissionUptimeWebhook(BaseModel):
|
|
|
|
|
status: Optional[str] = None
|
|
|
|
|
service_name: Optional[str] = None
|
|
|
|
|
customer_name: Optional[str] = None
|
|
|
|
|
timestamp: Optional[datetime] = None
|
|
|
|
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 23:09:14 +01:00
|
|
|
def _first_query_param(request: Request, *names: str) -> Optional[str]:
|
|
|
|
|
for name in names:
|
|
|
|
|
value = request.query_params.get(name)
|
|
|
|
|
if value and str(value).strip():
|
|
|
|
|
return str(value).strip()
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_query_timestamp(request: Request) -> Optional[datetime]:
|
|
|
|
|
raw = _first_query_param(request, "timestamp", "time", "event_time")
|
|
|
|
|
if not raw:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _event_from_query(request: Request) -> MissionCallEvent:
|
|
|
|
|
call_id = _first_query_param(request, "call_id", "callid", "id", "session_id", "uuid")
|
|
|
|
|
if not call_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Missing call_id query parameter")
|
|
|
|
|
|
|
|
|
|
return MissionCallEvent(
|
|
|
|
|
call_id=call_id,
|
|
|
|
|
caller_number=_first_query_param(request, "caller_number", "caller", "from", "number", "phone"),
|
|
|
|
|
queue_name=_first_query_param(request, "queue_name", "queue", "group", "line"),
|
|
|
|
|
timestamp=_parse_query_timestamp(request),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
def _get_webhook_token() -> str:
|
|
|
|
|
db_token = MissionService.get_setting_value("mission_webhook_token", "") or ""
|
|
|
|
|
env_token = (getattr(settings, "MISSION_WEBHOOK_TOKEN", "") or "").strip()
|
|
|
|
|
return db_token.strip() or env_token
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_mission_webhook_token(request: Request, token: Optional[str] = None) -> None:
|
|
|
|
|
configured = _get_webhook_token()
|
|
|
|
|
if not configured:
|
2026-03-03 23:49:24 +01:00
|
|
|
logger.warning("Mission webhook token not configured for path=%s", request.url.path)
|
2026-03-03 22:11:45 +01:00
|
|
|
raise HTTPException(status_code=403, detail="Mission webhook token not configured")
|
|
|
|
|
|
|
|
|
|
candidate = token or request.headers.get("x-mission-token") or request.query_params.get("token")
|
|
|
|
|
if not candidate or candidate.strip() != configured:
|
2026-03-03 23:49:24 +01:00
|
|
|
logger.warning("Mission webhook forbidden for path=%s", request.url.path)
|
2026-03-03 22:11:45 +01:00
|
|
|
raise HTTPException(status_code=403, detail="Forbidden")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_uptime_payload(payload: MissionUptimeWebhook) -> Dict[str, Any]:
|
|
|
|
|
raw = dict(payload.payload or {})
|
|
|
|
|
|
|
|
|
|
status_candidate = payload.status or raw.get("status") or raw.get("event")
|
|
|
|
|
if not status_candidate and isinstance(raw.get("monitor"), dict):
|
|
|
|
|
status_candidate = raw.get("monitor", {}).get("status")
|
|
|
|
|
|
|
|
|
|
service_name = payload.service_name or raw.get("service_name") or raw.get("monitor_name")
|
|
|
|
|
if not service_name and isinstance(raw.get("monitor"), dict):
|
|
|
|
|
service_name = raw.get("monitor", {}).get("name")
|
|
|
|
|
|
|
|
|
|
customer_name = payload.customer_name or raw.get("customer_name") or raw.get("customer")
|
|
|
|
|
timestamp = payload.timestamp or raw.get("timestamp")
|
|
|
|
|
|
|
|
|
|
status = str(status_candidate or "UNKNOWN").upper().strip()
|
|
|
|
|
if status not in {"UP", "DOWN", "DEGRADED"}:
|
|
|
|
|
status = "UNKNOWN"
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"status": status,
|
|
|
|
|
"service_name": str(service_name or "Unknown Service"),
|
|
|
|
|
"customer_name": str(customer_name or "").strip() or None,
|
|
|
|
|
"timestamp": timestamp,
|
|
|
|
|
"raw": raw,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/mission/state")
|
|
|
|
|
async def get_mission_state():
|
|
|
|
|
return MissionService.get_state()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.websocket("/mission/ws")
|
|
|
|
|
async def mission_ws(websocket: WebSocket):
|
|
|
|
|
token = websocket.query_params.get("token")
|
|
|
|
|
auth_header = (websocket.headers.get("authorization") or "").strip()
|
|
|
|
|
if not token and auth_header.lower().startswith("bearer "):
|
|
|
|
|
token = auth_header.split(" ", 1)[1].strip()
|
|
|
|
|
if not token:
|
|
|
|
|
token = (websocket.cookies.get("access_token") or "").strip() or None
|
|
|
|
|
|
|
|
|
|
payload = AuthService.verify_token(token) if token else None
|
|
|
|
|
if not payload:
|
|
|
|
|
await websocket.close(code=1008)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
await mission_ws_manager.connect(websocket)
|
|
|
|
|
try:
|
|
|
|
|
await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
|
|
|
|
|
while True:
|
|
|
|
|
await websocket.receive_text()
|
|
|
|
|
except WebSocketDisconnect:
|
|
|
|
|
await mission_ws_manager.disconnect(websocket)
|
|
|
|
|
except Exception:
|
|
|
|
|
await mission_ws_manager.disconnect(websocket)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/mission/webhook/telefoni/ringing")
|
|
|
|
|
async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, token: Optional[str] = Query(None)):
|
|
|
|
|
_validate_mission_webhook_token(request, token)
|
|
|
|
|
|
2026-03-03 23:49:24 +01:00
|
|
|
logger.info(
|
|
|
|
|
"☎️ Mission webhook ringing call_id=%s caller=%s queue=%s method=%s",
|
|
|
|
|
event.call_id,
|
|
|
|
|
event.caller_number,
|
|
|
|
|
event.queue_name,
|
|
|
|
|
request.method,
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
timestamp = event.timestamp or datetime.utcnow()
|
|
|
|
|
context = MissionService.resolve_contact_context(event.caller_number)
|
|
|
|
|
queue_name = (event.queue_name or "Ukendt kø").strip()
|
|
|
|
|
|
|
|
|
|
execute_query(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO mission_call_state (
|
|
|
|
|
call_id, queue_name, caller_number, contact_name, company_name, customer_tag,
|
|
|
|
|
state, started_at, answered_at, ended_at, updated_at, last_payload
|
|
|
|
|
)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, 'ringing', %s, NULL, NULL, NOW(), %s::jsonb)
|
|
|
|
|
ON CONFLICT (call_id)
|
|
|
|
|
DO UPDATE SET
|
|
|
|
|
queue_name = EXCLUDED.queue_name,
|
|
|
|
|
caller_number = EXCLUDED.caller_number,
|
|
|
|
|
contact_name = EXCLUDED.contact_name,
|
|
|
|
|
company_name = EXCLUDED.company_name,
|
|
|
|
|
customer_tag = EXCLUDED.customer_tag,
|
|
|
|
|
state = 'ringing',
|
|
|
|
|
ended_at = NULL,
|
|
|
|
|
answered_at = NULL,
|
|
|
|
|
started_at = LEAST(mission_call_state.started_at, EXCLUDED.started_at),
|
|
|
|
|
updated_at = NOW(),
|
|
|
|
|
last_payload = EXCLUDED.last_payload
|
|
|
|
|
""",
|
|
|
|
|
(
|
|
|
|
|
event.call_id,
|
|
|
|
|
queue_name,
|
|
|
|
|
event.caller_number,
|
|
|
|
|
context.get("contact_name"),
|
|
|
|
|
context.get("company_name"),
|
|
|
|
|
context.get("customer_tag"),
|
|
|
|
|
timestamp,
|
|
|
|
|
json.dumps(event.model_dump(mode="json")),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
event_row = MissionService.insert_event(
|
|
|
|
|
event_type="incoming_call",
|
|
|
|
|
title=f"Indgående opkald i {queue_name}",
|
|
|
|
|
severity="warning",
|
|
|
|
|
source="telefoni",
|
|
|
|
|
customer_name=context.get("company_name"),
|
|
|
|
|
payload={
|
|
|
|
|
"call_id": event.call_id,
|
|
|
|
|
"queue_name": queue_name,
|
|
|
|
|
"caller_number": event.caller_number,
|
|
|
|
|
**context,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
call_payload = {
|
|
|
|
|
"call_id": event.call_id,
|
|
|
|
|
"queue_name": queue_name,
|
|
|
|
|
"caller_number": event.caller_number,
|
|
|
|
|
**context,
|
|
|
|
|
"timestamp": timestamp,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await mission_ws_manager.broadcast("call_ringing", call_payload)
|
|
|
|
|
await mission_ws_manager.broadcast("live_feed_event", event_row)
|
|
|
|
|
await mission_ws_manager.broadcast("kpi_update", MissionService.get_kpis())
|
|
|
|
|
|
|
|
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 23:09:14 +01:00
|
|
|
@router.get("/mission/webhook/telefoni/ringing")
|
|
|
|
|
async def mission_telefoni_ringing_get(request: Request, token: Optional[str] = Query(None)):
|
|
|
|
|
event = _event_from_query(request)
|
|
|
|
|
return await mission_telefoni_ringing(event, request, token)
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
@router.post("/mission/webhook/telefoni/answered")
|
|
|
|
|
async def mission_telefoni_answered(event: MissionCallEvent, request: Request, token: Optional[str] = Query(None)):
|
|
|
|
|
_validate_mission_webhook_token(request, token)
|
|
|
|
|
|
2026-03-03 23:49:24 +01:00
|
|
|
logger.info(
|
|
|
|
|
"✅ Mission webhook answered call_id=%s caller=%s queue=%s method=%s",
|
|
|
|
|
event.call_id,
|
|
|
|
|
event.caller_number,
|
|
|
|
|
event.queue_name,
|
|
|
|
|
request.method,
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
execute_query(
|
|
|
|
|
"""
|
|
|
|
|
UPDATE mission_call_state
|
|
|
|
|
SET state = 'answered',
|
|
|
|
|
answered_at = COALESCE(answered_at, NOW()),
|
|
|
|
|
updated_at = NOW(),
|
|
|
|
|
last_payload = %s::jsonb
|
|
|
|
|
WHERE call_id = %s
|
|
|
|
|
""",
|
|
|
|
|
(json.dumps(event.model_dump(mode="json")), event.call_id),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
event_row = MissionService.insert_event(
|
|
|
|
|
event_type="call_answered",
|
|
|
|
|
title="Opkald besvaret",
|
|
|
|
|
severity="info",
|
|
|
|
|
source="telefoni",
|
|
|
|
|
payload={"call_id": event.call_id, "queue_name": event.queue_name, "caller_number": event.caller_number},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await mission_ws_manager.broadcast("call_answered", {"call_id": event.call_id})
|
|
|
|
|
await mission_ws_manager.broadcast("live_feed_event", event_row)
|
|
|
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 23:09:14 +01:00
|
|
|
@router.get("/mission/webhook/telefoni/answered")
|
|
|
|
|
async def mission_telefoni_answered_get(request: Request, token: Optional[str] = Query(None)):
|
|
|
|
|
event = _event_from_query(request)
|
|
|
|
|
return await mission_telefoni_answered(event, request, token)
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
@router.post("/mission/webhook/telefoni/hangup")
|
|
|
|
|
async def mission_telefoni_hangup(event: MissionCallEvent, request: Request, token: Optional[str] = Query(None)):
|
|
|
|
|
_validate_mission_webhook_token(request, token)
|
|
|
|
|
|
2026-03-03 23:49:24 +01:00
|
|
|
logger.info(
|
|
|
|
|
"📴 Mission webhook hangup call_id=%s caller=%s queue=%s method=%s",
|
|
|
|
|
event.call_id,
|
|
|
|
|
event.caller_number,
|
|
|
|
|
event.queue_name,
|
|
|
|
|
request.method,
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
execute_query(
|
|
|
|
|
"""
|
|
|
|
|
UPDATE mission_call_state
|
|
|
|
|
SET state = 'hangup',
|
|
|
|
|
ended_at = NOW(),
|
|
|
|
|
updated_at = NOW(),
|
|
|
|
|
last_payload = %s::jsonb
|
|
|
|
|
WHERE call_id = %s
|
|
|
|
|
""",
|
|
|
|
|
(json.dumps(event.model_dump(mode="json")), event.call_id),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
event_row = MissionService.insert_event(
|
|
|
|
|
event_type="call_ended",
|
|
|
|
|
title="Opkald afsluttet",
|
|
|
|
|
severity="info",
|
|
|
|
|
source="telefoni",
|
|
|
|
|
payload={"call_id": event.call_id, "queue_name": event.queue_name, "caller_number": event.caller_number},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await mission_ws_manager.broadcast("call_hangup", {"call_id": event.call_id})
|
|
|
|
|
await mission_ws_manager.broadcast("live_feed_event", event_row)
|
|
|
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 23:09:14 +01:00
|
|
|
@router.get("/mission/webhook/telefoni/hangup")
|
|
|
|
|
async def mission_telefoni_hangup_get(request: Request, token: Optional[str] = Query(None)):
|
|
|
|
|
event = _event_from_query(request)
|
|
|
|
|
return await mission_telefoni_hangup(event, request, token)
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
@router.post("/mission/webhook/uptime")
|
|
|
|
|
async def mission_uptime_webhook(payload: MissionUptimeWebhook, request: Request, token: Optional[str] = Query(None)):
|
|
|
|
|
_validate_mission_webhook_token(request, token)
|
|
|
|
|
|
|
|
|
|
normalized = _normalize_uptime_payload(payload)
|
|
|
|
|
status = normalized["status"]
|
|
|
|
|
service_name = normalized["service_name"]
|
|
|
|
|
customer_name = normalized["customer_name"]
|
|
|
|
|
alert_key = MissionService.build_alert_key(service_name, customer_name)
|
|
|
|
|
|
|
|
|
|
current = execute_query_single("SELECT is_active, started_at FROM mission_uptime_alerts WHERE alert_key = %s", (alert_key,))
|
|
|
|
|
|
|
|
|
|
if status in {"DOWN", "DEGRADED"}:
|
|
|
|
|
started_at = (current or {}).get("started_at")
|
|
|
|
|
is_active = bool((current or {}).get("is_active"))
|
|
|
|
|
if not started_at or not is_active:
|
|
|
|
|
started_at = datetime.utcnow()
|
|
|
|
|
|
|
|
|
|
execute_query(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO mission_uptime_alerts (
|
|
|
|
|
alert_key, service_name, customer_name, status, is_active, started_at, resolved_at,
|
|
|
|
|
updated_at, raw_payload, normalized_payload
|
|
|
|
|
)
|
|
|
|
|
VALUES (%s, %s, %s, %s, TRUE, %s, NULL, NOW(), %s::jsonb, %s::jsonb)
|
|
|
|
|
ON CONFLICT (alert_key)
|
|
|
|
|
DO UPDATE SET
|
|
|
|
|
status = EXCLUDED.status,
|
|
|
|
|
is_active = TRUE,
|
|
|
|
|
started_at = COALESCE(mission_uptime_alerts.started_at, EXCLUDED.started_at),
|
|
|
|
|
resolved_at = NULL,
|
|
|
|
|
updated_at = NOW(),
|
|
|
|
|
raw_payload = EXCLUDED.raw_payload,
|
|
|
|
|
normalized_payload = EXCLUDED.normalized_payload
|
|
|
|
|
""",
|
|
|
|
|
(
|
|
|
|
|
alert_key,
|
|
|
|
|
service_name,
|
|
|
|
|
customer_name,
|
|
|
|
|
status,
|
|
|
|
|
started_at,
|
|
|
|
|
json.dumps(payload.model_dump(mode="json")),
|
|
|
|
|
json.dumps(normalized, default=str),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
event_type = "uptime_down" if status == "DOWN" else "uptime_degraded"
|
|
|
|
|
severity = "critical" if status == "DOWN" else "warning"
|
|
|
|
|
title = f"{service_name} er {status}"
|
|
|
|
|
elif status == "UP":
|
|
|
|
|
execute_query(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO mission_uptime_alerts (
|
|
|
|
|
alert_key, service_name, customer_name, status, is_active, started_at, resolved_at,
|
|
|
|
|
updated_at, raw_payload, normalized_payload
|
|
|
|
|
)
|
|
|
|
|
VALUES (%s, %s, %s, %s, FALSE, NULL, NOW(), NOW(), %s::jsonb, %s::jsonb)
|
|
|
|
|
ON CONFLICT (alert_key)
|
|
|
|
|
DO UPDATE SET
|
|
|
|
|
status = EXCLUDED.status,
|
|
|
|
|
is_active = FALSE,
|
|
|
|
|
resolved_at = NOW(),
|
|
|
|
|
updated_at = NOW(),
|
|
|
|
|
raw_payload = EXCLUDED.raw_payload,
|
|
|
|
|
normalized_payload = EXCLUDED.normalized_payload
|
|
|
|
|
""",
|
|
|
|
|
(
|
|
|
|
|
alert_key,
|
|
|
|
|
service_name,
|
|
|
|
|
customer_name,
|
|
|
|
|
status,
|
|
|
|
|
json.dumps(payload.model_dump(mode="json")),
|
|
|
|
|
json.dumps(normalized, default=str),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
event_type = "uptime_up"
|
|
|
|
|
severity = "success"
|
|
|
|
|
title = f"{service_name} er UP"
|
|
|
|
|
else:
|
|
|
|
|
event_type = "uptime_unknown"
|
|
|
|
|
severity = "info"
|
|
|
|
|
title = f"{service_name} status ukendt"
|
|
|
|
|
|
|
|
|
|
event_row = MissionService.insert_event(
|
|
|
|
|
event_type=event_type,
|
|
|
|
|
title=title,
|
|
|
|
|
severity=severity,
|
|
|
|
|
source="uptime",
|
|
|
|
|
customer_name=customer_name,
|
|
|
|
|
payload={"alert_key": alert_key, **normalized},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await mission_ws_manager.broadcast(
|
|
|
|
|
"uptime_alert",
|
|
|
|
|
{
|
|
|
|
|
"alert_key": alert_key,
|
|
|
|
|
"status": status,
|
|
|
|
|
"service_name": service_name,
|
|
|
|
|
"customer_name": customer_name,
|
|
|
|
|
"active_alerts": MissionService.get_active_alerts(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
await mission_ws_manager.broadcast("live_feed_event", event_row)
|
|
|
|
|
|
|
|
|
|
return {"status": "ok", "normalized": normalized}
|