725 lines
26 KiB
Python
725 lines
26 KiB
Python
import json
|
||
import logging
|
||
import base64
|
||
import re
|
||
from datetime import datetime
|
||
from typing import Optional
|
||
from urllib.error import URLError, HTTPError
|
||
from urllib.request import Request as UrlRequest, urlopen
|
||
from urllib.parse import urlsplit, urlunsplit
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
||
|
||
from app.core.auth_service import AuthService
|
||
from app.core.auth_dependencies import require_permission
|
||
from app.core.config import settings
|
||
from app.core.database import execute_query, execute_query_single
|
||
from app.services.sms_service import SmsService
|
||
|
||
from .schemas import TelefoniCallLinkUpdate, TelefoniUserMappingUpdate, TelefoniClickToCallRequest, SmsSendRequest
|
||
from .service import TelefoniService
|
||
from .utils import (
|
||
digits_only,
|
||
extract_extension,
|
||
ip_in_whitelist,
|
||
is_outbound_call,
|
||
normalize_e164,
|
||
phone_suffix_8,
|
||
)
|
||
from .websocket import manager
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter()
|
||
|
||
|
||
@router.post("/sms/send")
|
||
async def send_sms(payload: SmsSendRequest, request: Request):
|
||
user_id = getattr(request.state, "user_id", None)
|
||
logger.info("📨 SMS send requested by user_id=%s", user_id)
|
||
|
||
contact_id = payload.contact_id
|
||
if not contact_id:
|
||
suffix8 = phone_suffix_8(payload.to)
|
||
contact = TelefoniService.find_contact_by_phone_suffix(suffix8)
|
||
contact_id = int(contact["id"]) if contact and contact.get("id") else None
|
||
|
||
if not contact_id:
|
||
raise HTTPException(status_code=400, detail="SMS skal knyttes til en kontakt")
|
||
|
||
try:
|
||
result = SmsService.send_sms(payload.to, payload.message, payload.sender)
|
||
execute_query(
|
||
"""
|
||
INSERT INTO sms_messages (kontakt_id, bruger_id, recipient, sender, message, status, provider_response)
|
||
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
|
||
""",
|
||
(
|
||
contact_id,
|
||
user_id,
|
||
result.get("recipient") or payload.to,
|
||
payload.sender or settings.SMS_SENDER,
|
||
payload.message,
|
||
"sent",
|
||
json.dumps(result.get("result") or {}),
|
||
),
|
||
)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except RuntimeError as e:
|
||
execute_query(
|
||
"""
|
||
INSERT INTO sms_messages (kontakt_id, bruger_id, recipient, sender, message, status, provider_response)
|
||
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
|
||
""",
|
||
(
|
||
contact_id,
|
||
user_id,
|
||
payload.to,
|
||
payload.sender or settings.SMS_SENDER,
|
||
payload.message,
|
||
"failed",
|
||
json.dumps({"error": str(e)}),
|
||
),
|
||
)
|
||
raise HTTPException(status_code=502, detail=str(e))
|
||
except Exception as e:
|
||
logger.error("❌ SMS send failed: %s", e)
|
||
raise HTTPException(status_code=500, detail="SMS send failed")
|
||
|
||
return {
|
||
"status": "ok",
|
||
**result,
|
||
}
|
||
|
||
|
||
def _get_client_ip(request: Request) -> str:
|
||
cf_ip = request.headers.get("cf-connecting-ip")
|
||
if cf_ip:
|
||
return cf_ip.strip()
|
||
|
||
x_real_ip = request.headers.get("x-real-ip")
|
||
if x_real_ip:
|
||
return x_real_ip.strip()
|
||
|
||
xff = request.headers.get("x-forwarded-for")
|
||
if xff:
|
||
return xff.split(",")[0].strip()
|
||
return request.client.host if request.client else ""
|
||
|
||
|
||
def _validate_yealink_request(request: Request, token: Optional[str]) -> None:
|
||
env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
|
||
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
|
||
accepted_tokens = {s for s in (env_secret, db_secret) if s}
|
||
whitelist = (getattr(settings, "TELEFONI_IP_WHITELIST", "") or "").strip()
|
||
client_ip = _get_client_ip(request)
|
||
path = request.url.path
|
||
|
||
def _mask(value: Optional[str]) -> str:
|
||
if not value:
|
||
return "<empty>"
|
||
stripped = value.strip()
|
||
if len(stripped) <= 8:
|
||
return "***"
|
||
return f"{stripped[:4]}...{stripped[-4:]}"
|
||
|
||
if not accepted_tokens and not whitelist:
|
||
logger.error(
|
||
"❌ Telefoni callback rejected path=%s reason=no_security_config ip=%s",
|
||
path,
|
||
client_ip,
|
||
)
|
||
raise HTTPException(status_code=403, detail="Telefoni callbacks not configured")
|
||
|
||
if token and token.strip() in accepted_tokens:
|
||
logger.debug("✅ Telefoni callback accepted path=%s auth=token ip=%s", path, client_ip)
|
||
return
|
||
|
||
if token and accepted_tokens:
|
||
logger.warning(
|
||
"⚠️ Telefoni callback token mismatch path=%s ip=%s provided=%s accepted_sources=%s",
|
||
path,
|
||
client_ip,
|
||
_mask(token),
|
||
"+".join([name for name, value in (("env", env_secret), ("db", db_secret)) if value]) or "none",
|
||
)
|
||
elif not token:
|
||
logger.info("ℹ️ Telefoni callback without token path=%s ip=%s", path, client_ip)
|
||
|
||
if whitelist:
|
||
if ip_in_whitelist(client_ip, whitelist):
|
||
logger.debug("✅ Telefoni callback accepted path=%s auth=ip_whitelist ip=%s", path, client_ip)
|
||
return
|
||
logger.warning(
|
||
"⚠️ Telefoni callback IP not in whitelist path=%s ip=%s whitelist=%s",
|
||
path,
|
||
client_ip,
|
||
whitelist,
|
||
)
|
||
else:
|
||
logger.info("ℹ️ Telefoni callback whitelist not configured path=%s ip=%s", path, client_ip)
|
||
|
||
logger.warning("❌ Telefoni callback forbidden path=%s ip=%s", path, client_ip)
|
||
raise HTTPException(status_code=403, detail="Forbidden")
|
||
|
||
|
||
@router.get("/telefoni/established")
|
||
async def yealink_established(
|
||
request: Request,
|
||
callid: Optional[str] = Query(None),
|
||
call_id: Optional[str] = Query(None),
|
||
caller: Optional[str] = Query(None),
|
||
callee: Optional[str] = Query(None),
|
||
remote: Optional[str] = Query(None),
|
||
local: Optional[str] = Query(None),
|
||
active_user: Optional[str] = Query(None),
|
||
called_number: Optional[str] = Query(None),
|
||
token: Optional[str] = Query(None),
|
||
):
|
||
"""Yealink Action URL: Established"""
|
||
_validate_yealink_request(request, token)
|
||
|
||
resolved_callid = (callid or call_id or "").strip()
|
||
if not resolved_callid:
|
||
raise HTTPException(status_code=422, detail="Missing callid/call_id")
|
||
|
||
def _sanitize(value: Optional[str]) -> Optional[str]:
|
||
if value is None:
|
||
return None
|
||
v = value.strip()
|
||
if not v:
|
||
return None
|
||
if v.startswith("$"):
|
||
return None
|
||
return v
|
||
|
||
def _is_external_number(value: Optional[str]) -> bool:
|
||
d = digits_only(value)
|
||
return len(d) >= 8
|
||
|
||
def _is_internal_number(value: Optional[str], local_ext: Optional[str]) -> bool:
|
||
d = digits_only(value)
|
||
if not d:
|
||
return False
|
||
local_d = digits_only(local_ext)
|
||
if local_d and d.endswith(local_d):
|
||
return True
|
||
return len(d) <= 6
|
||
|
||
local_value = _sanitize(local) or _sanitize(active_user)
|
||
caller_value = _sanitize(caller) or _sanitize(remote)
|
||
callee_value = _sanitize(callee)
|
||
called_number_value = _sanitize(called_number)
|
||
|
||
local_extension = extract_extension(local_value) or local_value
|
||
|
||
is_outbound = False
|
||
if called_number_value and _is_external_number(called_number_value):
|
||
is_outbound = True
|
||
elif caller_value and local_extension:
|
||
if not _is_internal_number(caller_value, local_extension):
|
||
is_outbound = is_outbound_call(caller_value, local_extension)
|
||
|
||
direction = "outbound" if is_outbound else "inbound"
|
||
candidates = [
|
||
callee_value,
|
||
called_number_value,
|
||
_sanitize(remote),
|
||
caller_value,
|
||
] if direction == "outbound" else [
|
||
caller_value,
|
||
_sanitize(remote),
|
||
callee_value,
|
||
called_number_value,
|
||
]
|
||
|
||
ekstern_raw = None
|
||
for candidate in candidates:
|
||
if candidate and _is_external_number(candidate):
|
||
ekstern_raw = candidate
|
||
break
|
||
|
||
if not ekstern_raw:
|
||
for candidate in candidates:
|
||
if candidate:
|
||
ekstern_raw = candidate
|
||
break
|
||
ekstern_e164 = normalize_e164(ekstern_raw)
|
||
ekstern_value = ekstern_e164 or ((ekstern_raw or "").strip() or None)
|
||
suffix8 = phone_suffix_8(ekstern_raw)
|
||
|
||
user_ids = TelefoniService.find_user_by_extension(local_extension)
|
||
|
||
kontakt = TelefoniService.find_contact_by_phone_suffix(suffix8)
|
||
kontakt_id = kontakt.get("id") if kontakt else None
|
||
|
||
# Get extended contact details if we found a contact
|
||
contact_details = {}
|
||
if kontakt_id:
|
||
contact_details = TelefoniService.get_contact_details(kontakt_id)
|
||
|
||
payload = {
|
||
"callid": resolved_callid,
|
||
"call_id": call_id,
|
||
"caller": caller_value,
|
||
"callee": callee_value,
|
||
"remote": remote,
|
||
"local": local_value,
|
||
"active_user": active_user,
|
||
"called_number": called_number_value,
|
||
"direction": direction,
|
||
"client_ip": _get_client_ip(request),
|
||
"user_agent": request.headers.get("user-agent"),
|
||
"received_at": datetime.utcnow().isoformat(),
|
||
}
|
||
|
||
# Store call with first user_id (or None if no users)
|
||
primary_user_id = user_ids[0] if user_ids else None
|
||
row = TelefoniService.upsert_call(
|
||
callid=resolved_callid,
|
||
user_id=primary_user_id,
|
||
direction=direction,
|
||
ekstern_nummer=ekstern_value,
|
||
intern_extension=(local_extension or "")[:16] or None,
|
||
kontakt_id=kontakt_id,
|
||
raw_payload=json.dumps(payload),
|
||
started_at=datetime.utcnow(),
|
||
)
|
||
|
||
# Send websocket notification to ALL users with this extension
|
||
if user_ids:
|
||
call_data = {
|
||
"call_id": str(row.get("id") or resolved_callid),
|
||
"number": ekstern_e164 or (ekstern_raw or ""),
|
||
"direction": direction,
|
||
"contact": kontakt,
|
||
"recent_cases": contact_details.get("recent_cases", []),
|
||
"last_call": contact_details.get("last_call"),
|
||
}
|
||
for user_id in user_ids:
|
||
await manager.send_to_user(user_id, "incoming_call", call_data)
|
||
logger.info("📞 Telefoni notification sent to %d user(s) for extension=%s", len(user_ids), local_extension)
|
||
else:
|
||
logger.info("⚠️ Telefoni established: no mapped user for extension=%s (callid=%s)", local_extension, resolved_callid)
|
||
|
||
return {"status": "ok"}
|
||
|
||
|
||
@router.get("/telefoni/terminated")
|
||
async def yealink_terminated(
|
||
request: Request,
|
||
callid: Optional[str] = Query(None),
|
||
call_id: Optional[str] = Query(None),
|
||
duration: Optional[str] = Query(None),
|
||
call_duration: Optional[str] = Query(None),
|
||
token: Optional[str] = Query(None),
|
||
):
|
||
"""Yealink Action URL: Terminated"""
|
||
_validate_yealink_request(request, token)
|
||
|
||
resolved_callid = (callid or call_id or "").strip()
|
||
if not resolved_callid:
|
||
raise HTTPException(status_code=422, detail="Missing callid/call_id")
|
||
|
||
duration_raw = (duration or call_duration or "").strip() or None
|
||
duration_value: Optional[int] = None
|
||
if duration_raw:
|
||
try:
|
||
duration_value = int(duration_raw)
|
||
except ValueError:
|
||
# Accept common timer formats from PBX/phones: mm:ss or hh:mm:ss
|
||
if ":" in duration_raw:
|
||
parts = duration_raw.split(":")
|
||
try:
|
||
nums = [int(p) for p in parts]
|
||
if len(nums) == 2:
|
||
duration_value = nums[0] * 60 + nums[1]
|
||
elif len(nums) == 3:
|
||
duration_value = nums[0] * 3600 + nums[1] * 60 + nums[2]
|
||
except ValueError:
|
||
duration_value = None
|
||
|
||
if duration_value is None:
|
||
logger.info("⚠️ Telefoni terminated with unparseable duration='%s' (callid=%s)", duration_raw, resolved_callid)
|
||
|
||
updated = TelefoniService.terminate_call(resolved_callid, duration_value)
|
||
if not updated:
|
||
logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid)
|
||
return {"status": "ok"}
|
||
|
||
|
||
@router.websocket("/telefoni/ws")
|
||
async def telefoni_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:
|
||
env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
|
||
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
|
||
accepted_tokens = {s for s in (env_secret, db_secret) if s}
|
||
if token and token.strip() in accepted_tokens:
|
||
user_id_param = websocket.query_params.get("user_id")
|
||
try:
|
||
user_id = int(user_id_param) if user_id_param else None
|
||
except (TypeError, ValueError):
|
||
user_id = None
|
||
|
||
if not user_id:
|
||
logger.info("⚠️ Telefoni WS rejected: shared secret requires user_id")
|
||
await websocket.close(code=1008)
|
||
return
|
||
else:
|
||
logger.info("⚠️ Telefoni WS rejected: invalid or missing token")
|
||
await websocket.close(code=1008)
|
||
return
|
||
else:
|
||
user_id_value = payload.get("sub") or payload.get("user_id")
|
||
try:
|
||
user_id = int(user_id_value)
|
||
except (TypeError, ValueError):
|
||
logger.info("⚠️ Telefoni WS rejected: invalid user id in token")
|
||
await websocket.close(code=1008)
|
||
return
|
||
|
||
await manager.connect(user_id, websocket)
|
||
logger.info("✅ Telefoni WS connected for user_id=%s", user_id)
|
||
try:
|
||
while True:
|
||
# Keep alive / ignore client messages
|
||
await websocket.receive_text()
|
||
except WebSocketDisconnect:
|
||
logger.info("ℹ️ Telefoni WS disconnected for user_id=%s", user_id)
|
||
await manager.disconnect(user_id, websocket)
|
||
except Exception:
|
||
logger.info("ℹ️ Telefoni WS disconnected (exception) for user_id=%s", user_id)
|
||
await manager.disconnect(user_id, websocket)
|
||
|
||
|
||
@router.post("/telefoni/test-popup")
|
||
async def telefoni_test_popup(
|
||
request: Request,
|
||
token: Optional[str] = Query(None),
|
||
user_id: Optional[int] = Query(None),
|
||
extension: Optional[str] = Query(None),
|
||
):
|
||
"""Trigger test popup for currently authenticated user."""
|
||
target_user_id = getattr(request.state, "user_id", None)
|
||
|
||
env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
|
||
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
|
||
accepted_tokens = {s for s in (env_secret, db_secret) if s}
|
||
token_valid = bool(token and token.strip() in accepted_tokens)
|
||
|
||
if not target_user_id:
|
||
if not token_valid:
|
||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||
|
||
if user_id:
|
||
target_user_id = int(user_id)
|
||
elif extension:
|
||
# find_user_by_extension now returns list - take first match for test
|
||
user_ids = TelefoniService.find_user_by_extension(extension)
|
||
target_user_id = user_ids[0] if user_ids else None
|
||
|
||
if not target_user_id:
|
||
raise HTTPException(status_code=422, detail="Provide user_id or extension when using token auth")
|
||
|
||
conn_count = await manager.connection_count_for_user(int(target_user_id))
|
||
await manager.send_to_user(
|
||
int(target_user_id),
|
||
"incoming_call",
|
||
{
|
||
"call_id": f"test-{int(datetime.utcnow().timestamp())}",
|
||
"number": "+4511223344",
|
||
"direction": "inbound",
|
||
"contact": {
|
||
"id": None,
|
||
"name": "Test popup",
|
||
"company": "BMC Hub",
|
||
},
|
||
"recent_cases": [
|
||
{"id": 1, "titel": "Test sag 1", "created_at": datetime.utcnow()},
|
||
{"id": 2, "titel": "Test sag 2", "created_at": datetime.utcnow()},
|
||
],
|
||
"last_call": {
|
||
"started_at": datetime.utcnow(),
|
||
"bruger_navn": "Test Medarbejder",
|
||
"duration_sec": 125,
|
||
},
|
||
},
|
||
)
|
||
return {
|
||
"status": "ok",
|
||
"message": "Test popup event sent",
|
||
"user_id": int(target_user_id),
|
||
"ws_connections": conn_count,
|
||
"auth_mode": "token" if token_valid and not getattr(request.state, "user_id", None) else "session",
|
||
}
|
||
|
||
|
||
@router.get("/telefoni/users")
|
||
async def list_telefoni_users():
|
||
"""List users for log filtering (auth-protected by middleware)."""
|
||
rows = execute_query(
|
||
"""
|
||
SELECT user_id, username, full_name, telefoni_extension, telefoni_aktiv, telefoni_phone_ip, telefoni_phone_username
|
||
FROM users
|
||
ORDER BY COALESCE(full_name, username) ASC
|
||
""",
|
||
(),
|
||
)
|
||
return rows or []
|
||
|
||
|
||
@router.patch("/telefoni/admin/users/{user_id}", dependencies=[Depends(require_permission("users.manage"))])
|
||
async def update_telefoni_user_mapping(user_id: int, data: TelefoniUserMappingUpdate):
|
||
existing = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
|
||
if not existing:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
|
||
rows = execute_query(
|
||
"""
|
||
UPDATE users
|
||
SET
|
||
telefoni_extension = COALESCE(%s, telefoni_extension),
|
||
telefoni_aktiv = COALESCE(%s, telefoni_aktiv),
|
||
telefoni_phone_ip = COALESCE(%s, telefoni_phone_ip),
|
||
telefoni_phone_username = COALESCE(%s, telefoni_phone_username),
|
||
telefoni_phone_password = CASE
|
||
WHEN %s IS NULL OR %s = '' THEN telefoni_phone_password
|
||
ELSE %s
|
||
END
|
||
WHERE user_id = %s
|
||
RETURNING user_id, username, full_name, telefoni_extension, telefoni_aktiv, telefoni_phone_ip, telefoni_phone_username
|
||
""",
|
||
(
|
||
data.telefoni_extension,
|
||
data.telefoni_aktiv,
|
||
data.telefoni_phone_ip,
|
||
data.telefoni_phone_username,
|
||
data.telefoni_phone_password,
|
||
data.telefoni_phone_password,
|
||
data.telefoni_phone_password,
|
||
user_id,
|
||
),
|
||
)
|
||
return rows[0] if rows else {"status": "ok"}
|
||
|
||
|
||
def _get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]:
|
||
row = execute_query_single("SELECT value FROM settings WHERE key = %s", (key,))
|
||
if not row:
|
||
return default
|
||
value = row.get("value")
|
||
if value is None or value == "":
|
||
return default
|
||
return str(value)
|
||
|
||
|
||
@router.post("/telefoni/click-to-call")
|
||
async def click_to_call(payload: TelefoniClickToCallRequest):
|
||
enabled = (_get_setting_value("telefoni_click_to_call_enabled", "false") or "false").lower() == "true"
|
||
if not enabled:
|
||
raise HTTPException(status_code=400, detail="Click-to-call is disabled")
|
||
|
||
template = _get_setting_value("telefoni_action_url_template", "") or ""
|
||
if not template:
|
||
raise HTTPException(status_code=400, detail="telefoni_action_url_template is not configured")
|
||
|
||
number_normalized = normalize_e164(payload.number) or payload.number.strip()
|
||
extension_value = (payload.extension or "").strip()
|
||
phone_ip_value = ""
|
||
phone_username_value = ""
|
||
phone_password_value = ""
|
||
|
||
if payload.user_id:
|
||
user_row = execute_query_single(
|
||
"""
|
||
SELECT telefoni_extension, telefoni_phone_ip, telefoni_phone_username, telefoni_phone_password
|
||
FROM users
|
||
WHERE user_id = %s
|
||
""",
|
||
(payload.user_id,),
|
||
)
|
||
if user_row:
|
||
if not extension_value:
|
||
extension_value = (user_row.get("telefoni_extension") or "").strip()
|
||
phone_ip_value = (user_row.get("telefoni_phone_ip") or "").strip()
|
||
phone_username_value = (user_row.get("telefoni_phone_username") or "").strip()
|
||
phone_password_value = (user_row.get("telefoni_phone_password") or "").strip()
|
||
|
||
if "{number}" not in template and "{raw_number}" not in template:
|
||
raise HTTPException(status_code=400, detail="Template must contain {number} or {raw_number}")
|
||
|
||
if "{phone_ip}" in template and not phone_ip_value:
|
||
raise HTTPException(status_code=400, detail="Template requires {phone_ip}, but selected user has no phone IP")
|
||
if "{phone_username}" in template and not phone_username_value:
|
||
raise HTTPException(status_code=400, detail="Template requires {phone_username}, but selected user has no phone username")
|
||
if "{phone_password}" in template and not phone_password_value:
|
||
raise HTTPException(status_code=400, detail="Template requires {phone_password}, but selected user has no phone password")
|
||
|
||
raw_number_clean = re.sub(r"\s+", "", payload.number.strip())
|
||
resolved_url = (
|
||
template
|
||
.replace("{number}", number_normalized)
|
||
.replace("{raw_number}", raw_number_clean)
|
||
.replace("{extension}", extension_value)
|
||
.replace("{phone_ip}", phone_ip_value)
|
||
.replace("{phone_username}", phone_username_value)
|
||
.replace("{phone_password}", phone_password_value)
|
||
)
|
||
|
||
auth_header: Optional[str] = None
|
||
if "@" in resolved_url:
|
||
parsed = urlsplit(resolved_url)
|
||
if not parsed.scheme or not parsed.netloc:
|
||
raise HTTPException(status_code=400, detail="Action URL template resolves to invalid URL")
|
||
|
||
netloc = parsed.netloc
|
||
if "@" in netloc:
|
||
userinfo, host_part = netloc.rsplit("@", 1)
|
||
if ":" in userinfo:
|
||
username, password = userinfo.split(":", 1)
|
||
else:
|
||
username, password = userinfo, ""
|
||
|
||
credentials = f"{username}:{password}".encode("utf-8")
|
||
auth_header = "Basic " + base64.b64encode(credentials).decode("ascii")
|
||
resolved_url = urlunsplit((parsed.scheme, host_part, parsed.path, parsed.query, parsed.fragment))
|
||
|
||
logger.info("📞 Click-to-call trigger: number=%s extension=%s", number_normalized, extension_value or "-")
|
||
|
||
try:
|
||
request = UrlRequest(resolved_url, method="GET")
|
||
if auth_header:
|
||
request.add_header("Authorization", auth_header)
|
||
with urlopen(request, timeout=8) as response:
|
||
status = getattr(response, "status", 200)
|
||
except HTTPError as e:
|
||
logger.error("❌ Click-to-call HTTP error: %s", e)
|
||
raise HTTPException(status_code=502, detail=f"Action URL returned HTTP {e.code}")
|
||
except URLError as e:
|
||
logger.error("❌ Click-to-call URL error: %s", e)
|
||
raise HTTPException(status_code=502, detail="Could not reach Action URL")
|
||
except Exception as e:
|
||
logger.error("❌ Click-to-call failed: %s", e)
|
||
raise HTTPException(status_code=500, detail="Click-to-call failed")
|
||
|
||
display_url = resolved_url
|
||
if auth_header:
|
||
display_url = "[basic-auth] " + resolved_url
|
||
|
||
return {
|
||
"status": "ok",
|
||
"action_url": display_url,
|
||
"http_status": status,
|
||
"number": number_normalized,
|
||
}
|
||
|
||
|
||
@router.get("/telefoni/calls")
|
||
async def list_calls(
|
||
user_id: Optional[int] = Query(None),
|
||
date_from: Optional[str] = Query(None),
|
||
date_to: Optional[str] = Query(None),
|
||
without_case: bool = Query(False),
|
||
limit: int = Query(200, ge=1, le=2000),
|
||
offset: int = Query(0, ge=0),
|
||
):
|
||
where = []
|
||
params = []
|
||
|
||
if user_id is not None:
|
||
where.append("t.bruger_id = %s")
|
||
params.append(user_id)
|
||
if date_from:
|
||
where.append("t.started_at >= %s")
|
||
params.append(date_from)
|
||
if date_to:
|
||
where.append("t.started_at <= %s")
|
||
params.append(date_to)
|
||
if without_case:
|
||
where.append("t.sag_id IS NULL")
|
||
|
||
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||
|
||
query = f"""
|
||
SELECT
|
||
t.id,
|
||
t.callid,
|
||
t.bruger_id,
|
||
t.direction,
|
||
t.ekstern_nummer,
|
||
COALESCE(
|
||
NULLIF(TRIM(t.ekstern_nummer), ''),
|
||
NULLIF(TRIM(t.raw_payload->>'caller'), ''),
|
||
NULLIF(TRIM(t.raw_payload->>'callee'), '')
|
||
) AS display_number,
|
||
t.intern_extension,
|
||
t.kontakt_id,
|
||
t.sag_id,
|
||
t.started_at,
|
||
t.ended_at,
|
||
t.duration_sec,
|
||
t.created_at,
|
||
u.username,
|
||
u.full_name,
|
||
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name,
|
||
(
|
||
SELECT cu.name
|
||
FROM contact_companies cc
|
||
JOIN customers cu ON cu.id = cc.customer_id
|
||
WHERE cc.contact_id = c.id
|
||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||
LIMIT 1
|
||
) AS contact_company,
|
||
s.titel AS sag_titel
|
||
FROM telefoni_opkald t
|
||
LEFT JOIN users u ON u.user_id = t.bruger_id
|
||
LEFT JOIN contacts c ON c.id = t.kontakt_id
|
||
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||
{where_sql}
|
||
ORDER BY t.started_at DESC
|
||
LIMIT %s OFFSET %s
|
||
"""
|
||
params.extend([limit, offset])
|
||
|
||
rows = execute_query(query, tuple(params))
|
||
return rows or []
|
||
|
||
|
||
@router.patch("/telefoni/calls/{call_id}")
|
||
async def update_call_links(call_id: int, data: TelefoniCallLinkUpdate):
|
||
existing = execute_query_single("SELECT id FROM telefoni_opkald WHERE id = %s", (call_id,))
|
||
if not existing:
|
||
raise HTTPException(status_code=404, detail="Call not found")
|
||
|
||
fields = []
|
||
params = []
|
||
|
||
if "sag_id" in data.model_fields_set:
|
||
fields.append("sag_id = %s")
|
||
params.append(data.sag_id)
|
||
if "kontakt_id" in data.model_fields_set:
|
||
fields.append("kontakt_id = %s")
|
||
params.append(data.kontakt_id)
|
||
|
||
if not fields:
|
||
raise HTTPException(status_code=400, detail="No fields provided")
|
||
|
||
query = f"""
|
||
UPDATE telefoni_opkald
|
||
SET {", ".join(fields)}
|
||
WHERE id = %s
|
||
RETURNING *
|
||
"""
|
||
params.append(call_id)
|
||
|
||
rows = execute_query(query, tuple(params))
|
||
return rows[0] if rows else {"status": "ok"}
|