2026-03-03 22:11:45 +01:00
|
|
|
import json
|
|
|
|
|
from typing import Any, Dict, Optional
|
|
|
|
|
|
|
|
|
|
from app.core.database import execute_query, execute_query_single
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MissionService:
|
2026-03-04 00:33:12 +01:00
|
|
|
@staticmethod
|
|
|
|
|
def get_ring_timeout_seconds() -> int:
|
|
|
|
|
raw = MissionService.get_setting_value("mission_call_ring_timeout_seconds", "180") or "180"
|
|
|
|
|
try:
|
|
|
|
|
value = int(raw)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
value = 180
|
|
|
|
|
return max(30, min(value, 3600))
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def expire_stale_ringing_calls() -> None:
|
|
|
|
|
timeout_seconds = MissionService.get_ring_timeout_seconds()
|
|
|
|
|
execute_query(
|
|
|
|
|
"""
|
|
|
|
|
UPDATE mission_call_state
|
|
|
|
|
SET state = 'hangup',
|
|
|
|
|
ended_at = COALESCE(ended_at, NOW()),
|
|
|
|
|
updated_at = NOW()
|
|
|
|
|
WHERE state = 'ringing'
|
|
|
|
|
AND started_at < (NOW() - make_interval(secs => %s))
|
|
|
|
|
""",
|
|
|
|
|
(timeout_seconds,),
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
@staticmethod
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def parse_json_setting(key: str, default: Any) -> Any:
|
|
|
|
|
raw = MissionService.get_setting_value(key, None)
|
|
|
|
|
if raw is None:
|
|
|
|
|
return default
|
|
|
|
|
try:
|
|
|
|
|
return json.loads(raw)
|
|
|
|
|
except Exception:
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def build_alert_key(service_name: str, customer_name: Optional[str]) -> str:
|
|
|
|
|
customer_part = (customer_name or "").strip().lower() or "global"
|
|
|
|
|
return f"{service_name.strip().lower()}::{customer_part}"
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def resolve_contact_context(caller_number: Optional[str]) -> Dict[str, Optional[str]]:
|
|
|
|
|
if not caller_number:
|
|
|
|
|
return {"contact_name": None, "company_name": None, "customer_tag": 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_name,
|
|
|
|
|
(
|
|
|
|
|
SELECT t.name
|
|
|
|
|
FROM entity_tags et
|
|
|
|
|
JOIN tags t ON t.id = et.tag_id
|
|
|
|
|
WHERE et.entity_type = 'contact'
|
|
|
|
|
AND et.entity_id = c.id
|
|
|
|
|
AND LOWER(t.name) IN ('vip', 'serviceaftale', 'service agreement')
|
|
|
|
|
ORDER BY t.name
|
|
|
|
|
LIMIT 1
|
|
|
|
|
) AS customer_tag
|
|
|
|
|
FROM contacts c
|
|
|
|
|
WHERE RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = RIGHT(regexp_replace(%s, '\\D', '', 'g'), 8)
|
|
|
|
|
OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = RIGHT(regexp_replace(%s, '\\D', '', 'g'), 8)
|
|
|
|
|
LIMIT 1
|
|
|
|
|
"""
|
|
|
|
|
row = execute_query_single(query, (caller_number, caller_number))
|
|
|
|
|
if not row:
|
|
|
|
|
return {"contact_name": None, "company_name": None, "customer_tag": None}
|
|
|
|
|
|
|
|
|
|
contact_name = f"{(row.get('first_name') or '').strip()} {(row.get('last_name') or '').strip()}".strip() or None
|
|
|
|
|
return {
|
|
|
|
|
"contact_name": contact_name,
|
|
|
|
|
"company_name": row.get("company_name"),
|
|
|
|
|
"customer_tag": row.get("customer_tag"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def insert_event(
|
|
|
|
|
*,
|
|
|
|
|
event_type: str,
|
|
|
|
|
title: str,
|
|
|
|
|
severity: str = "info",
|
|
|
|
|
source: Optional[str] = None,
|
|
|
|
|
customer_name: Optional[str] = None,
|
|
|
|
|
payload: Optional[Dict[str, Any]] = None,
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
rows = execute_query(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO mission_events (event_type, severity, title, source, customer_name, payload)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
|
|
|
|
|
RETURNING id, event_type, severity, title, source, customer_name, payload, created_at
|
|
|
|
|
""",
|
|
|
|
|
(event_type, severity, title, source, customer_name, json.dumps(payload or {})),
|
|
|
|
|
)
|
|
|
|
|
return rows[0] if rows else {}
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_kpis() -> Dict[str, int]:
|
|
|
|
|
query = """
|
|
|
|
|
SELECT
|
|
|
|
|
COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet') AS open_cases,
|
|
|
|
|
COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) = 'åben' AND ansvarlig_bruger_id IS NULL) AS new_cases,
|
|
|
|
|
COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet' AND ansvarlig_bruger_id IS NULL) AS unassigned_cases,
|
|
|
|
|
COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet' AND deadline IS NOT NULL AND deadline::date = CURRENT_DATE) AS deadlines_today,
|
|
|
|
|
COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet' AND deadline IS NOT NULL AND deadline::date < CURRENT_DATE) AS overdue_deadlines
|
|
|
|
|
FROM sag_sager
|
|
|
|
|
"""
|
|
|
|
|
row = execute_query_single(query) or {}
|
|
|
|
|
return {
|
|
|
|
|
"open_cases": int(row.get("open_cases") or 0),
|
|
|
|
|
"new_cases": int(row.get("new_cases") or 0),
|
|
|
|
|
"unassigned_cases": int(row.get("unassigned_cases") or 0),
|
|
|
|
|
"deadlines_today": int(row.get("deadlines_today") or 0),
|
|
|
|
|
"overdue_deadlines": int(row.get("overdue_deadlines") or 0),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_employee_deadlines() -> list[Dict[str, Any]]:
|
|
|
|
|
rows = execute_query(
|
|
|
|
|
"""
|
|
|
|
|
SELECT
|
|
|
|
|
COALESCE(u.full_name, u.username, 'Ukendt') AS employee_name,
|
|
|
|
|
COUNT(*) FILTER (WHERE s.deadline::date = CURRENT_DATE) AS deadlines_today,
|
|
|
|
|
COUNT(*) FILTER (WHERE s.deadline::date < CURRENT_DATE) AS overdue_deadlines
|
|
|
|
|
FROM sag_sager s
|
|
|
|
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
|
|
|
|
WHERE s.deleted_at IS NULL
|
|
|
|
|
AND LOWER(s.status) <> 'afsluttet'
|
|
|
|
|
AND s.deadline IS NOT NULL
|
|
|
|
|
GROUP BY COALESCE(u.full_name, u.username, 'Ukendt')
|
|
|
|
|
HAVING COUNT(*) FILTER (WHERE s.deadline::date = CURRENT_DATE) > 0
|
|
|
|
|
OR COUNT(*) FILTER (WHERE s.deadline::date < CURRENT_DATE) > 0
|
|
|
|
|
ORDER BY overdue_deadlines DESC, deadlines_today DESC, employee_name ASC
|
|
|
|
|
"""
|
|
|
|
|
) or []
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"employee_name": row.get("employee_name"),
|
|
|
|
|
"deadlines_today": int(row.get("deadlines_today") or 0),
|
|
|
|
|
"overdue_deadlines": int(row.get("overdue_deadlines") or 0),
|
|
|
|
|
}
|
|
|
|
|
for row in rows
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_active_calls() -> list[Dict[str, Any]]:
|
2026-03-04 00:33:12 +01:00
|
|
|
MissionService.expire_stale_ringing_calls()
|
2026-03-03 22:11:45 +01:00
|
|
|
rows = execute_query(
|
|
|
|
|
"""
|
|
|
|
|
SELECT call_id, queue_name, caller_number, contact_name, company_name, customer_tag, state, started_at, answered_at, ended_at, updated_at
|
|
|
|
|
FROM mission_call_state
|
|
|
|
|
WHERE state = 'ringing'
|
|
|
|
|
ORDER BY started_at DESC
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
return rows or []
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_active_alerts() -> list[Dict[str, Any]]:
|
|
|
|
|
rows = execute_query(
|
|
|
|
|
"""
|
|
|
|
|
SELECT alert_key, service_name, customer_name, status, is_active, started_at, resolved_at, updated_at
|
|
|
|
|
FROM mission_uptime_alerts
|
|
|
|
|
WHERE is_active = TRUE
|
|
|
|
|
ORDER BY started_at ASC NULLS LAST
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
return rows or []
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_live_feed(limit: int = 20) -> list[Dict[str, Any]]:
|
|
|
|
|
rows = execute_query(
|
|
|
|
|
"""
|
|
|
|
|
SELECT id, event_type, severity, title, source, customer_name, payload, created_at
|
|
|
|
|
FROM mission_events
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
LIMIT %s
|
|
|
|
|
""",
|
|
|
|
|
(limit,),
|
|
|
|
|
)
|
|
|
|
|
return rows or []
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_state() -> Dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"kpis": MissionService.get_kpis(),
|
|
|
|
|
"active_calls": MissionService.get_active_calls(),
|
|
|
|
|
"employee_deadlines": MissionService.get_employee_deadlines(),
|
|
|
|
|
"active_alerts": MissionService.get_active_alerts(),
|
|
|
|
|
"live_feed": MissionService.get_live_feed(20),
|
|
|
|
|
"config": {
|
|
|
|
|
"display_queues": MissionService.parse_json_setting("mission_display_queues", []),
|
|
|
|
|
"sound_enabled": str(MissionService.get_setting_value("mission_sound_enabled", "true")).lower() == "true",
|
|
|
|
|
"sound_volume": int(MissionService.get_setting_value("mission_sound_volume", "70") or 70),
|
|
|
|
|
"sound_events": MissionService.parse_json_setting("mission_sound_events", ["incoming_call", "uptime_down", "critical_event"]),
|
|
|
|
|
"kpi_visible": MissionService.parse_json_setting(
|
|
|
|
|
"mission_kpi_visible",
|
|
|
|
|
["open_cases", "new_cases", "unassigned_cases", "deadlines_today", "overdue_deadlines"],
|
|
|
|
|
),
|
|
|
|
|
"customer_filter": MissionService.get_setting_value("mission_customer_filter", "") or "",
|
|
|
|
|
},
|
|
|
|
|
}
|