@@ -4973,6 +5459,7 @@
const res = await fetch(`/api/v1/subscriptions/by-sag/${subscriptionCaseId}`);
if (res.status === 404) {
showSubscriptionCreateForm();
+ setModuleContentState('subscription', false);
return;
}
if (!res.ok) {
@@ -4980,9 +5467,11 @@
}
const subscription = await res.json();
renderSubscription(subscription);
+ setModuleContentState('subscription', true);
} catch (e) {
console.error('Error loading subscription:', e);
showSubscriptionCreateForm();
+ setModuleContentState('subscription', true);
}
}
diff --git a/app/modules/telefoni/__init__.py b/app/modules/telefoni/__init__.py
new file mode 100644
index 0000000..89c5eb1
--- /dev/null
+++ b/app/modules/telefoni/__init__.py
@@ -0,0 +1 @@
+"""Telefoni module package."""
diff --git a/app/modules/telefoni/backend/__init__.py b/app/modules/telefoni/backend/__init__.py
new file mode 100644
index 0000000..2fc9824
--- /dev/null
+++ b/app/modules/telefoni/backend/__init__.py
@@ -0,0 +1 @@
+"""Telefoni backend package."""
diff --git a/app/modules/telefoni/backend/router.py b/app/modules/telefoni/backend/router.py
new file mode 100644
index 0000000..84fb8fd
--- /dev/null
+++ b/app/modules/telefoni/backend/router.py
@@ -0,0 +1,667 @@
+import json
+import logging
+import base64
+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()
+
+ if not accepted_tokens and not whitelist:
+ logger.error("❌ Telefoni callbacks are not secured (no TELEFONI_SHARED_SECRET or TELEFONI_IP_WHITELIST set)")
+ raise HTTPException(status_code=403, detail="Telefoni callbacks not configured")
+
+ if token and token.strip() in accepted_tokens:
+ return
+
+ if whitelist:
+ client_ip = _get_client_ip(request)
+ if ip_in_whitelist(client_ip, whitelist):
+ return
+
+ 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_id = TelefoniService.find_user_by_extension(local_extension)
+
+ kontakt = TelefoniService.find_contact_by_phone_suffix(suffix8)
+ kontakt_id = kontakt.get("id") if kontakt else None
+
+ 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(),
+ }
+
+ row = TelefoniService.upsert_call(
+ callid=resolved_callid,
+ user_id=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(),
+ )
+
+ if user_id:
+ await manager.send_to_user(
+ user_id,
+ "incoming_call",
+ {
+ "call_id": str(row.get("id") or resolved_callid),
+ "number": ekstern_e164 or (ekstern_raw or ""),
+ "direction": direction,
+ "contact": kontakt,
+ },
+ )
+ 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:
+ target_user_id = TelefoniService.find_user_by_extension(extension)
+
+ 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",
+ },
+ },
+ )
+ 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")
+
+ resolved_url = (
+ template
+ .replace("{number}", number_normalized)
+ .replace("{raw_number}", payload.number.strip())
+ .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"}
diff --git a/app/modules/telefoni/backend/schemas.py b/app/modules/telefoni/backend/schemas.py
new file mode 100644
index 0000000..e93ff47
--- /dev/null
+++ b/app/modules/telefoni/backend/schemas.py
@@ -0,0 +1,28 @@
+from pydantic import BaseModel
+from typing import Optional
+
+
+class TelefoniCallLinkUpdate(BaseModel):
+ sag_id: Optional[int] = None
+ kontakt_id: Optional[int] = None
+
+
+class TelefoniUserMappingUpdate(BaseModel):
+ telefoni_extension: Optional[str] = None
+ telefoni_aktiv: Optional[bool] = None
+ telefoni_phone_ip: Optional[str] = None
+ telefoni_phone_username: Optional[str] = None
+ telefoni_phone_password: Optional[str] = None
+
+
+class TelefoniClickToCallRequest(BaseModel):
+ number: str
+ extension: Optional[str] = None
+ user_id: Optional[int] = None
+
+
+class SmsSendRequest(BaseModel):
+ to: str
+ message: str
+ sender: Optional[str] = None
+ contact_id: Optional[int] = None
diff --git a/app/modules/telefoni/backend/service.py b/app/modules/telefoni/backend/service.py
new file mode 100644
index 0000000..0069f87
--- /dev/null
+++ b/app/modules/telefoni/backend/service.py
@@ -0,0 +1,111 @@
+import logging
+from datetime import datetime
+from typing import Any, Optional
+
+from app.core.database import execute_query, execute_query_single
+
+logger = logging.getLogger(__name__)
+
+
+class TelefoniService:
+ @staticmethod
+ def find_user_by_extension(extension: Optional[str]) -> Optional[int]:
+ if not extension:
+ return None
+ row = execute_query_single(
+ "SELECT user_id FROM users WHERE telefoni_aktiv = TRUE AND telefoni_extension = %s LIMIT 1",
+ (extension,),
+ )
+ return int(row["user_id"]) if row and row.get("user_id") is not None else None
+
+ @staticmethod
+ def find_contact_by_phone_suffix(suffix8: Optional[str]) -> Optional[dict]:
+ if not suffix8:
+ return None
+
+ query = """
+ SELECT
+ c.id,
+ c.first_name,
+ c.last_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 company
+ FROM contacts c
+ WHERE RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = %s
+ OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = %s
+ ORDER BY c.id ASC
+ LIMIT 1
+ """
+ row = execute_query_single(query, (suffix8, suffix8))
+ if not row:
+ return None
+ return {
+ "id": row["id"],
+ "name": f"{(row.get('first_name') or '').strip()} {(row.get('last_name') or '').strip()}".strip(),
+ "company": row.get("company"),
+ }
+
+ @staticmethod
+ def upsert_call(
+ *,
+ callid: str,
+ user_id: Optional[int],
+ direction: str,
+ ekstern_nummer: Optional[str],
+ intern_extension: Optional[str],
+ kontakt_id: Optional[int],
+ raw_payload: Any,
+ started_at: datetime,
+ ) -> dict:
+ query = """
+ INSERT INTO telefoni_opkald
+ (callid, bruger_id, direction, ekstern_nummer, intern_extension, kontakt_id, started_at, raw_payload)
+ VALUES
+ (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
+ ON CONFLICT (callid)
+ DO UPDATE SET
+ raw_payload = EXCLUDED.raw_payload,
+ direction = EXCLUDED.direction,
+ intern_extension = COALESCE(telefoni_opkald.intern_extension, EXCLUDED.intern_extension),
+ ekstern_nummer = COALESCE(telefoni_opkald.ekstern_nummer, EXCLUDED.ekstern_nummer),
+ bruger_id = COALESCE(telefoni_opkald.bruger_id, EXCLUDED.bruger_id),
+ kontakt_id = COALESCE(telefoni_opkald.kontakt_id, EXCLUDED.kontakt_id),
+ started_at = LEAST(telefoni_opkald.started_at, EXCLUDED.started_at)
+ RETURNING *
+ """
+ rows = execute_query(
+ query,
+ (
+ callid,
+ user_id,
+ direction,
+ ekstern_nummer,
+ intern_extension,
+ kontakt_id,
+ started_at,
+ raw_payload,
+ ),
+ )
+ return rows[0] if rows else {}
+
+ @staticmethod
+ def terminate_call(callid: str, duration_sec: Optional[int]) -> bool:
+ if not callid:
+ return False
+ rows = execute_query(
+ """
+ UPDATE telefoni_opkald
+ SET ended_at = NOW(),
+ duration_sec = %s
+ WHERE callid = %s
+ RETURNING id
+ """,
+ (duration_sec, callid),
+ )
+ return bool(rows)
diff --git a/app/modules/telefoni/backend/utils.py b/app/modules/telefoni/backend/utils.py
new file mode 100644
index 0000000..499f443
--- /dev/null
+++ b/app/modules/telefoni/backend/utils.py
@@ -0,0 +1,102 @@
+import ipaddress
+import re
+from typing import Optional
+
+
+def digits_only(value: Optional[str]) -> str:
+ if not value:
+ return ""
+ return re.sub(r"\D+", "", value)
+
+
+def normalize_e164(number: Optional[str]) -> Optional[str]:
+ if not number:
+ return None
+
+ raw = number.strip().replace(" ", "").replace("-", "")
+ if not raw:
+ return None
+
+ if raw.startswith("+"):
+ n = "+" + digits_only(raw)
+ return n if len(n) >= 9 else None
+
+ if raw.startswith("0045"):
+ rest = digits_only(raw[4:])
+ return "+45" + rest if len(rest) == 8 else ("+" + digits_only(raw) if len(digits_only(raw)) >= 9 else None)
+
+ d = digits_only(raw)
+ if len(d) == 8:
+ return "+45" + d
+
+ if d.startswith("45") and len(d) == 10:
+ return "+" + d
+
+ # Generic international (best-effort)
+ if len(d) >= 9:
+ return "+" + d
+
+ return None
+
+
+def phone_suffix_8(number: Optional[str]) -> Optional[str]:
+ d = digits_only(number)
+ if len(d) < 8:
+ return None
+ return d[-8:]
+
+
+def is_outbound_call(caller: Optional[str], local_extension: Optional[str]) -> bool:
+ caller_d = digits_only(caller)
+ local_d = digits_only(local_extension)
+ if not caller_d or not local_d:
+ return False
+ return caller_d.endswith(local_d)
+
+
+def extract_extension(local_value: Optional[str]) -> Optional[str]:
+ if not local_value:
+ return None
+
+ raw = local_value.strip()
+ if not raw:
+ return None
+
+ if raw.isdigit():
+ return raw
+
+ # Common SIP format: sip:204_99773@pbx.sipserver.dk -> 204
+ sip_match = re.search(r"sip:([0-9]+)", raw, flags=re.IGNORECASE)
+ if sip_match:
+ return sip_match.group(1)
+
+ # Fallback: first digit run (at least 2 chars)
+ generic = re.search(r"([0-9]{2,})", raw)
+ if generic:
+ return generic.group(1)
+
+ return None
+
+
+def ip_in_whitelist(client_ip: str, whitelist_csv: str) -> bool:
+ if not client_ip or not whitelist_csv:
+ return False
+
+ try:
+ ip_obj = ipaddress.ip_address(client_ip)
+ except ValueError:
+ return False
+
+ for entry in [e.strip() for e in whitelist_csv.split(",") if e.strip()]:
+ try:
+ if "/" in entry:
+ net = ipaddress.ip_network(entry, strict=False)
+ if ip_obj in net:
+ return True
+ else:
+ if ip_obj == ipaddress.ip_address(entry):
+ return True
+ except ValueError:
+ continue
+
+ return False
diff --git a/app/modules/telefoni/backend/websocket.py b/app/modules/telefoni/backend/websocket.py
new file mode 100644
index 0000000..b358969
--- /dev/null
+++ b/app/modules/telefoni/backend/websocket.py
@@ -0,0 +1,67 @@
+import asyncio
+import json
+import logging
+from typing import Dict, Set
+
+from fastapi import WebSocket
+
+logger = logging.getLogger(__name__)
+
+
+class TelefoniConnectionManager:
+ def __init__(self) -> None:
+ self._lock = asyncio.Lock()
+ self._connections: Dict[int, Set[WebSocket]] = {}
+
+ async def connect(self, user_id: int, websocket: WebSocket) -> None:
+ await websocket.accept()
+ async with self._lock:
+ self._connections.setdefault(user_id, set()).add(websocket)
+ logger.info("📞 WS manager: user_id=%s now has %s connection(s)", user_id, len(self._connections.get(user_id, set())))
+
+ async def disconnect(self, user_id: int, websocket: WebSocket) -> None:
+ async with self._lock:
+ ws_set = self._connections.get(user_id)
+ if not ws_set:
+ return
+ ws_set.discard(websocket)
+ if not ws_set:
+ self._connections.pop(user_id, None)
+ logger.info("📞 WS manager: user_id=%s disconnected (0 connections)", user_id)
+ else:
+ logger.info("📞 WS manager: user_id=%s now has %s connection(s)", user_id, len(ws_set))
+
+ async def active_users(self) -> list[int]:
+ async with self._lock:
+ return sorted(self._connections.keys())
+
+ async def connection_count_for_user(self, user_id: int) -> int:
+ async with self._lock:
+ return len(self._connections.get(user_id, set()))
+
+ async def send_to_user(self, user_id: int, event: str, payload: dict) -> None:
+ message = json.dumps({"event": event, "data": payload}, default=str)
+ async with self._lock:
+ targets = list(self._connections.get(user_id, set()))
+
+ if not targets:
+ active = await self.active_users()
+ logger.info("⚠️ WS send skipped: no active connections for user_id=%s (active users=%s)", user_id, active)
+ return
+
+ dead: list[WebSocket] = []
+ for ws in targets:
+ try:
+ await ws.send_text(message)
+ except Exception as e:
+ logger.warning("⚠️ WS send failed for user %s: %s", user_id, e)
+ dead.append(ws)
+
+ if dead:
+ async with self._lock:
+ ws_set = self._connections.get(user_id, set())
+ for ws in dead:
+ ws_set.discard(ws)
+
+
+manager = TelefoniConnectionManager()
diff --git a/app/modules/telefoni/frontend/__init__.py b/app/modules/telefoni/frontend/__init__.py
new file mode 100644
index 0000000..2bfc9e0
--- /dev/null
+++ b/app/modules/telefoni/frontend/__init__.py
@@ -0,0 +1 @@
+"""Telefoni frontend package."""
diff --git a/app/modules/telefoni/frontend/views.py b/app/modules/telefoni/frontend/views.py
new file mode 100644
index 0000000..04fa62d
--- /dev/null
+++ b/app/modules/telefoni/frontend/views.py
@@ -0,0 +1,14 @@
+import logging
+
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+templates = Jinja2Templates(directory="app")
+
+
+@router.get("/telefoni", response_class=HTMLResponse)
+async def telefoni_log_page(request: Request):
+ return templates.TemplateResponse("modules/telefoni/templates/log.html", {"request": request})
diff --git a/app/modules/telefoni/templates/log.html b/app/modules/telefoni/templates/log.html
new file mode 100644
index 0000000..27ec684
--- /dev/null
+++ b/app/modules/telefoni/templates/log.html
@@ -0,0 +1,512 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Telefoni - BMC Hub{% endblock %}
+
+{% block content %}
+
+
+
+
Telefoni
+
Opkaldslog fra Yealink Action URL (Established/Terminated)
+
+
+
+
+
+
+
+ Bruger
+
+ Alle
+
+
+
+ Dato fra
+
+
+
+ Dato til
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Dato
+ Bruger
+ Retning
+ Nummer
+ Kontakt
+ Sag
+ Varighed
+
+
+
+ Indlæser...
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/services/eset_service.py b/app/services/eset_service.py
index 9269fd3..2edc075 100644
--- a/app/services/eset_service.py
+++ b/app/services/eset_service.py
@@ -4,7 +4,7 @@ ESET PROTECT Integration Service
import logging
import time
import httpx
-from typing import Dict, Optional, Any
+from typing import Dict, Optional, Any, List, Mapping, Sequence, Tuple, Union
from app.core.config import settings
logger = logging.getLogger(__name__)
@@ -80,7 +80,12 @@ class EsetService:
return self._access_token
return await self._authenticate(client)
- async def _get_json(self, client: httpx.AsyncClient, url: str, params: Optional[dict] = None) -> Optional[Dict[str, Any]]:
+ async def _get_json(
+ self,
+ client: httpx.AsyncClient,
+ url: str,
+ params: Optional[Union[Mapping[str, Any], Sequence[Tuple[str, str]]]] = None,
+ ) -> Optional[Dict[str, Any]]:
token = await self._get_access_token(client)
if not token:
return None
@@ -142,4 +147,94 @@ class EsetService:
logger.error(f"ESET API error: {str(e)}")
return None
+ async def get_devices_batch(self, device_uuids: List[str]) -> Optional[Dict[str, Any]]:
+ """Fetch multiple devices in one call via /v1/devices:batchGet."""
+ if not self.enabled:
+ logger.warning("ESET not enabled")
+ return None
+
+ uuids = [str(u).strip() for u in (device_uuids or []) if str(u).strip()]
+ if not uuids:
+ return {"devices": []}
+
+ url = f"{self.base_url}/v1/devices:batchGet"
+ params = [("devicesUuids", u) for u in uuids]
+
+ async with httpx.AsyncClient(verify=self.verify_ssl, timeout=settings.ESET_TIMEOUT_SECONDS) as client:
+ payload = await self._get_json(client, url, params=params)
+ if not payload:
+ logger.warning("ESET batchGet payload empty")
+ return payload
+
+ @staticmethod
+ def extract_installed_software(device_payload: Dict[str, Any]) -> List[str]:
+ """Extract installed software names/versions from ESET device payload."""
+ if not isinstance(device_payload, dict):
+ return []
+
+ device_raw = device_payload.get("device") if isinstance(device_payload.get("device"), dict) else device_payload
+ if not isinstance(device_raw, dict):
+ return []
+ def _normalize_version(value: Any) -> str:
+ if isinstance(value, dict):
+ name = str(value.get("name") or "").strip()
+ if name:
+ return name
+ version_id = str(value.get("id") or "").strip()
+ if version_id:
+ return version_id
+ major = value.get("major")
+ minor = value.get("minor")
+ patch = value.get("patch")
+ if major is not None and minor is not None and patch is not None:
+ return f"{major}.{minor}.{patch}"
+ return ""
+ if value is None:
+ return ""
+ return str(value).strip()
+
+ result: List[str] = []
+
+ def _add_item(name: Any, version: Any = None) -> None:
+ item_name = str(name or "").strip()
+ if not item_name:
+ return
+ item_version = _normalize_version(version)
+ result.append(f"{item_name} {item_version}".strip() if item_version else item_name)
+
+ for comp in device_raw.get("deployedComponents") or []:
+ if isinstance(comp, dict):
+ _add_item(comp.get("displayName") or comp.get("name"), comp.get("version"))
+ elif isinstance(comp, str):
+ _add_item(comp)
+
+ for key in ("installedSoftware", "applications", "applicationInventory", "softwareInventory", "activeProducts"):
+ for comp in device_raw.get(key) or []:
+ if isinstance(comp, dict):
+ _add_item(
+ comp.get("displayName")
+ or comp.get("name")
+ or comp.get("softwareName")
+ or comp.get("applicationName")
+ or comp.get("productName")
+ or comp.get("product"),
+ comp.get("version")
+ or comp.get("applicationVersion")
+ or comp.get("softwareVersion")
+ or comp.get("productVersion"),
+ )
+ elif isinstance(comp, str):
+ _add_item(comp)
+
+ # keep order, remove duplicates
+ deduped: List[str] = []
+ seen = set()
+ for item in result:
+ key = item.lower()
+ if key in seen:
+ continue
+ seen.add(key)
+ deduped.append(item)
+ return deduped
+
eset_service = EsetService()
diff --git a/app/services/sms_service.py b/app/services/sms_service.py
new file mode 100644
index 0000000..43c18c2
--- /dev/null
+++ b/app/services/sms_service.py
@@ -0,0 +1,96 @@
+import base64
+import json
+import logging
+import re
+from typing import Dict, Any
+from urllib.error import HTTPError, URLError
+from urllib.request import Request as UrlRequest, urlopen
+
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+class SmsService:
+ API_URL = "https://api.cpsms.dk/v2/send"
+
+ @staticmethod
+ def normalize_recipient(number: str) -> str:
+ cleaned = re.sub(r"[^0-9+]", "", (number or "").strip())
+ if not cleaned:
+ raise ValueError("Mobilnummer mangler")
+
+ if cleaned.startswith("+"):
+ cleaned = cleaned[1:]
+ if cleaned.startswith("00"):
+ cleaned = cleaned[2:]
+
+ if not cleaned.isdigit():
+ raise ValueError("Ugyldigt mobilnummer")
+
+ if len(cleaned) == 8:
+ cleaned = "45" + cleaned
+
+ if len(cleaned) < 8:
+ raise ValueError("Ugyldigt mobilnummer")
+
+ return cleaned
+
+ @staticmethod
+ def _authorization_header() -> str:
+ username = (settings.SMS_USERNAME or "").strip()
+ api_key = (settings.SMS_API_KEY or "").strip()
+
+ if not username or not api_key:
+ raise ValueError("SMS er ikke konfigureret (SMS_USERNAME/SMS_API_KEY mangler)")
+
+ raw = f"{username}:{api_key}".encode("utf-8")
+ token = base64.b64encode(raw).decode("ascii")
+ return f"Basic {token}"
+
+ @classmethod
+ def send_sms(cls, to: str, message: str, sender: str | None = None) -> Dict[str, Any]:
+ sms_message = (message or "").strip()
+ if not sms_message:
+ raise ValueError("SMS-besked må ikke være tom")
+ if len(sms_message) > 1530:
+ raise ValueError("SMS-besked er for lang (max 1530 tegn)")
+
+ recipient = cls.normalize_recipient(to)
+ sms_sender = (sender or settings.SMS_SENDER or "").strip()
+ if not sms_sender:
+ raise ValueError("SMS afsender mangler (SMS_SENDER)")
+
+ payload = {
+ "to": recipient,
+ "message": sms_message,
+ "from": sms_sender,
+ }
+
+ body = json.dumps(payload).encode("utf-8")
+ request = UrlRequest(cls.API_URL, data=body, method="POST")
+ request.add_header("Content-Type", "application/json")
+ request.add_header("Authorization", cls._authorization_header())
+
+ try:
+ with urlopen(request, timeout=15) as response:
+ response_body = response.read().decode("utf-8")
+ response_data = json.loads(response_body) if response_body else {}
+ return {
+ "http_status": getattr(response, "status", 200),
+ "provider": "cpsms",
+ "recipient": recipient,
+ "result": response_data,
+ }
+ except HTTPError as e:
+ error_body = ""
+ try:
+ error_body = e.read().decode("utf-8")
+ except Exception:
+ error_body = ""
+
+ logger.error("❌ CPSMS HTTP error: status=%s body=%s", e.code, error_body)
+ raise RuntimeError(f"CPSMS fejl ({e.code})")
+ except URLError as e:
+ logger.error("❌ CPSMS connection error: %s", e)
+ raise RuntimeError("Kunne ikke kontakte CPSMS")
diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html
index 9a9567a..3d60e3a 100644
--- a/app/settings/frontend/settings.html
+++ b/app/settings/frontend/settings.html
@@ -83,6 +83,9 @@
Integrationer
+
+ Telefoni
+
Notifikationer
@@ -200,6 +203,137 @@